├── web ├── .env ├── src │ ├── react-app-env.d.ts │ ├── services │ │ ├── file.service.ts │ │ ├── config.service.ts │ │ ├── wails.service.ts │ │ ├── lua.service.ts │ │ ├── api.service.ts │ │ ├── connection.service.ts │ │ └── websocket.service.ts │ ├── setupTests.ts │ ├── events │ │ └── ConfigEvent.ts │ ├── App.test.tsx │ ├── reportWebVitals.ts │ ├── i18n.ts │ ├── index.tsx │ ├── locales │ │ ├── resources.ts │ │ ├── zh-Hans.json │ │ ├── zh-Hant.json │ │ └── en_US.json │ ├── index.css │ ├── components │ │ ├── common │ │ │ ├── Loading.tsx │ │ │ ├── ErrorMessageBar.tsx │ │ │ ├── ReadFileTextField.tsx │ │ │ ├── ConfirmButton.tsx │ │ │ ├── DragSlider.tsx │ │ │ ├── FormatTextField.tsx │ │ │ └── Pagination.tsx │ │ ├── NotFoundKey.tsx │ │ ├── NoKeySelected.tsx │ │ ├── utils.ts │ │ ├── info │ │ │ ├── PubSub.tsx │ │ │ ├── Client.tsx │ │ │ └── SlowLog.tsx │ │ ├── settings │ │ │ ├── AboutDialog.tsx │ │ │ └── AppSettings.tsx │ │ ├── KeyList.tsx │ │ ├── ZsetKey.tsx │ │ ├── HashKeySearch.tsx │ │ ├── SetKeySearch.tsx │ │ ├── ZsetKeyCursorSearch.tsx │ │ ├── panel │ │ │ ├── StringKeyPanel.tsx │ │ │ ├── ListKeyPanel.tsx │ │ │ ├── SetKeyPanel.tsx │ │ │ ├── ZsetKeyPanel.tsx │ │ │ └── HashKeyPanel.tsx │ │ ├── MainTab.tsx │ │ ├── command │ │ │ ├── Command.tsx │ │ │ └── Suggestion.tsx │ │ ├── lua │ │ │ ├── Luas.tsx │ │ │ └── Scripting.tsx │ │ ├── configuration │ │ │ └── DatabaseConfiguration.tsx │ │ └── StringKey.tsx │ ├── App.css │ ├── logo.svg │ ├── lua.svg │ ├── themes.ts │ └── App.tsx ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── craco.config.js ├── .gitignore ├── tsconfig.json ├── package.json └── README.md ├── desktop ├── appicon.png ├── web │ └── build │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── index.html ├── build │ ├── appicon.png │ ├── windows │ │ ├── icon.ico │ │ ├── info.json │ │ ├── wails.exe.manifest │ │ └── installer │ │ │ └── project.nsi │ ├── darwin │ │ └── Info.plist │ └── README.md ├── iconset │ ├── icon_128x128.png │ ├── icon_16x16.png │ ├── icon_192x192.png │ ├── icon_256x256.png │ ├── icon_32x32.png │ ├── icon_512x512.png │ ├── icon_64x64.png │ ├── icon_16x16@2x.png │ ├── icon_32x32@2x.png │ ├── icon_64x64@2x.png │ ├── icon_128x128@2x.png │ ├── icon_256x256@2x.png │ └── icon_512x512@2x.png ├── RedisWebManager.AppImage.desktop ├── .gitignore ├── dmg-spec.json ├── wails.json ├── app_test.go ├── go.mod ├── main.go └── app.go ├── server ├── appicon.png ├── Dockerfile ├── jsonrpc2.go ├── go.mod └── .goreleaser.yml ├── api ├── config.go ├── parser │ ├── parser.go │ ├── redisparser_suggestion_error_listener.go │ ├── redisparser_suggestion_visitor.go │ ├── redisparser_suggestion_visitor_test.go │ ├── redis_error_strategy.go │ ├── redisparser_visitor.go │ └── redisparser_base_visitor.go ├── go.mod ├── api.go ├── lua_test.go ├── lua.go ├── connection_test.go ├── storage_test.go └── storage.go ├── .gitignore ├── LICENSE ├── logo.svg ├── .github └── workflows │ ├── main.yml │ └── release-server.yml ├── README.zh_CN.md └── README.md /web/.env: -------------------------------------------------------------------------------- 1 | GENERATE_SOURCEMAP = false -------------------------------------------------------------------------------- /web/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /desktop/appicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/appicon.png -------------------------------------------------------------------------------- /server/appicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/server/appicon.png -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /desktop/web/build/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/web/public/logo192.png -------------------------------------------------------------------------------- /web/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/web/public/logo512.png -------------------------------------------------------------------------------- /desktop/build/appicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/build/appicon.png -------------------------------------------------------------------------------- /desktop/web/build/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/web/build/favicon.ico -------------------------------------------------------------------------------- /desktop/web/build/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/web/build/logo192.png -------------------------------------------------------------------------------- /desktop/web/build/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/web/build/logo512.png -------------------------------------------------------------------------------- /desktop/build/windows/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/build/windows/icon.ico -------------------------------------------------------------------------------- /desktop/iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_128x128.png -------------------------------------------------------------------------------- /desktop/iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_16x16.png -------------------------------------------------------------------------------- /desktop/iconset/icon_192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_192x192.png -------------------------------------------------------------------------------- /desktop/iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_256x256.png -------------------------------------------------------------------------------- /desktop/iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_32x32.png -------------------------------------------------------------------------------- /desktop/iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_512x512.png -------------------------------------------------------------------------------- /desktop/iconset/icon_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_64x64.png -------------------------------------------------------------------------------- /desktop/iconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /desktop/iconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /desktop/iconset/icon_64x64@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_64x64@2x.png -------------------------------------------------------------------------------- /desktop/iconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /desktop/iconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /desktop/iconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slowrookie/redis-web-manager/HEAD/desktop/iconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /desktop/RedisWebManager.AppImage.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Redis Web Manager 3 | Exec=RedisWebManager 4 | Icon=icon_128x128 5 | Type=Application 6 | Categories=GTK;GNOME;Utility;Development; -------------------------------------------------------------------------------- /web/src/services/file.service.ts: -------------------------------------------------------------------------------- 1 | import { defaultService } from "./api.service"; 2 | 3 | export const readFile = (): Promise => { 4 | return defaultService.request({method: 'ReadFile'}) 5 | } -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | ENTRYPOINT ["/usr/bin/RedisWebManager"] 4 | 5 | COPY RedisWebManager /usr/bin/RedisWebManager 6 | 7 | VOLUME [ "/root/.com.github.slowrookie.redis-web-manager" ] 8 | 9 | EXPOSE 63790 -------------------------------------------------------------------------------- /desktop/.gitignore: -------------------------------------------------------------------------------- 1 | # production 2 | /build/bin 3 | 4 | go.sum 5 | 6 | /rwm_data 7 | /rwm* 8 | .connections.json 9 | .config.json 10 | 11 | redis-web-manager 12 | redis-web-manager.exe 13 | __debug_bin 14 | .DS_Store 15 | 16 | /frontend 17 | 18 | .vscode 19 | .idea -------------------------------------------------------------------------------- /web/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /web/src/events/ConfigEvent.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | 3 | // change config event 4 | export enum ConfigEventAction { 5 | Language, 6 | Theme 7 | } 8 | export interface IConfigEvent { 9 | action: ConfigEventAction, 10 | params?: any 11 | } 12 | export const ConfigEvent = new Subject(); -------------------------------------------------------------------------------- /web/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /web/craco.config.js: -------------------------------------------------------------------------------- 1 | const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin'); 2 | 3 | module.exports = { 4 | devServer: { 5 | devMiddleware: { 6 | writeToDisk: false, 7 | }, 8 | }, 9 | webpack: { 10 | plugins: { 11 | add: [new MonacoWebpackPlugin({languages: ['json', 'lua']})] 12 | } 13 | } 14 | }; -------------------------------------------------------------------------------- /desktop/dmg-spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Redis Web Mananger", 3 | "contents": [ 4 | { 5 | "x": 448, 6 | "y": 200, 7 | "type": "link", 8 | "path": "/Applications" 9 | }, 10 | { 11 | "x": 192, 12 | "y": 200, 13 | "type": "file", 14 | "path": "./build/bin/RedisWebManager.app" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /desktop/build/windows/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "fixed": { 3 | "file_version": "{{.Info.ProductVersion}}" 4 | }, 5 | "info": { 6 | "0000": { 7 | "ProductVersion": "{{.Info.ProductVersion}}", 8 | "CompanyName": "{{.Info.CompanyName}}", 9 | "FileDescription": "{{.Info.ProductName}}", 10 | "LegalCopyright": "{{.Info.Copyright}}", 11 | "ProductName": "{{.Info.ProductName}}", 12 | "Comments": "{{.Info.Comments}}" 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | yarn.lock 25 | 26 | package-lock.json 27 | -------------------------------------------------------------------------------- /web/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /web/src/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import { initReactI18next } from 'react-i18next'; 3 | 4 | import { resources } from './locales/resources'; 5 | 6 | i18n 7 | .use(initReactI18next) 8 | .init({ 9 | resources, 10 | fallbackLng: 'en_US', // language to use if translations in user language are not available. 11 | lng: 'zh_Hans', 12 | debug: true, 13 | interpolation: { 14 | escapeValue: false, // not needed for react as it escapes by default 15 | } 16 | }); 17 | 18 | export default i18n; -------------------------------------------------------------------------------- /api/config.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type Config struct { 4 | Theme string `json:"theme"` 5 | Language string `json:"language"` 6 | } 7 | 8 | const ( 9 | configStorageCollection = "config" 10 | configStorageKey = "system_config" 11 | ) 12 | 13 | var DefaultConfig = &Config{} 14 | 15 | func (c *Config) Get() error { 16 | return GlobalStorage.Read(configStorageCollection, configStorageKey, c) 17 | } 18 | 19 | func (c *Config) Set() error { 20 | return GlobalStorage.Write(configStorageCollection, configStorageKey, *c) 21 | } 22 | -------------------------------------------------------------------------------- /web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /web/src/locales/resources.ts: -------------------------------------------------------------------------------- 1 | import en_US from './en_US.json'; 2 | import zh_hans from './zh-Hans.json'; 3 | import zh_hant from './zh-Hant.json'; 4 | 5 | export const resources = { 6 | en_US: { 7 | translation: en_US 8 | }, 9 | zh_Hans: { 10 | translation: zh_hans 11 | }, 12 | "zh-CN": { 13 | translation: zh_hans 14 | }, 15 | zh_hant: { 16 | translation: zh_hant 17 | }, 18 | } 19 | 20 | export const supportedLanguages: {[index: string]: string} = { 21 | en_US: "English", 22 | zh_Hans: "简体中文", 23 | zh_hant: "繁體中文", 24 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | .idea 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | yarn.lock 27 | 28 | package-lock.json 29 | 30 | web/node_modules 31 | api/go.sum 32 | server/go.sum 33 | server/web/build/** 34 | desktop/web/build/** 35 | 36 | /gen -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | /* scrollbar */ 16 | ::-webkit-scrollbar { 17 | width: 5px; 18 | } 19 | 20 | ::-webkit-scrollbar-thumb { 21 | height: 30px; 22 | background-color: #ccc; 23 | } -------------------------------------------------------------------------------- /web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /desktop/web/build/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /api/parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import "github.com/antlr/antlr4/runtime/Go/antlr" 4 | 5 | // Suggestions command 6 | func Suggestions(command string) []string { 7 | input := antlr.NewInputStream(command) 8 | lexer := NewRedisLexer(input) 9 | stream := antlr.NewCommonTokenStream(lexer, 0) 10 | p := NewRedisParser(stream) 11 | p.BuildParseTrees = true 12 | 13 | //redisErrorStrategy := &RedisErrorStrategy{} 14 | //p.SetErrorHandler(redisErrorStrategy) 15 | redisErrorListener := &SuggestionRedisErrorListener{} 16 | p.RemoveErrorListeners() 17 | p.AddErrorListener(redisErrorListener) 18 | p.Command() 19 | 20 | return redisErrorListener.ExpectedTokens 21 | } 22 | -------------------------------------------------------------------------------- /web/src/services/config.service.ts: -------------------------------------------------------------------------------- 1 | import { defaultService } from "./api.service"; 2 | 3 | export interface Config { 4 | theme: string, 5 | language: string 6 | } 7 | 8 | export interface About { 9 | version: string 10 | commit: string 11 | date: string 12 | builtBy: string 13 | environment: string 14 | } 15 | 16 | export const getConfig = (): Promise => { 17 | return defaultService.request({method: 'Config'}); 18 | } 19 | 20 | export const setConfig = (config: Config): Promise => { 21 | return defaultService.request({method: 'SetConfig', params: config}); 22 | } 23 | 24 | export const about = (): Promise => { 25 | return defaultService.request({method: 'AboutInfo'}); 26 | } -------------------------------------------------------------------------------- /web/src/components/common/Loading.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Overlay, Spinner, SpinnerSize, Stack } from "@fluentui/react"; 3 | import React from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | export interface ILoadingProps { 7 | loading: boolean 8 | } 9 | 10 | export const Loading = (props: ILoadingProps) => { 11 | const { loading } = props, { t } = useTranslation(); 12 | 13 | return (<> 14 | {loading && 15 | 16 | 17 | 18 | 19 | 20 | } 21 | ) 22 | } -------------------------------------------------------------------------------- /web/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/parser/redisparser_suggestion_error_listener.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "github.com/antlr/antlr4/runtime/Go/antlr" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | type SuggestionRedisErrorListener struct { 10 | *antlr.DefaultErrorListener 11 | ExpectedTokens []string 12 | } 13 | 14 | func (d *SuggestionRedisErrorListener) SyntaxError(recognizer antlr.Recognizer, offendingSymbol interface{}, line, column int, msg string, e antlr.RecognitionException) { 15 | expecting := (recognizer.(antlr.Parser)).GetExpectedTokens() 16 | expectStr := expecting.StringVerbose([]string{}, recognizer.GetSymbolicNames(), true) 17 | reg := regexp.MustCompile("[{} ]") 18 | expectStr = reg.ReplaceAllString(expectStr, "") 19 | d.ExpectedTokens = strings.Split(expectStr, ",") 20 | } 21 | -------------------------------------------------------------------------------- /web/src/components/NotFoundKey.tsx: -------------------------------------------------------------------------------- 1 | import {FontIcon, Stack, Text, useTheme} from '@fluentui/react' 2 | import React from 'react' 3 | 4 | export interface INotFoundProps { 5 | message?: string 6 | } 7 | 8 | const NotFoundKey = (props: INotFoundProps) => { 9 | const theme = useTheme() 10 | 11 | return ( 12 | 13 | 14 | 404 15 | {props.message && ( 16 | 17 | {props.message} 18 | 19 | )} 20 | 21 | ) 22 | } 23 | 24 | export default NotFoundKey 25 | -------------------------------------------------------------------------------- /web/src/components/common/ErrorMessageBar.tsx: -------------------------------------------------------------------------------- 1 | import { MessageBar, MessageBarType } from '@fluentui/react'; 2 | import React, { useEffect, useState } from 'react'; 3 | 4 | export interface IErrorMessageBar { 5 | error?: Error 6 | } 7 | 8 | export const ErrorMessageBar = (props: IErrorMessageBar) => { 9 | const { error } = props; 10 | 11 | const [_error, _setError] = useState(); 12 | 13 | useEffect(() => { 14 | _setError(error); 15 | }, [error]) 16 | 17 | return (<> 18 | { 19 | _error && { _setError(undefined); }} truncated={true}> 20 | {_error} 21 | 22 | } 23 | ) 24 | } -------------------------------------------------------------------------------- /web/src/services/wails.service.ts: -------------------------------------------------------------------------------- 1 | import { APIService } from "./api.service"; 2 | 3 | export interface RequestOptions { 4 | method: string 5 | params?: Array | {} 6 | } 7 | 8 | export class WailsService implements APIService { 9 | 10 | request = async (opt: RequestOptions): Promise => { 11 | const go = (window as any).go; 12 | const method = go.main.App[opt.method]; 13 | if (!method) return new Promise((resolve, reject) => { 14 | const msg = `Mehthod ${opt.method} not found`; 15 | return reject(msg) 16 | }) 17 | 18 | console.log("Request: ", opt); 19 | return ((opt.params ? method(opt.params) : method()) as Promise).then(v => { 20 | console.log("Response: ", v) 21 | return v; 22 | }); 23 | } 24 | 25 | } 26 | 27 | export const defaultWailsService = new WailsService(); -------------------------------------------------------------------------------- /desktop/build/darwin/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | CFBundlePackageTypeAPPL 4 | CFBundleName{{.Info.ProductName}} 5 | CFBundleExecutable{{.Name}} 6 | CFBundleIdentifiercom.wails.{{.Name}} 7 | CFBundleVersion{{.Info.ProductVersion}} 8 | CFBundleGetInfoString{{.Info.Comments}} 9 | CFBundleShortVersionString{{.Info.ProductVersion}} 10 | CFBundleIconFileiconfile 11 | LSMinimumSystemVersion10.13.0 12 | NSHighResolutionCapabletrue 13 | NSHumanReadableCopyright{{.Info.Copyright}} 14 | -------------------------------------------------------------------------------- /web/src/services/lua.service.ts: -------------------------------------------------------------------------------- 1 | import { defaultService } from "./api.service"; 2 | 3 | export interface Lua { 4 | connectionID: string 5 | id: string 6 | name: string 7 | keys: Array 8 | args: Array 9 | script: string 10 | lastExecutionAt: number 11 | elapsed: string 12 | result: any 13 | } 14 | 15 | export const eidtLua = (lua: Lua): Promise => { 16 | return defaultService.request({ method: lua.id ? 'EditLua' : 'NewLua', params: lua }); 17 | } 18 | 19 | export const deleteLua = (lua: Lua): Promise => { 20 | return defaultService.request({ method: 'DeleteLua', params: lua }); 21 | } 22 | 23 | export const loadLuas = (connectionId: string): Promise => { 24 | return defaultService.request({ method: 'LoadLuas', params: connectionId }); 25 | } 26 | 27 | export const executionScript = (lua: Lua): Promise => { 28 | return defaultService.request({ method: 'ExecutionScript', params: lua }); 29 | } -------------------------------------------------------------------------------- /desktop/wails.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "RedisWebManager", 3 | "frontend:dir": "../web/", 4 | "frontend:build": "", 5 | "frontend:install": "", 6 | "frontend:dev": "", 7 | "frontend:dev:watcher": "", 8 | "wailsjsdir": "./web/build", 9 | "version": "2", 10 | "Path": "", 11 | "BuildDir": "", 12 | "outputfilename": "RedisWebManager", 13 | "OutputType": "", 14 | "Platform": "", 15 | "runNonNativeBuildHooks": false, 16 | "postBuildHooks": null, 17 | "Author": { 18 | "name": "jiaxing.liu", 19 | "email": "liujiaxingemail@gmail.com" 20 | }, 21 | "Info": { 22 | "companyName": "https://github.com/slowrookie", 23 | "productName": "Redis Web Manager", 24 | "productVersion": "1.0.0", 25 | "copyright": "Copyright@slowrookie", 26 | "comments": "Built using Wails (https://wails.app)" 27 | }, 28 | "debounceMS": 100, 29 | "devserverurl": "http://localhost:34115", 30 | "appargs": "", 31 | "nsisType": "" 32 | } -------------------------------------------------------------------------------- /api/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/slowrookie/redis-web-manager/api 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220527190237-ee62e23da966 7 | github.com/json-iterator/go v1.1.12 8 | github.com/redis/go-redis/v9 v9.0.3 9 | github.com/samber/lo v1.21.0 10 | github.com/stretchr/testify v1.8.1 11 | golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e 12 | golang.org/x/net v0.7.0 13 | ) 14 | 15 | require ( 16 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 19 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 20 | github.com/modern-go/reflect2 v1.0.2 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect 23 | golang.org/x/sys v0.5.0 // indirect 24 | gopkg.in/yaml.v3 v3.0.1 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | "runtime" 10 | ) 11 | 12 | var AppRoot string 13 | 14 | func AppDataPath() string { 15 | const project = "com.github.slowrookie.redis-web-manager" 16 | 17 | switch runtime.GOOS { 18 | case "windows": 19 | return fmt.Sprintf("%s/%s", os.Getenv("APPDATA"), project) 20 | case "darwin": 21 | return fmt.Sprintf("%s/Library/Containers/%s", os.Getenv("HOME"), project) 22 | case "linux": 23 | return fmt.Sprintf("%s/.%s", os.Getenv("HOME"), project) 24 | } 25 | // default path 26 | dir, err := filepath.Abs(filepath.Dir(os.Args[0])) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | return dir 31 | } 32 | 33 | func init() { 34 | AppRoot = AppDataPath() 35 | if err := os.MkdirAll(AppRoot, os.ModePerm); err != nil { 36 | panic(err) 37 | } 38 | 39 | // storage 40 | if err := GlobalStorage.Initialize(path.Join(AppRoot, "storage")); err != nil { 41 | panic(err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /web/src/services/api.service.ts: -------------------------------------------------------------------------------- 1 | import { defaultWailsService } from "./wails.service"; 2 | import { defaultWebSocketService } from "./websocket.service"; 3 | 4 | export interface RequestOptions { 5 | method: string 6 | params?: Array | {} 7 | } 8 | 9 | export class APIService { 10 | 11 | request = async (opt: RequestOptions): Promise => { 12 | const go = (window as any).go; 13 | const method = go.main.App[opt.method]; 14 | if (!method) return new Promise((resolve, reject) => { 15 | const msg = `Mehthod ${opt.method} not found`; 16 | return reject(msg) 17 | }) 18 | 19 | console.log("Request: ", opt); 20 | return ((opt.params ? method(opt.params) : method()) as Promise).then(v => { 21 | console.log("Response: ", v) 22 | return v; 23 | }); 24 | } 25 | 26 | } 27 | 28 | export const isWails = !!(window as any).go; 29 | 30 | export const defaultService = isWails ? defaultWailsService : defaultWebSocketService; 31 | -------------------------------------------------------------------------------- /web/src/components/NoKeySelected.tsx: -------------------------------------------------------------------------------- 1 | import { FontIcon, Stack, Text, Link, useTheme } from '@fluentui/react' 2 | import React from 'react' 3 | import { useTranslation } from 'react-i18next' 4 | 5 | 6 | const NoKeySelected = (props: any) => { 7 | const theme = useTheme(), { t } = useTranslation(); 8 | 9 | const fontColorStyle = { 10 | color: theme.palette.neutralTertiaryAlt 11 | } 12 | 13 | return ( 14 | 16 | 17 | 18 | {t('Welcome to discussions!')} 19 | 20 | 21 | https://github.com/slowrookie/redis-web-manager 22 | 23 | 24 | ) 25 | } 26 | 27 | export default NoKeySelected 28 | -------------------------------------------------------------------------------- /desktop/app_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "testing" 7 | 8 | "github.com/slowrookie/redis-web-manager/api" 9 | "github.com/stretchr/testify/suite" 10 | ) 11 | 12 | type AppSuite struct { 13 | suite.Suite 14 | } 15 | 16 | var app *App 17 | 18 | func (sut *AppSuite) SetupTest() { 19 | app = NewApp() 20 | } 21 | 22 | func (sut *AppSuite) TestConnections() { 23 | connections := app.Connections() 24 | sut.GreaterOrEqual(len(connections), 1) 25 | } 26 | 27 | func (sut *AppSuite) TestOpenConnection() { 28 | var con *api.Connection 29 | for _, v := range app.Connections() { 30 | if v.Name == "localhost" { 31 | con = v 32 | } 33 | } 34 | connection, err := api.GetConnection(con.ID) 35 | sut.ErrorIs(err, nil) 36 | sut.NotEqual(connection, nil) 37 | err = connection.Open() 38 | sut.ErrorIs(err, nil) 39 | log.Println(fmt.Sprintf("%#v", connection)) 40 | commands := [][]interface{}{{"ping"}} 41 | ret, err := connection.Command(commands) 42 | sut.NotEmpty(ret) 43 | } 44 | 45 | func TestConnectionSuite(t *testing.T) { 46 | suite.Run(t, new(AppSuite)) 47 | } 48 | -------------------------------------------------------------------------------- /desktop/build/windows/wails.exe.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | true/pm 12 | permonitorv2,permonitor 13 | 14 | 15 | -------------------------------------------------------------------------------- /api/lua_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/suite" 9 | ) 10 | 11 | type LuaSuite struct { 12 | suite.Suite 13 | } 14 | 15 | func (sut *LuaSuite) SetupTest() { 16 | 17 | } 18 | 19 | func (sut *LuaSuite) TestLoadLuas() { 20 | incrByScript := ` 21 | local key = KEYS[1] 22 | local change = ARGV[1] 23 | local value = redis.call("GET", key) 24 | if not value then 25 | value = 0 26 | end 27 | value = value + change 28 | redis.call("SET", key, value) 29 | return value 30 | ` 31 | 32 | id := strconv.FormatInt(time.Now().UnixNano(), 10) 33 | lua := &Lua{ 34 | ConnectionID: id, 35 | ID: id, 36 | Keys: []string{}, 37 | Args: nil, 38 | Script: incrByScript, 39 | } 40 | 41 | err := lua.New() 42 | sut.ErrorIs(err, nil) 43 | 44 | var luas = make([]Lua, 0) 45 | err = LoadLuas(&luas) 46 | sut.ErrorIs(err, nil) 47 | sut.GreaterOrEqual(len(luas), 1) 48 | 49 | err = lua.Delete() 50 | sut.ErrorIs(err, nil) 51 | } 52 | 53 | func TestLuaSuite(t *testing.T) { 54 | suite.Run(t, new(LuaSuite)) 55 | } 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2017-2022 slowrookie 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/jsonrpc2.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "sync" 8 | 9 | "go.lsp.dev/jsonrpc2" 10 | ) 11 | 12 | var handlers []jsonrpc2.Handler 13 | 14 | // RegisterHandler . 15 | func RegisterHandler(handler jsonrpc2.Handler) { 16 | handlers = append(handlers, handler) 17 | } 18 | 19 | // DispatchHandlers . 20 | func DispatchHandlers() jsonrpc2.Handler { 21 | return func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { 22 | if len(handlers) <= 0 { 23 | return errors.New("no handler found") 24 | } 25 | var err error 26 | for _, h := range handlers { 27 | err = h(ctx, reply, req) 28 | if err != nil { 29 | break 30 | } 31 | } 32 | return err 33 | } 34 | } 35 | 36 | // ServerConn over http or websocket 37 | func ServerConn(ctx context.Context, netConn io.ReadWriteCloser, server jsonrpc2.StreamServer) { 38 | wg := new(sync.WaitGroup) 39 | wg.Add(1) 40 | closedConns := make(chan error) 41 | stream := jsonrpc2.NewRawStream(netConn) 42 | go func() { 43 | conn := jsonrpc2.NewConn(stream) 44 | closedConns <- server.ServeStream(ctx, conn) 45 | stream.Close() 46 | }() 47 | wg.Wait() 48 | } 49 | -------------------------------------------------------------------------------- /web/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/slowrookie/redis-web-manager 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/json-iterator/go v1.1.12 7 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 8 | github.com/slowrookie/redis-web-manager/api v0.0.0-20220525113429-86ac49708877 9 | go.lsp.dev/jsonrpc2 v0.10.0 10 | golang.org/x/net v0.7.0 11 | ) 12 | 13 | require ( 14 | github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220527190237-ee62e23da966 // indirect 15 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 16 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 17 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 18 | github.com/modern-go/reflect2 v1.0.2 // indirect 19 | github.com/redis/go-redis/v9 v9.0.3 // indirect 20 | github.com/samber/lo v1.21.0 // indirect 21 | github.com/segmentio/asm v1.2.0 // indirect 22 | github.com/segmentio/encoding v0.3.5 // indirect 23 | golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect 24 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect 25 | golang.org/x/sys v0.5.0 // indirect 26 | ) 27 | 28 | replace github.com/slowrookie/redis-web-manager/api => ../api 29 | -------------------------------------------------------------------------------- /server/.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - id: "redis-web-manager" 6 | binary: RedisWebManager 7 | env: 8 | - CGO_ENABLED=0 9 | ldflags: 10 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser -X main.MODE=release 11 | goos: 12 | - linux 13 | - darwin 14 | - windows 15 | goarch: 16 | - amd64 17 | - arm64 18 | 19 | #dockers: 20 | # - 21 | # goos: linux 22 | # goarch: amd64 23 | # image_templates: 24 | # - "docker.io/slowrookie/redis-web-manager:{{ .Tag }}" 25 | # - "docker.io/slowrookie/redis-web-manager:latest" 26 | # skip_push: false 27 | # dockerfile: Dockerfile 28 | 29 | dockers: 30 | - image_templates: 31 | - "docker.io/slowrookie/redis-web-manager:{{ .Tag }}-amd64" 32 | use: buildx 33 | goos: linux 34 | goarch: amd64 35 | dockerfile: Dockerfile 36 | build_flag_templates: 37 | - "--platform=linux/amd64" 38 | - image_templates: 39 | - "docker.io/slowrookie/redis-web-manager:{{ .Tag }}-arm64v8" 40 | goos: linux 41 | use: buildx 42 | goarch: arm64 43 | dockerfile: Dockerfile 44 | build_flag_templates: 45 | - "--platform=linux/arm64/v8" 46 | docker_manifests: 47 | - name_template: docker.io/slowrookie/redis-web-manager:latest 48 | image_templates: 49 | - docker.io/slowrookie/redis-web-manager:{{ .Tag }}-amd64 50 | - docker.io/slowrookie/redis-web-manager:{{ .Tag }}-arm64v8 -------------------------------------------------------------------------------- /api/lua.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | const ( 10 | luaStorageCollection = "luas" 11 | ) 12 | 13 | type Lua struct { 14 | ConnectionID string `json:"connectionID"` 15 | ID string `json:"id"` 16 | Name string `json:"name"` 17 | Keys []string `json:"keys"` 18 | Args []interface{} `json:"args"` 19 | Script string `json:"script"` 20 | LastExecutionAt int64 `json:"lastExecutionAt"` 21 | Elapsed string `json:"elapsed"` 22 | Result interface{} `json:"result"` 23 | } 24 | 25 | // New add lua script 26 | func (l *Lua) New() error { 27 | id := strconv.FormatInt(time.Now().UnixNano(), 10) 28 | l.ID = id 29 | return GlobalStorage.Write(luaStorageCollection, fmt.Sprintf("%s_%s", l.ConnectionID, l.ID), l) 30 | } 31 | 32 | // Edit . 33 | func (l *Lua) Edit() error { 34 | return GlobalStorage.Write(luaStorageCollection, fmt.Sprintf("%s_%s", l.ConnectionID, l.ID), l) 35 | } 36 | 37 | // Delete . 38 | func (l *Lua) Delete() error { 39 | return GlobalStorage.Delete(luaStorageCollection, fmt.Sprintf("%s_%s", l.ConnectionID, l.ID)) 40 | } 41 | 42 | // LoadLuas load all lua scripts 43 | func LoadLuas(luas *[]Lua) error { 44 | var bytes [][]byte 45 | if err := GlobalStorage.ReadAll(luaStorageCollection, &bytes); err != nil { 46 | return err 47 | } 48 | 49 | if err := RecordsToStruct(bytes, luas); err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /api/parser/redisparser_suggestion_visitor.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "github.com/antlr/antlr4/runtime/Go/antlr" 5 | "github.com/redis/go-redis/v9" 6 | "github.com/samber/lo" 7 | "golang.org/x/net/context" 8 | "strconv" 9 | ) 10 | 11 | // SuggestionRedisParserVisitor . 12 | type SuggestionRedisParserVisitor struct { 13 | BaseRedisParserVisitor 14 | ExpectedTokens []string 15 | Expects []string 16 | DB *int 17 | Client redis.UniversalClient 18 | } 19 | 20 | func (s *SuggestionRedisParserVisitor) Visit(tree antlr.ParseTree) interface{} { 21 | return tree.Accept(s) 22 | } 23 | 24 | func (s *SuggestionRedisParserVisitor) VisitChildren(node antlr.RuleNode) interface{} { 25 | for _, child := range node.GetChildren() { 26 | return child.(antlr.ParseTree).Accept(s) 27 | } 28 | return nil 29 | } 30 | 31 | func (s *SuggestionRedisParserVisitor) VisitCommand(ctx *CommandContext) interface{} { 32 | return s.VisitChildren(ctx) 33 | } 34 | 35 | func (s *SuggestionRedisParserVisitor) VisitConnectionManagement(ctx *ConnectionManagementContext) interface{} { 36 | if len(s.ExpectedTokens) <= 0 { 37 | return s.VisitChildren(ctx) 38 | } 39 | 40 | if s.ExpectedTokens[0] == ctx.parser.GetSymbolicNames()[RedisLexerDB] { 41 | ctx := context.Background() 42 | ret := s.Client.ConfigGet(ctx, "databases").Val() 43 | databases, _ := strconv.Atoi(ret["databases"]) 44 | s.Expects = lo.Map[int, string](lo.Range(databases), func(v int, _ int) string { 45 | return strconv.Itoa(v) 46 | }) 47 | } 48 | return s.VisitChildren(ctx) 49 | } 50 | -------------------------------------------------------------------------------- /web/src/components/common/ReadFileTextField.tsx: -------------------------------------------------------------------------------- 1 | import { CommandBarButton, ITextFieldProps, Stack, TextField } from '@fluentui/react'; 2 | import React, { useRef, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import {readFile} from '../../services/file.service'; 5 | 6 | export const ReadFileTextFiled = (props: ITextFieldProps) => { 7 | const { t } = useTranslation(); 8 | const fileChosenRef = useRef(); 9 | const [errorMessage, setErrorMessage] = useState(); 10 | 11 | return { 12 | return ( 13 | 14 | {defaultRender(fieldProps)} 15 | 16 | { 17 | if (!e.target.files) return; 18 | let file = e.target.files[0]; 19 | const fileReader = new FileReader() 20 | fileReader.onloadend = () => { 21 | props.onChange && props.onChange(e, fileReader.result?.toString()); 22 | } 23 | fileReader.readAsText(file); 24 | }} /> 25 | { 26 | // fileChosenRef.current.click(); 27 | readFile().then(v => { 28 | props.onChange && props.onChange(e, v); 29 | }).catch(err => setErrorMessage(err)) 30 | }}> 31 | 32 | ) 33 | }} /> 34 | } 35 | -------------------------------------------------------------------------------- /web/src/components/common/ConfirmButton.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultButton, Dialog, DialogFooter, IconButton, IDialogContentProps, PrimaryButton, TooltipHost } from '@fluentui/react'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | export interface IConfirmButtonProps { 6 | label: string 7 | dialogContentProps: IDialogContentProps 8 | disabled: boolean 9 | onConfirm: () => void 10 | onCancel?: () => void 11 | } 12 | 13 | export const ConfirmButton = (props: IConfirmButtonProps) => { 14 | const { label, dialogContentProps, onConfirm, onCancel } = props; 15 | 16 | const { t } = useTranslation(); 17 | 18 | const [hiddenDialog, setHiddenDialog] = useState(true), 19 | [disabled, setDisabled] = useState(props.disabled); 20 | 21 | useEffect(() => { 22 | setDisabled(props.disabled); 23 | }, [props.disabled]) 24 | 25 | return (
26 | 27 | { 30 | setHiddenDialog(false); 31 | }} /> 32 | 33 | 40 |
) 41 | } -------------------------------------------------------------------------------- /api/parser/redisparser_suggestion_visitor_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/antlr/antlr4/runtime/Go/antlr" 8 | "github.com/stretchr/testify/suite" 9 | ) 10 | 11 | type SuggestionRedisParserVisitorSuite struct { 12 | suite.Suite 13 | } 14 | 15 | func (sut *SuggestionRedisParserVisitorSuite) SetupTest() { 16 | 17 | } 18 | 19 | func (sut *SuggestionRedisParserVisitorSuite) TestVisitCommand() { 20 | input := antlr.NewInputStream("") 21 | lexer := NewRedisLexer(input) 22 | stream := antlr.NewCommonTokenStream(lexer, 0) 23 | p := NewRedisParser(stream) 24 | p.BuildParseTrees = true 25 | 26 | redisErrorStrategy := &RedisErrorStrategy{} 27 | p.SetErrorHandler(redisErrorStrategy) 28 | p.Command() 29 | 30 | sut.Assert().GreaterOrEqual(len(redisErrorStrategy.Expects), 1) 31 | fmt.Println(redisErrorStrategy.Expects) 32 | } 33 | 34 | func (sut *SuggestionRedisParserVisitorSuite) TestVisitClientCommand() { 35 | input := antlr.NewInputStream("CLIENT") 36 | lexer := NewRedisLexer(input) 37 | stream := antlr.NewCommonTokenStream(lexer, 0) 38 | p := NewRedisParser(stream) 39 | p.BuildParseTrees = true 40 | 41 | //redisErrorStrategy := &RedisErrorStrategy{} 42 | //p.SetErrorHandler(redisErrorStrategy) 43 | redisErrorListener := &SuggestionRedisErrorListener{} 44 | p.RemoveErrorListeners() 45 | p.AddErrorListener(redisErrorListener) 46 | p.Command() 47 | 48 | sut.Assert().GreaterOrEqual(len(redisErrorListener.ExpectedTokens), 1) 49 | fmt.Println(redisErrorListener.ExpectedTokens) 50 | } 51 | 52 | func TestConnectionSuite(t *testing.T) { 53 | suite.Run(t, new(SuggestionRedisParserVisitorSuite)) 54 | } 55 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redis-web-manager", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@craco/craco": "^6.4.3", 7 | "@fluentui/react": "^8.44.0", 8 | "@fluentui/react-hooks": "^8.5.4", 9 | "@fluentui/react-icons-mdl2": "^1.2.8", 10 | "@fluentui/theme-samples": "^8.4.6", 11 | "@monaco-editor/react": "^4.4.5", 12 | "@types/lodash": "^4.14.170", 13 | "@types/node": "^16.7.13", 14 | "@types/react": "^17.0.20", 15 | "@types/react-dom": "^17.0.13", 16 | "@types/uuid": "^8.3.4", 17 | "dayjs": "^1.10.4", 18 | "i18next": "^20.2.1", 19 | "monaco-editor": "^0.33.0", 20 | "react": "^17.0.2", 21 | "react-dom": "^17.0.2", 22 | "react-i18next": "^11.8.13", 23 | "react-scripts": "5.0.0", 24 | "recharts": "^2.0.9", 25 | "rxjs": "^7.5.2", 26 | "typescript": "^4.4.2", 27 | "web-vitals": "^2.1.0" 28 | }, 29 | "devDependencies": { 30 | "@testing-library/jest-dom": "^5.11.4", 31 | "@testing-library/react": "^11.1.0", 32 | "@testing-library/user-event": "^12.1.10", 33 | "@types/jest": "^27.0.1", 34 | "monaco-editor-webpack-plugin": "^7.0.1" 35 | }, 36 | "scripts": { 37 | "start": "craco start", 38 | "build": "craco build && cp -r ./build/** ../desktop/web/build", 39 | "test": "craco test", 40 | "eject": "react-scripts eject" 41 | }, 42 | "eslintConfig": { 43 | "extends": [ 44 | "react-app", 45 | "react-app/jest" 46 | ] 47 | }, 48 | "browserslist": { 49 | "production": [ 50 | ">0.2%", 51 | "not dead", 52 | "not op_mini all" 53 | ], 54 | "development": [ 55 | "last 1 chrome version", 56 | "last 1 firefox version", 57 | "last 1 safari version" 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 25 | Redis Web Manager 26 | 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /desktop/web/build/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 25 | Redis Web Manager 26 | 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /web/src/components/utils.ts: -------------------------------------------------------------------------------- 1 | export const CONNECTIONS = "CONNECTIONS"; 2 | 3 | export const KeyTypes: { [index: string]: string } = { 4 | STRING: 'STRING', 5 | LIST: 'LIST', 6 | SET: 'SET', 7 | ZSET: 'ZSET', 8 | HASH: 'HASH', 9 | STREAM: 'STREAM' 10 | } 11 | 12 | export const ComponentType: { [index: string]: string } = { 13 | INFO: 'INFO', 14 | CONSOLE: 'CONSOLE', 15 | STRING: 'STRING', 16 | LIST: 'LIST', 17 | SET: 'SET', 18 | HASH: 'HASH', 19 | ZSET: 'ZSET', 20 | STREAM: 'STREAM' 21 | } 22 | 23 | export const parseInfo = (res: string) => { 24 | const infoObject: any = {}; 25 | const categories = res.split("#"); 26 | categories.forEach(c => { 27 | const lines = c.split("\r\n"), partObject: any = {}; 28 | if (lines[0].trim()) { 29 | var key = lines[0].trim() 30 | lines.forEach((e, i) => { 31 | const parts = lines[i].split(":"); 32 | if (parts[1]) { 33 | partObject[parts[0]] = parts[1]; 34 | } 35 | }); 36 | infoObject[key] = partObject; 37 | } 38 | }); 39 | return infoObject; 40 | } 41 | 42 | export const parseClient = (res: string) => { 43 | const lines = res.split("\n"), clients: Array = []; 44 | lines.forEach(c => { 45 | var client: any = {} 46 | c && c.split(" ").forEach(v => { 47 | var kv = v.split("="); 48 | if (kv && kv.length === 2) { 49 | client[kv[0]] = kv[1]; 50 | } 51 | }) 52 | Object.keys(client).length && clients.push(client) 53 | }) 54 | return clients; 55 | } 56 | 57 | export const parseArrayToObject = (arr: any) => { 58 | if (!Array.isArray(arr)) return {}; 59 | const obj: any = {}; 60 | [...Array(arr.length / 2)].forEach((_, i) => { 61 | const index = 2 * i; 62 | obj[arr[index]] = Array.isArray(arr[index + 1]) ? parseArrayToObject(arr[index + 1]) : arr[index + 1]; 63 | }) 64 | return obj; 65 | } -------------------------------------------------------------------------------- /api/parser/redis_error_strategy.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/antlr/antlr4/runtime/Go/antlr" 9 | ) 10 | 11 | type RedisErrorStrategy struct { 12 | antlr.DefaultErrorStrategy 13 | errorRecoveryMode bool 14 | Expects []string 15 | } 16 | 17 | func (s *RedisErrorStrategy) beginErrorCondition(recognizer antlr.Parser) { 18 | s.errorRecoveryMode = true 19 | } 20 | 21 | func (s *RedisErrorStrategy) ReportError(recognizer antlr.Parser, e antlr.RecognitionException) { 22 | // if we've already Reported an error and have not Matched a token 23 | // yet successfully, don't Report any errors. 24 | if s.InErrorRecoveryMode(recognizer) { 25 | return // don't Report spurious errors 26 | } 27 | s.beginErrorCondition(recognizer) 28 | 29 | switch t := e.(type) { 30 | default: 31 | fmt.Println("unknown recognition error type: " + reflect.TypeOf(e).Name()) 32 | // fmt.Println(e.stack) 33 | recognizer.NotifyErrorListeners(e.GetMessage(), e.GetOffendingToken(), e) 34 | case *antlr.NoViableAltException: 35 | s.ReportNoViableAlternative(recognizer, t) 36 | case *antlr.InputMisMatchException: 37 | s.ReportInputMisMatch(recognizer, t) 38 | case *antlr.FailedPredicateException: 39 | s.ReportFailedPredicate(recognizer, t) 40 | } 41 | } 42 | 43 | func (s *RedisErrorStrategy) ReportInputMisMatch(recognizer antlr.Parser, e *antlr.InputMisMatchException) { 44 | s.setExpects(recognizer) 45 | } 46 | 47 | func (s *RedisErrorStrategy) ReportMissingToken(recognizer antlr.Parser) { 48 | if s.InErrorRecoveryMode(recognizer) { 49 | return 50 | } 51 | s.beginErrorCondition(recognizer) 52 | s.setExpects(recognizer) 53 | } 54 | 55 | func (s *RedisErrorStrategy) setExpects(recognizer antlr.Parser) { 56 | expecting := s.GetExpectedTokens(recognizer) 57 | expectStr := expecting.StringVerbose(recognizer.GetLiteralNames(), recognizer.GetSymbolicNames(), true) 58 | if len(expectStr) > 0 { 59 | expectStr = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(expectStr, "{", ""), "}", ""), "'", "") 60 | } 61 | s.Expects = strings.Split(expectStr, ",") 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Main 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | # push: 9 | # branches: [ master ] 10 | # paths-ignore: 11 | # - '**/README.md' 12 | # - '**/*.yml' 13 | # pull_request: 14 | # branches: [ master ] 15 | 16 | # Allows you to run this workflow manually from the Actions tab 17 | workflow_dispatch: 18 | 19 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 20 | jobs: 21 | # This workflow contains a single job called "build" 22 | build-frontend: 23 | # The type of runner that the job will run on 24 | runs-on: ubuntu-latest 25 | 26 | # Steps represent a sequence of tasks that will be executed as part of the job 27 | steps: 28 | # Checks-out 29 | - uses: actions/checkout@v2 30 | # use node.js 31 | - uses: actions/setup-node@v2 32 | with: 33 | node-version: '14' 34 | - name: Cache Node.js modules 35 | uses: actions/cache@v2 36 | with: 37 | # npm cache files are stored in `~/.npm` on Linux/macOS 38 | path: ~/.npm 39 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 40 | - run: | 41 | cd ./web 42 | npm install 43 | npm run build 44 | # upload 45 | - uses: actions/upload-artifact@v2 46 | with: 47 | name: frontend-artifact 48 | path: ./web/build 49 | 50 | build-golang: 51 | name: Test Build Latest Release 52 | needs: build-frontend 53 | runs-on: ubuntu-latest 54 | steps: 55 | # checkout 56 | - uses: actions/checkout@v2 57 | # download 58 | - uses: actions/download-artifact@v2 59 | with: 60 | name: frontend-artifact 61 | path: ./web/build 62 | # go build 63 | - uses: actions/setup-go@v1 64 | with: 65 | go-version: 1.16 66 | - run: | 67 | go get -a 68 | go build -v 69 | ls 70 | 71 | -------------------------------------------------------------------------------- /web/src/lua.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 14 | 19 | 20 | 23 | 24 | -------------------------------------------------------------------------------- /web/src/components/info/PubSub.tsx: -------------------------------------------------------------------------------- 1 | import { DetailsList, DetailsListLayoutMode, SelectionMode, Toggle } from '@fluentui/react'; 2 | import React, { useCallback, useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Connection, executeCommand } from '../../services/connection.service'; 5 | import { ErrorMessageBar } from '../common/ErrorMessageBar'; 6 | 7 | export interface IPubSub { 8 | connection: Connection 9 | } 10 | 11 | export const PubSub = (props: IPubSub) => { 12 | const { t } = useTranslation(); 13 | 14 | const [autoRefresh, setAutoRefresh] = useState(false), 15 | [error, setError] = useState(), 16 | [items, setItems] = useState>([]); 17 | 18 | const pubsubList = useCallback((id) => { 19 | setError(undefined); 20 | executeCommand>>({ id: id, commands: [['PUBSUB', 'CHANNELS']] }) 21 | .then((ret) => { 22 | if (!ret || !ret.length) return; 23 | var channels = ret[0].map(v => { 24 | return { name: v } 25 | }) 26 | setItems(channels) 27 | }) 28 | .catch(err => setError(err)); 29 | }, []) 30 | 31 | useEffect(() => { 32 | pubsubList(props.connection.id); 33 | const timer = setInterval(() => { 34 | pubsubList(props.connection.id); 35 | }, 2000); 36 | if (!autoRefresh) { 37 | clearInterval(timer); 38 | }; 39 | return () => { 40 | clearInterval(timer) 41 | }; 42 | }, [props.connection, autoRefresh, pubsubList]) 43 | 44 | return (<> 45 | 46 | { setAutoRefresh(!autoRefresh) }} /> 47 | 56 | ) 57 | } -------------------------------------------------------------------------------- /.github/workflows/release-server.yml: -------------------------------------------------------------------------------- 1 | name: Release Server 2 | 3 | # 新tag则release 4 | on: 5 | push: 6 | tags: 7 | - 'v*server*' 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 13 | jobs: 14 | # This workflow contains a single job called "build" 15 | build-frontend: 16 | # The type of runner that the job will run on 17 | runs-on: ubuntu-latest 18 | 19 | # Steps represent a sequence of tasks that will be executed as part of the job 20 | steps: 21 | # Checks-out 22 | - uses: actions/checkout@v2 23 | # use node.js 24 | - uses: actions/setup-node@v2 25 | with: 26 | node-version: '14' 27 | - run: | 28 | cd ./web 29 | npm install 30 | npm run build 31 | - name: Upload frontend build 32 | uses: actions/upload-artifact@v2 33 | with: 34 | name: frontend-artifact 35 | path: ./web/build 36 | 37 | goreleaser: 38 | runs-on: ubuntu-latest 39 | needs: build-frontend 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v2 43 | with: 44 | fetch-depth: 0 45 | - name: Download frontend build 46 | uses: actions/download-artifact@v2 47 | with: 48 | name: frontend-artifact 49 | path: ./server/web/build 50 | 51 | - name: Set up Go 52 | uses: actions/setup-go@v2 53 | with: 54 | go-version: 1.19 55 | # - run: | 56 | # go get -a 57 | 58 | - name: Login to Docker Hub 59 | uses: docker/login-action@v1 60 | with: 61 | username: ${{ secrets.DOCKERHUB_USERNAME }} 62 | password: ${{ secrets.DOCKERHUB_TOKEN }} 63 | 64 | - name: Run GoReleaser 65 | uses: goreleaser/goreleaser-action@v2 66 | with: 67 | # either 'goreleaser' (default) or 'goreleaser-pro' 68 | distribution: goreleaser 69 | version: latest 70 | args: release --rm-dist 71 | workdir: ./server 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | -------------------------------------------------------------------------------- /desktop/build/README.md: -------------------------------------------------------------------------------- 1 | # Build Directory 2 | 3 | The build directory is used to house all the build files and assets for your application. 4 | 5 | The structure is: 6 | 7 | * bin - Output directory 8 | * dialog - Icons for dialogs 9 | * tray - Icons for the system tray 10 | * mac - MacOS specific files 11 | * linux - Linux specific files 12 | * windows - Windows specific files 13 | 14 | ## Dialog Icons 15 | 16 | Place any PNG file in this directory to be able to use them in message dialogs. 17 | The files should have names in the following format: `name[-(light|dark)][2x].png` 18 | 19 | Examples: 20 | 21 | * `mypic.png` - Standard definition icon with ID `mypic` 22 | * `mypic-light.png` - Standard definition icon with ID `mypic`, used when system theme is light 23 | * `mypic-dark.png` - Standard definition icon with ID `mypic`, used when system theme is dark 24 | * `mypic2x.png` - High definition icon with ID `mypic` 25 | * `mypic-light2x.png` - High definition icon with ID `mypic`, used when system theme is light 26 | * `mypic-dark2x.png` - High definition icon with ID `mypic`, used when system theme is dark 27 | 28 | ### Order of preference 29 | 30 | Icons are selected with the following order of preference: 31 | 32 | For High Definition displays: 33 | * name-(theme)2x.png 34 | * name2x.png 35 | * name-(theme).png 36 | * name.png 37 | 38 | For Standard Definition displays: 39 | * name-(theme).png 40 | * name.png 41 | 42 | ## Tray 43 | 44 | Place any PNG file in this directory to be able to use them as tray icons. 45 | The name of the filename will be the ID to reference the image. 46 | 47 | Example: 48 | 49 | * `mypic.png` - May be referenced using `runtime.Tray.SetIcon("mypic")` 50 | 51 | ## Mac 52 | 53 | The `darwin` directory holds files specific to Mac builds, such as `Info.plist`. 54 | These may be customised and used as part of the build. To return these files to the default state, simply delete them and 55 | build with the `-package` flag. 56 | 57 | ## Windows 58 | 59 | The `windows` directory contains the manifest and rc files used when building with the `-package` flag. 60 | These may be customised for your application. To return these files to the default state, simply delete them and 61 | build with the `-package` flag. -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /api/connection_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | jsoniter "github.com/json-iterator/go" 6 | "strconv" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/suite" 11 | ) 12 | 13 | type ConnectionSuite struct { 14 | suite.Suite 15 | } 16 | 17 | func (sut *ConnectionSuite) SetupTest() { 18 | 19 | } 20 | 21 | func (sut *ConnectionSuite) TestOpen() { 22 | id := strconv.FormatInt(time.Now().UnixNano(), 10) 23 | connection := &Connection{ 24 | ID: id, 25 | Host: "localhost", 26 | Port: 6379, 27 | } 28 | 29 | err := connection.Open() 30 | sut.ErrorIs(err, nil) 31 | sut.NotEqual(connection._client, nil) 32 | } 33 | 34 | func (sut *ConnectionSuite) TestConnection() { 35 | id := strconv.FormatInt(time.Now().UnixNano(), 10) 36 | connection := &Connection{ 37 | ID: id, 38 | Host: "localhost", 39 | Port: 6379, 40 | } 41 | err := connection.Open() 42 | sut.ErrorIs(err, nil) 43 | commands := [][]interface{}{ 44 | {"CONFIG", "GET", "*"}, 45 | {"INFO"}, 46 | } 47 | ret, err := connection.Command(commands) 48 | sut.ErrorIs(err, nil) 49 | var json = jsoniter.ConfigCompatibleWithStandardLibrary 50 | retJson, err := json.Marshal(ret) 51 | sut.ErrorIs(err, nil) 52 | fmt.Println(string(retJson)) 53 | } 54 | 55 | func (sut *ConnectionSuite) TestScripting() { 56 | id := strconv.FormatInt(time.Now().UnixNano(), 10) 57 | connection := &Connection{ 58 | ID: id, 59 | Host: "localhost", 60 | Port: 6379, 61 | } 62 | 63 | incrByScript := ` 64 | local key = KEYS[1] 65 | local change = ARGV[1] 66 | local value = redis.call("GET", key) 67 | if not value then 68 | value = 0 69 | end 70 | value = value + change 71 | redis.call("SET", key, value) 72 | return value 73 | ` 74 | // args 75 | var args = make([]interface{}, 0) 76 | args = append(args, 1) 77 | 78 | lua := &Lua{ 79 | ConnectionID: id, 80 | ID: id, 81 | Keys: []string{"my_counter"}, 82 | Args: args, 83 | Script: incrByScript, 84 | } 85 | 86 | err := connection.Scripting(lua) 87 | sut.ErrorIs(err, nil) 88 | sut.GreaterOrEqual(lua.Result, int64(1)) 89 | fmt.Println(fmt.Sprintf("%#v", lua)) 90 | fmt.Println(fmt.Sprintf("%+v", lua.Result)) 91 | } 92 | 93 | func TestConnectionSuite(t *testing.T) { 94 | suite.Run(t, new(ConnectionSuite)) 95 | } 96 | -------------------------------------------------------------------------------- /desktop/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/slowrookie/redis-web-manager 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/json-iterator/go v1.1.12 7 | github.com/slowrookie/redis-web-manager/api v0.0.0-20220525113429-86ac49708877 8 | github.com/stretchr/testify v1.8.1 9 | github.com/wailsapp/wails/v2 v2.2.0 10 | ) 11 | 12 | require ( 13 | github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220527190237-ee62e23da966 // indirect 14 | github.com/bep/debounce v1.2.1 // indirect 15 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 16 | github.com/davecgh/go-spew v1.1.1 // indirect 17 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 18 | github.com/go-ole/go-ole v1.2.6 // indirect 19 | github.com/google/uuid v1.3.0 // indirect 20 | github.com/imdario/mergo v0.3.13 // indirect 21 | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect 22 | github.com/labstack/echo/v4 v4.9.0 // indirect 23 | github.com/labstack/gommon v0.3.1 // indirect 24 | github.com/leaanthony/go-ansi-parser v1.4.0 // indirect 25 | github.com/leaanthony/gosod v1.0.3 // indirect 26 | github.com/leaanthony/slicer v1.6.0 // indirect 27 | github.com/mattn/go-colorable v0.1.12 // indirect 28 | github.com/mattn/go-isatty v0.0.14 // indirect 29 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 30 | github.com/modern-go/reflect2 v1.0.2 // indirect 31 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect 32 | github.com/pkg/errors v0.9.1 // indirect 33 | github.com/pmezard/go-difflib v1.0.0 // indirect 34 | github.com/redis/go-redis/v9 v9.0.3 // indirect 35 | github.com/rivo/uniseg v0.2.0 // indirect 36 | github.com/samber/lo v1.27.1 // indirect 37 | github.com/tkrajina/go-reflector v0.5.6 // indirect 38 | github.com/valyala/bytebufferpool v1.0.0 // indirect 39 | github.com/valyala/fasttemplate v1.2.1 // indirect 40 | github.com/wailsapp/mimetype v1.4.1 // indirect 41 | golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect 42 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect 43 | golang.org/x/net v0.7.0 // indirect 44 | golang.org/x/sys v0.5.0 // indirect 45 | golang.org/x/text v0.7.0 // indirect 46 | gopkg.in/yaml.v3 v3.0.1 // indirect 47 | ) 48 | 49 | replace github.com/slowrookie/redis-web-manager/api => ../api 50 | -------------------------------------------------------------------------------- /web/src/components/info/Client.tsx: -------------------------------------------------------------------------------- 1 | import { DetailsList, DetailsListLayoutMode, SelectionMode, Toggle } from '@fluentui/react'; 2 | import React, { useCallback, useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Connection, executeCommand } from '../../services/connection.service'; 5 | import { ErrorMessageBar } from '../common/ErrorMessageBar'; 6 | import { parseClient } from '../utils'; 7 | 8 | export interface IClientProps { 9 | connection: Connection 10 | } 11 | 12 | export const Client = (props: IClientProps) => { 13 | const { connection } = props, 14 | { t } = useTranslation(); 15 | 16 | const [autoRefresh, setAutoRefresh] = useState(false), 17 | [error, setError] = useState(), 18 | [items, setItems] = useState>([]); 19 | 20 | const clientList = useCallback((id) => { 21 | setError(undefined); 22 | executeCommand>({ id: id, commands: [['CLIENT', 'LIST']] }).then((ret) => { 23 | if (!ret || !ret.length) return; 24 | setItems(parseClient(ret[0])) 25 | }) 26 | .catch(err => setError(err)); 27 | }, []) 28 | 29 | useEffect(() => { 30 | clientList(connection.id); 31 | const timer = setInterval(() => { 32 | clientList(connection.id); 33 | }, 2000); 34 | if (!autoRefresh) { 35 | clearInterval(timer); 36 | }; 37 | return () => clearInterval(timer); 38 | }, [connection, autoRefresh, clientList]) 39 | 40 | return (<> 41 | 42 | { setAutoRefresh(!autoRefresh) }} /> 43 | 56 | ) 57 | } -------------------------------------------------------------------------------- /web/src/themes.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from "@fluentui/style-utilities"; 2 | import { Theme } from "@fluentui/theme"; 3 | import { DefaultTheme, DarkTheme, WordTheme, TeamsTheme } from '@fluentui/theme-samples'; 4 | 5 | DarkTheme.name = 'dark'; 6 | 7 | export const themes: { [index: string]: Theme } = { 8 | default: DefaultTheme, 9 | light: DefaultTheme, 10 | dark: DarkTheme, 11 | word: WordTheme, 12 | teams: TeamsTheme, 13 | customDark: createTheme({ 14 | palette: { 15 | themePrimary: '#007acc', 16 | themeLighterAlt: '#f3f9fd', 17 | themeLighter: '#cfe7f7', 18 | themeLight: '#a8d3f0', 19 | themeTertiary: '#5aabe0', 20 | themeSecondary: '#1988d2', 21 | themeDarkAlt: '#006eb8', 22 | themeDark: '#005d9b', 23 | themeDarker: '#004572', 24 | neutralLighterAlt: '#282828', 25 | neutralLighter: '#313131', 26 | neutralLight: '#3f3f3f', 27 | neutralQuaternaryAlt: '#484848', 28 | neutralQuaternary: '#4f4f4f', 29 | neutralTertiaryAlt: '#6d6d6d', 30 | neutralTertiary: '#3d3d3d', 31 | neutralSecondary: '#7a7a7a', 32 | neutralPrimaryAlt: '#b4b4b4', 33 | neutralPrimary: '#cccccc', 34 | neutralDark: '#d8d8d8', 35 | black: '#e2e2e2', 36 | white: '#1e1e1e', 37 | } 38 | }), 39 | CustomLight: createTheme({ 40 | palette: { 41 | themePrimary: '#0078d4', 42 | themeLighterAlt: '#eff6fc', 43 | themeLighter: '#deecf9', 44 | themeLight: '#c7e0f4', 45 | themeTertiary: '#71afe5', 46 | themeSecondary: '#2b88d8', 47 | themeDarkAlt: '#106ebe', 48 | themeDark: '#005a9e', 49 | themeDarker: '#004578', 50 | neutralLighterAlt: '#faf9f8', 51 | neutralLighter: '#f3f2f1', 52 | neutralLight: '#edebe9', 53 | neutralQuaternaryAlt: '#e1dfdd', 54 | neutralQuaternary: '#d0d0d0', 55 | neutralTertiaryAlt: '#c8c6c4', 56 | neutralTertiary: '#a19f9d', 57 | neutralSecondary: '#605e5c', 58 | neutralPrimaryAlt: '#3b3a39', 59 | neutralPrimary: '#323130', 60 | neutralDark: '#201f1e', 61 | black: '#000000', 62 | white: '#ffffff', 63 | } 64 | }) 65 | } 66 | 67 | export const getCustomTheme = (theme: string) => { 68 | theme = theme || 'light' 69 | const th = themes[theme] 70 | th.fonts.medium.fontSize = 12; 71 | return th 72 | } -------------------------------------------------------------------------------- /api/storage_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "runtime" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/suite" 11 | ) 12 | 13 | // Reacord is the test struct 14 | type Record struct { 15 | Id string `json:"id"` 16 | Name string `json:"name"` 17 | Email string `json:"email"` 18 | } 19 | 20 | type StorageSuite struct { 21 | suite.Suite 22 | storage *Storage 23 | } 24 | 25 | func (sut *StorageSuite) SetupTest() { 26 | const project = "com.github.slowrookie.redis-web-manager" 27 | 28 | var dir string 29 | 30 | switch runtime.GOOS { 31 | case "windows": 32 | dir = fmt.Sprintf("%s/%s", os.Getenv("APPDATA"), project) 33 | case "darwin": 34 | dir = fmt.Sprintf("%s/Library/Containers/%s", os.Getenv("HOME"), project) 35 | case "linux": 36 | dir = fmt.Sprintf("%s/.%s", os.Getenv("HOME"), project) 37 | } 38 | 39 | sut.storage = &Storage{dir: path.Join(dir, "storage")} 40 | } 41 | 42 | func (sut *StorageSuite) TestWrite() { 43 | err := sut.storage.Write("records", "one record", Record{Id: "123", Name: "slowrookie", Email: "liujiaxingemail@gmail.com"}) 44 | sut.ErrorIs(err, nil) 45 | sut.storage.Delete("records", "one record") 46 | } 47 | 48 | func (sut *StorageSuite) TestRead() { 49 | err := sut.storage.Write("records", "one record", Record{Id: "123", Name: "slowrookie", Email: "liujiaxingemail@gmail.com"}) 50 | sut.ErrorIs(err, nil) 51 | 52 | var record = Record{} 53 | err = sut.storage.Read("records", "one record", &record) 54 | sut.ErrorIs(err, nil) 55 | sut.Equal("123", record.Id) 56 | 57 | sut.storage.Delete("records", "one record") 58 | } 59 | 60 | func (sut *StorageSuite) TestReadAll() { 61 | nums := []int{1, 2, 3, 4, 5} 62 | for _, n := range nums { 63 | err := sut.storage.Write("records", fmt.Sprintf("record%d", n), Record{Id: fmt.Sprintf("%d", n), Name: "slowrookie", Email: "liujiaxingemail@gmail.com"}) 64 | sut.ErrorIs(err, nil) 65 | } 66 | 67 | var byts [][]byte 68 | err := sut.storage.ReadAll("records", &byts) 69 | 70 | var records []Record 71 | // for _, bts := range byts { 72 | // var record = Record{} 73 | // json.Unmarshal(bts, &record) 74 | // records = append(records, record) 75 | // } 76 | 77 | RecordsToStruct(byts, &records) 78 | 79 | sut.ErrorIs(err, nil) 80 | sut.GreaterOrEqual(len(nums), len(records)) 81 | } 82 | 83 | func TestStorageSuite(t *testing.T) { 84 | suite.Run(t, new(StorageSuite)) 85 | } 86 | -------------------------------------------------------------------------------- /web/src/components/settings/AboutDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, Image, Stack, Text } from '@fluentui/react'; 2 | import React, { useEffect, useState } from 'react'; 3 | import logo from '../../logo.svg'; 4 | import { About, about } from '../../services/config.service'; 5 | 6 | export interface IAboutDialogProps { 7 | hidden: boolean 8 | onDismiss: () => void 9 | } 10 | 11 | export const AboutDialog = (props: IAboutDialogProps) => { 12 | 13 | const [aboutData, setAboutData] = useState(); 14 | 15 | useEffect(() => { 16 | about().then((ret) => { 17 | if (!ret) return; 18 | setAboutData(ret) 19 | }) 20 | .finally(() => { }) 21 | }, []); 22 | 23 | return (<> 24 | 64 | ) 65 | 66 | } -------------------------------------------------------------------------------- /web/src/components/common/DragSlider.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, useTheme } from '@fluentui/react'; 2 | import React, { CSSProperties, MouseEvent, useEffect, useRef, useState } from 'react'; 3 | 4 | const MIN_WIDTH = 230; 5 | 6 | const DragSliderStyle: CSSProperties = { 7 | display: 'flex', 8 | height: '100%', 9 | width: 4, 10 | flexDirection: 'column-reverse', 11 | cursor: 'col-resize' 12 | } 13 | 14 | export interface IDragSliderProps { 15 | children: any 16 | } 17 | 18 | // Dragslider 19 | export const DragSlider = (props: IDragSliderProps) => { 20 | const theme = useTheme(); 21 | 22 | const [dragSliderStyle, setDragSliderStyle] = useState({ ...DragSliderStyle, background: theme.palette.neutralLight }), 23 | [moving, setMoving] = useState(false), 24 | [lastWidth, setLastWidth] = useState(MIN_WIDTH), 25 | _selfRef = useRef(null); 26 | 27 | useEffect(() => { 28 | const handleMousemove = (e: globalThis.MouseEvent): any => { 29 | if (!_selfRef || !_selfRef.current) { 30 | return; 31 | } 32 | const diff = e.clientX - _selfRef.current.getBoundingClientRect().x; 33 | diff > MIN_WIDTH && setLastWidth(diff); 34 | } 35 | 36 | if (moving) { 37 | window.addEventListener('mousemove', handleMousemove); 38 | } else { 39 | window.removeEventListener('mousemove', handleMousemove); 40 | } 41 | 42 | return (() => { 43 | window.removeEventListener('mousemove', handleMousemove); 44 | }) 45 | }, [moving, _selfRef]) 46 | 47 | // change style 48 | const onToggleHover = () => { 49 | setDragSliderStyle({ 50 | ...dragSliderStyle, 51 | background: dragSliderStyle.background === theme.palette.neutralQuaternaryAlt ? theme.palette.neutralLight : theme.palette.neutralQuaternaryAlt, 52 | }) 53 | } 54 | 55 | const onMouseDown = (e: MouseEvent) => { 56 | e.stopPropagation(); 57 | setMoving(true); 58 | } 59 | 60 | const onMouseUp = (e: MouseEvent) => { 61 | e.stopPropagation(); 62 | setMoving(false); 63 | } 64 | 65 | return ( 66 |
67 | 68 |
{props.children}
69 |
74 |
75 |
76 | ); 77 | } -------------------------------------------------------------------------------- /web/src/services/connection.service.ts: -------------------------------------------------------------------------------- 1 | import { defaultService } from "./api.service"; 2 | 3 | 4 | export interface Connection { 5 | id: string, 6 | name: string, 7 | host: string, 8 | port: number, 9 | addrs?: Array, 10 | username: string, 11 | auth: string, 12 | keysPattern: string, 13 | namespaceSeparator: string, 14 | timeoutConnect: number, 15 | timeoutExecute: number, 16 | dbScanLimit: number, 17 | dataScanLimit: number, 18 | tls?: { 19 | enable: boolean 20 | cert?: string 21 | key?: string 22 | ca?: string 23 | }, 24 | ssh?: { 25 | enable: boolean 26 | user?: string 27 | password?: string 28 | privateKey?: string 29 | host?: string 30 | port?: number 31 | }, 32 | isCluster?: boolean 33 | isSentinel?: boolean 34 | sentinelPassword?: string 35 | masterName?: string 36 | routeByLatency?: boolean 37 | routeRandomly?: boolean 38 | } 39 | export interface Command { 40 | id: string, 41 | commands: Array 42 | } 43 | 44 | export const getConnections = (): Promise => { 45 | return defaultService.request({ method: 'Connections' }) 46 | } 47 | 48 | export const testConnection = (connection: Connection): Promise => { 49 | return defaultService.request({ method: 'TestConnection', params: connection }); 50 | } 51 | 52 | export const saveConnection = (connection: Connection): Promise => { 53 | return defaultService.request({ method: connection.id ? 'EditConnection' : 'NewConnection', params: connection }); 54 | } 55 | 56 | export const deleteConnection = (id: string): Promise => { 57 | return defaultService.request({ method: 'DeleteConnection', params: id }); 58 | } 59 | 60 | export const openConnection = (id: string): Promise<{ database: Array, info: string }> => { 61 | return defaultService.request({ method: 'OpenConnection', params: id }); 62 | } 63 | 64 | export const disconnectionConnection = (id: string): Promise => { 65 | return defaultService.request({ method: 'DisConnection', params: id }); 66 | } 67 | 68 | export const copyConnection = (connection: Connection): Promise => { 69 | return defaultService.request({ method: 'NewConnection', params: connection }); 70 | } 71 | 72 | export const executeCommand = (command: Command): Promise => { 73 | return defaultService.request({ method: 'CommandConnection', params: command }).then(v => JSON.parse(v)); 74 | } 75 | 76 | export const suggestions = (command: Command): Promise => { 77 | return defaultService.request({ method: 'Suggestions', params: command }); 78 | } -------------------------------------------------------------------------------- /web/src/components/KeyList.tsx: -------------------------------------------------------------------------------- 1 | import { CheckboxVisibility, DetailsList, DetailsListLayoutMode, Selection, SelectionMode, TooltipHost } from '@fluentui/react'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Subject } from 'rxjs'; 4 | import { Connection, executeCommand } from '../services/connection.service'; 5 | import { ErrorMessageBar } from './common/ErrorMessageBar'; 6 | import { ComponentType } from './utils'; 7 | 8 | export interface IKeyListProps { 9 | connection: Connection 10 | db: number 11 | keys: Array, 12 | onSelectedKey: (type: string, keyName: string) => void, 13 | onSelectedKeys?: (keys: Array) => void 14 | } 15 | 16 | export const KeyList = (props: IKeyListProps) => { 17 | const { connection, db, keys, onSelectedKey, onSelectedKeys } = props; 18 | const [error, setError] = useState(); 19 | 20 | const [SelectionEvent] = useState(new Subject>()); 21 | 22 | useEffect(() => { 23 | const sub = SelectionEvent.subscribe((keys: Array) => { 24 | onSelectedKeys && onSelectedKeys(keys); 25 | const key = keys[0]; 26 | setError(undefined); 27 | executeCommand>({ id: connection.id, commands: [['SELECT', db], ['TYPE', key]] }) 28 | .then((ret) => { 29 | if (!ret || !ret.length) return; 30 | onSelectedKey(ComponentType[ret[1].toUpperCase()], key); 31 | }) 32 | .catch(err => setError(err)) 33 | .finally(() => { }); 34 | }) 35 | return () => sub && sub.unsubscribe(); 36 | }, [db, connection.id, onSelectedKey, onSelectedKeys, SelectionEvent]) 37 | 38 | const selection = new Selection({ 39 | onSelectionChanged: () => { 40 | selection.getSelection().length && SelectionEvent.next((selection.getSelection() as Array)); 41 | } 42 | }) 43 | 44 | return (<> 45 | {/* error */} 46 | 47 | {/* {list} */} 48 | { 59 | return 60 | {item} 61 | 62 | } 63 | }]} 64 | items={[...keys]} /> 65 | ) 66 | } -------------------------------------------------------------------------------- /web/src/components/ZsetKey.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@fluentui/react'; 2 | import React, { useCallback, useEffect, useRef, useState } from 'react'; 3 | import { Connection, executeCommand } from '../services/connection.service'; 4 | import { ErrorMessageBar } from './common/ErrorMessageBar'; 5 | import { KeyHeader } from './KeyHeader'; 6 | import { ZsetKeyCursor } from './ZsetKeyCursor'; 7 | import { ZsetKeyIndex } from './ZsetKeyIndex'; 8 | 9 | export interface IZsetKeyProps { 10 | connection: Connection 11 | db: number 12 | component: string, 13 | keyName: string, 14 | onKeyNameChanged: (oldKeyName: string, keyName: string) => void 15 | onDeletedKey: (keyName: string) => void 16 | } 17 | 18 | export interface IZsetKeyItem { 19 | row: number, 20 | value: string, 21 | score: string 22 | } 23 | 24 | const DefaultZsetKeyProps = { 25 | keyName: '', 26 | TTL: -1, 27 | } 28 | 29 | export const ZsetKey = (props: IZsetKeyProps) => { 30 | 31 | const { connection, db, keyName, onKeyNameChanged } = props; 32 | 33 | const childRef = useRef(); 34 | 35 | const [keyProps, setKeyProps] = useState({ ...DefaultZsetKeyProps, keyName }), 36 | [error, setError] = useState(), 37 | [searchType, setSearchType] = useState('CURSOR'); 38 | 39 | const load = useCallback(() => { 40 | setError(undefined); 41 | executeCommand>({ 42 | id: connection.id, commands: [ 43 | ['SELECT', db], 44 | ['TTL', keyProps.keyName], 45 | ] 46 | }).then((ret) => { 47 | if (!ret || !ret.length) return; 48 | setKeyProps(kprop => { return { ...kprop, TTL: ret[1] } }) 49 | }) 50 | .catch(err => setError(err)) 51 | }, [connection.id, db, keyProps.keyName]); 52 | 53 | useEffect(() => { 54 | load(); 55 | }, [load]) 56 | 57 | return ( 58 | 59 | {/* error */} 60 | 61 | 62 | { 64 | setKeyProps({ ...keyProps, keyName: newValue }); 65 | onKeyNameChanged(oldValue, newValue); 66 | }} 67 | onTTLChanged={(v) => { setKeyProps({ ...keyProps, TTL: v }) }} 68 | onRefresh={() => { childRef && childRef.current && childRef.current.refresh(); load(); }} /> 69 | 70 | {searchType === 'INDEX' && setSearchType(v)} childRef={childRef} />} 71 | {searchType === 'CURSOR' && setSearchType(v)} childRef={childRef} />} 72 | 73 | 74 | ) 75 | } -------------------------------------------------------------------------------- /desktop/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "github.com/wailsapp/wails/v2/pkg/options/assetserver" 7 | "io" 8 | "log" 9 | "os" 10 | "path" 11 | 12 | "github.com/slowrookie/redis-web-manager/api" 13 | _ "github.com/slowrookie/redis-web-manager/api" 14 | "github.com/wailsapp/wails/v2" 15 | "github.com/wailsapp/wails/v2/pkg/logger" 16 | "github.com/wailsapp/wails/v2/pkg/options" 17 | "github.com/wailsapp/wails/v2/pkg/options/mac" 18 | "github.com/wailsapp/wails/v2/pkg/options/windows" 19 | ) 20 | 21 | var ( 22 | version = "Development" 23 | commit = "Development" 24 | date = "Now" 25 | builtBy = "Development" 26 | MODE = "Debug" 27 | ) 28 | 29 | //go:embed web/build 30 | var assets embed.FS 31 | 32 | //go:embed build/appicon.png 33 | var icon []byte 34 | 35 | func main() { 36 | // log 37 | f, err := os.OpenFile(path.Join(api.AppRoot, "rwm.log"), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 38 | if err != nil { 39 | log.Fatalf("error opening file: %v", err) 40 | } 41 | defer func(f *os.File) { 42 | _ = f.Close() 43 | }(f) 44 | log.SetOutput(io.MultiWriter(os.Stdout, f)) 45 | log.Println(fmt.Sprintf("Root Path: %s", api.AppRoot)) 46 | 47 | app := NewApp() 48 | var about = make(map[string]string) 49 | about["version"] = version 50 | about["commit"] = commit 51 | about["date"] = date 52 | about["builtBy"] = builtBy 53 | about["environment"] = MODE 54 | app.About = about 55 | 56 | err = wails.Run(&options.App{ 57 | Title: "Redis Web Manager", 58 | Width: 1024, 59 | Height: 800, 60 | MinWidth: 720, 61 | MinHeight: 570, 62 | DisableResize: false, 63 | Fullscreen: false, 64 | Frameless: false, 65 | StartHidden: false, 66 | HideWindowOnClose: false, 67 | BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 255}, 68 | AssetServer: &assetserver.Options{Assets: assets}, 69 | LogLevel: logger.DEBUG, 70 | OnStartup: app.startup, 71 | OnDomReady: app.domReady, 72 | OnShutdown: app.shutdown, 73 | Bind: []interface{}{ 74 | app, 75 | }, 76 | // Windows platform specific options 77 | Windows: &windows.Options{ 78 | WebviewIsTransparent: true, 79 | WindowIsTranslucent: true, 80 | DisableWindowIcon: false, 81 | }, 82 | Mac: &mac.Options{ 83 | TitleBar: mac.TitleBarDefault(), 84 | WebviewIsTransparent: true, 85 | WindowIsTranslucent: true, 86 | About: &mac.AboutInfo{ 87 | Title: "Redis Web Manager", 88 | Message: "Redis Web Manager is a desktop application developed with React & Golang, used to manage Redis, and supports multi-platform operation.", 89 | Icon: icon, 90 | }, 91 | }, 92 | }) 93 | 94 | if err != nil { 95 | log.Fatal(err) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /web/src/components/info/SlowLog.tsx: -------------------------------------------------------------------------------- 1 | import { DetailsList, DetailsListLayoutMode, SelectionMode, Toggle } from '@fluentui/react'; 2 | import React, { useCallback, useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Connection, executeCommand } from '../../services/connection.service'; 5 | import { ErrorMessageBar } from '../common/ErrorMessageBar'; 6 | 7 | export interface ISlowLogProps { 8 | connection: Connection 9 | } 10 | 11 | export const SlowLog = (props: ISlowLogProps) => { 12 | const { t } = useTranslation(); 13 | 14 | const [autoRefresh, setAutoRefresh] = useState(false), 15 | [error, setError] = useState(), 16 | [items, setItems] = useState>([]); 17 | 18 | const slowlogList = useCallback((id) => { 19 | setError(undefined); 20 | executeCommand>>({ id: id, commands: [['SLOWLOG', 'GET']] }) 21 | .then((ret) => { 22 | if (!ret || !ret.length) return; 23 | var logs = ret[0].map(v => { 24 | return { 25 | time: v[1], 26 | duration: v[2], 27 | command: v[3].join(' '), 28 | addr: v[4], 29 | } 30 | }) 31 | setItems(logs) 32 | }) 33 | .catch(err => setError(err)); 34 | }, []) 35 | 36 | useEffect(() => { 37 | slowlogList(props.connection.id); 38 | const timer = setInterval(() => { 39 | slowlogList(props.connection.id); 40 | }, 2000); 41 | if (!autoRefresh) { 42 | clearInterval(timer); 43 | }; 44 | return () => { 45 | clearInterval(timer) 46 | }; 47 | }, [props.connection, autoRefresh, slowlogList]) 48 | 49 | return (<> 50 | 51 | { setAutoRefresh(!autoRefresh) }} /> 52 | 64 | ) 65 | } -------------------------------------------------------------------------------- /web/src/components/HashKeySearch.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton, Callout, IconButton, Stack, TextField, TooltipHost, useTheme } from '@fluentui/react'; 2 | import React, { KeyboardEvent, MouseEvent, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | export interface IHashKeySearchProps { 6 | id: string 7 | onSearch: (pattern: string, count: number) => void 8 | length: number 9 | defaultMatchPattern: string 10 | defaultCount: number 11 | } 12 | 13 | export const HashKeySearch = (props: IHashKeySearchProps) => { 14 | const { id, onSearch, length, defaultMatchPattern, defaultCount } = props; 15 | 16 | const theme = useTheme(), 17 | { t } = useTranslation(); 18 | 19 | const textFieldStyles = { 20 | fieldGroup: { borderColor: theme.palette.neutralQuaternaryAlt } 21 | }; 22 | 23 | const [searchVisible, setSearchVisible] = useState(false), 24 | [condition, setCondition] = useState({ pattern: defaultMatchPattern, count: defaultCount, length }); 25 | 26 | const handleSearch = (ev: MouseEvent | KeyboardEvent) => { 27 | ev.preventDefault(); 28 | condition.pattern && condition.count && onSearch(condition.pattern, condition.count); 29 | setSearchVisible(false); 30 | } 31 | 32 | return (
33 | 34 | setSearchVisible(true)} /> 35 | 36 | 37 | {searchVisible && setSearchVisible(false)} setInitialFocus={true}> 38 | 39 | 40 | setCondition({ ...condition, pattern: v || '' })} 42 | onKeyDown={(ev: KeyboardEvent) => { 43 | if (ev.key === 'Enter') { 44 | handleSearch(ev); 45 | } 46 | }} /> 47 | 48 | { setCondition({ ...condition, count: v ? Number(v) : 0 }); }} 50 | onKeyDown={(ev: KeyboardEvent) => { 51 | if (ev.key === 'Enter') { 52 | handleSearch(ev); 53 | } 54 | }} 55 | /> 56 | 57 | 58 | 59 | setSearchVisible(false)} /> 60 | 61 | 62 | 63 | 64 | 65 | 66 | } 67 |
) 68 | } -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Theme, ThemeProvider } from '@fluentui/react'; 2 | import React, { useEffect, useMemo, useState } from 'react'; 3 | import { I18nextProvider } from 'react-i18next'; 4 | import './App.css'; 5 | import { ErrorMessageBar } from './components/common/ErrorMessageBar'; 6 | import { Loading } from './components/common/Loading'; 7 | import { MainTab } from './components/MainTab'; 8 | import { ConfigEvent, ConfigEventAction, IConfigEvent } from './events/ConfigEvent'; 9 | import i18n from './i18n'; 10 | import './RegisterIcons'; 11 | import { Config, getConfig, setConfig } from './services/config.service'; 12 | import { getCustomTheme } from './themes'; 13 | 14 | function App() { 15 | const [_config, _setConfig] = useState({ theme: 'light', language: navigator.language}), 16 | [theme, setTheme] = useState(getCustomTheme('light')), 17 | [error, setError] = useState(), 18 | [loading, setLoading] = useState(true); 19 | 20 | useEffect(() => { 21 | setLoading(true); 22 | getConfig().then((ret) => { 23 | if (!ret) return; 24 | _setConfig({ ...ret }); 25 | setTheme(getCustomTheme(ret.theme)); 26 | }) 27 | .catch(setError) 28 | .finally(() => { 29 | setLoading(false); 30 | }) 31 | }, []); 32 | 33 | useEffect(() => { 34 | const sub = ConfigEvent.subscribe((v: IConfigEvent) => { 35 | switch (v.action) { 36 | case ConfigEventAction.Language: 37 | _setConfig(c => { 38 | c.language = v.params; 39 | saveConfig(c); 40 | return c; 41 | }) 42 | i18n.changeLanguage(v.params); 43 | break; 44 | case ConfigEventAction.Theme: 45 | _setConfig(c => { 46 | c.theme = v.params; 47 | saveConfig(c); 48 | return c; 49 | }) 50 | setTheme(getCustomTheme(v.params)); 51 | break; 52 | default: 53 | break; 54 | } 55 | }) 56 | return () => sub && sub.unsubscribe(); 57 | }, [_config]) 58 | 59 | const saveConfig = (conf: Config) => { 60 | setLoading(true); 61 | setConfig(conf).catch(setError).finally(() => { 62 | setLoading(false); 63 | }) 64 | } 65 | 66 | const mainTab = useMemo(() => ( 67 | 68 | ), [_config]) 69 | 70 | return (<> 71 | 72 | 73 | {/* loading */} 74 | 75 | {/* error */} 76 | 77 | {/* mainTab */} 78 | {mainTab} 79 | {/* */} 84 | 85 | 86 | ); 87 | } 88 | 89 | export default App; 90 | -------------------------------------------------------------------------------- /web/src/components/common/FormatTextField.tsx: -------------------------------------------------------------------------------- 1 | import { Dropdown, IDropdownOption, ITextFieldProps, Label, Stack, Text, useTheme } from '@fluentui/react'; 2 | import Editor, {loader} from '@monaco-editor/react'; 3 | import * as monaco from 'monaco-editor'; 4 | import { FormEvent, useEffect, useRef, useState } from 'react'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | loader.config({monaco}) 8 | export interface IFormatTextFieldProps extends ITextFieldProps { 9 | value: string 10 | label: string 11 | onChange: (e: FormEvent, v?: string) => void 12 | length?: number 13 | actions?: any 14 | } 15 | 16 | export const FormatTextField = (props: IFormatTextFieldProps) => { 17 | const { length, label, onChange, actions } = props; 18 | const theme = useTheme(); 19 | const { t } = useTranslation(); 20 | 21 | const editorRef = useRef(null), 22 | [_length, _setLength] = useState(), 23 | [contentType, setContentType] = useState('text'); 24 | 25 | useEffect(() => { 26 | _setLength(length || 0); 27 | }, [length]) 28 | 29 | return ( 30 | 31 | 32 | 33 | 34 | {_length && {`${t('Size')} ${_length} bytes`}} 35 | 36 | , option?: IDropdownOption) => { 42 | option && setContentType(`${option.key}`) 43 | }} 44 | /> 45 | {actions && actions} 46 | 47 | 48 | 49 | { 56 | onChange && onChange(e, v); 57 | }} 58 | onMount={(editor, monaco) => { 59 | editor.updateOptions({ 60 | minimap: { enabled: false }, 61 | lineNumbers: 'off', 62 | glyphMargin: false, 63 | folding: false, 64 | lineDecorationsWidth: 0, 65 | scrollbar: { 66 | verticalScrollbarSize: 5, 67 | horizontalScrollbarSize: 5, 68 | }, 69 | }); 70 | editorRef.current = editor; 71 | }} 72 | /> 73 | 74 | 75 | 76 | ) 77 | } -------------------------------------------------------------------------------- /web/src/components/SetKeySearch.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton, Callout, IconButton, Stack, TextField, TooltipHost, useTheme } from '@fluentui/react'; 2 | import React, { KeyboardEvent, MouseEvent, useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | export interface ISetKeySearchProps { 6 | id: string 7 | onSearch: (pattern: string, count: number) => void 8 | length: number 9 | defaultMatchPattern: string 10 | defaultCount: number 11 | } 12 | 13 | export const SetKeySearch = (props: ISetKeySearchProps) => { 14 | const { id, onSearch, length, defaultMatchPattern, defaultCount } = props; 15 | 16 | const theme = useTheme(), 17 | { t } = useTranslation(); 18 | 19 | const textFieldStyles = { 20 | fieldGroup: { borderColor: theme.palette.neutralQuaternaryAlt } 21 | }; 22 | 23 | const [searchVisible, setSearchVisible] = useState(false), 24 | [condition, setCondition] = useState({ pattern: defaultMatchPattern, count: defaultCount, length }); 25 | 26 | useEffect(() => { 27 | setCondition(condition => { 28 | return { ...condition, length: props.length }; 29 | }) 30 | }, [props]) 31 | 32 | const handleSearch = (ev: MouseEvent | KeyboardEvent) => { 33 | ev.preventDefault(); 34 | onSearch(condition.pattern, condition.count); 35 | setSearchVisible(false); 36 | } 37 | 38 | return (
39 | 40 | setSearchVisible(true)} /> 41 | 42 | 43 | {searchVisible && setSearchVisible(false)} setInitialFocus={true}> 44 | 45 | 46 | setCondition({ ...condition, pattern: v || '' })} 48 | onKeyDown={(ev: KeyboardEvent) => { 49 | if (ev.key === 'Enter') { 50 | handleSearch(ev); 51 | } 52 | }} /> 53 | 54 | { setCondition({ ...condition, count: Number(v) }); }} 56 | onKeyDown={(ev: KeyboardEvent) => { 57 | if (ev.key === 'Enter') { 58 | handleSearch(ev); 59 | } 60 | }} 61 | /> 62 | 63 | 64 | 65 | setSearchVisible(false)} /> 66 | 67 | 68 | 69 | 70 | 71 | 72 | } 73 |
) 74 | } -------------------------------------------------------------------------------- /web/src/components/ZsetKeyCursorSearch.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton, Callout, IconButton, Stack, TextField, TooltipHost, useTheme } from '@fluentui/react'; 2 | import React, { KeyboardEvent, MouseEvent, useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | export interface IZsetKeyCursorProps { 6 | id: string 7 | onSearch: (pattern: string, count: number) => void 8 | length: number 9 | defaultMatchPattern: string 10 | defaultCount: number 11 | } 12 | 13 | export const ZsetKeySearch = (props: IZsetKeyCursorProps) => { 14 | const { id, onSearch, length, defaultMatchPattern, defaultCount } = props; 15 | 16 | const theme = useTheme(), 17 | { t } = useTranslation(); 18 | 19 | const textFieldStyles = { 20 | fieldGroup: { borderColor: theme.palette.neutralQuaternaryAlt } 21 | }; 22 | 23 | const [searchVisible, setSearchVisible] = useState(false), 24 | [condition, setCondition] = useState({ pattern: defaultMatchPattern, count: defaultCount, length }); 25 | 26 | useEffect(() => { 27 | setCondition(condition => { 28 | return { ...condition, length: props.length }; 29 | }) 30 | }, [props]) 31 | 32 | const handleSearch = (ev: MouseEvent | KeyboardEvent) => { 33 | ev.preventDefault(); 34 | onSearch(condition.pattern, condition.count); 35 | setSearchVisible(false); 36 | } 37 | 38 | return (
39 | 40 | setSearchVisible(true)} /> 41 | 42 | 43 | {searchVisible && setSearchVisible(false)} setInitialFocus={true}> 44 | 45 | 46 | setCondition({ ...condition, pattern: v || '' })} 48 | onKeyDown={(ev: KeyboardEvent) => { 49 | if (ev.key === 'Enter') { 50 | handleSearch(ev); 51 | } 52 | }} /> 53 | 54 | { setCondition({ ...condition, count: Number(v) }); }} 56 | onKeyDown={(ev: KeyboardEvent) => { 57 | if (ev.key === 'Enter') { 58 | handleSearch(ev); 59 | } 60 | }} 61 | /> 62 | 63 | 64 | 65 | setSearchVisible(false)} /> 66 | 67 | 68 | 69 | 70 | 71 | 72 | } 73 |
) 74 | } -------------------------------------------------------------------------------- /web/src/components/settings/AppSettings.tsx: -------------------------------------------------------------------------------- 1 | import { ContextualMenu, ContextualMenuItemType, Icon } from '@fluentui/react'; 2 | import React, { useRef, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { ConfigEvent, ConfigEventAction } from '../../events/ConfigEvent'; 5 | import { supportedLanguages } from '../../locales/resources'; 6 | import { Config } from '../../services/config.service'; 7 | import { AboutDialog } from './AboutDialog'; 8 | 9 | export interface IAppSettings { 10 | config: Config 11 | } 12 | 13 | export const AppSettings = (props: IAppSettings) => { 14 | const { t } = useTranslation(), 15 | [aboutDialogHidden, setAboutDialogHidden] = useState(true), 16 | [showContextualMenu, setShowContextualMenu] = useState(false), 17 | linkRef = useRef(null); 18 | 19 | const handleChangeLanguage = (language: string) => { 20 | ConfigEvent.next({ action: ConfigEventAction.Language, params: language }) 21 | } 22 | 23 | const handleChangeTheme = (theme: string) => { 24 | ConfigEvent.next({ action: ConfigEventAction.Theme, params: theme }) 25 | } 26 | 27 | return (<> 28 | {/* settings */} 29 |
{ 30 | e.stopPropagation(); 31 | e.preventDefault(); 32 | setShowContextualMenu(true); 33 | }}> 34 | 35 | 36 | handleChangeTheme('light') }, 43 | { key: 'darkTheme', text: t('Theme-Dark'), title: t('Theme-Dark'), onClick: () => handleChangeTheme('dark') }, 44 | { key: 'WordTheme', text: t('Theme-Word'), title: t('Theme-Word'), onClick: () => handleChangeTheme('word') }, 45 | { key: 'TeamsTheme', text: t('Theme-Teams'), title: t('Theme-Teams'), onClick: () => handleChangeTheme('teams') }, 46 | ], 47 | }, 48 | }, 49 | { 50 | key: 'localeLanguage', text: t('Language'), iconProps: { iconName: 'LocaleLanguage', style: { lineHeight: '14px' } }, subMenuProps: { 51 | items: Object.keys(supportedLanguages).map(v => { 52 | return { key: v, text: supportedLanguages[v], onClick: () => handleChangeLanguage(v) } 53 | }), 54 | }, 55 | }, 56 | { key: 'divider_about', itemType: ContextualMenuItemType.Divider }, 57 | { 58 | key: 'about', id: 'settingAbout', text: t('About'), iconProps: { iconName: 'Info', style: { lineHeight: '14px' } }, onClick: () => { 59 | setAboutDialogHidden(false); 60 | } 61 | }, 62 | ]} 63 | hidden={!showContextualMenu} 64 | target={linkRef} 65 | onItemClick={() => { setShowContextualMenu(false) }} 66 | onDismiss={() => { setShowContextualMenu(false) }} 67 | /> 68 | 69 | {/* about dialog */} 70 |
73 | ) 74 | 75 | } -------------------------------------------------------------------------------- /web/src/services/websocket.service.ts: -------------------------------------------------------------------------------- 1 | import { filter, first, lastValueFrom, ReplaySubject, Subject, retry } from 'rxjs'; 2 | import { WebSocketSubject } from 'rxjs/webSocket'; 3 | import { v1 as uuidv1 } from 'uuid'; 4 | 5 | const JSONRPC_VERSION = '2.0' 6 | 7 | export interface IJSONRPCRequest { 8 | jsonrpc?: string 9 | method: string 10 | params?: Array | {} 11 | id?: string | number 12 | } 13 | 14 | export interface IJSONRPCResponse { 15 | id?: string 16 | result: any 17 | error: any 18 | } 19 | 20 | export enum ConnectionState { 21 | CONNECTED = "Connected", 22 | CONNECTING = "Connecting", 23 | CLOSING = "Closing", 24 | DISCONNECTED = "Disconnected" 25 | } 26 | 27 | export class WebSocketService { 28 | private connectionState = new ReplaySubject(1); 29 | private socket: WebSocketSubject; 30 | 31 | private messageObserver = new Subject(); 32 | private binaryObserver = new Subject(); 33 | 34 | constructor(url: string) { 35 | this.connectionState.next(ConnectionState.CONNECTING); 36 | 37 | this.socket = new WebSocketSubject({ 38 | binaryType: 'arraybuffer', 39 | url, 40 | openObserver: { 41 | next: () => this.connectionState.next(ConnectionState.CONNECTED) 42 | }, 43 | closingObserver: { 44 | next: () => this.connectionState.next(ConnectionState.CLOSING) 45 | }, 46 | closeObserver: { 47 | next: () => this.connectionState.next(ConnectionState.DISCONNECTED) 48 | }, 49 | deserializer: (e: MessageEvent) => { 50 | try { 51 | if (e.data instanceof ArrayBuffer) { 52 | return e.data; 53 | } else { 54 | return JSON.parse(e.data); 55 | } 56 | } catch (e) { 57 | console.error(e); 58 | return null; 59 | } 60 | } 61 | }) 62 | 63 | // message 64 | this.socket.pipe( 65 | retry(), 66 | filter((v: any, index: number) => !(v instanceof ArrayBuffer)) 67 | ).subscribe(message => { 68 | this.messageObserver.next(message); 69 | }) 70 | 71 | // binary message 72 | this.socket.pipe( 73 | retry(), 74 | filter((value: any, index: number) => value instanceof ArrayBuffer) 75 | ).subscribe(message => { 76 | this.binaryObserver.next(message); 77 | }) 78 | 79 | this.connectionState.subscribe((state) => { 80 | console.log(`WebSocket state ${state}`); 81 | }); 82 | 83 | } 84 | 85 | request = async (request: IJSONRPCRequest): Promise => { 86 | if (!request.jsonrpc) request.jsonrpc = JSONRPC_VERSION; 87 | if (!request.id) request.id = uuidv1(); 88 | if (!request.params) request.params = []; 89 | 90 | this.socket.next(request); 91 | 92 | const obs = this.messageObserver.pipe( 93 | filter((v: any) => request.id === v.id), 94 | first() 95 | ); 96 | 97 | return lastValueFrom(obs).then((message: IJSONRPCResponse) => { 98 | if (message.error) { 99 | throw message.error.message || message.error; 100 | } 101 | return message.result; 102 | }); 103 | } 104 | 105 | } 106 | 107 | let address = process.env.NODE_ENV === 'development' ? 'ws://localhost:63790/ws' : `ws://${window.location.host}/ws`; 108 | 109 | export const defaultWebSocketService = new WebSocketService(address); -------------------------------------------------------------------------------- /api/storage.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "path" 11 | ) 12 | 13 | const suffix = "json" 14 | 15 | var GlobalStorage = &Storage{} 16 | 17 | type Storage struct { 18 | dir string 19 | } 20 | 21 | // Initialize . 22 | func (o *Storage) Initialize(dir string) error { 23 | o.dir = dir 24 | if _, err := os.Stat(o.dir); err == nil { 25 | log.Println(fmt.Sprintf(" Using %s alread exists", o.dir)) 26 | return nil 27 | } 28 | return os.MkdirAll(o.dir, os.ModePerm) 29 | } 30 | 31 | // Write v to collection 32 | func (o *Storage) Write(collection string, key string, value interface{}) error { 33 | if len(collection) <= 0 { 34 | return errors.New("storage write, no collection") 35 | } 36 | if len(key) <= 0 { 37 | return errors.New("storage write, no key") 38 | } 39 | if err := os.MkdirAll(path.Join(o.dir, collection), os.ModePerm); err != nil { 40 | return err 41 | } 42 | bytes, err := json.Marshal(value) 43 | if err != nil { 44 | return err 45 | } 46 | dest := path.Join(o.dir, collection, fmt.Sprintf("%s.%s", key, suffix)) 47 | destTmp := fmt.Sprintf("%s.tmp", dest) 48 | if err := ioutil.WriteFile(destTmp, bytes, 0644); err != nil { 49 | return err 50 | } 51 | return os.Rename(destTmp, dest) 52 | } 53 | 54 | // Read record from collection 55 | func (o *Storage) Read(collection string, key string, value interface{}) error { 56 | if len(collection) <= 0 { 57 | return errors.New("storage read, no collection") 58 | } 59 | if len(key) <= 0 { 60 | return errors.New("storage read, no key") 61 | } 62 | dest := path.Join(o.dir, collection, fmt.Sprintf("%s.%s", key, suffix)) 63 | if _, err := os.Stat(dest); err != nil { 64 | return nil 65 | } 66 | record, err := ioutil.ReadFile(dest) 67 | if err != nil { 68 | return err 69 | } 70 | return json.Unmarshal(record, &value) 71 | } 72 | 73 | // Delete key from collection 74 | func (o *Storage) Delete(collection string, key string) error { 75 | if len(collection) <= 0 { 76 | return errors.New("storage delete, no collection") 77 | } 78 | if len(key) <= 0 { 79 | return errors.New("storage delete, no key") 80 | } 81 | dest := path.Join(o.dir, collection, fmt.Sprintf("%s.%s", key, suffix)) 82 | if _, err := os.Stat(dest); err != nil { 83 | return err 84 | } 85 | return os.RemoveAll(dest) 86 | } 87 | 88 | // ReadAll . 89 | func (o *Storage) ReadAll(collection string, records *[][]byte) error { 90 | if len(collection) <= 0 { 91 | return errors.New("storage read all, no collection") 92 | } 93 | 94 | files, err := ioutil.ReadDir(path.Join(o.dir, collection)) 95 | if err != nil { 96 | log.Printf("storage read all error (is empty): %s \n", err) 97 | return nil 98 | } 99 | 100 | for _, file := range files { 101 | if file.IsDir() { 102 | continue 103 | } 104 | bytes, err := ioutil.ReadFile(path.Join(o.dir, collection, file.Name())) 105 | if err != nil { 106 | return err 107 | } 108 | *records = append(*records, bytes) 109 | } 110 | 111 | return nil 112 | } 113 | 114 | // RecordsToStruct is convert records to []T 115 | func RecordsToStruct[T any](records [][]byte, vs *[]T) error { 116 | for _, bts := range records { 117 | var record T 118 | err := json.Unmarshal(bts, &record) 119 | if err != nil { 120 | return err 121 | } 122 | *vs = append(*vs, record) 123 | } 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /web/src/components/panel/StringKeyPanel.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultButton, Overlay, Panel, PanelType, PrimaryButton, Spinner, SpinnerSize, Stack, TextField, useTheme } from '@fluentui/react'; 2 | import React, { useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Connection, executeCommand } from '../../services/connection.service'; 5 | import { ErrorMessageBar } from '../common/ErrorMessageBar'; 6 | import { FormatTextField } from '../common/FormatTextField'; 7 | 8 | export interface IStringkeyPanelProps { 9 | connection: Connection 10 | db: number 11 | keyType: string 12 | isOpen: boolean 13 | onDismiss: () => void 14 | onSave: (keyItem: any) => void, 15 | } 16 | 17 | export interface ISringKey { 18 | name: string 19 | value: string 20 | } 21 | 22 | export const StringKeyPanel = (props: IStringkeyPanelProps) => { 23 | const { connection, db, onDismiss, onSave, isOpen, keyType } = props; 24 | const { t } = useTranslation(), theme = useTheme(); 25 | 26 | const [keyItem, setKeyItem] = useState({ name: '', value: '' }), 27 | [error, setError] = useState(), 28 | [loading, setLoading] = useState(false); 29 | 30 | const handleSave = (refresh = false) => { 31 | setError(undefined); 32 | setLoading(true); 33 | executeCommand>({ id: connection.id, commands: [['SELECT', db], ['SET', keyItem.name, keyItem.value]] }) 34 | .then((ret) => { 35 | if (!ret || !ret.length) return; 36 | onDismiss(); 37 | refresh && onSave && onSave(keyItem); 38 | }) 39 | .catch(err => setError(err)) 40 | .finally(() => setLoading(false)); 41 | } 42 | 43 | return ( 44 | onDismiss()} 50 | headerText={`${t('Edit')} ${keyType} (${connection.name}:db${db})`} 51 | onRenderFooterContent={() => { 52 | var disabled = !!!keyItem.name; 53 | return ( 54 | 55 | 56 | { handleSave() }}> 57 | { handleSave(true) }}> 58 | onDismiss()}> 59 | 60 | ) 61 | }} 62 | isFooterAtBottom={true} 63 | > 64 | 65 | {loading && 66 | 67 | 68 | 69 | 70 | 71 | } 72 | 73 | 74 | { 76 | setKeyItem({ ...keyItem, name: v || '' }); 77 | }} /> 78 | 79 | { 80 | setKeyItem({ ...keyItem, value: v || '' }); 81 | }} /> 82 | 83 | 84 | 85 | 86 | 87 | ) 88 | } -------------------------------------------------------------------------------- /README.zh_CN.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Logo 4 | 5 | # RedisWebManager 6 | 7 | [![](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/slowrookie/redis-web-manager/blob/master/LICENSE) 8 | [![](https://github.com/slowrookie/redis-web-manager/actions/workflows/release.yml/badge.svg)](https://github.com/slowrookie/redis-web-manager/actions/workflows/release.yml) 9 | ![](https://shields.io/github/v/release/slowrookie/redis-web-manager) 10 | [![Docker Hub](https://img.shields.io/docker/pulls/slowrookie/redis-web-manager.svg)](https://hub.docker.com/r/slowrookie/redis-web-manager) 11 | 12 | 13 | 简体中文 | [English](README.md) 14 | 15 | Redis Web Manager 是一款使用 React & Golang 开发的应用同时提供桌面版和Web版,用于管理Redis,支持多平台。 16 | 17 | ## 介绍 18 | - [项目截图](#项目截图) 19 | - [下载](#下载与安装) 20 | - [项目结构](#项目结构) 21 | - [相关仓库](#相关仓库) 22 | - [维护者](#维护者) 23 | - [如何贡献](#如何贡献) 24 | - [使用许可](#使用许可) 25 | 26 | ## 项目截图 27 | 28 | 1 29 | 2 30 | 3 31 | 4 32 | ## 下载与安装 33 | 34 | [Release](https://github.com/slowrookie/redis-web-manager/releases) 35 | 36 | 桌面应用包名: 37 | * `Windows`: RedisWebManager_windows_amd64.exe 38 | * `Linux` : RedisWebManager_amd64.AppImage 39 | * `MacOS`: RedisWebManager.dmg 40 | 41 | Web应用服务名: 42 | * `Windows`: 43 | * redis-web-manager_${version}-server.2_windows_amd64.tar.gz 44 | * redis-web-manager_${version}-server.2_windows_arm64.tar.gz 45 | * `Linux`: 46 | * redis-web-manager_${version}-server.2_linux_amd64.tar.gz 47 | * redis-web-manager_${version}-server.2_linux_arm64.tar.gz 48 | * `MacOS`: 49 | * redis-web-manager_${version}-server.2_darwin_amd64.tar.gz 50 | * redis-web-manager_${version}-server.2_darwin_arm64.tar.gz 51 | * `Docker`: 52 | * https://hub.docker.com/r/slowrookie/redis-web-manager 53 | ```shell 54 | docker run --rm -d -p 63790:63790/tcp slowrookie/redis-web-manager:latest 55 | ``` 56 | 57 | 58 | ## 项目结构 59 | 60 | - `api` 主要功能逻辑 61 | - `web` 前端项目文件 62 | - `desktop` 桌面应用构建相关 63 | - `server` 服务应用构建相关 64 | 65 | ## 相关仓库 66 | 67 | - [microsoft/fluentui](https://github.com/microsoft/fluentui) 68 | - [wails2](https://github.com/wailsapp/wails) 69 | - [go-redis/redis](https://github.com/go-redis/redis) 70 | 71 | ## 维护者 72 | 73 | [@slowrookie](https://github.com/slowrookie) 74 | 75 | ## 如何贡献 76 | 77 | 欢迎你的加入![提一个 Issue](https://github.com/slowrookie/redis-web-manager/issues/new) 或者提交一个 Pull Request。 78 | 79 | ## 特别感谢 80 | 81 |

82 | 特别感谢 JetBrains 提供许可支持!
83 | 84 | JetBrains 85 | 86 |

87 | 88 | ## 使用许可 89 | 90 | [MIT](LICENSE) © slowrookie 91 | 92 | ![](https://visitor-badge.glitch.me/badge?page_id=slowrookie.redis-web-manager) -------------------------------------------------------------------------------- /web/src/components/MainTab.tsx: -------------------------------------------------------------------------------- 1 | import { Depths, Icon, Pivot, PivotItem, Stack } from '@fluentui/react'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Connection } from '../services/connection.service'; 4 | import { AppSettings } from './settings/AppSettings'; 5 | import { ConnectionItem, IConnectionItemProps } from './ConnectionItem'; 6 | import { ConnectionList } from './ConnectionList'; 7 | import { Config } from '../services/config.service'; 8 | export interface IMainTabProps { 9 | config: Config 10 | } 11 | 12 | interface IMainTab { 13 | connectionItems: Array, 14 | selectedKey: string | undefined, 15 | showConnectionList: boolean, 16 | } 17 | 18 | export const MainTab = (props: IMainTabProps) => { 19 | const 20 | [mainTab, setMainTab] = useState({ connectionItems: [], selectedKey: '', showConnectionList: true }); 21 | 22 | useEffect(() => { 23 | if (!mainTab.connectionItems.length) { 24 | setMainTab(m => { 25 | return { ...m, selectedKey: '', showConnectionList: true } 26 | }) 27 | } 28 | }, [mainTab.connectionItems]) 29 | 30 | const handleConnectionClick = (v: Connection) => { 31 | const exists = mainTab.connectionItems.filter(item => item.connection.id === v.id); 32 | const connections = mainTab.connectionItems; 33 | !exists.length && connections.push({ connection: v }); 34 | setMainTab({ ...mainTab, connectionItems: [...connections], selectedKey: v.id, showConnectionList: false }); 35 | } 36 | 37 | return (<> 38 | { 45 | if (item?.props.itemKey === "Home") { 46 | setMainTab({ ...mainTab, selectedKey: 'Home', showConnectionList: true }); 47 | return; 48 | } 49 | setMainTab({ ...mainTab, selectedKey: item?.props.itemKey, showConnectionList: false }) 50 | }} > 51 | 52 | {/* Home */} 53 | ( 55 | 56 | {defaultRenderer(link)} 57 | {/* Settings */} 58 | 59 | 60 | )}> 61 |
62 | 63 |
64 |
65 | 66 | {mainTab.connectionItems.map((v, index) => { 67 | return ( 69 | 70 | {defaultRenderer(link)} 71 | { 72 | e.stopPropagation(); 73 | e.preventDefault(); 74 | const connections = mainTab.connectionItems.filter((_, i) => index !== i); 75 | mainTab.connectionItems = [...connections]; 76 | setMainTab({ ...mainTab, connectionItems: [...connections], selectedKey: connections.length > 0 ? connections[0].connection.id : undefined, showConnectionList: !connections.length && false }); 77 | }} 78 | /> 79 | 80 | )} 81 | > 82 |
83 | 84 |
85 |
86 | })} 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /web/src/locales/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "Overview": "总览", 3 | "Server Info": "服务信息", 4 | "Database": "数据库", 5 | "CLI": "命令", 6 | "Configuration": "配置", 7 | "Load more": "加载更多", 8 | "Theme": "主题", 9 | "Theme-Dark": "黑色", 10 | "Theme-Light": "默认", 11 | "Theme-Word": "Word", 12 | "Theme-Teams": "Teams", 13 | "Language": "语言", 14 | "About": "关于", 15 | "The test connection to the Redis server is successful!": "测试连接 Redis 服务成功!", 16 | "The connection to the Redis server is successful!": "连接 Reids 服务器成功!", 17 | "Save success!": "保存成功!", 18 | "Connection settings": "连接设置", 19 | "Test Connection": "测试连接", 20 | "OK": "确认", 21 | "Cancel": "取消", 22 | "Connecting...": "连接中...", 23 | "Basic": "基础", 24 | "Name": "名称", 25 | "Connection name": "连接名称", 26 | "Address": "地址", 27 | "Service address": "服务地址", 28 | "Host": "主机", 29 | "Port": "端口", 30 | "Private key": "私钥", 31 | "Password": "密码", 32 | "(Optional) Service authentication password": "(可选)服务验证密码", 33 | "Username": "用户名", 34 | "(Optional) Service authentication user name Reids> 6.0": "(可选)服务认证用户名 Reids > 6.0", 35 | "Advanced": "高级", 36 | "Key load": "键加载", 37 | "Default key filtering": "默认键过滤", 38 | "Namespace separator": "命名空间分隔符", 39 | "Timeouts and limits": "超时和限制", 40 | "Connection timeout (ms)": "连接超时(毫秒)", 41 | "Execution timeout (ms)": "执行超时(毫秒)", 42 | "Database display limit": "数据库发现限制", 43 | "Data scan limit": "数据加载数量限制", 44 | "Copy": "复制", 45 | "Edit": "编辑", 46 | "Delete": "删除", 47 | "Service Info": "服务器信息", 48 | "Console": "控制台", 49 | "Refresh": "刷新", 50 | "Disconnect": "断开连接", 51 | "Add key": "添加键", 52 | "Remove keys": "删除选中键", 53 | "Delete this key?": "删除该键?", 54 | "Filter": "过滤", 55 | "Size": "大小", 56 | "View": "查看", 57 | "page": "页", 58 | "total": "共", 59 | "items": "条", 60 | "Previous page": "上一页", 61 | "Next page": "上一页", 62 | "Auto refresh": "自动刷新", 63 | "Redis version": "Redis 版本", 64 | "Used memory": "已经使用的内存", 65 | "Clients": "客户端", 66 | "Commands processed": "已执行的命令", 67 | "Uptime in days": "正常运行天数", 68 | "Memory usage": "内存占用", 69 | "Connected Duration (sec)": "连接时长 (sec)", 70 | "Idle Duration (sec)": "空闲时长 (sec)", 71 | "Flgs": "标记", 72 | "Property": "属性", 73 | "Value": "值", 74 | "Slow log": "慢查询", 75 | "Push/subscribe channel": "推送/订阅 通道", 76 | "Channel": "通道", 77 | "Client address": "客户端地址", 78 | "Command": "命令", 79 | "Dealt with": "处理于", 80 | "Duration (μs)": "时长(μs)", 81 | "Flags": "标记", 82 | "Current Database": "当前数据库", 83 | "Clear": "清空", 84 | "Current page search...": "当前页面搜索...", 85 | "Add": "添加", 86 | "Rename": "重命名", 87 | "Update": "更新", 88 | "Reload": "重载", 89 | "Delete Key": "删除键", 90 | "Ascending": "升序", 91 | "Descending": "降序", 92 | "Delete this item?": "删除该项?", 93 | "Index search": "索引搜索", 94 | "Cursor search": "游标搜索", 95 | "Save": "保存", 96 | "Saving...": "保存中...", 97 | "Key name": "键名", 98 | "Key value": "键值", 99 | "Filed name": "字段名", 100 | "Field value": "字段值", 101 | "Value (JSON object)": "值 (JSON对象)", 102 | "Incorrect format": "格式有误", 103 | "Score": "评分", 104 | "Load...": "加载中...", 105 | "Search": "搜索", 106 | "Groups": "分组", 107 | "Upload": "上传", 108 | "Security": "安全", 109 | "Welcome to discussions!": "欢迎讨论!", 110 | "general": "一般", 111 | "cluster": "集群", 112 | "sentinel": "哨兵", 113 | "Route": "路由", 114 | "RouteByLatency": "延迟", 115 | "RouteRandomly": "随机", 116 | "Sentinel password": "哨兵密码", 117 | "Master name": "主节点名称", 118 | "Type": "类型", 119 | "Execution": "运行", 120 | "Last Execution At": "上次执行时间", 121 | "Elapsed": "耗时" 122 | } -------------------------------------------------------------------------------- /web/src/locales/zh-Hant.json: -------------------------------------------------------------------------------- 1 | { 2 | "Overview": "總覽", 3 | "Server Info": "服務信息", 4 | "Database": "數據庫", 5 | "CLI": "命令", 6 | "Configuration": "配置", 7 | "Load more": "加載更多", 8 | "Theme": "主題", 9 | "Theme-Dark": "深色主題", 10 | "Theme-Light": "亮色主題", 11 | "Theme-Word": "Word", 12 | "Theme-Teams": "Teams", 13 | "Language": "語言", 14 | "About": "關於", 15 | "The test connection to the Redis server is successful!": "與Redis服務器的測試連接成功!", 16 | "The connection to the Redis server is successful!": "與Redis服務器的連接成功!", 17 | "Save success!": "保存成功!", 18 | "Connection settings": "連接設置", 19 | "Test Connection": "測試連接", 20 | "OK": "確認", 21 | "Cancel": "取消", 22 | "Connecting...": "連接中...", 23 | "Basic": "基礎", 24 | "Name": "名稱", 25 | "Connection name": "鏈接名稱", 26 | "Address": "地址", 27 | "Service address": "服務地址", 28 | "Host": "主機", 29 | "Port": "端口", 30 | "Private key": "私鑰", 31 | "Password": "密碼", 32 | "(Optional) Service authentication password": "(可選)服務認證密碼", 33 | "Username": "用戶名", 34 | "(Optional) Service authentication user name Reids> 6.0": "(可選)服務認證用戶名Reids> 6.0", 35 | "Advanced": "高級", 36 | "Key load": "鍵加載", 37 | "Default key filtering": "默認鍵過濾", 38 | "Namespace separator": "命名空間分隔符", 39 | "Timeouts and limits": "超時和限制", 40 | "Connection timeout (ms)": "連接超時(毫秒)", 41 | "Execution timeout (ms)": "執行超時(毫秒)", 42 | "Database display limit": "數據庫顯示限制", 43 | "Data scan limit": "數據加載數量限制", 44 | "Copy": "複製", 45 | "Edit": "編輯", 46 | "Delete": "刪除", 47 | "Service Info": "服務信息", 48 | "Console": "控制台", 49 | "Refresh": "刷新", 50 | "Disconnect": "斷開連接", 51 | "Add key": "添加鍵", 52 | "Remove keys": "删除選中鍵", 53 | "Delete this key?": "刪除此鍵嗎?", 54 | "Filter": "過濾", 55 | "Size": "大小", 56 | "View": "查看", 57 | "page": "頁", 58 | "total": "共", 59 | "items": "項", 60 | "Previous page": "上一頁", 61 | "Next page": "下一頁", 62 | "Auto refresh": "自動刷新", 63 | "Redis version": "Redis 版本", 64 | "Used memory": "已使用的内存", 65 | "Clients": "客戶端", 66 | "Commands processed": "已執行的命令", 67 | "Uptime in days": "正常運行天數", 68 | "Memory usage": "内存占用", 69 | "Connected Duration (sec)": "鏈接時長 (sec)", 70 | "Idle Duration (sec)": "空閑時長 (sec)", 71 | "Flgs": "標記", 72 | "Property": "屬性", 73 | "Value": "值", 74 | "Slow log": "慢查詢", 75 | "Push/subscribe channel": "推送/訂閱 通道", 76 | "Channel": "通道", 77 | "Client address": "客戶端地址", 78 | "Command": "指令", 79 | "Dealt with": "處理與", 80 | "Duration (μs)": "時長 (μs)", 81 | "Flags": "標記", 82 | "Current Database": "當前數據庫", 83 | "Clear": "清空", 84 | "Current page search...": "當前頁面搜索...", 85 | "Add": "添加", 86 | "Rename": "重命名", 87 | "Update": "更新", 88 | "Reload": "重載", 89 | "Delete Key": "刪除鍵", 90 | "Ascending": "升序", 91 | "Descending": "降序", 92 | "Delete this item?": "刪除該項?", 93 | "Index search": "索引搜索", 94 | "Cursor search": "游標搜索", 95 | "Save": "保存", 96 | "Saving...": "保存中...", 97 | "Key name": "鍵名", 98 | "Key value": "鍵值", 99 | "Filed name": "字段名", 100 | "Field value": "字段值", 101 | "Value (JSON object)": "值 (JSON 對象)", 102 | "Incorrect format": "格式有誤", 103 | "Score": "評分", 104 | "Load...": "加載中...", 105 | "Search": "搜索", 106 | "Groups": "分組", 107 | "Upload": "上傳", 108 | "Security": "安全", 109 | "Welcome to discussions!": "歡迎討論!", 110 | "general": "一般", 111 | "cluster": "集群", 112 | "sentinel": "哨兵", 113 | "Route": "路由", 114 | "RouteByLatency": "延遲", 115 | "RouteRandomly": "隨機", 116 | "Sentinel password": "哨兵密碼", 117 | "Master name": "主節點名稱", 118 | "Type": "類型", 119 | "Execution": "運行", 120 | "Last Execution At": "上次執行時間", 121 | "Elapsed": "耗時" 122 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Logo 4 | 5 | # RedisWebManager 6 | 7 | [![](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/slowrookie/redis-web-manager/blob/master/LICENSE) 8 | [![](https://github.com/slowrookie/redis-web-manager/actions/workflows/release.yml/badge.svg)](https://github.com/slowrookie/redis-web-manager/actions/workflows/release.yml) 9 | ![](https://shields.io/github/v/release/slowrookie/redis-web-manager) 10 | [![Docker Hub](https://img.shields.io/docker/pulls/slowrookie/redis-web-manager.svg)](https://hub.docker.com/r/slowrookie/redis-web-manager) 11 | 12 | 13 | English | [简体中文](README.zh_CN.md) 14 | 15 | Redis Web Manager is an application developed with React & Golang that provides both desktop and web versions for managing Redis and supports multiple platforms. 16 | 17 | ## Introduction 18 | - [Project Screenshot](#Project-screenshot) 19 | - [Download & Install](#Download--install) 20 | - [Project Structure](#Project-structure) 21 | - [Related Efforts](#Related-efforts) 22 | - [Maintainers](#Maintainers) 23 | - [Contributing](#Contributing) 24 | - [License](#License) 25 | 26 | ## Project Screenshot 27 | 28 | 1 29 | 2 30 | 3 31 | 4 32 | 33 | ## Download & Install 34 | 35 | [Release](https://github.com/slowrookie/redis-web-manager/releases) 36 | 37 | Desktop: 38 | * `Windows`: RedisWebManager_windows_amd64.exe 39 | * `Linux` : RedisWebManager_amd64.AppImage 40 | * `MacOS`: RedisWebManager.dmg 41 | 42 | Web Server: 43 | * `Windows`: 44 | * redis-web-manager_${version}-server.2_windows_amd64.tar.gz 45 | * redis-web-manager_${version}-server.2_windows_arm64.tar.gz 46 | * `Linux`: 47 | * redis-web-manager_${version}-server.2_linux_amd64.tar.gz 48 | * redis-web-manager_${version}-server.2_linux_arm64.tar.gz 49 | * `MacOS`: 50 | * redis-web-manager_${version}-server.2_darwin_amd64.tar.gz 51 | * redis-web-manager_${version}-server.2_darwin_arm64.tar.gz 52 | * `Docker`: 53 | * https://hub.docker.com/r/slowrookie/redis-web-manager 54 | ```shell 55 | docker run --rm -d -p 63790:63790/tcp slowrookie/redis-web-manager:latest 56 | ``` 57 | 58 | 59 | ## Project Structure 60 | 61 | - `api` main function logic 62 | - `web` frontend project 63 | - `desktop` desktop build 64 | - `server` web server build 65 | 66 | ## Related Efforts 67 | 68 | - [microsoft/fluentui](https://github.com/microsoft/fluentui) 69 | - [wails2](https://github.com/wailsapp/wails) 70 | - [go-redis/redis](https://github.com/go-redis/redis) 71 | 72 | ## Maintainers 73 | 74 | [@slowrookie](https://github.com/slowrookie) 75 | 76 | ## Contributing 77 | 78 | Welcome to join us! [Open an issue](https://github.com/slowrookie/redis-web-manager/issues/new) or submit PRs. 79 | 80 | ## Special Thanks 81 | 82 |

83 | Special thanks to JetBrains for licensing support!
84 | 85 | JetBrains 86 | 87 |

88 | 89 | ## License 90 | 91 | [MIT](LICENSE) © slowrookie 92 | 93 | ![](https://visitor-badge.glitch.me/badge?page_id=slowrookie.redis-web-manager) -------------------------------------------------------------------------------- /web/src/components/panel/ListKeyPanel.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultButton, Overlay, Panel, PanelType, PrimaryButton, Spinner, SpinnerSize, Stack, TextField, useTheme } from '@fluentui/react'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Connection, executeCommand } from '../../services/connection.service'; 5 | import { ErrorMessageBar } from '../common/ErrorMessageBar'; 6 | import { FormatTextField } from '../common/FormatTextField'; 7 | 8 | export interface IListKeyPanelProps { 9 | connection: Connection 10 | db: number 11 | keyType: string 12 | keyName?: string 13 | keyValue?: string 14 | isOpen: boolean 15 | index?: number 16 | disabledKeyName?: boolean 17 | onDismiss: () => void 18 | onSave: (keyItem: any) => void, 19 | } 20 | 21 | export interface IListKey { 22 | name: string, 23 | value: string 24 | } 25 | 26 | 27 | export const ListKeyPanel = (props: IListKeyPanelProps) => { 28 | const { connection, db, index, keyType, keyName, keyValue, onDismiss, onSave, isOpen, disabledKeyName } = props; 29 | const { t } = useTranslation(), theme = useTheme(); 30 | 31 | const [keyItem, setKeyItem] = useState({ name: '', value: '' }), 32 | [error, setError] = useState(), 33 | [loading, setLoading] = useState(false); 34 | 35 | useEffect(() => { 36 | setKeyItem({ name: keyName || '', value: keyValue || '' }); 37 | }, [keyName, keyValue]) 38 | 39 | const handleSave = (save = false) => { 40 | setError(undefined); 41 | setLoading(true); 42 | var c = (index && index >= 0) ? ['LSET', keyItem.name, index, keyItem.value] : ['LPUSH', keyItem.name, keyItem.value]; 43 | executeCommand>({ id: connection.id, commands: [['SELECT', db], c] }) 44 | .then((ret) => { 45 | if (!ret || !ret.length) return; 46 | onDismiss && onDismiss(); 47 | save && onSave && onSave({ ...keyItem, index }); 48 | }) 49 | .catch(err => setError(err)) 50 | .finally(() => setLoading(false)); 51 | } 52 | 53 | return ( 54 | onDismiss()} 60 | headerText={`${t('Edit')} ${keyType} (${connection.name}:db${db})`} 61 | onRenderFooterContent={() => { 62 | var disabled = !!!keyItem.name; 63 | return ( 64 | 65 | 66 | { handleSave() }}> 67 | { handleSave(true) }}> 68 | onDismiss()}> 69 | 70 | ) 71 | }} 72 | isFooterAtBottom={true} 73 | > 74 | 75 | {loading && 76 | 77 | 78 | 79 | 80 | 81 | } 82 | 83 | 84 | { 86 | setKeyItem({ ...keyItem, name: v || '' }); 87 | }} /> 88 | 89 | { 90 | setKeyItem({ ...keyItem, value: v || '' }); 91 | }} /> 92 | 93 | 94 | 95 | 96 | 97 | ) 98 | } -------------------------------------------------------------------------------- /web/src/components/panel/SetKeyPanel.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultButton, Overlay, Panel, PanelType, PrimaryButton, Spinner, SpinnerSize, Stack, TextField, useTheme } from '@fluentui/react'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Connection, executeCommand } from '../../services/connection.service'; 5 | import { ErrorMessageBar } from '../common/ErrorMessageBar'; 6 | import { FormatTextField } from '../common/FormatTextField'; 7 | 8 | export interface ISetKeyPanelProps { 9 | connection: Connection 10 | db: number 11 | keyType: string 12 | keyName?: string 13 | keyValue?: string 14 | isOpen: boolean 15 | index?: number 16 | disabledKeyName?: boolean 17 | onDismiss: () => void 18 | onSave: (keyItem: any) => void, 19 | } 20 | 21 | export interface ISetKey { 22 | name: string, 23 | value: string 24 | } 25 | 26 | export const SetKeyPanel = (props: ISetKeyPanelProps) => { 27 | 28 | const { connection, db, index, keyType, keyName, keyValue, onDismiss, onSave, isOpen, disabledKeyName } = props; 29 | const { t } = useTranslation(), theme = useTheme(); 30 | 31 | const [keyItem, setKeyItem] = useState({ name: '', value: '' }), 32 | [error, setError] = useState(), 33 | [loading, setLoading] = useState(false); 34 | 35 | useEffect(() => { 36 | setKeyItem({ name: keyName || '', value: keyValue || '' }); 37 | }, [keyName, keyValue]) 38 | 39 | const handleSave = (save = false) => { 40 | setError(undefined); 41 | setLoading(true); 42 | 43 | const commands = [['SELECT', db]]; 44 | (index && index >= 0 && keyValue) && commands.push(['SREM', keyItem.name, keyValue]); 45 | commands.push(['SADD', keyItem.name, keyItem.value]); 46 | 47 | executeCommand>({ id: connection.id, commands }) 48 | .then((ret) => { 49 | if (!ret || !ret.length) return; 50 | onDismiss && onDismiss(); 51 | save && onSave && onSave({ ...keyItem, index }); 52 | }) 53 | .catch(err => setError(err)) 54 | .finally(() => setLoading(false)); 55 | } 56 | 57 | return ( 58 | onDismiss()} 64 | headerText={`${t('Edit')} ${keyType} (${connection.name}:db${db})`} 65 | onRenderFooterContent={() => { 66 | var disabled = !!!keyItem.name; 67 | return ( 68 | 69 | 70 | { handleSave() }}> 71 | { handleSave(true) }}> 72 | onDismiss()}> 73 | 74 | ) 75 | }} 76 | isFooterAtBottom={true} 77 | > 78 | 79 | {loading && 80 | 81 | 82 | 83 | 84 | 85 | } 86 | 87 | 88 | { 90 | setKeyItem({ ...keyItem, name: v || '' }); 91 | }} /> 92 | 93 | { 94 | setKeyItem({ ...keyItem, value: v || '' }); 95 | }} /> 96 | 97 | 98 | 99 | 100 | 101 | ) 102 | } -------------------------------------------------------------------------------- /api/parser/redisparser_visitor.go: -------------------------------------------------------------------------------- 1 | // Code generated from RedisParser.g4 by ANTLR 4.10.1. DO NOT EDIT. 2 | 3 | package parser // RedisParser 4 | 5 | import "github.com/antlr/antlr4/runtime/Go/antlr" 6 | 7 | // A complete Visitor for a parse tree produced by RedisParser. 8 | type RedisParserVisitor interface { 9 | antlr.ParseTreeVisitor 10 | 11 | // Visit a parse tree produced by RedisParser#command. 12 | VisitCommand(ctx *CommandContext) interface{} 13 | 14 | // Visit a parse tree produced by RedisParser#bitmap. 15 | VisitBitmap(ctx *BitmapContext) interface{} 16 | 17 | // Visit a parse tree produced by RedisParser#clusterManagement. 18 | VisitClusterManagement(ctx *ClusterManagementContext) interface{} 19 | 20 | // Visit a parse tree produced by RedisParser#clusterSub. 21 | VisitClusterSub(ctx *ClusterSubContext) interface{} 22 | 23 | // Visit a parse tree produced by RedisParser#connectionManagement. 24 | VisitConnectionManagement(ctx *ConnectionManagementContext) interface{} 25 | 26 | // Visit a parse tree produced by RedisParser#clientSub. 27 | VisitClientSub(ctx *ClientSubContext) interface{} 28 | 29 | // Visit a parse tree produced by RedisParser#generic. 30 | VisitGeneric(ctx *GenericContext) interface{} 31 | 32 | // Visit a parse tree produced by RedisParser#objectSub. 33 | VisitObjectSub(ctx *ObjectSubContext) interface{} 34 | 35 | // Visit a parse tree produced by RedisParser#geospatialIndices. 36 | VisitGeospatialIndices(ctx *GeospatialIndicesContext) interface{} 37 | 38 | // Visit a parse tree produced by RedisParser#hash. 39 | VisitHash(ctx *HashContext) interface{} 40 | 41 | // Visit a parse tree produced by RedisParser#hyperloglog. 42 | VisitHyperloglog(ctx *HyperloglogContext) interface{} 43 | 44 | // Visit a parse tree produced by RedisParser#list. 45 | VisitList(ctx *ListContext) interface{} 46 | 47 | // Visit a parse tree produced by RedisParser#pubSub. 48 | VisitPubSub(ctx *PubSubContext) interface{} 49 | 50 | // Visit a parse tree produced by RedisParser#pubSubSub. 51 | VisitPubSubSub(ctx *PubSubSubContext) interface{} 52 | 53 | // Visit a parse tree produced by RedisParser#scriptingAndFunctions. 54 | VisitScriptingAndFunctions(ctx *ScriptingAndFunctionsContext) interface{} 55 | 56 | // Visit a parse tree produced by RedisParser#functionSub. 57 | VisitFunctionSub(ctx *FunctionSubContext) interface{} 58 | 59 | // Visit a parse tree produced by RedisParser#scriptSub. 60 | VisitScriptSub(ctx *ScriptSubContext) interface{} 61 | 62 | // Visit a parse tree produced by RedisParser#serverManagement. 63 | VisitServerManagement(ctx *ServerManagementContext) interface{} 64 | 65 | // Visit a parse tree produced by RedisParser#aclSub. 66 | VisitAclSub(ctx *AclSubContext) interface{} 67 | 68 | // Visit a parse tree produced by RedisParser#commandSub. 69 | VisitCommandSub(ctx *CommandSubContext) interface{} 70 | 71 | // Visit a parse tree produced by RedisParser#configSub. 72 | VisitConfigSub(ctx *ConfigSubContext) interface{} 73 | 74 | // Visit a parse tree produced by RedisParser#latencySub. 75 | VisitLatencySub(ctx *LatencySubContext) interface{} 76 | 77 | // Visit a parse tree produced by RedisParser#memorySub. 78 | VisitMemorySub(ctx *MemorySubContext) interface{} 79 | 80 | // Visit a parse tree produced by RedisParser#moduleSub. 81 | VisitModuleSub(ctx *ModuleSubContext) interface{} 82 | 83 | // Visit a parse tree produced by RedisParser#slowlogSub. 84 | VisitSlowlogSub(ctx *SlowlogSubContext) interface{} 85 | 86 | // Visit a parse tree produced by RedisParser#set. 87 | VisitSet(ctx *SetContext) interface{} 88 | 89 | // Visit a parse tree produced by RedisParser#sortedSet. 90 | VisitSortedSet(ctx *SortedSetContext) interface{} 91 | 92 | // Visit a parse tree produced by RedisParser#stream. 93 | VisitStream(ctx *StreamContext) interface{} 94 | 95 | // Visit a parse tree produced by RedisParser#xgroupSub. 96 | VisitXgroupSub(ctx *XgroupSubContext) interface{} 97 | 98 | // Visit a parse tree produced by RedisParser#xinfoSub. 99 | VisitXinfoSub(ctx *XinfoSubContext) interface{} 100 | 101 | // Visit a parse tree produced by RedisParser#stringCmd. 102 | VisitStringCmd(ctx *StringCmdContext) interface{} 103 | 104 | // Visit a parse tree produced by RedisParser#transactions. 105 | VisitTransactions(ctx *TransactionsContext) interface{} 106 | } 107 | -------------------------------------------------------------------------------- /web/src/components/command/Command.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, useTheme } from '@fluentui/react'; 2 | import React, { CSSProperties, useState } from 'react'; 3 | import { Connection, executeCommand } from '../../services/connection.service'; 4 | import { Suggestion } from './Suggestion'; 5 | 6 | 7 | export interface ICommandProps { 8 | connection: Connection 9 | } 10 | 11 | declare type InputLine = 'I' | 'O' 12 | 13 | interface IInputLine { 14 | type: InputLine 15 | ret: string 16 | db?: number 17 | command?: string 18 | disabled?: boolean 19 | } 20 | 21 | const defaultInputLine: IInputLine = { type: 'I', command: '', disabled: false, ret: '', db: 0 }; 22 | 23 | const formatArray = (arr: Array, index: number, tab: number): any => { 24 | return arr.map((v, i) => { 25 | if (Array.isArray(v)) { 26 | return formatArray(v, i + 1, tab + 1) 27 | } 28 | var prefix = ''; 29 | if (index > 0) { 30 | prefix += (i === 0 ? `${index}) ` : [...Array(tab)].fill('\t').join()) 31 | } 32 | return prefix + `${i + 1}) ${v}`; 33 | }).join('\r\n'); 34 | } 35 | 36 | const formatRet = (ret: any) => { 37 | if (Array.isArray(ret)) { 38 | return formatArray(ret, 0, 0); 39 | } 40 | return ret; 41 | } 42 | 43 | export const Command = (props: ICommandProps) => { 44 | const theme = useTheme(); 45 | const inputStyle: CSSProperties = { 46 | border: 'none', 47 | outline: 'none', 48 | color: theme.palette.themePrimary, 49 | height: 14, 50 | lineHeight: '14px', 51 | width: '100%' 52 | } 53 | 54 | const [lines, setLines] = useState>([defaultInputLine]), 55 | [errorMessage, setErroMessage] = useState(''), 56 | [selectedDB, setSelectedDB] = useState(0); 57 | 58 | const handleSearch = (v?: string) => { 59 | if (!v) return; 60 | 61 | setErroMessage(''); 62 | let commands: Array> = [[]]; 63 | let currentCommand = v.trim().split(" "); 64 | let currentDB: number = selectedDB; 65 | // clear 66 | if (currentCommand[0].toUpperCase() === 'CLEAR') { 67 | setLines([{ ...defaultInputLine, db: currentDB }]); 68 | return; 69 | } 70 | // select 71 | if (currentCommand[0].toUpperCase() === 'SELECT') { 72 | if (currentCommand.length > 1) { 73 | currentDB = Number(currentCommand[1]); 74 | setSelectedDB(Number(currentCommand[1])); 75 | } 76 | commands = [currentCommand]; 77 | } else { 78 | commands = [['SELECT', currentDB], currentCommand]; 79 | } 80 | 81 | executeCommand>({ id: props.connection.id, commands }) 82 | .then((ret) => { 83 | lines[lines.length - 1] = { ...lines[lines.length - 1], disabled: true, command: v } 84 | lines.push({ type: 'O', ret: formatRet(ret[ret.length - 1]) }); 85 | lines.push({ ...defaultInputLine, db: currentDB }); 86 | setLines([...lines]); 87 | }) 88 | .catch((err) => { 89 | setErroMessage(err) 90 | }); 91 | } 92 | 93 | const handleChange = (v?: string) => {} 94 | 95 | return ( 96 |
97 | 98 | 99 | {lines && lines.map((line, i) => 100 | 101 | {line.type === "I" && (<> 102 | {`${line.db} >`} 103 | 104 | {line.disabled ? ({line.command}) : 105 | ( 106 | <> 107 | 108 | 109 | )} 110 | 111 | )} 112 | 113 | {line.type === "O" &&
114 |
{line.ret}
115 |
} 116 | 117 |
)} 118 |
119 |
120 |
121 | ) 122 | } -------------------------------------------------------------------------------- /web/src/components/command/Suggestion.tsx: -------------------------------------------------------------------------------- 1 | import { Callout, CheckboxVisibility, DetailsList, DetailsListLayoutMode, DirectionalHint, FocusZone, FocusZoneDirection, SelectionMode, TextField } from '@fluentui/react'; 2 | import { useId } from '@fluentui/react-hooks'; 3 | import React, { ChangeEvent, KeyboardEvent, useEffect, useState } from 'react'; 4 | import { Connection, suggestions } from '../../services/connection.service'; 5 | 6 | export interface ISuggestionProps { 7 | connection: Connection, 8 | onChange?: (command?: string) => void 9 | onSearch: (command?: string) => void 10 | errorMessage?: string 11 | } 12 | 13 | 14 | export const Suggestion = (props: ISuggestionProps) => { 15 | 16 | const [command, setCommand] = useState(), 17 | searchBoxId = useId(`command-callout-button-${props.connection.id}`), 18 | [isCalloutVisible, setCalloutVisible] = useState(false), 19 | [expects, setExpects] = useState>([]), 20 | [_errorMessage, _setErrorMessage] = useState(); 21 | 22 | const handleChange = (e?: ChangeEvent, value?: string) => { 23 | setCommand(value || ""); 24 | _setErrorMessage(""); 25 | 26 | if (value) { 27 | suggestions({ id: props.connection.id, commands: [[value]] }).then((v: any) => { 28 | if (v) { 29 | const tokens = value.split(' '); 30 | const matchToken = tokens[tokens.length - 1]; 31 | const eps = matchToken ? (v as Array).filter(e => e.indexOf(matchToken.toUpperCase()) >= 0) : v; 32 | setExpects(eps); 33 | setCalloutVisible(!!eps.length); 34 | } else { 35 | setExpects([]) 36 | setCalloutVisible(false); 37 | } 38 | }) 39 | } else { 40 | setCalloutVisible(false); 41 | } 42 | } 43 | 44 | const handleItemInvoked = (item?: any, index?: number, ev?: Event) => { 45 | if (command) { 46 | const cmdSplits = command.split(' '); 47 | cmdSplits[cmdSplits.length - 1] = item; 48 | setCommand(cmdSplits.join(' ').trim()); 49 | } else { 50 | setCommand(item.trim()); 51 | } 52 | setCalloutVisible(false); 53 | } 54 | 55 | const handleKeyUp = (e: KeyboardEvent) => { 56 | if (e.code === 'Enter') { 57 | props.onSearch && props.onSearch(command); 58 | } 59 | } 60 | 61 | useEffect(() => { 62 | _setErrorMessage(props.errorMessage); 63 | }, [props.errorMessage]) 64 | 65 | useEffect(() => { 66 | props.onChange && props.onChange(command); 67 | }, [command, props]) 68 | 69 | return <> 70 |
71 | 81 | setCalloutVisible(false)} 87 | setInitialFocus={false} 88 | hidden={!isCalloutVisible} 89 | calloutMaxHeight={300} 90 | style={{ width: 295 }} 91 | target={`#${searchBoxId}`} 92 | directionalHint={DirectionalHint.bottomLeftEdge} 93 | isBeakVisible={false} 94 | > 95 | 96 | { 108 | return item 109 | } 110 | }]} 111 | items={expects} /> 112 | 113 | 114 |
115 | 116 | 117 | } -------------------------------------------------------------------------------- /web/src/components/lua/Luas.tsx: -------------------------------------------------------------------------------- 1 | import { CheckboxVisibility, DefaultButton, Depths, DetailsList, DetailsListLayoutMode, PrimaryButton, Selection, SelectionMode, Spinner, SpinnerSize, Stack, TooltipHost, useTheme } from '@fluentui/react'; 2 | import React, { useCallback, useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Connection } from '../../services/connection.service'; 5 | import { deleteLua, loadLuas, Lua } from '../../services/lua.service'; 6 | import { ErrorMessageBar } from '../common/ErrorMessageBar'; 7 | import { Scripting } from './Scripting'; 8 | 9 | 10 | export interface ILuaProps { 11 | connection: Connection 12 | } 13 | 14 | export const Luas = (props: ILuaProps) => { 15 | const { connection } = props; 16 | 17 | const theme = useTheme(), 18 | [loading, setLoading] = useState(false), 19 | { t } = useTranslation(), 20 | [luas, setLuas] = useState([]), 21 | [error, setError] = useState(), 22 | [selectedLua, setSelectedLua] = useState(); 23 | 24 | const handleLoadLuas = useCallback(() => { 25 | setError(undefined); 26 | setLoading(true); 27 | loadLuas(connection.id) 28 | .then((v: Lua[]) => setLuas(v || [])) 29 | .catch((err: Error) => { setError(err); }) 30 | .finally(() => { setLoading(false) }); 31 | }, [connection.id]) 32 | 33 | useEffect(() => { 34 | handleLoadLuas(); 35 | }, [handleLoadLuas]) 36 | 37 | const [selection] = useState(new Selection({ 38 | onSelectionChanged: () => { 39 | setSelectedLua(selection.getSelection()[0] as Lua); 40 | } 41 | })) 42 | 43 | const handleAdd = () => { 44 | selection.setAllSelected(false); 45 | setSelectedLua({ connectionID: connection.id } as Lua) 46 | } 47 | 48 | const handleDelete = () => { 49 | selectedLua && deleteLua(selectedLua) 50 | .then(() => { 51 | handleLoadLuas(); 52 | selection.setAllSelected(false); 53 | }) 54 | .catch((err: Error) => { setError(err); }) 55 | .finally() 56 | } 57 | 58 | return ( 59 | 60 | 61 | {/* error */} 62 | 63 | {/* header */} 64 | 65 | 66 | 68 | 69 | 70 | 71 | 73 | 74 | 75 | {/* {list} */} 76 | 77 | {loading ? : 78 | { 90 | return 91 | {item.name} 92 | 93 | } 94 | }]} 95 | items={luas} /> 96 | } 97 | 98 | 99 | 100 | 101 | {selectedLua && { 102 | handleLoadLuas(); 103 | }} />} 104 | {/* {scripting()} */} 105 | 106 | 107 | ) 108 | } -------------------------------------------------------------------------------- /desktop/app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | jsoniter "github.com/json-iterator/go" 7 | "io/ioutil" 8 | 9 | "github.com/slowrookie/redis-web-manager/api" 10 | "github.com/wailsapp/wails/v2/pkg/runtime" 11 | ) 12 | 13 | // App struct 14 | type App struct { 15 | ctx context.Context 16 | About map[string]string 17 | } 18 | 19 | // NewApp creates a new App application struct 20 | func NewApp() *App { 21 | return &App{} 22 | } 23 | 24 | // startup is called at application startup 25 | func (a *App) startup(ctx context.Context) { 26 | // Perform your setup here 27 | a.ctx = ctx 28 | } 29 | 30 | // domReady is called after the front-end dom has been loaded 31 | func (a *App) domReady(ctx context.Context) { 32 | // Add your action here 33 | } 34 | 35 | // shutdown is called at application termination 36 | func (a *App) shutdown(ctx context.Context) { 37 | // Perform your teardown here 38 | fmt.Println("") 39 | } 40 | 41 | // export methods 42 | 43 | // AboutInfo . 44 | func (a *App) AboutInfo() map[string]string { 45 | return a.About 46 | } 47 | 48 | // Connections . 49 | func (a *App) Connections() []*api.Connection { 50 | _ = api.LoadConnections() 51 | return api.Connections 52 | } 53 | 54 | func (a *App) TestConnection(con api.Connection) error { 55 | return con.Test() 56 | } 57 | 58 | func (a *App) EditConnection(con api.Connection) error { 59 | return con.Edit() 60 | } 61 | 62 | func (a *App) DeleteConnection(id string) error { 63 | connection, err := api.GetConnection(id) 64 | if err != nil { 65 | return err 66 | } 67 | return connection.Delete() 68 | } 69 | 70 | func (a *App) OpenConnection(id string) error { 71 | connection, err := api.GetConnection(id) 72 | if err != nil { 73 | return err 74 | } 75 | return connection.Open() 76 | } 77 | 78 | func (a *App) DisConnection(id string) error { 79 | connection, err := api.GetConnection(id) 80 | if err != nil { 81 | return err 82 | } 83 | return connection.Disconnection() 84 | } 85 | 86 | func (a *App) NewConnection(con api.Connection) error { 87 | return con.New() 88 | } 89 | 90 | func (a *App) CommandConnection(cmd api.Command) (interface{}, error) { 91 | connection, err := api.GetConnection(cmd.ID) 92 | if err != nil { 93 | return nil, err 94 | } 95 | ret, err := connection.Command(cmd.Commands) 96 | if err != nil { 97 | return nil, err 98 | } 99 | var json = jsoniter.ConfigCompatibleWithStandardLibrary 100 | retJson, err := json.Marshal(ret) 101 | if err != nil { 102 | return nil, err 103 | } 104 | return string(retJson), nil 105 | } 106 | 107 | func (a *App) ExecutionScript(lua *api.Lua) (*api.Lua, error) { 108 | connection, err := api.GetConnection(lua.ConnectionID) 109 | if err != nil { 110 | return nil, err 111 | } 112 | err = connection.Scripting(lua) 113 | return lua, nil 114 | } 115 | 116 | func (a *App) Suggestions(cmd api.Command) []string { 117 | connection, err := api.GetConnection(cmd.ID) 118 | if err != nil { 119 | return nil 120 | } 121 | return connection.Suggestions(cmd.Commands[0][0].(string)) 122 | } 123 | 124 | // Config . 125 | func (a *App) Config() (api.Config, error) { 126 | if err := api.DefaultConfig.Get(); err != nil { 127 | return *api.DefaultConfig, err 128 | } 129 | return *api.DefaultConfig, nil 130 | } 131 | 132 | func (a *App) SetConfig(config api.Config) error { 133 | return config.Set() 134 | } 135 | 136 | // NewLua . 137 | func (a *App) NewLua(lua api.Lua) error { 138 | return lua.New() 139 | } 140 | 141 | func (a *App) EditLua(lua api.Lua) error { 142 | return lua.Edit() 143 | } 144 | 145 | func (a *App) DeleteLua(lua api.Lua) error { 146 | return lua.Delete() 147 | } 148 | 149 | func (a *App) LoadLuas(connectionId string) ([]api.Lua, error) { 150 | luas := make([]api.Lua, 0) 151 | if err := api.LoadLuas(&luas); nil != err { 152 | return nil, err 153 | } 154 | var _luas []api.Lua 155 | for _, v := range luas { 156 | if v.ConnectionID == connectionId { 157 | _luas = append(_luas, v) 158 | } 159 | } 160 | return _luas, nil 161 | } 162 | 163 | // ReadFile . 164 | func (a *App) ReadFile() (string, error) { 165 | filePath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{}) 166 | if err != nil { 167 | return "", err 168 | } 169 | bts, err := ioutil.ReadFile(filePath) 170 | if err != nil { 171 | return "", err 172 | } 173 | return string(bts), err 174 | } 175 | 176 | // Quit . 177 | func (a *App) Quit() { 178 | runtime.Quit(a.ctx) 179 | } 180 | -------------------------------------------------------------------------------- /web/src/components/common/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import { Dropdown, IconButton, IDropdownOption, Stack, Text, TooltipHost } from "@fluentui/react"; 2 | import React, { FormEvent, useEffect, useState } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | const PageSizeOptions: Array<{ key: number, text: string }> = [ 6 | { key: 10, text: '10' }, 7 | { key: 20, text: '20' }, 8 | { key: 50, text: '50' }, 9 | { key: 100, text: '100' }, 10 | { key: 500, text: '500' }, 11 | { key: 1000, text: '1000' }, 12 | { key: 2000, text: '2000' }, 13 | ] 14 | 15 | export interface IPaginationProps { 16 | onChange: (start: number, end: number) => void 17 | totalElements: number 18 | defaultPageSize?: number 19 | } 20 | 21 | export interface IPagination { 22 | empty: boolean 23 | first: boolean 24 | last: boolean 25 | pageNumber: number 26 | pageSize: number 27 | loadedElements: number 28 | totalElements: number 29 | totalPages: number 30 | start: number 31 | end: number 32 | } 33 | 34 | const defaultPagination: IPagination = { 35 | empty: true, 36 | first: false, 37 | last: false, 38 | pageNumber: 1, 39 | pageSize: 20, 40 | loadedElements: 0, 41 | totalElements: 0, 42 | totalPages: 0, 43 | start: 0, 44 | end: 19 45 | } 46 | 47 | export const Pagination = (props: IPaginationProps) => { 48 | 49 | const { onChange, totalElements, defaultPageSize } = props, 50 | { t } = useTranslation(); 51 | 52 | const [pagination, setPagination] = useState({ ...defaultPagination, pageSize: defaultPageSize || 20 }), 53 | [pages, setPages] = useState>([]); 54 | 55 | useEffect(() => { 56 | const pageSize = pagination.pageSize; 57 | const totalPages = Math.ceil((totalElements || 0) / (pageSize)); 58 | setPagination(page => { 59 | return { 60 | ...page, 61 | empty: !totalElements, 62 | first: pagination.pageNumber === 1, 63 | last: pagination.pageNumber === totalPages, 64 | pageNumber: pagination.pageNumber, 65 | pageSize, 66 | totalElements: totalElements, 67 | totalPages: totalPages, 68 | start: (pagination.pageNumber - 1) * pageSize, 69 | end: (pagination.pageNumber - 1) * pageSize + pageSize - 1 70 | } 71 | }); 72 | }, [totalElements, pagination.pageSize, pagination.pageNumber]); 73 | 74 | useEffect(() => { 75 | setPages([...Array(pagination.totalPages)].map((n, i) => { return { key: i + 1 + '', text: i + 1 + '' } })); 76 | }, [pagination.totalPages]) 77 | 78 | useEffect(() => { 79 | onChange && onChange(pagination.start, pagination.end); 80 | // eslint-disable-next-line 81 | }, [pagination.start, pagination.end]) 82 | 83 | const handlePageSizeChange = (e: FormEvent, o?: IDropdownOption) => { 84 | o && setPagination({ ...pagination, pageSize: Number(o.key), pageNumber: 1 }); 85 | } 86 | 87 | const handlePrePage = () => { 88 | setPagination({ ...pagination, pageNumber: pagination.pageNumber - 1 }); 89 | } 90 | 91 | const handleNextPage = () => { 92 | setPagination({ ...pagination, pageNumber: pagination.pageNumber + 1 }); 93 | } 94 | 95 | const handlePageNumberChange = (e: FormEvent, o?: IDropdownOption) => { 96 | o && setPagination({ ...pagination, pageNumber: Number(o.key) }); 97 | } 98 | 99 | return ( 100 | 101 | 102 | 107 | {`${t('items')}/${t('page')}`} 108 | 109 | {`${t('total')} ${pagination.totalElements} ${t('items')}`} 110 | 111 | 112 | 114 | 115 | 116 | 117 | 119 | 120 | 121 | 126 | {`/ ${pagination.totalPages} ${t('page')}`} 127 | 128 | 129 | ); 130 | } -------------------------------------------------------------------------------- /web/src/components/configuration/DatabaseConfiguration.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton, CheckboxVisibility, DetailsListLayoutMode, DetailsRow, IDetailsRowStyles, ScrollablePane, ScrollbarVisibility, SearchBox, Selection, SelectionMode, ShimmeredDetailsList, Stack, TooltipHost, useTheme } from '@fluentui/react'; 2 | import React, { useCallback, useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Connection, executeCommand } from '../../services/connection.service'; 5 | import { ErrorMessageBar } from '../common/ErrorMessageBar'; 6 | import _ from 'lodash'; 7 | 8 | export interface IDatabaseConfigProps { 9 | connection: Connection 10 | } 11 | 12 | export const DatabaseConfiguration = (props: IDatabaseConfigProps) => { 13 | 14 | const theme = useTheme(), { t } = useTranslation(); 15 | 16 | const [configs, setConfigs] = useState>([]), 17 | // [selectedKey, setSelectedKey] = useState(), 18 | [filter, setFilter] = useState("*"), 19 | [loading, setLoading] = useState(false), 20 | [error, setError] = useState(); 21 | 22 | const load = useCallback(() => { 23 | setLoading(true); 24 | executeCommand>({ id: props.connection.id, commands: [['CONFIG', 'GET', filter]] }) 25 | .then(ret => { 26 | if (!ret || !ret.length) return; 27 | let cfs = []; 28 | if(Array.isArray(ret[0])) { 29 | cfs = _.map(_.chunk(ret[0], 2), ([k, v]) => ({key: k, value: v})); 30 | } else { 31 | cfs = _.map(_.entries(ret[0]), ([k, v]) => ({key: k, value: v})); 32 | } 33 | setConfigs(cfs); 34 | }) 35 | .catch(err => setError(err)) 36 | .finally(() => setLoading(false)); 37 | }, [props.connection.id, filter]); 38 | 39 | useEffect(() => { 40 | load(); 41 | }, [load]); 42 | 43 | const selection = new Selection({ 44 | onSelectionChanged: () => { 45 | // TODO 46 | // setSelectedKey(selection.getSelection()[0] as Config); 47 | } 48 | }); 49 | 50 | return ( 51 | 52 | 53 | 54 | 55 | 56 | 57 | { setFilter(nv || '*') }} 61 | /> 62 | 63 | {/* 64 | { 66 | }} /> 67 | */} 68 | 69 | 70 | 71 | 72 | 73 | 74 | { }} 90 | onRenderRow={props => { 91 | const detailsRowStyles: Partial = { 92 | root: { 93 | backgroundColor: (props && props.itemIndex % 2 === 0) ? theme.palette.neutralLighterAlt : '' 94 | } 95 | }; 96 | return props ? : (<>) 97 | }} 98 | /> 99 | 100 | 101 | 102 | ) 103 | } -------------------------------------------------------------------------------- /api/parser/redisparser_base_visitor.go: -------------------------------------------------------------------------------- 1 | // Code generated from RedisParser.g4 by ANTLR 4.10.1. DO NOT EDIT. 2 | 3 | package parser // RedisParser 4 | 5 | import "github.com/antlr/antlr4/runtime/Go/antlr" 6 | 7 | type BaseRedisParserVisitor struct { 8 | *antlr.BaseParseTreeVisitor 9 | } 10 | 11 | func (v *BaseRedisParserVisitor) VisitCommand(ctx *CommandContext) interface{} { 12 | return v.VisitChildren(ctx) 13 | } 14 | 15 | func (v *BaseRedisParserVisitor) VisitBitmap(ctx *BitmapContext) interface{} { 16 | return v.VisitChildren(ctx) 17 | } 18 | 19 | func (v *BaseRedisParserVisitor) VisitClusterManagement(ctx *ClusterManagementContext) interface{} { 20 | return v.VisitChildren(ctx) 21 | } 22 | 23 | func (v *BaseRedisParserVisitor) VisitClusterSub(ctx *ClusterSubContext) interface{} { 24 | return v.VisitChildren(ctx) 25 | } 26 | 27 | func (v *BaseRedisParserVisitor) VisitConnectionManagement(ctx *ConnectionManagementContext) interface{} { 28 | return v.VisitChildren(ctx) 29 | } 30 | 31 | func (v *BaseRedisParserVisitor) VisitClientSub(ctx *ClientSubContext) interface{} { 32 | return v.VisitChildren(ctx) 33 | } 34 | 35 | func (v *BaseRedisParserVisitor) VisitGeneric(ctx *GenericContext) interface{} { 36 | return v.VisitChildren(ctx) 37 | } 38 | 39 | func (v *BaseRedisParserVisitor) VisitObjectSub(ctx *ObjectSubContext) interface{} { 40 | return v.VisitChildren(ctx) 41 | } 42 | 43 | func (v *BaseRedisParserVisitor) VisitGeospatialIndices(ctx *GeospatialIndicesContext) interface{} { 44 | return v.VisitChildren(ctx) 45 | } 46 | 47 | func (v *BaseRedisParserVisitor) VisitHash(ctx *HashContext) interface{} { 48 | return v.VisitChildren(ctx) 49 | } 50 | 51 | func (v *BaseRedisParserVisitor) VisitHyperloglog(ctx *HyperloglogContext) interface{} { 52 | return v.VisitChildren(ctx) 53 | } 54 | 55 | func (v *BaseRedisParserVisitor) VisitList(ctx *ListContext) interface{} { 56 | return v.VisitChildren(ctx) 57 | } 58 | 59 | func (v *BaseRedisParserVisitor) VisitPubSub(ctx *PubSubContext) interface{} { 60 | return v.VisitChildren(ctx) 61 | } 62 | 63 | func (v *BaseRedisParserVisitor) VisitPubSubSub(ctx *PubSubSubContext) interface{} { 64 | return v.VisitChildren(ctx) 65 | } 66 | 67 | func (v *BaseRedisParserVisitor) VisitScriptingAndFunctions(ctx *ScriptingAndFunctionsContext) interface{} { 68 | return v.VisitChildren(ctx) 69 | } 70 | 71 | func (v *BaseRedisParserVisitor) VisitFunctionSub(ctx *FunctionSubContext) interface{} { 72 | return v.VisitChildren(ctx) 73 | } 74 | 75 | func (v *BaseRedisParserVisitor) VisitScriptSub(ctx *ScriptSubContext) interface{} { 76 | return v.VisitChildren(ctx) 77 | } 78 | 79 | func (v *BaseRedisParserVisitor) VisitServerManagement(ctx *ServerManagementContext) interface{} { 80 | return v.VisitChildren(ctx) 81 | } 82 | 83 | func (v *BaseRedisParserVisitor) VisitAclSub(ctx *AclSubContext) interface{} { 84 | return v.VisitChildren(ctx) 85 | } 86 | 87 | func (v *BaseRedisParserVisitor) VisitCommandSub(ctx *CommandSubContext) interface{} { 88 | return v.VisitChildren(ctx) 89 | } 90 | 91 | func (v *BaseRedisParserVisitor) VisitConfigSub(ctx *ConfigSubContext) interface{} { 92 | return v.VisitChildren(ctx) 93 | } 94 | 95 | func (v *BaseRedisParserVisitor) VisitLatencySub(ctx *LatencySubContext) interface{} { 96 | return v.VisitChildren(ctx) 97 | } 98 | 99 | func (v *BaseRedisParserVisitor) VisitMemorySub(ctx *MemorySubContext) interface{} { 100 | return v.VisitChildren(ctx) 101 | } 102 | 103 | func (v *BaseRedisParserVisitor) VisitModuleSub(ctx *ModuleSubContext) interface{} { 104 | return v.VisitChildren(ctx) 105 | } 106 | 107 | func (v *BaseRedisParserVisitor) VisitSlowlogSub(ctx *SlowlogSubContext) interface{} { 108 | return v.VisitChildren(ctx) 109 | } 110 | 111 | func (v *BaseRedisParserVisitor) VisitSet(ctx *SetContext) interface{} { 112 | return v.VisitChildren(ctx) 113 | } 114 | 115 | func (v *BaseRedisParserVisitor) VisitSortedSet(ctx *SortedSetContext) interface{} { 116 | return v.VisitChildren(ctx) 117 | } 118 | 119 | func (v *BaseRedisParserVisitor) VisitStream(ctx *StreamContext) interface{} { 120 | return v.VisitChildren(ctx) 121 | } 122 | 123 | func (v *BaseRedisParserVisitor) VisitXgroupSub(ctx *XgroupSubContext) interface{} { 124 | return v.VisitChildren(ctx) 125 | } 126 | 127 | func (v *BaseRedisParserVisitor) VisitXinfoSub(ctx *XinfoSubContext) interface{} { 128 | return v.VisitChildren(ctx) 129 | } 130 | 131 | func (v *BaseRedisParserVisitor) VisitStringCmd(ctx *StringCmdContext) interface{} { 132 | return v.VisitChildren(ctx) 133 | } 134 | 135 | func (v *BaseRedisParserVisitor) VisitTransactions(ctx *TransactionsContext) interface{} { 136 | return v.VisitChildren(ctx) 137 | } 138 | -------------------------------------------------------------------------------- /web/src/components/panel/ZsetKeyPanel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultButton, 3 | Overlay, Panel, PanelType, PrimaryButton, 4 | Spinner, SpinnerSize, Stack, 5 | TextField, 6 | useTheme 7 | } from '@fluentui/react'; 8 | import React, { useEffect, useState } from 'react'; 9 | import { useTranslation } from 'react-i18next'; 10 | import { Connection, executeCommand } from '../../services/connection.service'; 11 | import { ErrorMessageBar } from '../common/ErrorMessageBar'; 12 | import { FormatTextField } from '../common/FormatTextField'; 13 | 14 | export interface IZsetKeyPanelProps { 15 | connection: Connection 16 | db: number 17 | index?: number 18 | onDismiss: () => void, 19 | onSave: (keyItem: any) => void, 20 | isOpen: boolean 21 | keyType: string 22 | keyName?: string 23 | keyValue?: string, 24 | keyScore?: string, 25 | disabledKeyName?: boolean 26 | } 27 | 28 | export interface HashKey { 29 | name: string 30 | field: string, 31 | value: string 32 | } 33 | 34 | export const ZsetKeyPanel = (props: IZsetKeyPanelProps) => { 35 | 36 | const { connection, db, keyType, onDismiss, onSave, isOpen, index, keyName, keyValue, keyScore, disabledKeyName } = props; 37 | const { t } = useTranslation(), theme = useTheme(); 38 | 39 | const [keyItem, setKeyItem] = useState({ name: '', value: '', score: '' }), 40 | [error, setError] = useState(), 41 | [loading, setLoading] = useState(false); 42 | 43 | useEffect(() => { 44 | setKeyItem({ name: keyName || '', value: keyValue || '', score: keyScore || '' }); 45 | }, [keyName, keyValue, keyScore]) 46 | 47 | const handleSave = (save?: boolean) => { 48 | setError(undefined); 49 | setLoading(true); 50 | var commands = [['SELECT', db]]; 51 | (index && index >= 0 && keyValue) && commands.push(['ZREM', keyItem.name, keyValue]); 52 | commands.push(['ZADD', keyItem.name, keyItem.score, keyItem.value]); 53 | executeCommand>({ id: connection.id, commands }) 54 | .then((ret) => { 55 | if (!ret || !ret.length) return; 56 | onDismiss && onDismiss(); 57 | save && onSave && onSave({ ...keyItem, index }); 58 | }) 59 | .catch(err => setError(err)) 60 | .finally(() => setLoading(false)); 61 | } 62 | 63 | return ( 64 | onDismiss()} 70 | headerText={`${t('Edit')} ${keyType} (${connection.name}:db${db})`} 71 | onRenderFooterContent={() => { 72 | var disabled = !!!keyItem.name || !keyItem.score; 73 | return ( 74 | 75 | 76 | { handleSave() }}> 77 | { handleSave(true) }}> 78 | onDismiss()}> 79 | 80 | ) 81 | }} 82 | isFooterAtBottom={true} 83 | > 84 | 85 | {loading && 86 | 87 | 88 | 89 | 90 | 91 | } 92 | 93 | 94 | { 96 | setKeyItem({ ...keyItem, name: v || '' }); 97 | }} /> 98 | 99 | { 101 | setKeyItem({ ...keyItem, score: v || '' }); 102 | }} /> 103 | 104 | 105 | { 106 | setKeyItem({ ...keyItem, value: v || ''}); 107 | }} /> 108 | 109 | 110 | 111 | 112 | 113 | 114 | ) 115 | } -------------------------------------------------------------------------------- /web/src/locales/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "Overview": "Overview", 3 | "Server Info": "Server Info", 4 | "Database": "Database", 5 | "CLI": "CLI", 6 | "Configuration": "Configuration", 7 | "Load more": "Load more", 8 | "Theme": "Theme", 9 | "Theme-Dark": "Dark", 10 | "Theme-Light": "Default", 11 | "Theme-Word": "Word", 12 | "Theme-Teams": "Teams", 13 | "Language": "Language", 14 | "About": "About", 15 | "The test connection to the Redis server is successful!": "The test connection to the Redis server is successful!", 16 | "The connection to the Redis server is successful!": "The connection to the Redis server is successful!", 17 | "Save success!": "Save success!", 18 | "Connection settings": "Connection settings", 19 | "Test Connection": "Test Connection", 20 | "OK": "OK", 21 | "Cancel": "Cancel", 22 | "Connecting...": "Connecting...", 23 | "Basic": "Basic", 24 | "Name": "Name", 25 | "Connection name": "Connection name", 26 | "Address": "Address", 27 | "Service address": "Service address", 28 | "Host": "Host", 29 | "Port": "Port", 30 | "Password": "Password", 31 | "Private key": "Private key", 32 | "(Optional) Service authentication password": "(Optional) Service authentication password", 33 | "Username": "Username", 34 | "(Optional) Service authentication user name Reids> 6.0": "(Optional) Service authentication user name Reids> 6.0", 35 | "Advanced": "Advanced", 36 | "Key load": "Key load", 37 | "Default key filtering": "Default key filtering", 38 | "Namespace separator": "Namespace separator", 39 | "Timeouts and limits": "Timeouts and limits", 40 | "Connection timeout (ms)": "Connection timeout (ms)", 41 | "Execution timeout (ms)": "Execution timeout (ms)", 42 | "Database display limit": "Database display limit", 43 | "Data scan limit": "Data scan limit", 44 | "Copy": "Copy", 45 | "Edit": "Edit", 46 | "Delete": "Delete", 47 | "Service Info": "Service Info", 48 | "Console": "Console", 49 | "Refresh": "Refresh", 50 | "Disconnect": "Disconnect", 51 | "Add key": "Add key", 52 | "Remove keys": "Remove keys", 53 | "Delete this key?": "Delete this key?", 54 | "Filter": "Filter", 55 | "Size": "Size", 56 | "View": "View", 57 | "page": "page", 58 | "total": "total", 59 | "items": "items", 60 | "Previous page": "Previous page", 61 | "Next page": "Next page", 62 | "Auto refresh": "Auto refresh", 63 | "Redis version": "Redis version", 64 | "Used memory": "Used memory", 65 | "Clients": "Clients", 66 | "Commands processed": "Commands processed", 67 | "Uptime in days": "Uptime in days", 68 | "Memory usage": "Memory usage", 69 | "Connected Duration (sec)": "Connected Duration (sec)", 70 | "Idle Duration (sec)": "Idle Duration (sec)", 71 | "Flgs": "Flgs", 72 | "Property": "Property", 73 | "Value": "Value", 74 | "Slow log": "Slow log", 75 | "Push/subscribe channel": "Push/subscribe channel", 76 | "Channel": "Channel", 77 | "Client address": "Client address", 78 | "Command": "Command", 79 | "Dealt with": "Dealt with", 80 | "Duration (μs)": "Duration (μs)", 81 | "Flags": "Flags", 82 | "Current Database": "db", 83 | "Clear": "Clear", 84 | "Current page search...": "Current page search...", 85 | "Add": "Add", 86 | "Rename": "Rename", 87 | "Update": "Update", 88 | "Reload": "Reload", 89 | "Delete Key": "Delete Key", 90 | "Ascending": "Ascending", 91 | "Descending": "Descending", 92 | "Delete this item?": "Delete this item?", 93 | "Index search": "Index search", 94 | "Cursor search": "Cursor search", 95 | "Save": "Save", 96 | "Saving...": "Saving...", 97 | "Key name": "Key name", 98 | "Key value": "Key value", 99 | "Filed name": "Filed name", 100 | "Field value": "Field value", 101 | "Value (JSON object)": "Value (JSON object)", 102 | "Incorrect format": "Incorrect format", 103 | "Score": "Score", 104 | "Load...": "Load...", 105 | "Search": "Search", 106 | "Groups": "Groups", 107 | "Upload": "Upload", 108 | "Security": "Security", 109 | "Welcome to discussions!": "Welcome to discussions!", 110 | "general": "General", 111 | "cluster": "Cluster", 112 | "sentinel": "Sentinel", 113 | "Route": "Route", 114 | "RouteByLatency": "Route by latency", 115 | "RouteRandomly": "Route randomly", 116 | "Sentinel password": "Sentinel Password", 117 | "Master name": "Master Name", 118 | "Type": "Type", 119 | "Execution": "Execution", 120 | "Last Execution At": "Last Execution At", 121 | "Elapsed": "Elapsed" 122 | } -------------------------------------------------------------------------------- /web/src/components/panel/HashKeyPanel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultButton, 3 | Overlay, Panel, PanelType, PrimaryButton, 4 | Spinner, SpinnerSize, Stack, 5 | TextField, 6 | useTheme 7 | } from '@fluentui/react'; 8 | import React, { useEffect, useState } from 'react'; 9 | import { useTranslation } from 'react-i18next'; 10 | import { Connection, executeCommand } from '../../services/connection.service'; 11 | import { ErrorMessageBar } from '../common/ErrorMessageBar'; 12 | import { FormatTextField } from '../common/FormatTextField'; 13 | 14 | export interface IHashKeyPanelProps { 15 | connection: Connection 16 | db: number 17 | index?: number 18 | onDismiss: () => void, 19 | onSave: (field: string, value: string, index?: number) => void, 20 | isOpen: boolean 21 | keyType: string 22 | keyName?: string 23 | KeyFieldName?: string 24 | KeyFieldValue?: string 25 | disabledKeyName?: boolean 26 | } 27 | 28 | export interface HashKey { 29 | name: string 30 | field: string, 31 | value: string 32 | } 33 | 34 | // if index >= 0 is edit 35 | export const HashKeyPanel = (props: IHashKeyPanelProps) => { 36 | const { connection, db, index, onDismiss, onSave, isOpen, keyType, keyName, KeyFieldName, KeyFieldValue, disabledKeyName } = props; 37 | const { t } = useTranslation(), theme = useTheme(); 38 | const [keyItem, setKeyItem] = useState({ name: keyName || '', field: KeyFieldName || '', value: KeyFieldValue || '' }), 39 | [error, setError] = useState(), 40 | [loading, setLoading] = useState(false); 41 | 42 | useEffect(() => { 43 | setKeyItem({ name: keyName || '', field: KeyFieldName || '', value: KeyFieldValue || '' }); 44 | }, [keyName, KeyFieldName, KeyFieldValue]) 45 | 46 | const handleSave = (save?: boolean) => { 47 | setError(undefined); 48 | setLoading(true); 49 | executeCommand>({ 50 | id: connection.id, commands: [ 51 | ['SELECT', db], 52 | ['HSET', keyItem.name, keyItem.field, keyItem.value]] 53 | }) 54 | .then((ret) => { 55 | if (!ret || !ret.length) return; 56 | onDismiss && onDismiss(); 57 | save && onSave && onSave(keyItem.field, keyItem.name, index); 58 | }) 59 | .catch(err => setError(err)) 60 | .finally(() => setLoading(false)); 61 | } 62 | 63 | return ( 64 | onDismiss()} 70 | headerText={`${t('Edit')} ${keyType} (${connection.name}:db${db})`} 71 | onRenderFooterContent={() => { 72 | var disabled = !!!keyItem.name || !keyItem.field; 73 | return ( 74 | 75 | 76 | { handleSave() }}> 77 | { handleSave(true) }}> 78 | onDismiss()}> 79 | 80 | ) 81 | }} 82 | isFooterAtBottom={true} 83 | > 84 | 85 | {loading && 86 | 87 | 88 | 89 | 90 | 91 | } 92 | 93 | 94 | { 96 | setKeyItem({ ...keyItem, name: v || '' }); 97 | }} /> 98 | 99 | = 0} value={keyItem.field} onChange={(e, v) => { 101 | setKeyItem({ ...keyItem, field: v || '' }); 102 | }} /> 103 | 104 | 105 | { 106 | setKeyItem({ ...keyItem, value: v || '' }); 107 | }}> 108 | 109 | 110 | 111 | 112 | 113 | 114 | ) 115 | } -------------------------------------------------------------------------------- /web/src/components/lua/Scripting.tsx: -------------------------------------------------------------------------------- 1 | import { Label, PrimaryButton, Stack, Text, TextField, useTheme } from '@fluentui/react'; 2 | import Editor, {loader} from '@monaco-editor/react'; 3 | import * as monaco from 'monaco-editor'; 4 | import dayjs from 'dayjs'; 5 | import React, { useEffect, useRef, useState } from 'react'; 6 | import { useTranslation } from 'react-i18next'; 7 | import { debounceTime, fromEvent, map } from 'rxjs'; 8 | import { eidtLua, executionScript, Lua } from '../../services/lua.service'; 9 | 10 | loader.config({monaco}) 11 | 12 | export interface ILuaProps { 13 | lua: Lua, 14 | onChanged: (lua: Lua) => void 15 | } 16 | 17 | export const Scripting = (props: ILuaProps) => { 18 | const { lua, onChanged } = props; 19 | 20 | const editorRef = useRef(null), 21 | theme = useTheme(), 22 | { t } = useTranslation(), 23 | [_lua, _setLua] = useState(lua), 24 | [ret, setRet] = useState(), 25 | [errorMsg, setErrorMsg] = useState(); 26 | 27 | useEffect(() => { 28 | _setLua({ ...lua }); 29 | }, [lua]) 30 | 31 | const handleExecution = () => { 32 | setRet(null); 33 | setErrorMsg(''); 34 | executionScript(_lua) 35 | .then(v => { 36 | setRet(v.result); 37 | _setLua(v) 38 | }) 39 | .catch(err => { 40 | setErrorMsg(err); 41 | setRet(err); 42 | }) 43 | .finally(() => { 44 | 45 | }) 46 | } 47 | 48 | const handleSave = () => { 49 | eidtLua(_lua) 50 | .then(v => { 51 | onChanged(_lua); 52 | }) 53 | .catch(err => { 54 | setErrorMsg(err) 55 | setRet(err) 56 | }) 57 | } 58 | 59 | // re-render eiditor after window resize 60 | const [display, setDisplay] = useState('block'); 61 | useEffect(() => { 62 | const sub = fromEvent(window, "resize").pipe( 63 | map(v => { 64 | setDisplay('none'); 65 | return v; 66 | }), 67 | debounceTime(250) 68 | ).subscribe(v => { 69 | setDisplay('block'); 70 | }) 71 | return () => sub && sub.unsubscribe(); 72 | }) 73 | 74 | return ( 75 | 76 | { 79 | _setLua({ ..._lua, name: v || '' }) 80 | }} /> 81 | 82 | { 85 | _setLua({ ..._lua, keys: v ? v.split(',') : [] }) 86 | }} 87 | description={`${'multiple keys are separated by commas `,`'}`} /> 88 | 89 | { 92 | _setLua({ ..._lua, args: v ? v.split(',') : [] }) 93 | }} 94 | description={`${'multiple args are separated by commas `,`'}`} /> 95 | 96 | {/* eidtor */} 97 | 98 |
99 | { 106 | _setLua({ ..._lua, script: v || '' }) 107 | }} 108 | onMount={(editor, monaco) => { 109 | editorRef.current = editor; 110 | }} 111 | /> 112 |
113 |
114 | 115 | {/* buttons */} 116 | 117 | {_lua && _lua.lastExecutionAt && (<> 118 | 119 | {dayjs(_lua?.lastExecutionAt).format("YYYY-MM-DD HH:mm:ss SSS")} 120 | )} 121 | {_lua && _lua.elapsed && (<> 122 | 123 | {_lua?.elapsed} 124 | )} 125 | 126 | { handleSave() }}> 127 | { handleExecution() }}> 128 | 129 | 130 | {/* result */} 131 | 136 |
137 | ) 138 | } -------------------------------------------------------------------------------- /desktop/build/windows/installer/project.nsi: -------------------------------------------------------------------------------- 1 | Unicode true 2 | 3 | #### 4 | ## Please note: Template replacements don't work in this file. They are provided with default defines like 5 | ## mentioned underneath. 6 | ## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo. 7 | ## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually 8 | ## from outside of Wails for debugging and development of the installer. 9 | ## 10 | ## For development first make a wails nsis build to populate the "wails_tools.nsh": 11 | ## > wails build --target windows/amd64 --nsis 12 | ## Then you can call makensis on this file with specifying the path to your binary: 13 | ## For a AMD64 only installer: 14 | ## > makensis -DARG_WAILS_AMD64_BINARY=../../bin/app.exe 15 | ## For a ARM64 only installer: 16 | ## > makensis -DARG_WAILS_ARM64_BINARY=../../bin/app.exe 17 | ## For a installer with both architectures: 18 | ## > makensis -DARG_WAILS_AMD64_BINARY=../../bin/app-amd64.exe -DARG_WAILS_ARM64_BINARY=../../bin/app-arm64.exe 19 | #### 20 | ## The following information is taken from the ProjectInfo file, but they can be overwritten here. 21 | #### 22 | ## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}" 23 | ## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}" 24 | ## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}" 25 | ## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}" 26 | ## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}" 27 | ### 28 | ## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" 29 | ## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" 30 | #### 31 | ## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html 32 | #### 33 | ## Include the wails tools 34 | #### 35 | !include "wails_tools.nsh" 36 | 37 | # The version information for this two must consist of 4 parts 38 | VIProductVersion "${INFO_PRODUCTVERSION}.0" 39 | VIFileVersion "${INFO_PRODUCTVERSION}.0" 40 | 41 | VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" 42 | VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" 43 | VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" 44 | VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" 45 | VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" 46 | VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" 47 | 48 | !include "MUI.nsh" 49 | 50 | !define MUI_ICON "../icon.ico" 51 | !define MUI_UNICON "../icon.ico" 52 | # !define MUI_WELCOMEFINISHPAGE_BITMAP "resources/leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 53 | !define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps 54 | !define MUI_ABORTWARNING # This will warn the user if they exit from the installer. 55 | 56 | !insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. 57 | # !insertmacro MUI_PAGE_LICENSE "resources/eula.txt" # Adds a EULA page to the installer 58 | !insertmacro MUI_PAGE_DIRECTORY # In which folder install page. 59 | !insertmacro MUI_PAGE_INSTFILES # Installing page. 60 | !insertmacro MUI_PAGE_FINISH # Finished installation page. 61 | 62 | !insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page 63 | 64 | !insertmacro MUI_LANGUAGE "English" # Set the Language of the installer 65 | 66 | ## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 67 | #!uninstfinalize 'signtool --file "%1"' 68 | #!finalize 'signtool --file "%1"' 69 | 70 | Name "${INFO_PRODUCTNAME}" 71 | OutFile "../../bin/${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. 72 | InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder). 73 | ShowInstDetails show # This will always show the installation details. 74 | 75 | Function .onInit 76 | !insertmacro wails.checkArchitecture 77 | FunctionEnd 78 | 79 | Section 80 | !insertmacro wails.webview2runtime 81 | 82 | SetOutPath $INSTDIR 83 | 84 | !insertmacro wails.files 85 | 86 | CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" 87 | CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" 88 | 89 | !insertmacro wails.writeUninstaller 90 | SectionEnd 91 | 92 | Section "uninstall" 93 | RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath 94 | 95 | RMDir /r $INSTDIR 96 | 97 | Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" 98 | Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" 99 | 100 | !insertmacro wails.deleteUninstaller 101 | SectionEnd 102 | -------------------------------------------------------------------------------- /web/src/components/StringKey.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultButton, Stack, TooltipHost } from '@fluentui/react'; 2 | import React, { useCallback, useEffect, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Connection, executeCommand } from '../services/connection.service'; 5 | import { ErrorMessageBar } from './common/ErrorMessageBar'; 6 | import { FormatTextField } from './common/FormatTextField'; 7 | import { Loading } from './common/Loading'; 8 | import { KeyHeader } from './KeyHeader'; 9 | 10 | const buttonStyles = { 11 | root: { 12 | minWidth: 'auto', 13 | padding: '0 6px' 14 | } 15 | } 16 | 17 | export interface IStringKeyProps { 18 | connection: Connection 19 | db: number 20 | component: string, 21 | keyName: string, 22 | onKeyNameChanged: (oldKeyName: string, keyName: string) => void 23 | onDeletedKey: (keyName: string) => void 24 | } 25 | 26 | export interface IStringKey { 27 | keyName: string 28 | TTL: number 29 | length: number 30 | value: string 31 | initialValue: string 32 | } 33 | 34 | const defaultStringKey: IStringKey = { 35 | keyName: '', 36 | TTL: -1, 37 | value: '', 38 | initialValue: '', 39 | length: 0 40 | } 41 | 42 | // 2M 43 | const MAX_LENGTH = 2 * 1024 * 1000; 44 | 45 | export const StringKey = (props: IStringKeyProps) => { 46 | 47 | const { keyName, connection, db, onKeyNameChanged } = props; 48 | 49 | const { t } = useTranslation(); 50 | 51 | const [keyProps, setKeyProps] = useState({ ...defaultStringKey, keyName: keyName }), 52 | [error, setError] = useState(), 53 | [loading, setLoading] = useState(false), 54 | [bigKey, setBigKey] = useState(false); 55 | 56 | useEffect(() => { 57 | setKeyProps({ ...defaultStringKey, keyName }) 58 | }, [keyName]) 59 | 60 | 61 | // load value 62 | const loadValue = useCallback(() => { 63 | executeCommand>({ 64 | id: connection.id, commands: [ 65 | ['SELECT', db], 66 | ['GET', keyProps.keyName], 67 | ] 68 | }).then((ret) => { 69 | setKeyProps(v => { 70 | v.value = ret[1]; 71 | v.initialValue = ret[1]; 72 | return {...v}; 73 | }) 74 | }) 75 | .catch(err => setError(err)) 76 | .finally(() => setLoading(false)); 77 | }, [connection.id, db, keyProps.keyName]) 78 | 79 | const load = useCallback(() => { 80 | setLoading(true); 81 | setError(undefined); 82 | executeCommand>({ 83 | id: connection.id, commands: [ 84 | ['SELECT', db], 85 | ['TTL', keyProps.keyName], 86 | ['STRLEN', keyProps.keyName], 87 | ] 88 | }).then((ret) => { 89 | if (!ret || !ret.length) return; 90 | setKeyProps({ 91 | keyName: keyProps.keyName, 92 | TTL: ret[1], 93 | length: ret[2], 94 | value: '', 95 | initialValue: '', 96 | }); 97 | if (Number(ret[2]) > MAX_LENGTH) { 98 | setBigKey(true) 99 | } else { 100 | loadValue(); 101 | } 102 | }) 103 | .catch(err => setError(err)) 104 | .finally(() => setLoading(false)); 105 | 106 | }, [connection.id, db, keyProps.keyName, loadValue]); 107 | 108 | useEffect(() => { 109 | load(); 110 | }, [load]); 111 | 112 | const handleValue = () => { 113 | setError(undefined); 114 | executeCommand({ 115 | id: connection.id, commands: [ 116 | ['SELECT', db], 117 | ['SET', keyProps.keyName, keyProps.value], 118 | ] 119 | }).then((ret) => { 120 | setKeyProps({ ...keyProps, initialValue: keyProps.value }); 121 | load(); 122 | }) 123 | .catch(err => setError(err)); 124 | } 125 | 126 | const textFieldActions = () => { 127 | return ( 128 | 129 | 131 | 132 | ) 133 | } 134 | 135 | return (<> 136 | 137 | 138 | {/* error */} 139 | 140 | 141 | { 143 | setKeyProps({ ...keyProps, keyName: newValue }); 144 | onKeyNameChanged(oldValue, newValue); 145 | }} 146 | onTTLChanged={(v) => { setKeyProps({ ...keyProps, TTL: v }) }} 147 | onRefresh={() => load()} /> 148 | 149 | 150 | setKeyProps({ ...keyProps, value: v || '' })} 152 | actions={textFieldActions()} 153 | > 154 | 155 | 156 | 157 | ) 158 | } --------------------------------------------------------------------------------