Repository: tiny-craft/tiny-rdm Branch: main Commit: a87f8f319be6 Files: 306 Total size: 1.3 MB Directory structure: gitextract_sgnqk2bp/ ├── .github/ │ ├── CONTRIBUTING.md │ ├── CONTRIBUTING_zh.md │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ ├── docker-publish.yml │ ├── release-linux-webkit2-41.yaml │ ├── release-linux.yaml │ ├── release-macos.yaml │ └── release-windows.yaml ├── .gitignore ├── .prettierignore ├── Dockerfile ├── LICENSE ├── README.md ├── README_es.md ├── README_fr.md ├── README_ja.md ├── README_ko.md ├── README_pt.md ├── README_ru.md ├── README_tr.md ├── README_tw.md ├── README_zh.md ├── backend/ │ ├── api/ │ │ ├── auth.go │ │ ├── browser_api.go │ │ ├── cli_api.go │ │ ├── connection_api.go │ │ ├── monitor_api.go │ │ ├── preferences_api.go │ │ ├── pubsub_api.go │ │ ├── router.go │ │ ├── system_api.go │ │ └── websocket_hub.go │ ├── consts/ │ │ ├── app_name_desktop.go │ │ ├── app_name_web.go │ │ └── default_config.go │ ├── services/ │ │ ├── browser_service.go │ │ ├── cli_service.go │ │ ├── connection_service.go │ │ ├── connection_service_web.go │ │ ├── ga_service.go │ │ ├── monitor_service.go │ │ ├── platform_desktop.go │ │ ├── platform_web.go │ │ ├── preferences_service.go │ │ ├── pubsub_service.go │ │ └── system_service.go │ ├── storage/ │ │ ├── connections.go │ │ ├── local_storage.go │ │ └── preferences.go │ ├── types/ │ │ ├── connection.go │ │ ├── js_resp.go │ │ ├── preferences.go │ │ ├── redis_wrapper.go │ │ └── view_type.go │ └── utils/ │ ├── coll/ │ │ └── set.go │ ├── constraints.go │ ├── convert/ │ │ ├── base64_convert.go │ │ ├── binary_convert.go │ │ ├── bitset_convert.go │ │ ├── brotli_convert.go │ │ ├── cmd_convert.go │ │ ├── common.go │ │ ├── common_nonwindows.go │ │ ├── common_windows.go │ │ ├── convert.go │ │ ├── deflate_convert.go │ │ ├── gzip_convert.go │ │ ├── hex_convert.go │ │ ├── json_convert.go │ │ ├── lz4_convert.go │ │ ├── msgpack_convert.go │ │ ├── php_convert.go │ │ ├── pickle_convert.go │ │ ├── unicode_json_convert.go │ │ ├── xml_convert.go │ │ ├── yaml_convert.go │ │ └── zstd_convert.go │ ├── map/ │ │ └── map_util.go │ ├── math/ │ │ └── math_util.go │ ├── proxy/ │ │ └── http.go │ ├── redis/ │ │ └── log_hook.go │ ├── slice/ │ │ └── slice_util.go │ └── string/ │ ├── any_convert.go │ ├── common.go │ ├── json_formatter.go │ └── key_convert.go ├── build/ │ ├── README.md │ ├── darwin/ │ │ ├── Info.dev.plist │ │ └── Info.plist │ ├── dmg/ │ │ ├── background.tiff │ │ ├── fix-app │ │ └── fix-app_zh │ ├── linux/ │ │ └── tiny-rdm_0.0.0_amd64/ │ │ ├── DEBIAN/ │ │ │ └── control │ │ └── usr/ │ │ ├── local/ │ │ │ └── bin/ │ │ │ └── .gitkeep │ │ └── share/ │ │ └── applications/ │ │ └── tiny-rdm.desktop │ └── windows/ │ ├── info.json │ ├── installer/ │ │ ├── project.nsi │ │ └── wails_tools.nsh │ └── wails.exe.manifest ├── docker/ │ ├── entrypoint.sh │ └── nginx.conf ├── docker-compose.yml ├── docs/ │ └── index.html ├── frontend/ │ ├── .prettierrc │ ├── README.md │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.vue │ │ ├── AppContent.vue │ │ ├── assets/ │ │ │ └── fonts/ │ │ │ └── OFL.txt │ │ ├── components/ │ │ │ ├── LoginPage.vue │ │ │ ├── common/ │ │ │ │ ├── AutoRefreshForm.vue │ │ │ │ ├── DropdownSelector.vue │ │ │ │ ├── EditableTableColumn.vue │ │ │ │ ├── EditableTableRow.vue │ │ │ │ ├── FileOpenInput.vue │ │ │ │ ├── FileSaveInput.vue │ │ │ │ ├── IconButton.vue │ │ │ │ ├── RedisTypeSelector.vue │ │ │ │ ├── RedisTypeTag.vue │ │ │ │ ├── ResizeableWrapper.vue │ │ │ │ ├── SwitchButton.vue │ │ │ │ ├── ToolbarControlWidget.vue │ │ │ │ └── TtlInput.vue │ │ │ ├── content/ │ │ │ │ ├── ContentLogPane.vue │ │ │ │ ├── ContentPane.vue │ │ │ │ ├── ContentServerPane.vue │ │ │ │ └── ContentValueTab.vue │ │ │ ├── content_value/ │ │ │ │ ├── ContentCli.vue │ │ │ │ ├── ContentEditor.vue │ │ │ │ ├── ContentEntryEditor.vue │ │ │ │ ├── ContentMonitor.vue │ │ │ │ ├── ContentPubsub.vue │ │ │ │ ├── ContentSearchInput.vue │ │ │ │ ├── ContentServerStatus.vue │ │ │ │ ├── ContentSlog.vue │ │ │ │ ├── ContentToolbar.vue │ │ │ │ ├── ContentValueHash.vue │ │ │ │ ├── ContentValueJson.vue │ │ │ │ ├── ContentValueList.vue │ │ │ │ ├── ContentValueSet.vue │ │ │ │ ├── ContentValueStream.vue │ │ │ │ ├── ContentValueString.vue │ │ │ │ ├── ContentValueWrapper.vue │ │ │ │ ├── ContentValueZSet.vue │ │ │ │ └── FormatSelector.vue │ │ │ ├── dialogs/ │ │ │ │ ├── AboutDialog.vue │ │ │ │ ├── AddFieldsDialog.vue │ │ │ │ ├── ConnectionDialog.vue │ │ │ │ ├── DecoderDialog.vue │ │ │ │ ├── DeleteKeyDialog.vue │ │ │ │ ├── ExportKeyDialog.vue │ │ │ │ ├── FlushDbDialog.vue │ │ │ │ ├── GroupDialog.vue │ │ │ │ ├── ImportKeyDialog.vue │ │ │ │ ├── KeyFilterDialog.vue │ │ │ │ ├── NewKeyDialog.vue │ │ │ │ ├── PreferencesDialog.vue │ │ │ │ ├── RenameKeyDialog.vue │ │ │ │ └── SetTtlDialog.vue │ │ │ ├── icons/ │ │ │ │ ├── Add.vue │ │ │ │ ├── AddGroup.vue │ │ │ │ ├── AddLink.vue │ │ │ │ ├── AlignCenter.vue │ │ │ │ ├── AlignLeft.vue │ │ │ │ ├── Binary.vue │ │ │ │ ├── Bottom.vue │ │ │ │ ├── Checkbox.vue │ │ │ │ ├── Checked.vue │ │ │ │ ├── Clear.vue │ │ │ │ ├── Close.vue │ │ │ │ ├── Cluster.vue │ │ │ │ ├── Code.vue │ │ │ │ ├── Config.vue │ │ │ │ ├── Connect.vue │ │ │ │ ├── Conversion.vue │ │ │ │ ├── Copy.vue │ │ │ │ ├── CopyLink.vue │ │ │ │ ├── Database.vue │ │ │ │ ├── Delete.vue │ │ │ │ ├── Detail.vue │ │ │ │ ├── Down.vue │ │ │ │ ├── Edit.vue │ │ │ │ ├── EditFile.vue │ │ │ │ ├── Export.vue │ │ │ │ ├── Filter.vue │ │ │ │ ├── Folder.vue │ │ │ │ ├── FullScreen.vue │ │ │ │ ├── Github.vue │ │ │ │ ├── Help.vue │ │ │ │ ├── Import.vue │ │ │ │ ├── Key.vue │ │ │ │ ├── Lang.vue │ │ │ │ ├── Layer.vue │ │ │ │ ├── ListView.vue │ │ │ │ ├── LoadAll.vue │ │ │ │ ├── LoadList.vue │ │ │ │ ├── Loading.vue │ │ │ │ ├── Log.vue │ │ │ │ ├── Logout.vue │ │ │ │ ├── Monitor.vue │ │ │ │ ├── Moon.vue │ │ │ │ ├── More.vue │ │ │ │ ├── OffScreen.vue │ │ │ │ ├── Pause.vue │ │ │ │ ├── Pin.vue │ │ │ │ ├── Play.vue │ │ │ │ ├── Plus.vue │ │ │ │ ├── Publish.vue │ │ │ │ ├── QRCode.vue │ │ │ │ ├── Record.vue │ │ │ │ ├── Refresh.vue │ │ │ │ ├── Save.vue │ │ │ │ ├── Search.vue │ │ │ │ ├── Server.vue │ │ │ │ ├── Sort.vue │ │ │ │ ├── SpellCheck.vue │ │ │ │ ├── Status.vue │ │ │ │ ├── Structure.vue │ │ │ │ ├── Subscribe.vue │ │ │ │ ├── Sun.vue │ │ │ │ ├── Terminal.vue │ │ │ │ ├── ThemeAuto.vue │ │ │ │ ├── Timer.vue │ │ │ │ ├── TreeView.vue │ │ │ │ ├── Twitter.vue │ │ │ │ ├── Unlink.vue │ │ │ │ ├── Update.vue │ │ │ │ ├── WindowClose.vue │ │ │ │ ├── WindowMax.vue │ │ │ │ ├── WindowMin.vue │ │ │ │ └── WindowRestore.vue │ │ │ ├── new_value/ │ │ │ │ ├── AddHashValue.vue │ │ │ │ ├── AddListValue.vue │ │ │ │ ├── AddZSetValue.vue │ │ │ │ ├── NewHashValue.vue │ │ │ │ ├── NewJsonValue.vue │ │ │ │ ├── NewListValue.vue │ │ │ │ ├── NewSetValue.vue │ │ │ │ ├── NewStreamValue.vue │ │ │ │ ├── NewStringValue.vue │ │ │ │ └── NewZSetValue.vue │ │ │ └── sidebar/ │ │ │ ├── BrowserPane.vue │ │ │ ├── BrowserTree.vue │ │ │ ├── ConnectionPane.vue │ │ │ ├── ConnectionTree.vue │ │ │ ├── ConnectionTreeItem.vue │ │ │ └── Ribbon.vue │ │ ├── consts/ │ │ │ ├── browser_tab_type.js │ │ │ ├── connection_type.js │ │ │ ├── key_view_type.js │ │ │ ├── localstorage_key.js │ │ │ ├── support_redis_type.js │ │ │ ├── text_align_type.js │ │ │ ├── tree_context_menu.js │ │ │ └── value_view_type.js │ │ ├── langs/ │ │ │ ├── en-us.json │ │ │ ├── es-es.json │ │ │ ├── fr-fr.json │ │ │ ├── index.js │ │ │ ├── ja-jp.json │ │ │ ├── ko-kr.json │ │ │ ├── pt-br.json │ │ │ ├── ru-ru.json │ │ │ ├── tr-tr.json │ │ │ ├── zh-cn.json │ │ │ └── zh-tw.json │ │ ├── main.js │ │ ├── objects/ │ │ │ ├── redisDatabaseItem.js │ │ │ ├── redisNodeItem.js │ │ │ ├── redisServerState.js │ │ │ └── tabItem.js │ │ ├── stores/ │ │ │ ├── browser.js │ │ │ ├── connections.js │ │ │ ├── dialog.js │ │ │ ├── preferences.js │ │ │ └── tab.js │ │ ├── styles/ │ │ │ ├── content.scss │ │ │ └── style.scss │ │ └── utils/ │ │ ├── analytics.js │ │ ├── api.js │ │ ├── byte_convert.js │ │ ├── chart.js │ │ ├── date.js │ │ ├── decoder_cmd.js │ │ ├── discrete.js │ │ ├── extra_theme.js │ │ ├── glob_pattern.js │ │ ├── i18n.js │ │ ├── key_convert.js │ │ ├── monaco.js │ │ ├── platform.js │ │ ├── promise.js │ │ ├── render.js │ │ ├── rgb.js │ │ ├── theme.js │ │ ├── version.js │ │ ├── wails_runtime.js │ │ └── websocket.js │ └── vite.config.js ├── go.mod ├── go.sum ├── main.go ├── main_web.go └── wails.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CONTRIBUTING.md ================================================ ## Tiny RDM Contribute Guide ### Multi-language Contributions #### Adding New Language 1. New file: Add a new JSON file in the [frontend/src/langs](../frontend/src/langs/), with the file naming format is " {language}-{region}.json", e.g. English is "en-us.json", simplified Chinese is "zh-cn.json". Highly recommended to duplicate the [en-us.json](../frontend/src/langs/en-us.json) file and rename it. 2. Fill content: Refer to [en-us.json](../frontend/src/langs/en-us.json), or duplicate the file and modify the language content. 3. Update codes: Edit[frontend/src/langs/index.js](.../frontend/src/langs/index.js), import the new language data inside. ```javascript import en from './en-us' // import your new localize file 'zh-cn' here import zh from './zh-cn' export const lang = { en, // export new language data 'zh' here zh, } ``` 4. Submit review once there are no issues with the translation context in the application. (learn how to submit) ### Code Submission`(To be completed)` #### Pull Request Title The format of PR's title like ": " - type: PR type - description: PR description PR type list below: | type | description | |----------|----------------------------------------------------| | revert | Revert a commit | | feat | New features | | perf | Performance improvements | | fix | Fix any bugs | | style | Style updates | | docs | Document updates | | refactor | Code refactors | | chore | Some chores | | ci | Automation process configuration or script updates | ================================================ FILE: .github/CONTRIBUTING_zh.md ================================================ ## Tiny RDM 代码贡献指南 ### 多国语言贡献 #### 增加新的语言 1. 创建文件:在[frontend/src/langs](../frontend/src/langs/)目录下新增语言配置JSON文件,文件名格式为“{语言}-{地区}.json”,如英文为“en-us.json”,简体中文为“zh-cn.json”,建议直接复制[en-us.json](../frontend/src/langs/en-us.json)文件进行改名。 2. 填充内容:参考[en-us.json](../frontend/src/langs/en-us.json),或者直接克隆一份文件,对语言部分内容进行修改。 3. 代码修改:在[frontend/src/langs/index.js](.../frontend/src/langs/index.js)文件内导入新增的语言数据 ```javascript import en from './en-us' // import your new localize file 'zh-cn' here import zh from './zh-cn' export const lang = { en, // export new language data 'zh' here zh, } ``` 4. 检查应用中对应翻译语境无问题后,可提交审核([查看如何提交](#pull_request)) ### 代码提交`(待完善)` #### PR提交规范 PR提交格式为“: ” - type: 提交类型 - description: 提交内容描述 其中提交类型如下: | 提交类型 | 类型描述 | |----------|--------------| | revert | 回退某个commit提交 | | feat | 新功能/新特性 | | perf | 功能、体验等方面的优化 | | fix | 修复问题 | | style | 样式相关修改 | | docs | 文档更新 | | refactor | 代码重构 | | chore | 杂项修改 | | ci | 自动化流程配置或脚本修改 | ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '[BUG]' labels: '' assignees: '' --- **Tiny RDM Version** What version of Tiny RDM are you using? **OS Version** Which OS and version you launch? (Mac/Windows/Linux) **Redis Version** Which version of Redis are you using? **Describe the bug** A clear and concise description of what the bug is. Steps to Reproduce: 1. 2. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '[FEATURE]' --- ================================================ FILE: .github/workflows/docker-publish.yml ================================================ name: Release Docker Image run-name: ${{ github.event.release.tag_name || github.event.inputs.tag || 'manual' }} on: release: types: [ published ] workflow_dispatch: inputs: tag: description: 'Version tag' required: true default: '1.0.0' env: REGISTRY: ghcr.io IMAGE_NAME: tiny-craft/tiny-rdm jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v4 - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }} type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} type=raw,value=latest - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Determine version id: version run: | if [ "${{ github.event_name }}" = "release" ]; then echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" else echo "value=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" fi - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64,linux/arm64 provenance: false sbom: false build-args: | APP_VERSION=${{ steps.version.outputs.value }} ================================================ FILE: .github/workflows/release-linux-webkit2-41.yaml ================================================ name: Release Linux App run-name: ${{ github.event.release.tag_name || github.event.inputs.tag }} on: release: types: [ published ] workflow_dispatch: inputs: tag: description: 'Version tag' required: true default: '1.0.0' jobs: release: name: Release Linux App runs-on: ubuntu-24.04 strategy: matrix: platform: - linux/amd64 steps: - name: Checkout source code uses: actions/checkout@v3 - name: Normalise platform tag id: normalise_platform shell: bash run: | tag=$(echo ${{ matrix.platform }} | sed -e 's/\//_/g') echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Normalise platform arch id: normalise_platform_arch run: | if [ "${{ matrix.platform }}" == "linux/amd64" ]; then echo "arch=x86_64" >> "$GITHUB_OUTPUT" elif [ "${{ matrix.platform }}" == "linux/aarch64" ]; then echo "arch=aarch64" >> "$GITHUB_OUTPUT" fi - name: Normalise version tag id: normalise_version shell: bash run: | if [ "${{ github.event.release.tag_name }}" == "" ]; then version=$(echo ${{ github.event.inputs.tag }} | sed -e 's/v//g') echo "version=$version" >> "$GITHUB_OUTPUT" else version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g') echo "version=$version" >> "$GITHUB_OUTPUT" fi - name: Setup Go uses: actions/setup-go@v6 with: go-version: stable - name: Install wails shell: bash run: go install github.com/wailsapp/wails/v2/cmd/wails@latest - name: Install Ubuntu prerequisites shell: bash run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse-dev libfuse2 - name: Setup Node uses: actions/setup-node@v3 with: node-version: 22 - name: Build frontend assets shell: bash run: | npm install -g npm@9 jq '.info.productVersion = "${{ steps.normalise_version.outputs.version }}"' wails.json > tmp.json mv tmp.json wails.json cd frontend jq '.version = "${{ steps.normalise_version.outputs.version }}"' package.json > tmp.json mv tmp.json package.json npm install - name: Build wails app for Linux shell: bash run: | CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} \ -ldflags "-X main.version=v${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.LINUX_GA_SECRET }}" \ -tags webkit2_41 \ -o tiny-rdm - name: Setup control template shell: bash run: | content=$(cat build/linux/tiny-rdm_0.0.0_amd64/DEBIAN/control) name=$(jq -r '.name' wails.json | tr -d ' ' | tr '[:upper:]' '[:lower:]') content=$(echo "$content" | sed -e "s/{{.Name}}/$name/g") content=$(echo "$content" | sed -e "s/{{.Info.ProductVersion}}/$(jq -r '.info.productVersion' wails.json)/g") content=$(echo "$content" | sed -e "s/{{.Author.Name}}/$(jq -r '.author.name' wails.json)/g") content=$(echo "$content" | sed -e "s/{{.Author.Email}}/$(jq -r '.author.email' wails.json)/g") content=$(echo "$content" | sed -e "s/{{.Info.Comments}}/$(jq -r '.info.comments' wails.json)/g") content=$(echo "$content" | sed -e "s/{{.libwebkit2gtk.PackageName}}/libwebkit2gtk-4.1-0/g") echo $content echo "$content" > build/linux/tiny-rdm_0.0.0_amd64/DEBIAN/control - name: Setup app template shell: bash run: | content=$(cat build/linux/tiny-rdm_0.0.0_amd64/usr/share/applications/tiny-rdm.desktop) content=$(echo "$content" | sed -e "s/{{.Info.ProductName}}/$(jq -r '.info.productName' wails.json)/g") content=$(echo "$content" | sed -e "s/{{.Info.Comments}}/$(jq -r '.info.comments' wails.json)/g") echo $content echo "$content" > build/linux/tiny-rdm_0.0.0_amd64/usr/share/applications/tiny-rdm.desktop - name: Package up deb file shell: bash run: | mv build/bin/tiny-rdm build/linux/tiny-rdm_0.0.0_amd64/usr/local/bin/ cd build/linux mv tiny-rdm_0.0.0_amd64 "tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64" sed -i 's/0.0.0/${{ steps.normalise_version.outputs.version }}/g' "tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64/DEBIAN/control" dpkg-deb --build -Zxz "tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64" - name: Rename deb working-directory: ./build/linux run: mv "tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64.deb" "tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}_webkit2_41.deb" - name: Upload release asset uses: softprops/action-gh-release@v1 with: tag_name: v${{ steps.normalise_version.outputs.version }} files: | ./build/linux/tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}_webkit2_41.deb token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/release-linux.yaml ================================================ name: Release Linux App run-name: ${{ github.event.release.tag_name || github.event.inputs.tag }} on: release: types: [ published ] workflow_dispatch: inputs: tag: description: 'Version tag' required: true default: '1.0.0' jobs: release: name: Release Linux App runs-on: ubuntu-22.04 strategy: matrix: platform: - linux/amd64 steps: - name: Checkout source code uses: actions/checkout@v3 - name: Normalise platform tag id: normalise_platform shell: bash run: | tag=$(echo ${{ matrix.platform }} | sed -e 's/\//_/g') echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Normalise platform arch id: normalise_platform_arch run: | if [ "${{ matrix.platform }}" == "linux/amd64" ]; then echo "arch=x86_64" >> "$GITHUB_OUTPUT" elif [ "${{ matrix.platform }}" == "linux/aarch64" ]; then echo "arch=aarch64" >> "$GITHUB_OUTPUT" fi - name: Normalise version tag id: normalise_version shell: bash run: | if [ "${{ github.event.release.tag_name }}" == "" ]; then version=$(echo ${{ github.event.inputs.tag }} | sed -e 's/v//g') echo "version=$version" >> "$GITHUB_OUTPUT" else version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g') echo "version=$version" >> "$GITHUB_OUTPUT" fi - name: Setup Go uses: actions/setup-go@v6 with: go-version: stable - name: Install wails shell: bash run: go install github.com/wailsapp/wails/v2/cmd/wails@latest - name: Install Ubuntu prerequisites shell: bash run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libfuse-dev libfuse2 - name: Setup Node uses: actions/setup-node@v3 with: node-version: 22 - name: Build frontend assets shell: bash run: | npm install -g npm@9 jq '.info.productVersion = "${{ steps.normalise_version.outputs.version }}"' wails.json > tmp.json mv tmp.json wails.json cd frontend jq '.version = "${{ steps.normalise_version.outputs.version }}"' package.json > tmp.json mv tmp.json package.json npm install - name: Build wails app for Linux shell: bash run: | CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} \ -ldflags "-X main.version=v${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.LINUX_GA_SECRET }}" \ -o tiny-rdm - name: Setup control template shell: bash run: | content=$(cat build/linux/tiny-rdm_0.0.0_amd64/DEBIAN/control) name=$(jq -r '.name' wails.json | tr -d ' ' | tr '[:upper:]' '[:lower:]') content=$(echo "$content" | sed -e "s/{{.Name}}/$name/g") content=$(echo "$content" | sed -e "s/{{.Info.ProductVersion}}/$(jq -r '.info.productVersion' wails.json)/g") content=$(echo "$content" | sed -e "s/{{.Author.Name}}/$(jq -r '.author.name' wails.json)/g") content=$(echo "$content" | sed -e "s/{{.Author.Email}}/$(jq -r '.author.email' wails.json)/g") content=$(echo "$content" | sed -e "s/{{.Info.Comments}}/$(jq -r '.info.comments' wails.json)/g") content=$(echo "$content" | sed -e "s/{{.libwebkit2gtk.PackageName}}/libwebkit2gtk-4.0-37/g") echo $content echo "$content" > build/linux/tiny-rdm_0.0.0_amd64/DEBIAN/control - name: Setup app template shell: bash run: | content=$(cat build/linux/tiny-rdm_0.0.0_amd64/usr/share/applications/tiny-rdm.desktop) content=$(echo "$content" | sed -e "s/{{.Info.ProductName}}/$(jq -r '.info.productName' wails.json)/g") content=$(echo "$content" | sed -e "s/{{.Info.Comments}}/$(jq -r '.info.comments' wails.json)/g") echo $content echo "$content" > build/linux/tiny-rdm_0.0.0_amd64/usr/share/applications/tiny-rdm.desktop - name: Package up deb file shell: bash run: | mv build/bin/tiny-rdm build/linux/tiny-rdm_0.0.0_amd64/usr/local/bin/ cd build/linux mv tiny-rdm_0.0.0_amd64 "tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64" sed -i 's/0.0.0/${{ steps.normalise_version.outputs.version }}/g' "tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64/DEBIAN/control" dpkg-deb --build -Zxz "tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64" - name: Package up appimage file run: | curl https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-${{ steps.normalise_platform_arch.outputs.arch }}.AppImage \ -o linuxdeploy \ -L chmod u+x linuxdeploy ./linuxdeploy --appdir AppDir pushd AppDir # Copy WebKit files. find /usr/lib* -name WebKitNetworkProcess -exec mkdir -p $(dirname '{}') \; -exec cp --parents '{}' "." \; || true find /usr/lib* -name WebKitWebProcess -exec mkdir -p $(dirname '{}') \; -exec cp --parents '{}' "." \; || true find /usr/lib* -name libwebkit2gtkinjectedbundle.so -exec mkdir -p $(dirname '{}') \; -exec cp --parents '{}' "." \; || true popd mkdir -p AppDir/usr/share/icons/hicolor/512x512/apps build_dir="build/linux/tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64" cp -r $build_dir/usr/share/icons/hicolor/512x512/apps/tiny-rdm.png AppDir/usr/share/icons/hicolor/512x512/apps/ cp $build_dir/usr/local/bin/tiny-rdm AppDir/usr/bin/ sed -i 's#/usr/local/bin/tiny-rdm#tiny-rdm#g' $build_dir/usr/share/applications/tiny-rdm.desktop curl -o linuxdeploy-plugin-gtk.sh "https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh" sed -i '/XDG_DATA_DIRS/a export WEBKIT_DISABLE_COMPOSITING_MODE=1' linuxdeploy-plugin-gtk.sh chmod +x linuxdeploy-plugin-gtk.sh curl -o AppDir/AppRun https://github.com/AppImage/AppImageKit/releases/download/continuous/AppRun-${{ steps.normalise_platform_arch.outputs.arch }} -L ./linuxdeploy --appdir AppDir \ --output=appimage \ --plugin=gtk \ -e $build_dir/usr/local/bin/tiny-rdm \ -d $build_dir/usr/share/applications/tiny-rdm.desktop - name: Rename deb working-directory: ./build/linux run: mv "tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64.deb" "tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.deb" - name: Rename appimage run: mv Tiny_RDM-${{ steps.normalise_platform_arch.outputs.arch }}.AppImage "tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.AppImage" - name: Upload release asset uses: softprops/action-gh-release@v1 with: tag_name: v${{ steps.normalise_version.outputs.version }} files: | ./build/linux/tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.deb tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.AppImage token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/release-macos.yaml ================================================ name: Release macOS App run-name: ${{ github.event.release.tag_name || github.event.inputs.tag }} on: release: types: [ published ] workflow_dispatch: inputs: tag: description: 'Version tag' required: true default: '1.0.0' jobs: release: name: Release macOS App runs-on: macos-latest # We can cross compile but need to be on macOS to notarise strategy: matrix: platform: - darwin/amd64 - darwin/arm64 # - darwin/universal steps: - name: Checkout source code uses: actions/checkout@v3 - name: Normalise platform tag id: normalise_platform shell: bash run: | tag=$(echo ${{ matrix.platform }} | sed -e 's/\//_/g' -e 's/darwin/mac/g' -e 's/amd64/intel/g') echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Normalise version tag id: normalise_version shell: bash run: | if [ "${{ github.event.release.tag_name }}" == "" ]; then version=$(echo ${{ github.event.inputs.tag }} | sed -e 's/v//g') echo "version=$version" >> "$GITHUB_OUTPUT" else version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g') echo "version=$version" >> "$GITHUB_OUTPUT" fi - name: Setup Go uses: actions/setup-go@v6 with: go-version: stable # - name: Install gon for macOS notarisation # shell: bash # run: wget https://github.com/mitchellh/gon/releases/download/v0.2.5/gon_macos.zip && unzip gon_macos.zip && mv gon /usr/local/bin # # - name: Import code signing certificate from Github Secrets # uses: Apple-Actions/import-codesign-certs@v1 # with: # p12-file-base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }} # p12-password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }} - name: Install wails shell: bash run: go install github.com/wailsapp/wails/v2/cmd/wails@latest - name: Setup Node uses: actions/setup-node@v3 with: node-version: 22 - name: Build frontend assets shell: bash run: | npm install -g npm@9 jq '.info.productVersion = "${{ steps.normalise_version.outputs.version }}"' wails.json > tmp.json mv tmp.json wails.json cd frontend jq '.version = "${{ steps.normalise_version.outputs.version }}"' package.json > tmp.json mv tmp.json package.json npm install - name: Build wails app for macOS shell: bash run: | CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} \ -ldflags "-X main.version=${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.MAC_GA_SECRET }}" # - name: Notarise macOS app + create dmg # shell: bash # run: gon -log-level=info gon.config.json # env: # AC_USERNAME: ${{ secrets.AC_USERNAME }} # AC_PASSWORD: ${{ secrets.AC_PASSWORD }} - name: Checkout create-image uses: actions/checkout@v2 with: repository: create-dmg/create-dmg path: ./build/create-dmg ref: master - name: Build macOS DMG shell: bash working-directory: ./build run: | mv bin/tinyrdm.app "bin/Tiny RDM.app" ./create-dmg/create-dmg \ --no-internet-enable \ --volname "Tiny RDM" \ --volicon "bin/Tiny RDM.app/Contents/Resources/iconfile.icns" \ --background "dmg/background.tiff" \ --text-size 12 \ --window-pos 400 400 \ --window-size 660 450 \ --icon-size 80 \ --icon "Tiny RDM.app" 180 180 \ --hide-extension "Tiny RDM.app" \ --app-drop-link 480 180 \ --add-file "Repair" "dmg/fix-app" 230 290 \ --add-file "损坏修复" "dmg/fix-app_zh" 430 290 \ "bin/TinyRDM-${{ steps.normalise_platform.outputs.tag }}.dmg" \ "bin" - name: Rename dmg working-directory: ./build/bin run: mv "TinyRDM-${{ steps.normalise_platform.outputs.tag }}.dmg" "TinyRDM_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.dmg" - name: Upload release asset (DMG Package) uses: softprops/action-gh-release@v1 with: tag_name: v${{ steps.normalise_version.outputs.version }} files: ./build/bin/TinyRDM_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.dmg token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/release-windows.yaml ================================================ name: Release Windows App run-name: ${{ github.event.release.tag_name || github.event.inputs.tag }} on: release: types: [ published ] workflow_dispatch: inputs: tag: description: 'Version tag' required: true default: '1.0.0' jobs: release: name: Release Windows App runs-on: windows-latest strategy: matrix: platform: - windows/amd64 - windows/arm64 steps: - name: Checkout source code uses: actions/checkout@v3 - name: Normalise platform tag id: normalise_platform shell: bash run: | tag=$(echo ${{ matrix.platform }} | sed -e 's/\//_/g' -e 's/amd64/x64/g') echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Normalise platform name id: normalise_platform_name shell: bash run: | pname=$(echo "${{ matrix.platform }}" | sed 's/windows\///g') echo "pname=$pname" >> "$GITHUB_OUTPUT" - name: Normalise version tag id: normalise_version shell: bash run: | if [ "${{ github.event.release.tag_name }}" == "" ]; then version=$(echo ${{ github.event.inputs.tag }} | sed -e 's/v//g') echo "version=$version" >> "$GITHUB_OUTPUT" else version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g') echo "version=$version" >> "$GITHUB_OUTPUT" fi - name: Setup Go uses: actions/setup-go@v6 with: go-version: stable - name: Install chocolatey uses: crazy-max/ghaction-chocolatey@v2 with: args: install nsis jq - name: Install wails shell: bash run: go install github.com/wailsapp/wails/v2/cmd/wails@latest - name: Setup Node uses: actions/setup-node@v3 with: node-version: 22 - name: Build frontend assets shell: bash run: | npm install -g npm@9 jq '.info.productVersion = "${{ steps.normalise_version.outputs.version }}"' wails.json > tmp.json mv tmp.json wails.json cd frontend jq '.version = "${{ steps.normalise_version.outputs.version }}"' package.json > tmp.json mv tmp.json package.json npm install - name: Build Windows portable app shell: bash run: | CGO_ENABLED=1 wails build -clean -platform ${{ matrix.platform }} \ -webview2 embed \ -ldflags "-X main.version=v${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.WINDOWS_GA_SECRET }}" - name: Compress portable binary working-directory: ./build/bin run: Compress-Archive "Tiny RDM.exe" "TinyRDM_Portable_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.zip" - name: Upload release asset (Portable) uses: softprops/action-gh-release@v1 with: tag_name: v${{ steps.normalise_version.outputs.version }} files: ./build/bin/TinyRDM_Portable_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.zip token: ${{ secrets.GITHUB_TOKEN }} - name: Build Windows NSIS installer shell: bash run: | export PATH="/c/Program Files (x86)/NSIS:$PATH" which makensis && echo "makensis found" || echo "makensis NOT found" CGO_ENABLED=1 wails build -clean -platform ${{ matrix.platform }} \ -nsis -webview2 embed \ -ldflags "-X main.version=v${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.WINDOWS_GA_SECRET }}" - name: Sign the installer uses: dlemstra/code-sign-action@v1 with: certificate: ${{ secrets.WIN_SIGNING_CERT }} password: ${{ secrets.WIN_SIGNING_CERT_PASSWORD }} folder: ./build/bin - name: Rename installer working-directory: ./build/bin run: Rename-Item -Path "tinyrdm-${{ steps.normalise_platform_name.outputs.pname }}-installer.exe" -NewName "TinyRDM_Setup_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.exe" - name: Upload release asset (Installer) uses: softprops/action-gh-release@v1 with: tag_name: v${{ steps.normalise_version.outputs.version }} files: ./build/bin/TinyRDM_Setup_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.exe token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ build/bin node_modules frontend/dist frontend/wailsjs frontend/package.json.md5 design/ .vscode .idea test ================================================ FILE: .prettierignore ================================================ /frontend/wailsjs/** ================================================ FILE: Dockerfile ================================================ # ============================================================ # Stage 1: Build frontend # ============================================================ FROM --platform=linux/amd64 node:22-alpine AS frontend-builder WORKDIR /app/frontend COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci --ignore-scripts COPY frontend/ ./ ENV NODE_OPTIONS=--max-old-space-size=4096 ENV VITE_WEB=true RUN npm run build # ============================================================ # Stage 2: Build Go backend (web mode) # ============================================================ FROM golang:1.25-alpine AS backend-builder WORKDIR /app COPY go.mod go.sum ./ ENV GOPROXY=https://goproxy.cn,https://goproxy.io,direct RUN GOFLAGS="-mod=mod" go mod download COPY backend/ ./backend/ COPY main_web.go ./ ARG APP_VERSION=1.0.0 RUN CGO_ENABLED=0 GOOS=linux GOFLAGS="-mod=mod" go build -tags web -ldflags "-s -w -X main.version=${APP_VERSION}" -o /app/tinyrdm-server . # ============================================================ # Stage 3: Runtime (nginx + Go backend) # ============================================================ FROM alpine:3.21 RUN apk add --no-cache ca-certificates tzdata nginx \ && rm -rf /var/cache/apk/* /tmp/* # Frontend static files COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html # Nginx config COPY docker/nginx.conf /etc/nginx/http.d/default.conf # Go backend binary WORKDIR /app COPY --from=backend-builder /app/tinyrdm-server . COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh EXPOSE 8086 ENV PORT=8088 ENV GIN_MODE=release ENV XDG_CONFIG_HOME=/app ENTRYPOINT ["/entrypoint.sh"] ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================

Tiny RDM

English | 简体中文 | 繁體中文 | 日本語 | 한국어 | Français | Español | Português (BR) | Русский | Türkçe

[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases) ![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total) [![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers) [![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork) [![Discord](https://img.shields.io/discord/1170373259133456434?label=Discord&color=5865F2)](https://discord.gg/VTFbBMGjWh) [![X](https://img.shields.io/badge/Twitter-black?logo=x&logoColor=white)](https://twitter.com/Lykin53448) Tiny RDM is a modern lightweight cross-platform Redis desktop manager available for Mac, Windows, and Linux. It also provides a web version that can be deployed via Docker.
screenshot screenshot ## Feature * Super lightweight, built on Webview2, without embedded browsers (Thanks to [Wails](https://github.com/wailsapp/wails)). * Provides visually and user-friendly UI, light and dark themes (Thanks to [Naive UI](https://github.com/tusen-ai/naive-ui) and [IconPark](https://iconpark.oceanengine.com)). * Multi-language support ([Need more languages ? Click here to contribute](.github/CONTRIBUTING.md)). * Better connection management: supports SSH Tunnel/SSL/Sentinel Mode/Cluster Mode/HTTP proxy/SOCKS5 proxy. * Visualize key value operations, CRUD support for Lists, Hashes, Strings, Sets, Sorted Sets, and Streams. * Support multiple data viewing format and decode/decompression methods. * Use SCAN for segmented loading, making it easy to list millions of keys. * Logs list for command operation history. * Provides command-line mode. * Provides slow logs list. * Segmented loading and querying for List/Hash/Set/Sorted Set. * Provide value decode/decompression for List/Hash/Set/Sorted Set. * Integrate with Monaco Editor * Support real-time commands monitoring. * Support import/export data. * Support publish/subscribe. * Support import/export connection profile. * Custom data encoder and decoder for value display ([Here are the instructions](https://tinyrdm.com/guide/custom-decoder/)). ## Installation Available to download for free from [here](https://github.com/tiny-craft/tiny-rdm/releases). > If you can't open it after installation on macOS, exec the following command then reopen: > ``` shell > sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app > ``` ## Build Guidelines ### Prerequisites * Go (latest version) * Node.js >= 20 * NPM >= 9 ### Install Wails ```bash go install github.com/wailsapp/wails/v2/cmd/wails@latest ``` ### Pull the Code ```bash git clone https://github.com/tiny-craft/tiny-rdm --depth=1 ``` ### Build Frontend ```bash npm install --prefix ./frontend ``` or ```bash cd frontend npm install ``` ### Compile and Run ```bash wails dev ``` ## Docker Deployment In addition to the desktop client, Tiny RDM also provides a web version that can be quickly deployed via Docker. ### Using Docker Compose (Recommended) Create a `docker-compose.yml` file: ```yaml services: tinyrdm: image: ghcr.io/tiny-craft/tiny-rdm:latest container_name: tinyrdm restart: unless-stopped ports: - "8086:8086" environment: - ADMIN_USERNAME=admin - ADMIN_PASSWORD=tinyrdm volumes: - ./data:/app/tinyrdm ``` Start the service: ```bash docker compose up -d ``` Once started, visit `http://localhost:8086` and log in with the credentials configured above. ### Using Docker Command ```bash docker run -d --name tinyrdm \ -p 8086:8086 \ -e ADMIN_USERNAME=admin \ -e ADMIN_PASSWORD=tinyrdm \ -v ./data:/app/tinyrdm \ ghcr.io/tiny-craft/tiny-rdm:latest ``` ### Environment Variables | Variable | Description | Default | |----------|-------------|---------| | `ADMIN_USERNAME` | Login username | - | | `ADMIN_PASSWORD` | Login password | - | ## About ### Wechat Official Account wechat ### Sponsor If this project helpful for you, feel free to buy me a cup of coffee ☕️. * Wechat Sponsor wechat ### Thanks Thanks to the following service providers for hosting sponsorship [![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ "Powered by NotiDC") ================================================ FILE: README_es.md ================================================

Tiny RDM

English | 简体中文 | 繁體中文 | 日本語 | 한국어 | Français | Español | Português (BR) | Русский | Türkçe

[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases) ![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total) [![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers) [![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork) Tiny RDM es un gestor Redis moderno, ligero y multiplataforma, disponible para Mac, Windows y Linux. También ofrece una versión web que se puede desplegar mediante Docker.
screenshot screenshot ## Características * Ultra ligero, basado en Webview2, sin navegador integrado (Gracias a [Wails](https://github.com/wailsapp/wails)) * Interfaz visual y fácil de usar, temas claro y oscuro (Gracias a [Naive UI](https://github.com/tusen-ai/naive-ui) e [IconPark](https://iconpark.oceanengine.com)) * Soporte multilingüe ([¿Necesitas más idiomas? Haz clic aquí para contribuir](.github/CONTRIBUTING.md)) * Gestión mejorada de conexiones: túnel SSH/SSL/modo Sentinel/modo Cluster/proxy HTTP/proxy SOCKS5 * Visualización de operaciones clave-valor, soporte CRUD para List, Hash, String, Set, Sorted Set y Stream * Soporte de múltiples formatos de visualización y métodos de decodificación/descompresión * Carga segmentada con SCAN para listar fácilmente millones de claves * Lista de registros del historial de comandos * Modo línea de comandos * Lista de registros lentos * Carga segmentada y consultas para List/Hash/Set/Sorted Set * Decodificación/descompresión de valores para List/Hash/Set/Sorted Set * Integración con Monaco Editor * Monitoreo de comandos en tiempo real * Importación/exportación de datos * Publicación/suscripción * Importación/exportación de perfiles de conexión * Codificador y decodificador de datos personalizados para la visualización de valores ([Instrucciones aquí](https://tinyrdm.com/guide/custom-decoder/)) ## Instalación Disponible para descargar gratis [aquí](https://github.com/tiny-craft/tiny-rdm/releases). > Si no puedes abrirlo después de la instalación en macOS, ejecuta el siguiente comando y vuelve a abrirlo: > ``` shell > sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app > ``` ## Guía de compilación ### Requisitos previos * Go (última versión) * Node.js >= 20 * NPM >= 9 ### Instalar Wails ```bash go install github.com/wailsapp/wails/v2/cmd/wails@latest ``` ### Obtener el código ```bash git clone https://github.com/tiny-craft/tiny-rdm --depth=1 ``` ### Compilar el frontend ```bash npm install --prefix ./frontend ``` o ```bash cd frontend npm install ``` ### Compilar y ejecutar ```bash wails dev ``` ## Despliegue con Docker Además del cliente de escritorio, Tiny RDM también ofrece una versión web que se puede desplegar rápidamente con Docker. ### Usando Docker Compose (recomendado) Crea un archivo `docker-compose.yml`: ```yaml services: tinyrdm: image: ghcr.io/tiny-craft/tiny-rdm:latest container_name: tinyrdm restart: unless-stopped ports: - "8086:8086" environment: - ADMIN_USERNAME=admin - ADMIN_PASSWORD=tinyrdm volumes: - ./data:/app/tinyrdm ``` Inicia el servicio: ```bash docker compose up -d ``` Una vez iniciado, visita `http://localhost:8086` e inicia sesión con las credenciales configuradas arriba. ### Usando el comando Docker ```bash docker run -d --name tinyrdm \ -p 8086:8086 \ -e ADMIN_USERNAME=admin \ -e ADMIN_PASSWORD=tinyrdm \ -v ./data:/app/tinyrdm \ ghcr.io/tiny-craft/tiny-rdm:latest ``` ### Variables de entorno | Variable | Descripción | Valor por defecto | |----------|-------------|-------------------| | `ADMIN_USERNAME` | Nombre de usuario | - | | `ADMIN_PASSWORD` | Contraseña | - | ## Acerca de ### Patrocinar Si este proyecto te resulta útil, no dudes en invitar al autor a un café ☕️ * Wechat Sponsor wechat ### Agradecimientos Gracias a los siguientes proveedores de servicios por el patrocinio de alojamiento [![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ "Powered by NotiDC") ================================================ FILE: README_fr.md ================================================

Tiny RDM

English | 简体中文 | 繁體中文 | 日本語 | 한국어 | Français | Español | Português (BR) | Русский | Türkçe

[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases) ![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total) [![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers) [![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork) Tiny RDM est un gestionnaire Redis moderne, léger et multiplateforme, disponible pour Mac, Windows et Linux. Une version web déployable via Docker est également proposée.
screenshot screenshot ## Fonctionnalités * Ultra léger, basé sur Webview2, sans navigateur intégré (Merci à [Wails](https://github.com/wailsapp/wails)) * Interface visuelle et conviviale, thèmes clair et sombre (Merci à [Naive UI](https://github.com/tusen-ai/naive-ui) et [IconPark](https://iconpark.oceanengine.com)) * Support multilingue ([Besoin de plus de langues ? Cliquez ici pour contribuer](.github/CONTRIBUTING.md)) * Gestion améliorée des connexions : tunnel SSH/SSL/mode Sentinelle/mode Cluster/proxy HTTP/proxy SOCKS5 * Visualisation des opérations clé-valeur, support CRUD pour List, Hash, String, Set, Sorted Set et Stream * Support de multiples formats d'affichage et méthodes de décodage/décompression * Chargement segmenté avec SCAN pour lister facilement des millions de clés * Liste des journaux d'historique des commandes * Mode ligne de commande * Liste des journaux lents * Chargement segmenté et requêtes pour List/Hash/Set/Sorted Set * Décodage/décompression des valeurs pour List/Hash/Set/Sorted Set * Intégration de Monaco Editor * Surveillance des commandes en temps réel * Import/export de données * Publication/abonnement * Import/export de profils de connexion * Encodeur et décodeur de données personnalisés pour l'affichage des valeurs ([Instructions ici](https://tinyrdm.com/guide/custom-decoder/)) ## Installation Disponible en téléchargement gratuit [ici](https://github.com/tiny-craft/tiny-rdm/releases). > Si vous ne pouvez pas l'ouvrir après l'installation sur macOS, exécutez la commande suivante puis relancez : > ``` shell > sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app > ``` ## Guide de compilation ### Prérequis * Go (dernière version) * Node.js >= 20 * NPM >= 9 ### Installer Wails ```bash go install github.com/wailsapp/wails/v2/cmd/wails@latest ``` ### Récupérer le code ```bash git clone https://github.com/tiny-craft/tiny-rdm --depth=1 ``` ### Compiler le frontend ```bash npm install --prefix ./frontend ``` ou ```bash cd frontend npm install ``` ### Compiler et exécuter ```bash wails dev ``` ## Déploiement Docker En plus du client de bureau, Tiny RDM propose une version web déployable rapidement via Docker. ### Avec Docker Compose (recommandé) Créez un fichier `docker-compose.yml` : ```yaml services: tinyrdm: image: ghcr.io/tiny-craft/tiny-rdm:latest container_name: tinyrdm restart: unless-stopped ports: - "8086:8086" environment: - ADMIN_USERNAME=admin - ADMIN_PASSWORD=tinyrdm volumes: - ./data:/app/tinyrdm ``` Démarrez le service : ```bash docker compose up -d ``` Une fois démarré, accédez à `http://localhost:8086` et connectez-vous avec les identifiants configurés ci-dessus. ### Avec la commande Docker ```bash docker run -d --name tinyrdm \ -p 8086:8086 \ -e ADMIN_USERNAME=admin \ -e ADMIN_PASSWORD=tinyrdm \ -v ./data:/app/tinyrdm \ ghcr.io/tiny-craft/tiny-rdm:latest ``` ### Variables d'environnement | Variable | Description | Valeur par défaut | |----------|-------------|-------------------| | `ADMIN_USERNAME` | Nom d'utilisateur | - | | `ADMIN_PASSWORD` | Mot de passe | - | ## À propos ### Sponsor Si ce projet vous est utile, n'hésitez pas à offrir un café ☕️ * Wechat Sponsor wechat ### Remerciements Merci aux fournisseurs de services suivants pour le parrainage d'hébergement [![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ "Powered by NotiDC") ================================================ FILE: README_ja.md ================================================

Tiny RDM

English | 简体中文 | 繁體中文 | 日本語 | 한국어 | Français | Español | Português (BR) | Русский | Türkçe

[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases) ![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total) [![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers) [![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork) [![Discord](https://img.shields.io/discord/1170373259133456434?label=Discord&color=5865F2)](https://discord.gg/VTFbBMGjWh) [![X](https://img.shields.io/badge/Twitter-black?logo=x&logoColor=white)](https://twitter.com/Lykin53448) Tiny RDMは、Mac、Windows、Linuxで利用可能な、モダンで軽量なクロスプラットフォームのRedisデスクトップマネージャーです。Docker経由でデプロイ可能なWeb版も提供しています。
screenshot screenshot ## 特徴 * 超軽量、Webview2をベースにしており、埋め込みブラウザなし([Wails](https://github.com/wailsapp/wails)に感謝)。 * 視覚的でユーザーフレンドリーなUI、ライトとダークテーマを提供([Naive UI](https://github.com/tusen-ai/naive-ui)と[IconPark](https://iconpark.oceanengine.com)に感謝)。 * 多言語サポート([もっと多くの言語が必要ですか?ここをクリックして貢献してください](.github/CONTRIBUTING.md))。 * より良い接続管理:SSHトンネル/SSL/センチネルモード/クラスターモード/HTTPプロキシ/SOCKS5プロキシをサポート。 * キー値操作の可視化、リスト、ハッシュ、文字列、セット、ソートセット、ストリームのCRUDサポート。 * 複数のデータ表示形式とデコード/解凍方法をサポート。 * SCANを使用してセグメント化された読み込みを行い、数百万のキーを簡単にリスト化。 * コマンド操作履歴のログリスト。 * コマンドラインモードを提供。 * スローログリストを提供。 * リスト/ハッシュ/セット/ソートセットのセグメント化された読み込みとクエリ。 * リスト/ハッシュ/セット/ソートセットの値のデコード/解凍を提供。 * Monaco Editorと統合。 * リアルタイムコマンド監視をサポート。 * データのインポート/エクスポートをサポート。 * パブリッシュ/サブスクライブをサポート。 * 接続プロファイルのインポート/エクスポートをサポート。 * 値表示のためのカスタムデータエンコーダーとデコーダーをサポート([こちらが手順です](https://tinyrdm.com/guide/custom-decoder/))。 ## インストール [こちら](https://github.com/tiny-craft/tiny-rdm/releases)から無料でダウンロードできます。 > macOSにインストール後に開けない場合、**信頼されていない**または**ゴミ箱に移動**というエラーが表示された場合は、以下のコマンドを実行してから再度開いてください: > ``` shell > sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app > ``` ## ビルドガイドライン ### 前提条件 * Go(最新バージョン) * Node.js >= 20 * NPM >= 9 ### Wailsのインストール ```bash go install github.com/wailsapp/wails/v2/cmd/wails@latest ``` ### コードの取得 ```bash git clone https://github.com/tiny-craft/tiny-rdm --depth=1 ``` ### フロントエンドのビルド ```bash npm install --prefix ./frontend ``` または ```bash cd frontend npm install ``` ### コンパイルと実行 ```bash wails dev ``` ## Dockerデプロイ デスクトップクライアントに加えて、Tiny RDMはDockerで素早くデプロイできるWeb版も提供しています。 ### Docker Composeを使用(推奨) `docker-compose.yml` ファイルを作成します: ```yaml services: tinyrdm: image: ghcr.io/tiny-craft/tiny-rdm:latest container_name: tinyrdm restart: unless-stopped ports: - "8086:8086" environment: - ADMIN_USERNAME=admin - ADMIN_PASSWORD=tinyrdm volumes: - ./data:/app/tinyrdm ``` サービスを起動します: ```bash docker compose up -d ``` 起動後、`http://localhost:8086` にアクセスし、上記で設定したユーザー名とパスワードでログインしてください。 ### Dockerコマンドを使用 ```bash docker run -d --name tinyrdm \ -p 8086:8086 \ -e ADMIN_USERNAME=admin \ -e ADMIN_PASSWORD=tinyrdm \ -v ./data:/app/tinyrdm \ ghcr.io/tiny-craft/tiny-rdm:latest ``` ### 環境変数の説明 | 変数 | 説明 | デフォルト値 | |------|------|-------------| | `ADMIN_USERNAME` | ログインユーザー名 | - | | `ADMIN_PASSWORD` | ログインパスワード | - | ## について ### Wechat公式アカウント wechat ### スポンサー このプロジェクトが役立つ場合は、コーヒーを一杯おごってください ☕️。 * Wechatスポンサー wechat ### 謝辞 以下のサービスプロバイダーによるホスティングスポンサーシップに感謝いたします [![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ "Powered by NotiDC") ================================================ FILE: README_ko.md ================================================

Tiny RDM

English | 简体中文 | 繁體中文 | 日本語 | 한국어 | Français | Español | Português (BR) | Русский | Türkçe

[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases) ![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total) [![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers) [![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork) Tiny RDM은 Mac, Windows, Linux에서 사용할 수 있는 현대적이고 가벼운 크로스 플랫폼 Redis 데스크톱 관리자입니다. Docker를 통해 배포할 수 있는 웹 버전도 제공합니다.
screenshot screenshot ## 기능 * 초경량, Webview2 기반으로 내장 브라우저 없음 ([Wails](https://github.com/wailsapp/wails) 감사합니다) * 시각적이고 사용자 친화적인 UI, 라이트/다크 테마 제공 ([Naive UI](https://github.com/tusen-ai/naive-ui) 및 [IconPark](https://iconpark.oceanengine.com) 감사합니다) * 다국어 지원 ([더 많은 언어가 필요하신가요? 여기를 클릭하여 기여하세요](.github/CONTRIBUTING.md)) * 향상된 연결 관리: SSH 터널/SSL/센티널 모드/클러스터 모드/HTTP 프록시/SOCKS5 프록시 지원 * 키-값 작업 시각화, List, Hash, String, Set, Sorted Set, Stream의 CRUD 지원 * 다양한 데이터 보기 형식 및 디코딩/압축 해제 방법 지원 * SCAN을 사용한 분할 로딩으로 수백만 개의 키를 쉽게 나열 * 명령 실행 이력 로그 목록 * 명령줄 모드 제공 * 슬로우 로그 목록 제공 * List/Hash/Set/Sorted Set의 분할 로딩 및 쿼리 * List/Hash/Set/Sorted Set 값의 디코딩/압축 해제 제공 * Monaco Editor 통합 * 실시간 명령 모니터링 지원 * 데이터 가져오기/내보내기 지원 * 발행/구독 지원 * 연결 프로필 가져오기/내보내기 지원 * 값 표시를 위한 사용자 정의 데이터 인코더 및 디코더 ([사용 방법](https://tinyrdm.com/guide/custom-decoder/)) ## 설치 [여기](https://github.com/tiny-craft/tiny-rdm/releases)에서 무료로 다운로드할 수 있습니다. > macOS에서 설치 후 열 수 없는 경우, 다음 명령을 실행한 후 다시 열어주세요: > ``` shell > sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app > ``` ## 빌드 가이드 ### 사전 요구 사항 * Go (최신 버전) * Node.js >= 20 * NPM >= 9 ### Wails 설치 ```bash go install github.com/wailsapp/wails/v2/cmd/wails@latest ``` ### 코드 가져오기 ```bash git clone https://github.com/tiny-craft/tiny-rdm --depth=1 ``` ### 프론트엔드 빌드 ```bash npm install --prefix ./frontend ``` 또는 ```bash cd frontend npm install ``` ### 컴파일 및 실행 ```bash wails dev ``` ## Docker 배포 데스크톱 클라이언트 외에도 Tiny RDM은 Docker를 통해 빠르게 배포할 수 있는 웹 버전을 제공합니다. ### Docker Compose 사용 (권장) `docker-compose.yml` 파일을 생성합니다: ```yaml services: tinyrdm: image: ghcr.io/tiny-craft/tiny-rdm:latest container_name: tinyrdm restart: unless-stopped ports: - "8086:8086" environment: - ADMIN_USERNAME=admin - ADMIN_PASSWORD=tinyrdm volumes: - ./data:/app/tinyrdm ``` 서비스를 시작합니다: ```bash docker compose up -d ``` 시작 후 `http://localhost:8086`에 접속하여 위에서 설정한 사용자 이름과 비밀번호로 로그인하세요. ### Docker 명령 사용 ```bash docker run -d --name tinyrdm \ -p 8086:8086 \ -e ADMIN_USERNAME=admin \ -e ADMIN_PASSWORD=tinyrdm \ -v ./data:/app/tinyrdm \ ghcr.io/tiny-craft/tiny-rdm:latest ``` ### 환경 변수 | 변수 | 설명 | 기본값 | |------|------|--------| | `ADMIN_USERNAME` | 로그인 사용자 이름 | - | | `ADMIN_PASSWORD` | 로그인 비밀번호 | - | ## 소개 ### 스폰서 이 프로젝트가 도움이 되셨다면 커피 한 잔 사주세요 ☕️ * Wechat 후원 wechat ### 감사 호스팅 후원을 제공해 주신 다음 서비스 제공업체에 감사드립니다 [![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ "Powered by NotiDC") ================================================ FILE: README_pt.md ================================================

Tiny RDM

English | 简体中文 | 繁體中文 | 日本語 | 한국어 | Français | Español | Português (BR) | Русский | Türkçe

[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases) ![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total) [![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers) [![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork) Tiny RDM é um gerenciador Redis moderno, leve e multiplataforma, disponível para Mac, Windows e Linux. Também oferece uma versão web que pode ser implantada via Docker.
screenshot screenshot ## Funcionalidades * Ultra leve, baseado em Webview2, sem navegador embutido (Graças ao [Wails](https://github.com/wailsapp/wails)) * Interface visual e amigável, temas claro e escuro (Graças ao [Naive UI](https://github.com/tusen-ai/naive-ui) e [IconPark](https://iconpark.oceanengine.com)) * Suporte multilíngue ([Precisa de mais idiomas? Clique aqui para contribuir](.github/CONTRIBUTING.md)) * Gerenciamento aprimorado de conexões: túnel SSH/SSL/modo Sentinel/modo Cluster/proxy HTTP/proxy SOCKS5 * Visualização de operações chave-valor, suporte CRUD para List, Hash, String, Set, Sorted Set e Stream * Suporte a múltiplos formatos de visualização e métodos de decodificação/descompressão * Carregamento segmentado com SCAN para listar facilmente milhões de chaves * Lista de logs do histórico de comandos * Modo linha de comando * Lista de logs lentos * Carregamento segmentado e consultas para List/Hash/Set/Sorted Set * Decodificação/descompressão de valores para List/Hash/Set/Sorted Set * Integração com Monaco Editor * Monitoramento de comandos em tempo real * Importação/exportação de dados * Publicação/assinatura * Importação/exportação de perfis de conexão * Codificador e decodificador de dados personalizados para exibição de valores ([Instruções aqui](https://tinyrdm.com/guide/custom-decoder/)) ## Instalação Disponível para download gratuito [aqui](https://github.com/tiny-craft/tiny-rdm/releases). > Se não conseguir abrir após a instalação no macOS, execute o seguinte comando e reabra: > ``` shell > sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app > ``` ## Guia de compilação ### Pré-requisitos * Go (versão mais recente) * Node.js >= 20 * NPM >= 9 ### Instalar Wails ```bash go install github.com/wailsapp/wails/v2/cmd/wails@latest ``` ### Obter o código ```bash git clone https://github.com/tiny-craft/tiny-rdm --depth=1 ``` ### Compilar o frontend ```bash npm install --prefix ./frontend ``` ou ```bash cd frontend npm install ``` ### Compilar e executar ```bash wails dev ``` ## Implantação com Docker Além do cliente desktop, o Tiny RDM também oferece uma versão web que pode ser implantada rapidamente via Docker. ### Usando Docker Compose (recomendado) Crie um arquivo `docker-compose.yml`: ```yaml services: tinyrdm: image: ghcr.io/tiny-craft/tiny-rdm:latest container_name: tinyrdm restart: unless-stopped ports: - "8086:8086" environment: - ADMIN_USERNAME=admin - ADMIN_PASSWORD=tinyrdm volumes: - ./data:/app/tinyrdm ``` Inicie o serviço: ```bash docker compose up -d ``` Após iniciar, acesse `http://localhost:8086` e faça login com as credenciais configuradas acima. ### Usando o comando Docker ```bash docker run -d --name tinyrdm \ -p 8086:8086 \ -e ADMIN_USERNAME=admin \ -e ADMIN_PASSWORD=tinyrdm \ -v ./data:/app/tinyrdm \ ghcr.io/tiny-craft/tiny-rdm:latest ``` ### Variáveis de ambiente | Variável | Descrição | Padrão | |----------|-----------|--------| | `ADMIN_USERNAME` | Nome de usuário | - | | `ADMIN_PASSWORD` | Senha | - | ## Sobre ### Patrocinar Se este projeto foi útil para você, sinta-se à vontade para pagar um café ☕️ * Wechat Sponsor wechat ### Agradecimentos Agradecemos aos seguintes provedores de serviços pelo patrocínio de hospedagem [![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ "Powered by NotiDC") ================================================ FILE: README_ru.md ================================================

Tiny RDM

English | 简体中文 | 繁體中文 | 日本語 | 한국어 | Français | Español | Português (BR) | Русский | Türkçe

[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases) ![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total) [![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers) [![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork) Tiny RDM — современный легковесный кроссплатформенный менеджер Redis для Mac, Windows и Linux. Также доступна веб-версия с возможностью развёртывания через Docker.
screenshot screenshot ## Возможности * Сверхлёгкий, на базе Webview2, без встроенного браузера (Спасибо [Wails](https://github.com/wailsapp/wails)) * Визуально приятный и удобный интерфейс, светлая и тёмная темы (Спасибо [Naive UI](https://github.com/tusen-ai/naive-ui) и [IconPark](https://iconpark.oceanengine.com)) * Поддержка нескольких языков ([Нужно больше языков? Нажмите здесь, чтобы помочь](.github/CONTRIBUTING.md)) * Улучшенное управление подключениями: SSH-туннель/SSL/режим Sentinel/режим Cluster/HTTP-прокси/SOCKS5-прокси * Визуализация операций с ключами, поддержка CRUD для List, Hash, String, Set, Sorted Set и Stream * Поддержка множества форматов отображения и методов декодирования/распаковки * Сегментированная загрузка через SCAN для удобной работы с миллионами ключей * Журнал истории выполненных команд * Режим командной строки * Список медленных запросов * Сегментированная загрузка и запросы для List/Hash/Set/Sorted Set * Декодирование/распаковка значений для List/Hash/Set/Sorted Set * Интеграция с Monaco Editor * Мониторинг команд в реальном времени * Импорт/экспорт данных * Публикация/подписка * Импорт/экспорт профилей подключений * Пользовательские кодировщики и декодировщики для отображения значений ([Инструкция](https://tinyrdm.com/guide/custom-decoder/)) ## Установка Доступно для бесплатного скачивания [здесь](https://github.com/tiny-craft/tiny-rdm/releases). > Если после установки на macOS приложение не открывается, выполните следующую команду и попробуйте снова: > ``` shell > sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app > ``` ## Руководство по сборке ### Требования * Go (последняя версия) * Node.js >= 20 * NPM >= 9 ### Установка Wails ```bash go install github.com/wailsapp/wails/v2/cmd/wails@latest ``` ### Получение кода ```bash git clone https://github.com/tiny-craft/tiny-rdm --depth=1 ``` ### Сборка фронтенда ```bash npm install --prefix ./frontend ``` или ```bash cd frontend npm install ``` ### Компиляция и запуск ```bash wails dev ``` ## Развёртывание через Docker Помимо десктопного клиента, Tiny RDM предоставляет веб-версию, которую можно быстро развернуть через Docker. ### С помощью Docker Compose (рекомендуется) Создайте файл `docker-compose.yml`: ```yaml services: tinyrdm: image: ghcr.io/tiny-craft/tiny-rdm:latest container_name: tinyrdm restart: unless-stopped ports: - "8086:8086" environment: - ADMIN_USERNAME=admin - ADMIN_PASSWORD=tinyrdm volumes: - ./data:/app/tinyrdm ``` Запустите сервис: ```bash docker compose up -d ``` После запуска откройте `http://localhost:8086` и войдите с указанными выше учётными данными. ### С помощью команды Docker ```bash docker run -d --name tinyrdm \ -p 8086:8086 \ -e ADMIN_USERNAME=admin \ -e ADMIN_PASSWORD=tinyrdm \ -v ./data:/app/tinyrdm \ ghcr.io/tiny-craft/tiny-rdm:latest ``` ### Переменные окружения | Переменная | Описание | По умолчанию | |------------|----------|--------------| | `ADMIN_USERNAME` | Имя пользователя | - | | `ADMIN_PASSWORD` | Пароль | - | ## О проекте ### Спонсорство Если этот проект оказался полезным, угостите автора чашкой кофе ☕️ * Wechat Sponsor wechat ### Благодарности Благодарим следующих поставщиков услуг за спонсорство хостинга [![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ "Powered by NotiDC") ================================================ FILE: README_tr.md ================================================

Tiny RDM

English | 简体中文 | 繁體中文 | 日本語 | 한국어 | Français | Español | Português (BR) | Русский | Türkçe

[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases) ![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total) [![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers) [![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork) Tiny RDM, Mac, Windows ve Linux için kullanılabilen modern, hafif ve çapraz platform bir Redis masaüstü yöneticisidir. Docker ile dağıtılabilen bir web sürümü de sunmaktadır.
screenshot screenshot ## Özellikler * Ultra hafif, Webview2 tabanlı, gömülü tarayıcı yok ([Wails](https://github.com/wailsapp/wails)'e teşekkürler) * Görsel ve kullanıcı dostu arayüz, açık ve koyu tema desteği ([Naive UI](https://github.com/tusen-ai/naive-ui) ve [IconPark](https://iconpark.oceanengine.com)'a teşekkürler) * Çoklu dil desteği ([Daha fazla dil mi gerekiyor? Katkıda bulunmak için tıklayın](.github/CONTRIBUTING.md)) * Gelişmiş bağlantı yönetimi: SSH Tüneli/SSL/Sentinel Modu/Cluster Modu/HTTP proxy/SOCKS5 proxy desteği * Anahtar-değer işlemlerinin görselleştirilmesi, List, Hash, String, Set, Sorted Set ve Stream için CRUD desteği * Çoklu veri görüntüleme formatı ve çözme/sıkıştırma açma yöntemleri desteği * SCAN ile segmentli yükleme, milyonlarca anahtarı kolayca listeleme * Komut işlem geçmişi günlük listesi * Komut satırı modu * Yavaş günlük listesi * List/Hash/Set/Sorted Set için segmentli yükleme ve sorgulama * List/Hash/Set/Sorted Set değerleri için çözme/sıkıştırma açma * Monaco Editor entegrasyonu * Gerçek zamanlı komut izleme desteği * Veri içe/dışa aktarma desteği * Yayınla/abone ol desteği * Bağlantı profili içe/dışa aktarma desteği * Değer görüntüleme için özel veri kodlayıcı ve çözücü ([Talimatlar burada](https://tinyrdm.com/guide/custom-decoder/)) ## Kurulum [Buradan](https://github.com/tiny-craft/tiny-rdm/releases) ücretsiz olarak indirilebilir. > macOS'ta kurulumdan sonra açamıyorsanız, aşağıdaki komutu çalıştırıp tekrar açın: > ``` shell > sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app > ``` ## Derleme Kılavuzu ### Gereksinimler * Go (en son sürüm) * Node.js >= 20 * NPM >= 9 ### Wails Kurulumu ```bash go install github.com/wailsapp/wails/v2/cmd/wails@latest ``` ### Kodu Çekme ```bash git clone https://github.com/tiny-craft/tiny-rdm --depth=1 ``` ### Frontend Derleme ```bash npm install --prefix ./frontend ``` veya ```bash cd frontend npm install ``` ### Derleme ve Çalıştırma ```bash wails dev ``` ## Docker ile Dağıtım Masaüstü istemcisinin yanı sıra, Tiny RDM Docker ile hızlıca dağıtılabilen bir web sürümü de sunmaktadır. ### Docker Compose Kullanımı (önerilen) Bir `docker-compose.yml` dosyası oluşturun: ```yaml services: tinyrdm: image: ghcr.io/tiny-craft/tiny-rdm:latest container_name: tinyrdm restart: unless-stopped ports: - "8086:8086" environment: - ADMIN_USERNAME=admin - ADMIN_PASSWORD=tinyrdm volumes: - ./data:/app/tinyrdm ``` Servisi başlatın: ```bash docker compose up -d ``` Başlatıldıktan sonra `http://localhost:8086` adresini ziyaret edin ve yukarıda yapılandırılan kimlik bilgileriyle giriş yapın. ### Docker Komutu Kullanımı ```bash docker run -d --name tinyrdm \ -p 8086:8086 \ -e ADMIN_USERNAME=admin \ -e ADMIN_PASSWORD=tinyrdm \ -v ./data:/app/tinyrdm \ ghcr.io/tiny-craft/tiny-rdm:latest ``` ### Ortam Değişkenleri | Değişken | Açıklama | Varsayılan | |----------|----------|------------| | `ADMIN_USERNAME` | Giriş kullanıcı adı | - | | `ADMIN_PASSWORD` | Giriş şifresi | - | ## Hakkında ### Sponsor Bu proje işinize yaradıysa, bir kahve ısmarlayabilirsiniz ☕️ * Wechat Sponsor wechat ### Teşekkürler Barındırma sponsorluğu sağlayan aşağıdaki hizmet sağlayıcılara teşekkür ederiz [![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ "Powered by NotiDC") ================================================ FILE: README_tw.md ================================================

Tiny RDM

English | 简体中文 | 繁體中文 | 日本語 | 한국어 | Français | Español | Português (BR) | Русский | Türkçe

[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases) ![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total) [![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers) [![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork) Tiny RDM 是一款現代化輕量級的跨平台 Redis 桌面管理工具,支援 Mac、Windows 和 Linux,同時提供 Web 版本,可透過 Docker 快速部署
screenshot screenshot ## 功能特性 * 極度輕量,基於 Webview2,無內嵌瀏覽器(感謝 [Wails](https://github.com/wailsapp/wails)) * 介面精美易用,提供淺色/深色主題(感謝 [Naive UI](https://github.com/tusen-ai/naive-ui) 和 [IconPark](https://iconpark.oceanengine.com)) * 多國語言支援([需要更多語言支援?點此貢獻](.github/CONTRIBUTING.md)) * 更好的連線管理:支援 SSH 隧道/SSL/哨兵模式/叢集模式/HTTP 代理/SOCKS5 代理 * 視覺化鍵值操作,支援 List、Hash、String、Set、Sorted Set 和 Stream 的 CRUD * 支援多種資料檢視格式及轉碼/解壓方式 * 採用 SCAN 分段載入,可輕鬆處理數百萬鍵列表 * 操作命令執行日誌展示 * 提供命令列模式 * 提供慢日誌展示 * List/Hash/Set/Sorted Set 的分段載入和查詢 * List/Hash/Set/Sorted Set 值的轉碼顯示 * 內建高級編輯器 Monaco Editor * 支援命令即時監控 * 支援匯入/匯出資料 * 支援發布訂閱 * 支援匯入/匯出連線設定 * 自訂資料展示編碼/解碼([操作指引](https://tinyrdm.com/guide/custom-decoder/)) ## 安裝 提供 Mac、Windows 和 Linux 安裝包,可[免費下載](https://github.com/tiny-craft/tiny-rdm/releases)。 > 如果在 macOS 上安裝後無法開啟,出現**不受信任**或**移到垃圾桶**的錯誤,執行以下命令後再啟動即可: > ``` shell > sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app > ``` ## 建置專案 ### 環境需求 * Go(最新版本) * Node.js >= 20 * NPM >= 9 ### 安裝 Wails ```bash go install github.com/wailsapp/wails/v2/cmd/wails@latest ``` ### 取得程式碼 ```bash git clone https://github.com/tiny-craft/tiny-rdm --depth=1 ``` ### 建置前端 ```bash npm install --prefix ./frontend ``` 或 ```bash cd frontend npm install ``` ### 編譯並執行 ```bash wails dev ``` ## Docker 部署 除桌面客戶端外,Tiny RDM 還提供 Web 版本,可透過 Docker 快速部署。 ### 使用 Docker Compose(推薦) 建立 `docker-compose.yml` 檔案: ```yaml services: tinyrdm: image: ghcr.io/tiny-craft/tiny-rdm:latest container_name: tinyrdm restart: unless-stopped ports: - "8086:8086" environment: - ADMIN_USERNAME=admin - ADMIN_PASSWORD=tinyrdm volumes: - ./data:/app/tinyrdm ``` 啟動服務: ```bash docker compose up -d ``` 啟動後造訪 `http://localhost:8086`,使用上方設定的帳號密碼登入。 ### 使用 Docker 命令 ```bash docker run -d --name tinyrdm \ -p 8086:8086 \ -e ADMIN_USERNAME=admin \ -e ADMIN_PASSWORD=tinyrdm \ -v ./data:/app/tinyrdm \ ghcr.io/tiny-craft/tiny-rdm:latest ``` ### 環境變數說明 | 變數 | 說明 | 預設值 | |------|------|--------| | `ADMIN_USERNAME` | 登入帳號 | - | | `ADMIN_PASSWORD` | 登入密碼 | - | ## 關於 ### 贊助 如果此專案對您有幫助,歡迎請作者喝杯咖啡 ☕️ * 微信贊賞 wechat ### 感謝 感謝以下服務商提供主機贊助 [![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ "Powered by NotiDC") ================================================ FILE: README_zh.md ================================================

Tiny RDM

English | 简体中文 | 繁體中文 | 日本語 | 한국어 | Français | Español | Português (BR) | Русский | Türkçe

[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases) ![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total) [![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers) [![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork) 一个现代化轻量级的跨平台Redis桌面客户端,支持Mac、Windows和Linux,同时提供Web版本,可通过Docker快速部署
screenshot screenshot ## 功能特性 * 极度轻量,基于Webview2,无内嵌浏览器(感谢[Wails](https://github.com/wailsapp/wails)) * 界面精美易用,提供浅色/深色主题(感谢[Naive UI](https://github.com/tusen-ai/naive-ui) 和 [IconPark](https://iconpark.oceanengine.com)) * 多国语言支持:英文/中文([需要更多语言支持?点我贡献语言](.github/CONTRIBUTING_zh.md)) * 更好用的连接管理:支持SSH隧道/SSL/哨兵模式/集群模式/HTTP代理/SOCKS5代理 * 可视化键值操作,增删查改一应俱全 * 支持多种数据查看格式以及转码/解压方式 * 采用SCAN分段加载,可轻松处理数百万键列表 * 操作命令执行日志展示 * 提供命令行操作 * 提供慢日志展示 * List/Hash/Set/Sorted Set的分段加载和查询 * List/Hash/Set/Sorted Set值的转码显示 * 内置高级编辑器Monaco Editor * 支持命令实时监控 * 支持导入/导出数据 * 支持发布订阅 * 支持导入/导出连接配置 * 自定义数据展示编码/解码([这是操作指引](https://tinyrdm.com/zh/guide/custom-decoder/)) ## 安装 提供Mac、Windows和Linux安装包,可[免费下载](https://github.com/tiny-craft/tiny-rdm/releases)。 > 如果在macOS上安装后无法打开,报错**不受信任**或者**移到垃圾箱**,执行下面命令后再启动即可: > ``` shell > sudo xattr -d com.apple.quarantine /Applications/Tiny\ RDM.app > ``` ## 构建客户端 ### 运行环境要求 * Go(最新版本) * Node.js >= 20 * NPM >= 9 ### 安装wails ```bash go install github.com/wailsapp/wails/v2/cmd/wails@latest ``` ### 拉取代码 ```bash git clone https://github.com/tiny-craft/tiny-rdm --depth=1 ``` ### 构建前端代码 ```bash npm install --prefix ./frontend ``` 或者 ```bash cd frontend npm install ``` ### 编译运行开发版本 ```bash wails dev ``` ## Docker 部署 除桌面客户端外,Tiny RDM 还提供 Web 版本,可通过 Docker 快速部署。 ### 使用 Docker Compose(推荐) 创建 `docker-compose.yml` 文件: ```yaml services: tinyrdm: image: ghcr.io/tiny-craft/tiny-rdm:latest container_name: tinyrdm restart: unless-stopped ports: - "8086:8086" environment: - ADMIN_USERNAME=admin - ADMIN_PASSWORD=tinyrdm volumes: - ./data:/app/tinyrdm ``` 启动服务: ```bash docker compose up -d ``` 启动后访问 `http://localhost:8086`,使用上面配置的用户名密码登录。 ### 使用 Docker 命令 ```bash docker run -d --name tinyrdm \ -p 8086:8086 \ -e ADMIN_USERNAME=admin \ -e ADMIN_PASSWORD=tinyrdm \ -v ./data:/app/tinyrdm \ ghcr.io/tiny-craft/tiny-rdm:latest ``` ### 环境变量说明 | 变量 | 说明 | 默认值 | |------|------|--------| | `ADMIN_USERNAME` | 登录用户名 | - | | `ADMIN_PASSWORD` | 登录密码 | - | ## 关于 如果你也同为独立开发者(团队),喜欢开源,或者对Tiny Craft的相关产品感兴趣,可以关注微信公众号或者加入QQ群,探讨心得,反馈意见,交个朋友。 ### 微信公众号(用户交流微信群) 我会不定期更新一些关于独立开发的思考和感悟,以及独立产品的介绍,欢迎扫码关注~👏 wechat ### B站官方账号 bilibili ### 独立开发互助QQ群 ``` 831077639 ``` ### 赞助 该项目完全为爱发电,如果对你有所帮助,可以请作者喝杯咖啡 ☕️ * 微信赞赏 wechat ### 感谢 感谢以下服务商提供主机赞助 [![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ "Powered by NotiDC") ================================================ FILE: backend/api/auth.go ================================================ //go:build web package api import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "os" "strings" "sync" "time" "github.com/gin-gonic/gin" ) // Auth configuration var ( authEnabled bool authUsername string authPassword string jwtSecret []byte sessionTTL = 24 * time.Hour ) // Rate limiter for login attempts type rateLimiter struct { mu sync.Mutex attempts map[string][]time.Time // ip -> timestamps maxRate int // max attempts per window window time.Duration maxEntries int // max tracked IPs to prevent memory exhaustion lastCleanup time.Time } var loginLimiter = &rateLimiter{ attempts: make(map[string][]time.Time), maxRate: 5, window: time.Minute, maxEntries: 10000, lastCleanup: time.Now(), } func (rl *rateLimiter) allow(ip string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() cutoff := now.Add(-rl.window) // Periodic full cleanup every 5 minutes to prevent memory leak if now.Sub(rl.lastCleanup) > 5*time.Minute { for k, times := range rl.attempts { valid := times[:0] for _, t := range times { if t.After(cutoff) { valid = append(valid, t) } } if len(valid) == 0 { delete(rl.attempts, k) } else { rl.attempts[k] = valid } } rl.lastCleanup = now } // Hard cap on tracked IPs to prevent memory exhaustion from distributed attacks if len(rl.attempts) >= rl.maxEntries { if _, exists := rl.attempts[ip]; !exists { // Too many tracked IPs, reject new ones as a safety measure return false } } // Clean old entries for this IP times := rl.attempts[ip] valid := times[:0] for _, t := range times { if t.After(cutoff) { valid = append(valid, t) } } rl.attempts[ip] = valid if len(valid) >= rl.maxRate { return false } rl.attempts[ip] = append(valid, now) return true } // InitAuth reads auth config from environment variables func InitAuth() { authUsername = os.Getenv("ADMIN_USERNAME") authPassword = os.Getenv("ADMIN_PASSWORD") authEnabled = authUsername != "" && authPassword != "" // Generate random JWT secret on each startup secret := make([]byte, 32) rand.Read(secret) jwtSecret = secret if ttl := os.Getenv("SESSION_TTL"); ttl != "" { if d, err := time.ParseDuration(ttl); err == nil { sessionTTL = d } } if authEnabled { fmt.Printf("Auth enabled for user: %s\n", authUsername) } else { fmt.Println("Auth disabled (set ADMIN_USERNAME and ADMIN_PASSWORD to enable)") } } // IsAuthEnabled returns whether authentication is enabled func IsAuthEnabled() bool { return authEnabled } // Simple JWT-like token: header.payload.signature (HMAC-SHA256) type tokenPayload struct { User string `json:"u"` Exp int64 `json:"e"` IP string `json:"ip"` } func generateToken(username, ip string) (string, time.Time) { exp := time.Now().Add(sessionTTL) payload := tokenPayload{User: username, Exp: exp.Unix(), IP: ip} data, _ := json.Marshal(payload) encoded := hex.EncodeToString(data) mac := hmac.New(sha256.New, jwtSecret) mac.Write(data) sig := hex.EncodeToString(mac.Sum(nil)) return encoded + "." + sig, exp } func validateToken(token, ip string) bool { parts := strings.SplitN(token, ".", 2) if len(parts) != 2 { return false } data, err := hex.DecodeString(parts[0]) if err != nil { return false } // Verify signature mac := hmac.New(sha256.New, jwtSecret) mac.Write(data) expectedSig := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(parts[1]), []byte(expectedSig)) { return false } // Parse and validate payload var payload tokenPayload if err := json.Unmarshal(data, &payload); err != nil { return false } if time.Now().Unix() > payload.Exp { return false } // IP binding if payload.IP != ip { return false } return true } func getClientIP(c *gin.Context) string { // Cloudflare if ip := c.GetHeader("CF-Connecting-IP"); ip != "" { return ip } if ip := c.GetHeader("X-Real-IP"); ip != "" { return ip } if ip := c.GetHeader("X-Forwarded-For"); ip != "" { return strings.Split(ip, ",")[0] } return c.ClientIP() } // AuthMiddleware protects API routes func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if !authEnabled { c.Next() return } // Get token from cookie token, err := c.Cookie("rdm_token") if err != nil || !validateToken(token, getClientIP(c)) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "success": false, "msg": "unauthorized", }) return } c.Next() } } // SecurityHeaders adds security headers to all responses func SecurityHeaders() gin.HandlerFunc { return func(c *gin.Context) { c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Frame-Options", "SAMEORIGIN") c.Header("X-XSS-Protection", "1; mode=block") c.Header("Referrer-Policy", "strict-origin-when-cross-origin") c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://static.cloudflareinsights.com https://analytics.tinycraft.cc; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss: https://static.cloudflareinsights.com https://analytics.tinycraft.cc; font-src 'self' data:;") c.Next() } } // registerAuthRoutes registers login/logout/status endpoints func registerAuthRoutes(r *gin.Engine) { r.POST("/api/auth/login", handleLogin) r.POST("/api/auth/logout", handleLogout) r.GET("/api/auth/status", handleAuthStatus) } func handleLogin(c *gin.Context) { ip := getClientIP(c) // Rate limiting if !loginLimiter.allow(ip) { c.JSON(http.StatusTooManyRequests, gin.H{ "success": false, "msg": "too many login attempts, please try again later", }) return } var req struct { Username string `json:"username"` Password string `json:"password"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "msg": "invalid request"}) return } // Constant-time comparison to prevent timing attacks userOK := hmac.Equal([]byte(req.Username), []byte(authUsername)) passOK := hmac.Equal([]byte(req.Password), []byte(authPassword)) if !userOK || !passOK { // Delay to slow down brute force time.Sleep(500 * time.Millisecond) c.JSON(http.StatusUnauthorized, gin.H{"success": false, "msg": "invalid credentials"}) return } token, exp := generateToken(req.Username, ip) // Set httpOnly, secure cookie secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" c.SetSameSite(http.SameSiteStrictMode) c.SetCookie("rdm_token", token, int(sessionTTL.Seconds()), "/", "", secure, true) c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "expires": exp.Unix(), }, }) } func handleLogout(c *gin.Context) { c.SetSameSite(http.SameSiteStrictMode) c.SetCookie("rdm_token", "", -1, "/", "", false, true) c.JSON(http.StatusOK, gin.H{"success": true}) } func handleAuthStatus(c *gin.Context) { if !authEnabled { c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{"enabled": false, "authenticated": true}, }) return } token, err := c.Cookie("rdm_token") authenticated := err == nil && validateToken(token, getClientIP(c)) c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{"enabled": true, "authenticated": authenticated}, }) } ================================================ FILE: backend/api/browser_api.go ================================================ //go:build web package api import ( "net/http" "tinyrdm/backend/services" "tinyrdm/backend/types" "github.com/gin-gonic/gin" ) func registerBrowserRoutes(rg *gin.RouterGroup) { g := rg.Group("/browser") g.POST("/open-connection", func(c *gin.Context) { var req struct { Name string `json:"name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().OpenConnection(req.Name)) }) g.POST("/close-connection", func(c *gin.Context) { var req struct { Name string `json:"name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().CloseConnection(req.Name)) }) g.POST("/open-database", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().OpenDatabase(req.Server, req.DB)) }) g.POST("/server-info", func(c *gin.Context) { var req struct { Name string `json:"name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().ServerInfo(req.Name)) }) g.POST("/load-next-keys", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Match string `json:"match"` KeyType string `json:"keyType"` ExactMatch bool `json:"exactMatch"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().LoadNextKeys(req.Server, req.DB, req.Match, req.KeyType, req.ExactMatch)) }) g.POST("/load-next-all-keys", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Match string `json:"match"` KeyType string `json:"keyType"` ExactMatch bool `json:"exactMatch"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().LoadNextAllKeys(req.Server, req.DB, req.Match, req.KeyType, req.ExactMatch)) }) g.POST("/load-all-keys", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Match string `json:"match"` KeyType string `json:"keyType"` ExactMatch bool `json:"exactMatch"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().LoadAllKeys(req.Server, req.DB, req.Match, req.KeyType, req.ExactMatch)) }) g.POST("/get-key-type", func(c *gin.Context) { var param types.KeySummaryParam if err := c.ShouldBindJSON(¶m); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().GetKeyType(param)) }) g.POST("/get-key-summary", func(c *gin.Context) { var param types.KeySummaryParam if err := c.ShouldBindJSON(¶m); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().GetKeySummary(param)) }) g.POST("/get-key-detail", func(c *gin.Context) { var param types.KeyDetailParam if err := c.ShouldBindJSON(¶m); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().GetKeyDetail(param)) }) g.POST("/convert-value", func(c *gin.Context) { var req struct { Value any `json:"value"` Decode string `json:"decode"` Format string `json:"format"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().ConvertValue(req.Value, req.Decode, req.Format)) }) g.POST("/set-key-value", func(c *gin.Context) { var param types.SetKeyParam if err := c.ShouldBindJSON(¶m); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().SetKeyValue(param)) }) g.POST("/get-hash-value", func(c *gin.Context) { var param types.GetHashParam if err := c.ShouldBindJSON(¶m); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().GetHashValue(param)) }) g.POST("/set-hash-value", func(c *gin.Context) { var param types.SetHashParam if err := c.ShouldBindJSON(¶m); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().SetHashValue(param)) }) g.POST("/add-hash-field", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` Action int `json:"action"` FieldItems []any `json:"fieldItems"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().AddHashField(req.Server, req.DB, req.Key, req.Action, req.FieldItems)) }) g.POST("/add-list-item", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` Action int `json:"action"` Items []any `json:"items"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().AddListItem(req.Server, req.DB, req.Key, req.Action, req.Items)) }) g.POST("/set-list-item", func(c *gin.Context) { var param types.SetListParam if err := c.ShouldBindJSON(¶m); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().SetListItem(param)) }) g.POST("/set-set-item", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` Remove bool `json:"remove"` Members []any `json:"members"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().SetSetItem(req.Server, req.DB, req.Key, req.Remove, req.Members)) }) g.POST("/update-set-item", func(c *gin.Context) { var param types.SetSetParam if err := c.ShouldBindJSON(¶m); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().UpdateSetItem(param)) }) g.POST("/update-zset-value", func(c *gin.Context) { var param types.SetZSetParam if err := c.ShouldBindJSON(¶m); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().UpdateZSetValue(param)) }) g.POST("/add-zset-value", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` Action int `json:"action"` ValueScore map[string]float64 `json:"valueScore"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().AddZSetValue(req.Server, req.DB, req.Key, req.Action, req.ValueScore)) }) g.POST("/add-stream-value", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` ID string `json:"id"` FieldItems []any `json:"fieldItems"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().AddStreamValue(req.Server, req.DB, req.Key, req.ID, req.FieldItems)) }) g.POST("/remove-stream-values", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` IDs []string `json:"ids"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().RemoveStreamValues(req.Server, req.DB, req.Key, req.IDs)) }) g.POST("/set-key-ttl", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` TTL int64 `json:"ttl"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().SetKeyTTL(req.Server, req.DB, req.Key, req.TTL)) }) g.POST("/batch-set-ttl", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Keys []any `json:"keys"` TTL int64 `json:"ttl"` SerialNo string `json:"serialNo"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().BatchSetTTL(req.Server, req.DB, req.Keys, req.TTL, req.SerialNo)) }) g.POST("/delete-key", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` Async bool `json:"async"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().DeleteKey(req.Server, req.DB, req.Key, req.Async)) }) g.POST("/delete-keys", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Keys []any `json:"keys"` SerialNo string `json:"serialNo"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().DeleteKeys(req.Server, req.DB, req.Keys, req.SerialNo)) }) g.POST("/delete-keys-by-pattern", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Pattern string `json:"pattern"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().DeleteKeysByPattern(req.Server, req.DB, req.Pattern)) }) g.POST("/rename-key", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Key string `json:"key"` NewKey string `json:"newKey"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().RenameKey(req.Server, req.DB, req.Key, req.NewKey)) }) g.POST("/export-key", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Keys []any `json:"keys"` Path string `json:"path"` IncludeExpire bool `json:"includeExpire"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().ExportKey(req.Server, req.DB, req.Keys, req.Path, req.IncludeExpire)) }) g.POST("/import-csv", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Path string `json:"path"` Conflict int `json:"conflict"` TTL int64 `json:"ttl"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().ImportCSV(req.Server, req.DB, req.Path, req.Conflict, req.TTL)) }) g.POST("/flush-db", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` Async bool `json:"async"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().FlushDB(req.Server, req.DB, req.Async)) }) g.POST("/get-slow-logs", func(c *gin.Context) { var req struct { Server string `json:"server"` Num int64 `json:"num"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().GetSlowLogs(req.Server, req.Num)) }) g.POST("/get-client-list", func(c *gin.Context) { var req struct { Server string `json:"server"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Browser().GetClientList(req.Server)) }) g.POST("/get-cmd-history", func(c *gin.Context) { var req struct { PageNo int `json:"pageNo"` PageSize int `json:"pageSize"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } if req.PageSize <= 0 { req.PageSize = 50 } c.JSON(http.StatusOK, services.Browser().GetCmdHistory(req.PageNo, req.PageSize)) }) g.POST("/clean-cmd-history", func(c *gin.Context) { c.JSON(http.StatusOK, services.Browser().CleanCmdHistory()) }) } ================================================ FILE: backend/api/cli_api.go ================================================ //go:build web package api import ( "net/http" "tinyrdm/backend/services" "tinyrdm/backend/types" "github.com/gin-gonic/gin" ) func registerCLIRoutes(rg *gin.RouterGroup) { g := rg.Group("/cli") g.POST("/start", func(c *gin.Context) { var req struct { Server string `json:"server"` DB int `json:"db"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Cli().StartCli(req.Server, req.DB)) }) g.POST("/close", func(c *gin.Context) { var req struct { Server string `json:"server"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Cli().CloseCli(req.Server)) }) // CLI input is handled via WebSocket - the frontend sends // {"event": "cmd:input:", "data": ""} over WS } ================================================ FILE: backend/api/connection_api.go ================================================ //go:build web package api import ( "fmt" "io" "net/http" "tinyrdm/backend/services" "tinyrdm/backend/types" "github.com/gin-gonic/gin" ) func registerConnectionRoutes(rg *gin.RouterGroup) { g := rg.Group("/connection") g.GET("/list", func(c *gin.Context) { c.JSON(http.StatusOK, services.Connection().ListConnection()) }) g.GET("/get", func(c *gin.Context) { name := c.Query("name") c.JSON(http.StatusOK, services.Connection().GetConnection(name)) }) g.POST("/save", func(c *gin.Context) { var req struct { Name string `json:"name"` Param types.ConnectionConfig `json:"param"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Connection().SaveConnection(req.Name, req.Param)) }) g.POST("/save-sorted", func(c *gin.Context) { var req struct { Conns []types.Connection `json:"conns"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Connection().SaveSortedConnection(req.Conns)) }) g.POST("/test", func(c *gin.Context) { var param types.ConnectionConfig if err := c.ShouldBindJSON(¶m); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Connection().TestConnection(param)) }) g.DELETE("/delete", func(c *gin.Context) { name := c.Query("name") c.JSON(http.StatusOK, services.Connection().DeleteConnection(name)) }) g.POST("/group/create", func(c *gin.Context) { var req struct { Name string `json:"name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Connection().CreateGroup(req.Name)) }) g.POST("/group/rename", func(c *gin.Context) { var req struct { Name string `json:"name"` NewName string `json:"newName"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Connection().RenameGroup(req.Name, req.NewName)) }) g.DELETE("/group/delete", func(c *gin.Context) { name := c.Query("name") includeConn := c.Query("includeConn") == "true" c.JSON(http.StatusOK, services.Connection().DeleteGroup(name, includeConn)) }) g.POST("/save-last-db", func(c *gin.Context) { var req struct { Name string `json:"name"` DB int `json:"db"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Connection().SaveLastDB(req.Name, req.DB)) }) g.POST("/save-refresh-interval", func(c *gin.Context) { var req struct { Name string `json:"name"` Interval int `json:"interval"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Connection().SaveRefreshInterval(req.Name, req.Interval)) }) g.POST("/export", func(c *gin.Context) { c.JSON(http.StatusOK, services.Connection().ExportConnections()) }) g.POST("/import", func(c *gin.Context) { c.JSON(http.StatusOK, services.Connection().ImportConnections()) }) // Web-specific: download connections as zip file g.GET("/export-download", func(c *gin.Context) { data, filename, err := services.Connection().ExportConnectionsToBytes() if err != nil { c.JSON(http.StatusInternalServerError, types.JSResp{Msg: "export failed"}) return } c.Header("Content-Disposition", "attachment; filename="+filename) c.Header("Content-Type", "application/zip") c.Header("Content-Length", fmt.Sprintf("%d", len(data))) c.Data(http.StatusOK, "application/zip", data) }) // Web-specific: import connections from uploaded zip file g.POST("/import-upload", func(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid file"}) return } src, err := file.Open() if err != nil { c.JSON(http.StatusInternalServerError, types.JSResp{Msg: "failed to read file"}) return } defer src.Close() data, err := io.ReadAll(src) if err != nil { c.JSON(http.StatusInternalServerError, types.JSResp{Msg: "failed to read file"}) return } resp := services.Connection().ImportConnectionsFromBytes(data) c.JSON(http.StatusOK, resp) }) g.POST("/list-sentinel-masters", func(c *gin.Context) { var param types.ConnectionConfig if err := c.ShouldBindJSON(¶m); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Connection().ListSentinelMasters(param)) }) g.POST("/parse-url", func(c *gin.Context) { var req struct { URL string `json:"url"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Connection().ParseConnectURL(req.URL)) }) } ================================================ FILE: backend/api/monitor_api.go ================================================ //go:build web package api import ( "net/http" "tinyrdm/backend/services" "tinyrdm/backend/types" "github.com/gin-gonic/gin" ) func registerMonitorRoutes(rg *gin.RouterGroup) { g := rg.Group("/monitor") g.POST("/start", func(c *gin.Context) { var req struct { Server string `json:"server"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Monitor().StartMonitor(req.Server)) }) g.POST("/stop", func(c *gin.Context) { var req struct { Server string `json:"server"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Monitor().StopMonitor(req.Server)) }) g.POST("/export-log", func(c *gin.Context) { var req struct { Logs []string `json:"logs"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Monitor().ExportLog(req.Logs)) }) } ================================================ FILE: backend/api/preferences_api.go ================================================ //go:build web package api import ( "net/http" "tinyrdm/backend/services" "tinyrdm/backend/types" "github.com/gin-gonic/gin" ) func registerPreferencesRoutes(rg *gin.RouterGroup) { g := rg.Group("/preferences") g.GET("/get", func(c *gin.Context) { c.JSON(http.StatusOK, services.Preferences().GetPreferences()) }) g.POST("/set", func(c *gin.Context) { var pf types.Preferences if err := c.ShouldBindJSON(&pf); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Preferences().SetPreferences(pf)) }) g.POST("/update", func(c *gin.Context) { var value map[string]any if err := c.ShouldBindJSON(&value); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Preferences().UpdatePreferences(value)) }) g.POST("/restore", func(c *gin.Context) { c.JSON(http.StatusOK, services.Preferences().RestorePreferences()) }) g.GET("/font-list", func(c *gin.Context) { c.JSON(http.StatusOK, services.Preferences().GetFontList()) }) g.GET("/buildin-decoder", func(c *gin.Context) { c.JSON(http.StatusOK, services.Preferences().GetBuildInDecoder()) }) g.GET("/check-update", func(c *gin.Context) { c.JSON(http.StatusOK, services.Preferences().CheckForUpdate()) }) } ================================================ FILE: backend/api/pubsub_api.go ================================================ //go:build web package api import ( "net/http" "tinyrdm/backend/services" "tinyrdm/backend/types" "github.com/gin-gonic/gin" ) func registerPubsubRoutes(rg *gin.RouterGroup) { g := rg.Group("/pubsub") g.POST("/publish", func(c *gin.Context) { var req struct { Server string `json:"server"` Channel string `json:"channel"` Payload string `json:"payload"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Pubsub().Publish(req.Server, req.Channel, req.Payload)) }) g.POST("/subscribe", func(c *gin.Context) { var req struct { Server string `json:"server"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Pubsub().StartSubscribe(req.Server)) }) g.POST("/unsubscribe", func(c *gin.Context) { var req struct { Server string `json:"server"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid request"}) return } c.JSON(http.StatusOK, services.Pubsub().StopSubscribe(req.Server)) }) } ================================================ FILE: backend/api/router.go ================================================ //go:build web package api import ( "log" "net/http" "strings" "tinyrdm/backend/services" "github.com/gin-gonic/gin" ) // maxRequestBodySize limits request body to 10MB to prevent memory exhaustion const maxRequestBodySize = 10 << 20 // 10MB // SetupRouter creates the Gin router with all API routes and static file serving func SetupRouter() *gin.Engine { gin.SetMode(gin.ReleaseMode) r := gin.Default() // Request body size limit r.Use(func(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBodySize) c.Next() }) // Security headers r.Use(SecurityHeaders()) // CORS - validate origin for cross-origin requests r.Use(func(c *gin.Context) { origin := c.GetHeader("Origin") if origin != "" { if isSameOrigin(c, origin) { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, X-Requested-With") } else { log.Printf("[cors] blocked origin=%s host=%s", origin, getRequestHost(c)) c.AbortWithStatus(http.StatusForbidden) return } } if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return } c.Next() }) // CSRF protection for state-changing requests r.Use(csrfProtection()) // Public routes (no auth required) registerAuthRoutes(r) r.GET("/api/preferences/version", func(c *gin.Context) { c.JSON(http.StatusOK, services.Preferences().GetAppVersion()) }) // WebSocket endpoint (auth checked via cookie + origin) r.GET("/ws", wsAuthCheck(), Hub().HandleWebSocket) // Protected API routes api := r.Group("/api") api.Use(AuthMiddleware()) registerConnectionRoutes(api) registerBrowserRoutes(api) registerCLIRoutes(api) registerMonitorRoutes(api) registerPubsubRoutes(api) registerPreferencesRoutes(api) registerSystemRoutes(api) return r } // getRequestHost returns the effective host, considering reverse proxy headers func getRequestHost(c *gin.Context) string { if fwdHost := c.GetHeader("X-Forwarded-Host"); fwdHost != "" { return fwdHost } return c.Request.Host } // stripPort removes port from host string ("example.com:8088" -> "example.com") func stripPort(host string) string { if idx := strings.LastIndex(host, ":"); idx >= 0 { // Make sure it's not part of IPv6 address if !strings.Contains(host, "]") || strings.LastIndex(host, "]") < idx { return host[:idx] } } return host } // extractOriginHost extracts hostname from Origin header value func extractOriginHost(origin string) string { host := origin if idx := strings.Index(host, "://"); idx >= 0 { host = host[idx+3:] } host = strings.TrimRight(host, "/") return host } // isSameOrigin checks if the Origin header matches the request host. // Compares hostnames only (ignoring port) to support reverse proxy scenarios // where the external port differs from the internal port. func isSameOrigin(c *gin.Context, origin string) bool { originHost := stripPort(extractOriginHost(origin)) requestHost := stripPort(getRequestHost(c)) return originHost == requestHost } // csrfProtection validates Origin/Referer for state-changing requests func csrfProtection() gin.HandlerFunc { return func(c *gin.Context) { method := c.Request.Method if method == "GET" || method == "HEAD" || method == "OPTIONS" { c.Next() return } // Check Origin header first origin := c.GetHeader("Origin") if origin != "" { if !isSameOrigin(c, origin) { log.Printf("[csrf] blocked origin=%s host=%s", origin, getRequestHost(c)) c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"success": false, "msg": "cross-origin request blocked"}) return } c.Next() return } // Fallback: check Referer referer := c.GetHeader("Referer") if referer != "" { refererHost := extractOriginHost(referer) if slashIdx := strings.Index(refererHost, "/"); slashIdx >= 0 { refererHost = refererHost[:slashIdx] } requestHost := stripPort(getRequestHost(c)) if stripPort(refererHost) != requestHost { log.Printf("[csrf] blocked referer=%s host=%s", referer, getRequestHost(c)) c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"success": false, "msg": "cross-origin request blocked"}) return } } c.Next() } } // wsAuthCheck validates auth and origin for WebSocket connections func wsAuthCheck() gin.HandlerFunc { return func(c *gin.Context) { origin := c.GetHeader("Origin") if origin != "" { if !isSameOrigin(c, origin) { log.Printf("[ws] blocked origin=%s host=%s", origin, getRequestHost(c)) c.AbortWithStatus(http.StatusForbidden) return } } if !IsAuthEnabled() { c.Next() return } token, err := c.Cookie("rdm_token") if err != nil || !validateToken(token, getClientIP(c)) { c.AbortWithStatus(http.StatusUnauthorized) return } c.Next() } } ================================================ FILE: backend/api/system_api.go ================================================ //go:build web package api import ( "fmt" "io" "net/http" "os" "path/filepath" "strings" "tinyrdm/backend/services" "tinyrdm/backend/types" "github.com/gin-gonic/gin" ) // safeTempPath validates that a path is within the OS temp directory. // Prevents directory traversal attacks. func safeTempPath(reqPath string) (string, error) { tmpDir := os.TempDir() cleaned := filepath.Clean(reqPath) abs, err := filepath.Abs(cleaned) if err != nil { return "", fmt.Errorf("invalid path") } // Ensure the resolved path is within tmpDir if !strings.HasPrefix(abs, filepath.Clean(tmpDir)+string(os.PathSeparator)) && abs != filepath.Clean(tmpDir) { return "", fmt.Errorf("access denied") } return abs, nil } // sanitizeFilename removes path separators and dangerous characters from filename func sanitizeFilename(name string) string { // Take only the base name to strip any directory components name = filepath.Base(name) // Remove any remaining path separators (extra safety) name = strings.ReplaceAll(name, "..", "") name = strings.ReplaceAll(name, "/", "") name = strings.ReplaceAll(name, "\\", "") if name == "" || name == "." { name = "upload" } return name } func registerSystemRoutes(rg *gin.RouterGroup) { g := rg.Group("/system") g.GET("/info", func(c *gin.Context) { c.JSON(http.StatusOK, services.System().Info()) }) // Web replacement for native file dialog - select file g.POST("/select-file", func(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "invalid file upload"}) return } // Sanitize filename to prevent path traversal safeName := sanitizeFilename(file.Filename) tmpDir := os.TempDir() dst := filepath.Join(tmpDir, safeName) if err := c.SaveUploadedFile(file, dst); err != nil { c.JSON(http.StatusInternalServerError, types.JSResp{Msg: "failed to save file"}) return } c.JSON(http.StatusOK, types.JSResp{ Success: true, Data: map[string]any{ "path": dst, }, }) }) // Web replacement for native file dialog - download file g.GET("/download", func(c *gin.Context) { reqPath := c.Query("path") if reqPath == "" { c.JSON(http.StatusBadRequest, types.JSResp{Msg: "path is required"}) return } // Validate path is within temp directory only safePath, err := safeTempPath(reqPath) if err != nil { c.JSON(http.StatusForbidden, types.JSResp{Msg: "access denied"}) return } file, err := os.Open(safePath) if err != nil { c.JSON(http.StatusNotFound, types.JSResp{Msg: "file not found"}) return } defer file.Close() stat, err := file.Stat() if err != nil { c.JSON(http.StatusInternalServerError, types.JSResp{Msg: "failed to read file"}) return } c.Header("Content-Disposition", "attachment; filename="+filepath.Base(safePath)) c.Header("Content-Type", "application/octet-stream") c.Header("Content-Length", fmt.Sprintf("%d", stat.Size())) io.Copy(c.Writer, file) }) } ================================================ FILE: backend/api/websocket_hub.go ================================================ //go:build web package api import ( "encoding/json" "log" "net/http" "sync" "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" ) const ( // wsMaxMessageSize limits incoming WebSocket messages to 1MB wsMaxMessageSize = 1 << 20 // wsWriteWait is the time allowed to write a message wsWriteWait = 10 * time.Second // wsMaxClients limits concurrent WebSocket connections wsMaxClients = 50 ) // WSMessage represents a WebSocket message type WSMessage struct { Event string `json:"event"` Data any `json:"data"` } // WSHub manages all WebSocket connections type WSHub struct { clients map[*websocket.Conn]bool mutex sync.RWMutex } var hub *WSHub var onceHub sync.Once func Hub() *WSHub { if hub == nil { onceHub.Do(func() { hub = &WSHub{ clients: make(map[*websocket.Conn]bool), } }) } return hub } var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { // Origin validation is handled by wsAuthCheck middleware // Allow all here to avoid double-checking return true }, } // Emit sends an event to all connected WebSocket clients func (h *WSHub) Emit(event string, data any) { msg := WSMessage{Event: event, Data: data} jsonData, err := json.Marshal(msg) if err != nil { return } h.mutex.RLock() defer h.mutex.RUnlock() for conn := range h.clients { conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) if err := conn.WriteMessage(websocket.TextMessage, jsonData); err != nil { log.Printf("ws write error: %v", err) } } } // HandleWebSocket handles WebSocket upgrade and connection lifecycle func (h *WSHub) HandleWebSocket(c *gin.Context) { // Check max clients h.mutex.RLock() clientCount := len(h.clients) h.mutex.RUnlock() if clientCount >= wsMaxClients { c.JSON(http.StatusServiceUnavailable, gin.H{"msg": "too many connections"}) return } conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { log.Printf("ws upgrade error: %v", err) return } // Set read limits to prevent oversized messages conn.SetReadLimit(wsMaxMessageSize) h.mutex.Lock() h.clients[conn] = true h.mutex.Unlock() defer func() { h.mutex.Lock() delete(h.clients, conn) h.mutex.Unlock() conn.Close() }() // read loop - handle incoming messages (e.g. CLI input) for { _, message, err := conn.ReadMessage() if err != nil { break } var msg WSMessage if err := json.Unmarshal(message, &msg); err != nil { continue } h.handleIncoming(msg) } } // handleIncoming processes messages from the client func (h *WSHub) handleIncoming(msg WSMessage) { // dispatch CLI input events etc. if handler, ok := incomingHandlers[msg.Event]; ok { handler(msg.Data) } } var incomingHandlers = map[string]func(data any){} // RegisterHandler registers a handler for incoming WebSocket events func RegisterHandler(event string, handler func(data any)) { incomingHandlers[event] = handler } ================================================ FILE: backend/consts/app_name_desktop.go ================================================ //go:build !web package consts const APP_DATA_FOLDER = "TinyRDM" ================================================ FILE: backend/consts/app_name_web.go ================================================ //go:build web package consts const APP_DATA_FOLDER = "tinyrdm" ================================================ FILE: backend/consts/default_config.go ================================================ package consts const DEFAULT_FONT_SIZE = 14 const DEFAULT_ASIDE_WIDTH = 300 const DEFAULT_WINDOW_WIDTH = 1024 const DEFAULT_WINDOW_HEIGHT = 768 const MIN_WINDOW_WIDTH = 960 const MIN_WINDOW_HEIGHT = 640 const DEFAULT_LOAD_SIZE = 10000 const DEFAULT_SCAN_SIZE = 3000 ================================================ FILE: backend/services/browser_service.go ================================================ package services import ( "context" "encoding/csv" "encoding/hex" "encoding/json" "errors" "fmt" "math" "net/url" "os" "slices" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "tinyrdm/backend/consts" "tinyrdm/backend/types" "tinyrdm/backend/utils/coll" convutil "tinyrdm/backend/utils/convert" maputil "tinyrdm/backend/utils/map" redis2 "tinyrdm/backend/utils/redis" sliceutil "tinyrdm/backend/utils/slice" strutil "tinyrdm/backend/utils/string" "github.com/redis/go-redis/v9" ) type slowLogItem struct { Timestamp int64 `json:"timestamp"` Client string `json:"client"` Addr string `json:"addr"` Cmd string `json:"cmd"` Cost int64 `json:"cost"` } type entryCursor struct { DB int Type string Key string Pattern string Cursor uint64 XLast string // last stream pos } type connectionItem struct { client redis.UniversalClient ctx context.Context cancelFunc context.CancelFunc cursor map[int]uint64 // current cursor of databases entryCursor map[int]entryCursor // current entry cursor of databases stepSize int64 db int // current database index } type browserService struct { ctx context.Context connMap map[string]*connectionItem cmdHistory []cmdHistoryItem mutex sync.Mutex } var browser *browserService var onceBrowser sync.Once func Browser() *browserService { if browser == nil { onceBrowser.Do(func() { browser = &browserService{ connMap: map[string]*connectionItem{}, } }) } return browser } func (b *browserService) Start(ctx context.Context) { b.ctx = ctx } func (b *browserService) Stop() { for _, item := range b.connMap { if item.client != nil { if item.cancelFunc != nil { item.cancelFunc() } item.client.Close() } } b.connMap = map[string]*connectionItem{} } // OpenConnection open redis server connection func (b *browserService) OpenConnection(name string) (resp types.JSResp) { // get connection config selConn := Connection().getConnection(name) // correct last database index lastDB := selConn.LastDB if selConn.DBFilterType == "show" && !slices.Contains(selConn.DBFilterList, lastDB) { lastDB = selConn.DBFilterList[0] } else if selConn.DBFilterType == "hide" && slices.Contains(selConn.DBFilterList, lastDB) { lastDB = selConn.DBFilterList[0] } item, db, err := b.getRedisClient2(name, lastDB) if err != nil { resp.Msg = err.Error() return } if lastDB != db { lastDB = db } client, ctx := item.client, item.ctx var totaldb int if selConn.DBFilterType == "" || selConn.DBFilterType == "none" { // get total databases if config, err := client.ConfigGet(ctx, "databases").Result(); err == nil { if total, err := strconv.Atoi(config["databases"]); err == nil { totaldb = total } } } // parse all db, response content like below var dbs []types.ConnectionDB var clusterKeyCount int64 cluster, isCluster := client.(*redis.ClusterClient) if isCluster { var keyCount atomic.Int64 err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { if size, serr := cli.DBSize(ctx).Result(); serr != nil { return serr } else { keyCount.Add(size) } return nil }) if err != nil { resp.Msg = "get db size error:" + err.Error() return } clusterKeyCount = keyCount.Load() // only one database in cluster mode dbs = []types.ConnectionDB{ { Name: "db0", Index: 0, MaxKeys: int(clusterKeyCount), }, } } else { // get database info var res string info := map[string]map[string]string{} if res, err = client.Info(ctx, "keyspace").Result(); err != nil { //resp.Msg = "get server info fail:" + err.Error() //return } else { info = b.parseInfo(res) } if totaldb <= 0 { // cannot retrieve the database count by "CONFIG GET databases", try to get max index from keyspace keyspace := info["Keyspace"] var db, maxDB int for dbName := range keyspace { if db, err = strconv.Atoi(strings.TrimLeft(dbName, "db")); err == nil { if maxDB < db { maxDB = db } } } totaldb = maxDB + 1 } queryDB := func(idx int) types.ConnectionDB { dbName := "db" + strconv.Itoa(idx) dbInfoStr := info["Keyspace"][dbName] var alias string if selConn.Alias != nil { alias = selConn.Alias[idx] } if len(dbInfoStr) > 0 { dbInfo := b.parseDBItemInfo(dbInfoStr) return types.ConnectionDB{ Name: dbName, Alias: alias, Index: idx, MaxKeys: dbInfo["keys"], Expires: dbInfo["expires"], AvgTTL: dbInfo["avg_ttl"], } } else { return types.ConnectionDB{ Name: dbName, Alias: alias, Index: idx, } } } switch selConn.DBFilterType { case "show": filterList := sliceutil.Unique(selConn.DBFilterList) for _, idx := range filterList { dbs = append(dbs, queryDB(idx)) } case "hide": hiddenList := coll.NewSet(selConn.DBFilterList...) for idx := 0; idx < totaldb; idx++ { if !hiddenList.Contains(idx) { dbs = append(dbs, queryDB(idx)) } } default: for idx := 0; idx < totaldb; idx++ { dbs = append(dbs, queryDB(idx)) } } } // get redis server version var version string if res, err := client.Info(ctx, "server").Result(); err == nil || errors.Is(err, redis.Nil) { info := b.parseInfo(res) serverInfo := maputil.Get(info, "Server", map[string]string{}) version = maputil.Get(serverInfo, "redis_version", "1.0.0") } resp.Success = true resp.Data = map[string]any{ "db": dbs, "view": selConn.KeyView, "lastDB": selConn.LastDB, "version": version, } return } // CloseConnection close redis server connection func (b *browserService) CloseConnection(name string) (resp types.JSResp) { if item, ok := b.connMap[name]; ok { delete(b.connMap, name) if item.cancelFunc != nil { item.cancelFunc() } if item.client != nil { item.client.Close() } } resp.Success = true return } func (b *browserService) createRedisClient(ctx context.Context, selConn types.ConnectionConfig) (client redis.UniversalClient, err error) { hook := redis2.NewHook(selConn.Name, func(cmd string, cost int64) { now := time.Now() //last := strings.LastIndex(cmd, ":") //if last != -1 { // cmd = cmd[:last] //} b.cmdHistory = append(b.cmdHistory, cmdHistoryItem{ Timestamp: now.UnixMilli(), Server: selConn.Name, Cmd: cmd, Cost: cost, }) }) client, err = Connection().createRedisClient(selConn) if err != nil { err = fmt.Errorf("create conenction error: %s", err.Error()) return } _ = client.Do(ctx, "CLIENT", "SETNAME", url.QueryEscape(selConn.Name)).Err() // add hook to each node in cluster mode if cluster, ok := client.(*redis.ClusterClient); ok { err = cluster.ForEachShard(ctx, func(ctx context.Context, cli *redis.Client) error { cli.AddHook(hook) return nil }) if err != nil { err = fmt.Errorf("get cluster nodes error: %s", err.Error()) return } } else { client.AddHook(hook) } if _, err = client.Ping(ctx).Result(); err != nil && !errors.Is(err, redis.Nil) { err = errors.New("can not connect to redis server:" + err.Error()) return } return } // get a redis client from local cache or create a new one // if db >= 0, it will also switch to target database index func (b *browserService) getRedisClient(server string, db int) (item *connectionItem, err error) { b.mutex.Lock() defer b.mutex.Unlock() var ok bool var client redis.UniversalClient if item, ok = b.connMap[server]; ok { if item.db == db || db < 0 { // return without switch database directly return } // close previous connection if database is not the same if item.cancelFunc != nil { item.cancelFunc() } item.client.Close() delete(b.connMap, server) } // recreate new connection after switch database selConn := Connection().getConnection(server) if selConn == nil { err = fmt.Errorf("no match connection \"%s\"", server) delete(b.connMap, server) return } ctx, cancelFunc := context.WithCancel(b.ctx) b.connMap[server] = &connectionItem{ ctx: ctx, cancelFunc: cancelFunc, } var connConfig = selConn.ConnectionConfig connConfig.LastDB = db client, err = b.createRedisClient(ctx, connConfig) if err != nil { delete(b.connMap, server) return } item = &connectionItem{ client: client, ctx: ctx, cancelFunc: cancelFunc, cursor: map[int]uint64{}, entryCursor: map[int]entryCursor{}, stepSize: int64(selConn.LoadSize), db: db, } if item.stepSize <= 0 { item.stepSize = consts.DEFAULT_LOAD_SIZE } b.connMap[server] = item return } // get redis client and try to reset selected database when not exists func (b *browserService) getRedisClient2(server string, db int) (item *connectionItem, selecetdDB int, err error) { selecetdDB = db item, err = b.getRedisClient(server, db) if err != nil { if strings.Contains(err.Error(), "DB index is out of range") && db != 0 { if item, err = b.getRedisClient(server, 0); err != nil { item = nil } else { selecetdDB = 0 } } } return } // load current database size func (b *browserService) loadDBSize(ctx context.Context, client redis.UniversalClient) int64 { keyCount, _ := client.DBSize(ctx).Result() return keyCount } // save current scan cursor func (b *browserService) setClientCursor(server string, db int, cursor uint64) { if _, ok := b.connMap[server]; ok { if cursor == 0 { delete(b.connMap[server].cursor, db) } else { b.connMap[server].cursor[db] = cursor } } } // parse command response content which use "redis info" // # Keyspace\r\ndb0:keys=2,expires=1,avg_ttl=1877111749\r\ndb1:keys=33,expires=0,avg_ttl=0\r\ndb3:keys=17,expires=0,avg_ttl=0\r\ndb5:keys=3,expires=0,avg_ttl=0\r\n func (b *browserService) parseInfo(info string) map[string]map[string]string { parsedInfo := map[string]map[string]string{} lines := strings.Split(info, "\r\n") if len(lines) > 0 { var subInfo map[string]string for _, line := range lines { if strings.HasPrefix(line, "#") { subInfo = map[string]string{} parsedInfo[strings.TrimSpace(strings.TrimLeft(line, "#"))] = subInfo } else { items := strings.SplitN(line, ":", 2) if len(items) < 2 { continue } subInfo[items[0]] = items[1] } } } return parsedInfo } // parse db item value, content format like below // keys=2,expires=1,avg_ttl=1877111749 func (b *browserService) parseDBItemInfo(info string) map[string]int { ret := map[string]int{} items := strings.Split(info, ",") for _, item := range items { kv := strings.SplitN(item, "=", 2) if len(kv) > 1 { ret[kv[0]], _ = strconv.Atoi(kv[1]) } } return ret } // ServerInfo get server info func (b *browserService) ServerInfo(name string) (resp types.JSResp) { item, err := b.getRedisClient(name, -1) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx // get database info res, err := client.Info(ctx).Result() if err != nil { resp.Msg = "get server info fail:" + err.Error() return } resp.Success = true resp.Data = b.parseInfo(res) return } // OpenDatabase open select database, and list all keys // @param path contain connection name and db name func (b *browserService) OpenDatabase(server string, db int) (resp types.JSResp) { b.setClientCursor(server, db, 0) item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx maxKeys := b.loadDBSize(ctx, client) resp.Success = true resp.Data = map[string]any{ "maxKeys": maxKeys, } return } // scan keys // @return loaded keys // @return next cursor // @return scan error func (b *browserService) scanKeys(ctx context.Context, client redis.UniversalClient, match, keyType string, cursor uint64, count int64) ([]any, uint64, error) { var err error filterType := len(keyType) > 0 scanSize := int64(Preferences().GetScanSize()) // define sub scan function scan := func(ctx context.Context, cli redis.UniversalClient, count int64, appendFunc func(k []any)) error { var loadedKey []string var scanCount int64 for { if filterType { loadedKey, cursor, err = cli.ScanType(ctx, cursor, match, scanSize, keyType).Result() } else { loadedKey, cursor, err = cli.Scan(ctx, cursor, match, scanSize).Result() } if err != nil { return err } else { ks := sliceutil.Map(loadedKey, func(i int) any { return strutil.EncodeRedisKey(loadedKey[i]) }) scanCount += int64(len(ks)) appendFunc(ks) } if (count > 0 && scanCount > count) || cursor == 0 { break } } return nil } keys := make([]any, 0) if cluster, ok := client.(*redis.ClusterClient); ok { // cluster mode var mutex sync.Mutex var totalMaster int64 cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { totalMaster += 1 return nil }) partCount := count / max(totalMaster, 1) err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { // FIXME: BUG? can not fully load in cluster mode? maybe remove the shared "cursor" return scan(ctx, cli, partCount, func(k []any) { mutex.Lock() keys = append(keys, k...) mutex.Unlock() }) }) } else { err = scan(ctx, client, count, func(k []any) { keys = append(keys, k...) }) } if err != nil { return keys, cursor, err } return keys, cursor, nil } // check if key exists func (b *browserService) existsKey(ctx context.Context, client redis.UniversalClient, key, keyType string) bool { var keyExists atomic.Bool if cluster, ok := client.(*redis.ClusterClient); ok { // cluster mode cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { if n := cli.Exists(ctx, key).Val(); n > 0 { if len(keyType) <= 0 || strings.ToLower(keyType) == cli.Type(ctx, key).Val() { keyExists.Store(true) } } return nil }) } else { if n := client.Exists(ctx, key).Val(); n > 0 { if len(keyType) <= 0 || strings.ToLower(keyType) == client.Type(ctx, key).Val() { keyExists.Store(true) } } } return keyExists.Load() } // LoadNextKeys load next key from saved cursor func (b *browserService) LoadNextKeys(server string, db int, match, keyType string, exactMatch bool) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } if match == "*" { exactMatch = false } client, ctx, count := item.client, item.ctx, item.stepSize var matchKeys []any var maxKeys int64 cursor := item.cursor[db] fullScan := match == "*" || match == "" if exactMatch && !fullScan { if b.existsKey(ctx, client, match, keyType) { matchKeys = []any{match} maxKeys = 1 } b.setClientCursor(server, db, 0) } else { matchKeys, cursor, err = b.scanKeys(ctx, client, match, keyType, cursor, count) if err != nil { resp.Msg = err.Error() return } b.setClientCursor(server, db, cursor) if fullScan { maxKeys = b.loadDBSize(ctx, client) } else { maxKeys = int64(len(matchKeys)) } } resp.Success = true resp.Data = map[string]any{ "keys": matchKeys, "end": cursor == 0, "maxKeys": maxKeys, } return } // LoadNextAllKeys load next all keys func (b *browserService) LoadNextAllKeys(server string, db int, match, keyType string, exactMatch bool) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx var matchKeys []any var maxKeys int64 fullScan := match == "*" || match == "" if exactMatch && !fullScan { if b.existsKey(ctx, client, match, keyType) { matchKeys = []any{match} maxKeys = 1 } } else { cursor := item.cursor[db] matchKeys, _, err = b.scanKeys(ctx, client, match, keyType, cursor, 0) if err != nil { resp.Msg = err.Error() return } b.setClientCursor(server, db, 0) if fullScan { maxKeys = b.loadDBSize(ctx, client) } else { maxKeys = int64(len(matchKeys)) } } resp.Success = true resp.Data = map[string]any{ "keys": matchKeys, "maxKeys": maxKeys, } return } // LoadAllKeys load all keys func (b *browserService) LoadAllKeys(server string, db int, match, keyType string, exactMatch bool) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx var matchKeys []any fullScan := match == "*" || match == "" if exactMatch && !fullScan { if b.existsKey(ctx, client, match, keyType) { matchKeys = []any{match} } } else { matchKeys, _, err = b.scanKeys(ctx, client, match, keyType, 0, 0) if err != nil { resp.Msg = err.Error() return } } resp.Success = true resp.Data = map[string]any{ "keys": matchKeys, } return } func (b *browserService) GetKeyType(param types.KeySummaryParam) (resp types.JSResp) { item, err := b.getRedisClient(param.Server, param.DB) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(param.Key) var keyType string keyType, err = client.Type(ctx, key).Result() if err != nil { resp.Msg = err.Error() return } if keyType == "none" { resp.Msg = "key not exists" return } var data types.KeySummary switch keyType { case "ReJSON-RL": data.Type = "JSON" default: data.Type = strings.ToLower(keyType) } resp.Success = true resp.Data = data return } // GetKeySummary get key summary info func (b *browserService) GetKeySummary(param types.KeySummaryParam) (resp types.JSResp) { item, err := b.getRedisClient(param.Server, param.DB) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(param.Key) pipe := client.Pipeline() typeVal := pipe.Type(ctx, key) ttlVal := pipe.TTL(ctx, key) _, err = pipe.Exec(ctx) if err != nil { resp.Msg = err.Error() return } if typeVal.Err() != nil { resp.Msg = typeVal.Err().Error() return } size, _ := client.MemoryUsage(ctx, key, 0).Result() data := types.KeySummary{ Type: typeVal.Val(), Size: size, } if data.Type == "none" { resp.Msg = "key not exists" return } if ttlVal.Err() != nil { data.TTL = -1 } else { if ttlVal.Val() < 0 { data.TTL = -1 } else { data.TTL = int64(ttlVal.Val().Seconds()) } } switch data.Type { case "string": data.Length, err = client.StrLen(ctx, key).Result() case "list": data.Length, err = client.LLen(ctx, key).Result() case "hash": data.Length, err = client.HLen(ctx, key).Result() case "set": data.Length, err = client.SCard(ctx, key).Result() case "zset": data.Length, err = client.ZCard(ctx, key).Result() case "stream": data.Length, err = client.XLen(ctx, key).Result() case "ReJSON-RL": data.Type = "JSON" data.Length = 0 default: err = errors.New("unknown key type") } if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = data return } // GetKeyDetail get key detail func (b *browserService) GetKeyDetail(param types.KeyDetailParam) (resp types.JSResp) { item, err := b.getRedisClient(param.Server, param.DB) if err != nil { resp.Msg = err.Error() return } client, ctx, entryCors := item.client, item.ctx, item.entryCursor key := strutil.DecodeRedisKey(param.Key) var keyType string keyType, err = client.Type(ctx, key).Result() if err != nil { resp.Msg = err.Error() return } if keyType == "none" { resp.Msg = "key not exists" return } var doConvert bool if (len(param.Decode) > 0 && param.Decode != types.DECODE_NONE) || (len(param.Format) > 0 && param.Format != types.FORMAT_RAW) { doConvert = true } var data types.KeyDetail data.KeyType = strings.ToLower(keyType) //var cursor uint64 matchPattern := param.MatchPattern if len(matchPattern) <= 0 { matchPattern = "*" } // define get entry cursor function getEntryCursor := func() (uint64, string, bool) { if entry, ok := entryCors[param.DB]; !ok || entry.Key != key || entry.Pattern != matchPattern { // not the same key or match pattern, reset cursor entry = entryCursor{ DB: param.DB, Key: key, Pattern: matchPattern, Cursor: 0, } entryCors[param.DB] = entry return 0, "", true } else { return entry.Cursor, entry.XLast, false } } // define set entry cursor function setEntryCursor := func(cursor uint64) { entryCors[param.DB] = entryCursor{ DB: param.DB, Type: "", Key: key, Pattern: matchPattern, Cursor: cursor, } } // define set last stream pos function setEntryXLast := func(last string) { entryCors[param.DB] = entryCursor{ DB: param.DB, Type: "", Key: key, Pattern: matchPattern, XLast: last, } } decoder := Preferences().GetDecoder() switch data.KeyType { case "string": var str string str, err = client.Get(ctx, key).Result() data.Value = strutil.EncodeRedisKey(str) //data.Value, data.Decode, data.Format = convutil.ConvertTo(str, param.Decode, param.Format, decoder) case "list": loadListHandle := func() ([]types.ListEntryItem, bool, bool, error) { var loadVal []string var cursor uint64 var reset bool var subErr error doFilter := matchPattern != "*" if param.Full || doFilter { // load all cursor, reset = 0, true loadVal, subErr = client.LRange(ctx, key, 0, -1).Result() } else { if param.Reset { cursor, reset = 0, true } else { cursor, _, reset = getEntryCursor() } scanSize := int64(Preferences().GetScanSize()) loadVal, subErr = client.LRange(ctx, key, int64(cursor), int64(cursor)+scanSize-1).Result() cursor = cursor + uint64(scanSize) if len(loadVal) < int(scanSize) { cursor = 0 } } setEntryCursor(cursor) items := make([]types.ListEntryItem, 0, len(loadVal)) for _, val := range loadVal { if doFilter && !strings.Contains(val, param.MatchPattern) { continue } items = append(items, types.ListEntryItem{ Index: len(items), Value: strutil.EncodeRedisKey(val), }) if doConvert { if dv, _, _ := convutil.ConvertTo(val, param.Decode, param.Format, decoder); dv != val { items[len(items)-1].DisplayValue = dv } } } if subErr != nil { return items, reset, false, subErr } return items, reset, cursor == 0, nil } data.Value, data.Reset, data.End, err = loadListHandle() data.Match, data.Decode, data.Format = param.MatchPattern, param.Decode, param.Format if err != nil { resp.Msg = err.Error() return } case "hash": if !strings.HasPrefix(matchPattern, "*") { matchPattern = "*" + matchPattern } if !strings.HasSuffix(matchPattern, "*") { matchPattern = matchPattern + "*" } loadHashHandle := func() ([]types.HashEntryItem, bool, bool, error) { var items []types.HashEntryItem var loadedVal []string var cursor uint64 var reset bool var subErr error scanSize := int64(Preferences().GetScanSize()) if param.Full || matchPattern != "*" { // load all cursor, reset = 0, true items = []types.HashEntryItem{} for { loadedVal, cursor, subErr = client.HScan(ctx, key, cursor, matchPattern, scanSize).Result() if subErr != nil { return nil, reset, false, subErr } for i := 0; i < len(loadedVal); i += 2 { items = append(items, types.HashEntryItem{ Key: loadedVal[i], Value: strutil.EncodeRedisKey(loadedVal[i+1]), }) if doConvert { if dv, _, _ := convutil.ConvertTo(loadedVal[i+1], param.Decode, param.Format, decoder); dv != loadedVal[i+1] { items[len(items)-1].DisplayValue = dv } } } if cursor == 0 { break } } } else { if param.Reset { cursor, reset = 0, true } else { cursor, _, reset = getEntryCursor() } loadedVal, cursor, subErr = client.HScan(ctx, key, cursor, matchPattern, scanSize).Result() if subErr != nil { return nil, reset, false, subErr } loadedLen := len(loadedVal) items = make([]types.HashEntryItem, loadedLen/2) for i := 0; i < loadedLen; i += 2 { items[i/2].Key = loadedVal[i] items[i/2].Value = strutil.EncodeRedisKey(loadedVal[i+1]) if doConvert { if dv, _, _ := convutil.ConvertTo(loadedVal[i+1], param.Decode, param.Format, decoder); dv != loadedVal[i+1] { items[i/2].DisplayValue = dv } } } } setEntryCursor(cursor) return items, reset, cursor == 0, nil } data.Value, data.Reset, data.End, err = loadHashHandle() data.Match, data.Decode, data.Format = param.MatchPattern, param.Decode, param.Format if err != nil { resp.Msg = err.Error() return } case "set": if !strings.HasPrefix(matchPattern, "*") { matchPattern = "*" + matchPattern } if !strings.HasSuffix(matchPattern, "*") { matchPattern = matchPattern + "*" } loadSetHandle := func() ([]types.SetEntryItem, bool, bool, error) { var items []types.SetEntryItem var cursor uint64 var reset bool var subErr error var loadedKey []string scanSize := int64(Preferences().GetScanSize()) if param.Full || matchPattern != "*" { // load all cursor, reset = 0, true items = []types.SetEntryItem{} for { loadedKey, cursor, subErr = client.SScan(ctx, key, cursor, matchPattern, scanSize).Result() if subErr != nil { return items, reset, false, subErr } for _, val := range loadedKey { items = append(items, types.SetEntryItem{ Value: strutil.EncodeRedisKey(val), }) if doConvert { if dv, _, _ := convutil.ConvertTo(val, param.Decode, param.Format, decoder); dv != val { items[len(items)-1].DisplayValue = dv } } } if cursor == 0 { break } } } else { if param.Reset { cursor, reset = 0, true } else { cursor, _, reset = getEntryCursor() } loadedKey, cursor, subErr = client.SScan(ctx, key, cursor, matchPattern, scanSize).Result() items = make([]types.SetEntryItem, len(loadedKey)) for i, val := range loadedKey { items[i].Value = strutil.EncodeRedisKey(val) if doConvert { if dv, _, _ := convutil.ConvertTo(val, param.Decode, param.Format, decoder); dv != val { items[i].DisplayValue = dv } } } } setEntryCursor(cursor) return items, reset, cursor == 0, nil } data.Value, data.Reset, data.End, err = loadSetHandle() data.Match, data.Decode, data.Format = param.MatchPattern, param.Decode, param.Format if err != nil { resp.Msg = err.Error() return } case "zset": if !strings.HasPrefix(matchPattern, "*") { matchPattern = "*" + matchPattern } if !strings.HasSuffix(matchPattern, "*") { matchPattern = matchPattern + "*" } loadZSetHandle := func() ([]types.ZSetEntryItem, bool, bool, error) { var items []types.ZSetEntryItem var reset bool var cursor uint64 scanSize := int64(Preferences().GetScanSize()) doFilter := matchPattern != "*" if param.Full || doFilter { // load all var loadedVal []string cursor, reset = 0, true items = []types.ZSetEntryItem{} for { loadedVal, cursor, err = client.ZScan(ctx, key, cursor, matchPattern, scanSize).Result() if err != nil { return items, reset, false, err } var score float64 for i := 0; i < len(loadedVal); i += 2 { if score, err = strconv.ParseFloat(loadedVal[i+1], 64); err == nil { items = append(items, types.ZSetEntryItem{ Value: strutil.EncodeRedisKey(loadedVal[i]), Score: score, }) if doConvert { if dv, _, _ := convutil.ConvertTo(loadedVal[i], param.Decode, param.Format, decoder); dv != loadedVal[i] { items[len(items)-1].DisplayValue = dv } } } } if cursor == 0 { break } } } else { if param.Reset { cursor, reset = 0, true } else { cursor, _, reset = getEntryCursor() } var loadedVal []redis.Z loadedVal, err = client.ZRangeWithScores(ctx, key, int64(cursor), int64(cursor)+scanSize-1).Result() cursor = cursor + uint64(scanSize) if len(loadedVal) < int(scanSize) { cursor = 0 } items = make([]types.ZSetEntryItem, 0, len(loadedVal)) for _, z := range loadedVal { val := strutil.AnyToString(z.Member, "", 0) if doFilter && !strings.Contains(val, param.MatchPattern) { continue } entry := types.ZSetEntryItem{ Value: strutil.EncodeRedisKey(val), } if math.IsInf(z.Score, 1) { entry.ScoreStr = "+inf" } else if math.IsInf(z.Score, -1) { entry.ScoreStr = "-inf" } else { entry.Score = z.Score } items = append(items, entry) if doConvert { if dv, _, _ := convutil.ConvertTo(val, param.Decode, param.Format, decoder); dv != val { items[len(items)-1].DisplayValue = dv } } } } setEntryCursor(cursor) return items, reset, cursor == 0, nil } data.Value, data.Reset, data.End, err = loadZSetHandle() data.Match, data.Decode, data.Format = param.MatchPattern, param.Decode, param.Format if err != nil { resp.Msg = err.Error() return } case "stream": loadStreamHandle := func() ([]types.StreamEntryItem, bool, bool, error) { var msgs []redis.XMessage var last string var reset bool doFilter := matchPattern != "*" if param.Full || doFilter { // load all last, reset = "", true msgs, err = client.XRevRange(ctx, key, "+", "-").Result() } else { scanSize := int64(Preferences().GetScanSize()) if param.Reset { last = "" } else { _, last, reset = getEntryCursor() } if len(last) <= 0 { last = "+" } if last != "+" { // add 1 more item when continue scan msgs, err = client.XRevRangeN(ctx, key, last, "-", scanSize+1).Result() msgs = msgs[1:] } else { msgs, err = client.XRevRangeN(ctx, key, last, "-", scanSize).Result() } scanCount := len(msgs) if scanCount <= 0 || scanCount < int(scanSize) { last = "" } else if scanCount > 0 { last = msgs[scanCount-1].ID } } setEntryXLast(last) items := make([]types.StreamEntryItem, 0, len(msgs)) for _, msg := range msgs { it := types.StreamEntryItem{ ID: msg.ID, Value: msg.Values, } var displayValue strings.Builder for k, v := range msg.Values { if displayValue.Len() > 0 { displayValue.WriteString(", ") } if str, ok := v.(string); ok { displayValue.WriteByte('"') displayValue.WriteString(k) displayValue.WriteByte('"') displayValue.WriteByte(':') displayValue.WriteString(str) } } it.DisplayValue = displayValue.String() if doFilter && !strings.Contains(it.DisplayValue, param.MatchPattern) { continue } items = append(items, it) } if err != nil { return items, reset, false, err } return items, reset, last == "", nil } data.Value, data.Reset, data.End, err = loadStreamHandle() data.Match, data.Decode, data.Format = param.MatchPattern, param.Decode, param.Format if err != nil { resp.Msg = err.Error() return } case "rejson-rl": var jsonStr string data.KeyType = "JSON" jsonStr, err = client.JSONGet(ctx, key).Result() data.Value, data.Decode, data.Format = convutil.ConvertTo(jsonStr, types.DECODE_NONE, types.FORMAT_JSON, nil) } if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = data return } // ConvertValue convert value with decode method and format // blank decode indicate auto decode // blank format indicate auto format func (b *browserService) ConvertValue(value any, decode, format string) (resp types.JSResp) { str := strutil.DecodeRedisKey(value) value, decode, format = convutil.ConvertTo(str, decode, format, Preferences().GetDecoder()) resp.Success = true resp.Data = map[string]any{ "value": value, "decode": decode, "format": format, } return } // SetKeyValue set value by key // @param ttl <= 0 means keep current ttl func (b *browserService) SetKeyValue(param types.SetKeyParam) (resp types.JSResp) { item, err := b.getRedisClient(param.Server, param.DB) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(param.Key) var expiration time.Duration if param.TTL < 0 { if expiration, err = client.PTTL(ctx, key).Result(); err != nil { expiration = redis.KeepTTL } } else { expiration = time.Duration(param.TTL) * time.Second } // use default decode type and format if len(param.Decode) <= 0 { param.Decode = types.DECODE_NONE } if len(param.Format) <= 0 { param.Format = types.FORMAT_RAW } var savedValue any switch strings.ToLower(param.KeyType) { case "string": if str, ok := param.Value.(string); !ok { resp.Msg = "invalid string value" return } else { if savedValue, err = convutil.SaveAs(str, param.Format, param.Decode, Preferences().GetDecoder()); err != nil { resp.Msg = fmt.Sprintf(`save to type "%s" fail: %s`, param.Format, err.Error()) return } _, err = client.Set(ctx, key, savedValue, 0).Result() // set expiration lonely, not "keepttl" if err == nil && expiration > 0 { client.Expire(ctx, key, expiration) } } case "list": if strs, ok := param.Value.([]any); !ok { resp.Msg = "invalid list value" return } else { err = client.LPush(ctx, key, strs...).Err() if err == nil && expiration > 0 { client.Expire(ctx, key, expiration) } } case "hash": if strs, ok := param.Value.([]any); !ok { resp.Msg = "invalid hash value" return } else { total := len(strs) if total > 1 { _, err = client.Pipelined(ctx, func(pipe redis.Pipeliner) error { for i := 0; i < total; i += 2 { pipe.HSet(ctx, key, strs[i], strs[i+1]) } if expiration > 0 { pipe.Expire(ctx, key, expiration) } return nil }) } } case "set": if strs, ok := param.Value.([]any); !ok || len(strs) <= 0 { resp.Msg = "invalid set value" return } else { if len(strs) > 0 { err = client.SAdd(ctx, key, strs...).Err() if err == nil && expiration > 0 { client.Expire(ctx, key, expiration) } } } case "zset": if strs, ok := param.Value.([]any); !ok || len(strs) <= 0 { resp.Msg = "invalid zset value" return } else { if len(strs) > 1 { var members []redis.Z for i := 0; i < len(strs); i += 2 { score, _ := strconv.ParseFloat(strs[i+1].(string), 64) members = append(members, redis.Z{ Score: score, Member: strs[i], }) } err = client.ZAdd(ctx, key, members...).Err() if err == nil && expiration > 0 { client.Expire(ctx, key, expiration) } } } case "stream": if strs, ok := param.Value.([]any); !ok { resp.Msg = "invalid stream value" return } else { if len(strs) > 2 { err = client.XAdd(ctx, &redis.XAddArgs{ Stream: key, ID: strs[0].(string), Values: strs[1:], }).Err() if err == nil && expiration > 0 { client.Expire(ctx, key, expiration) } } } case "json": err = client.JSONSet(ctx, key, ".", param.Value).Err() if err == nil && expiration > 0 { client.Expire(ctx, key, expiration) } var ok bool if savedValue, ok = param.Value.(string); !ok { savedValue = "" } } if err != nil { resp.Msg = err.Error() return } resp.Success = true respData := map[string]any{} if val, ok := savedValue.(string); ok { respData["value"] = strutil.EncodeRedisKey(val) } resp.Data = respData return } // GetHashValue get hash field func (b *browserService) GetHashValue(param types.GetHashParam) (resp types.JSResp) { item, err := b.getRedisClient(param.Server, param.DB) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(param.Key) val, err := client.HGet(ctx, key, param.Field).Result() if errors.Is(err, redis.Nil) { resp.Msg = "field in key not found" return } if err != nil { resp.Msg = err.Error() return } var displayVal string if (len(param.Decode) > 0 && param.Decode != types.DECODE_NONE) || (len(param.Format) > 0 && param.Format != types.FORMAT_RAW) { decoder := Preferences().GetDecoder() displayVal, _, _ = convutil.ConvertTo(val, param.Decode, param.Format, decoder) if displayVal == val { displayVal = "" } } resp.Data = types.HashEntryItem{ Key: param.Field, Value: val, DisplayValue: displayVal, } resp.Success = true return } // SetHashValue update hash field func (b *browserService) SetHashValue(param types.SetHashParam) (resp types.JSResp) { item, err := b.getRedisClient(param.Server, param.DB) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(param.Key) str := strutil.DecodeRedisKey(param.Value) var saveStr, displayStr string decoder := Preferences().GetDecoder() if saveStr, err = convutil.SaveAs(str, param.Format, param.Decode, decoder); err != nil { resp.Msg = fmt.Sprintf(`save to type "%s" fail: %s`, param.Format, err.Error()) return } if len(param.RetDecode) > 0 && len(param.RetFormat) > 0 { displayStr, _, _ = convutil.ConvertTo(saveStr, param.RetDecode, param.RetFormat, decoder) } var updated, added, removed []types.HashEntryItem var replaced []types.HashReplaceItem var affect int64 if len(param.NewField) <= 0 { // new field is empty, delete old field _, err = client.HDel(ctx, key, param.Field).Result() removed = append(removed, types.HashEntryItem{ Key: param.Field, }) } else if len(param.Field) <= 0 || param.Field == param.NewField { affect, err = client.HSet(ctx, key, param.NewField, saveStr).Result() if affect <= 0 { // update field value updated = append(updated, types.HashEntryItem{ Key: param.NewField, Value: saveStr, DisplayValue: displayStr, }) } else { // add new field added = append(added, types.HashEntryItem{ Key: param.NewField, Value: saveStr, DisplayValue: displayStr, }) } } else { // remove old field and add new field if _, err = client.HDel(ctx, key, param.Field).Result(); err != nil { resp.Msg = err.Error() return } affect, err = client.HSet(ctx, key, param.NewField, saveStr).Result() if affect <= 0 { // no new filed added, just update exists item removed = append(removed, types.HashEntryItem{ Key: param.Field, }) updated = append(updated, types.HashEntryItem{ Key: param.NewField, Value: saveStr, DisplayValue: displayStr, }) } else { // add new field replaced = append(replaced, types.HashReplaceItem{ Key: param.Field, NewKey: param.NewField, Value: saveStr, DisplayValue: displayStr, }) } } if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = struct { Added []types.HashEntryItem `json:"added,omitempty"` Removed []types.HashEntryItem `json:"removed,omitempty"` Updated []types.HashEntryItem `json:"updated,omitempty"` Replaced []types.HashReplaceItem `json:"replaced,omitempty"` }{ Added: added, Removed: removed, Updated: updated, Replaced: replaced, } return } // AddHashField add or update hash field func (b *browserService) AddHashField(server string, db int, k any, action int, fieldItems []any) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(k) var updated []types.HashEntryItem var added []types.HashEntryItem switch action { case 1: // ignore duplicated fields for i := 0; i < len(fieldItems); i += 2 { field, value := strutil.DecodeRedisKey(fieldItems[i]), strutil.DecodeRedisKey(fieldItems[i+1]) if succ, _ := client.HSetNX(ctx, key, field, value).Result(); succ { added = append(added, types.HashEntryItem{ Key: field, Value: value, DisplayValue: "", // TODO: convert to display value }) } } default: // overwrite duplicated fields total := len(fieldItems) if total > 1 { for i := 0; i < total; i += 2 { field, value := strutil.DecodeRedisKey(fieldItems[i]), strutil.DecodeRedisKey(fieldItems[i+1]) if affect, _ := client.HSet(ctx, key, field, value).Result(); affect > 0 { added = append(added, types.HashEntryItem{ Key: field, Value: value, DisplayValue: "", // TODO: convert to display value }) } else { updated = append(updated, types.HashEntryItem{ Key: field, Value: value, DisplayValue: "", // TODO: convert to display value }) } } } } if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = struct { Added []types.HashEntryItem `json:"added,omitempty"` Updated []types.HashEntryItem `json:"updated,omitempty"` }{ Added: added, Updated: updated, } return } // AddListItem add item to list or remove from it func (b *browserService) AddListItem(server string, db int, k any, action int, items []any) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(k) var leftPush, rightPush []types.ListEntryItem switch action { case 0: // push to head slices.Reverse(items) _, err = client.LPush(ctx, key, items...).Result() for i := len(items) - 1; i >= 0; i-- { leftPush = append(leftPush, types.ListEntryItem{ Value: items[i], DisplayValue: "", // TODO: convert to display value }) } default: // append to tail _, err = client.RPush(ctx, key, items...).Result() for _, it := range items { rightPush = append(rightPush, types.ListEntryItem{ Value: it, DisplayValue: "", // TODO: convert to display value }) } } if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = struct { Left []types.ListEntryItem `json:"left,omitempty"` Right []types.ListEntryItem `json:"right,omitempty"` }{ Left: leftPush, Right: rightPush, } return } // SetListItem update or remove list item by index func (b *browserService) SetListItem(param types.SetListParam) (resp types.JSResp) { item, err := b.getRedisClient(param.Server, param.DB) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(param.Key) str := strutil.DecodeRedisKey(param.Value) index := int64(param.Index) var replaced, removed []types.ListReplaceItem if len(str) <= 0 { // remove from list err = client.LSet(ctx, key, index, "---VALUE_REMOVED_BY_TINY_RDM---").Err() if err != nil { resp.Msg = err.Error() return } err = client.LRem(ctx, key, 1, "---VALUE_REMOVED_BY_TINY_RDM---").Err() if err != nil { resp.Msg = err.Error() return } removed = append(removed, types.ListReplaceItem{ Index: param.Index, }) } else { // replace index value var saveStr string decoder := Preferences().GetDecoder() if saveStr, err = convutil.SaveAs(str, param.Format, param.Decode, decoder); err != nil { resp.Msg = fmt.Sprintf(`save to type "%s" fail: %s`, param.Format, err.Error()) return } err = client.LSet(ctx, key, index, saveStr).Err() if err != nil { resp.Msg = err.Error() return } var displayStr string if len(param.RetDecode) > 0 && len(param.RetFormat) > 0 { displayStr, _, _ = convutil.ConvertTo(saveStr, param.RetDecode, param.RetFormat, decoder) } replaced = append(replaced, types.ListReplaceItem{ Index: param.Index, Value: saveStr, DisplayValue: displayStr, }) } resp.Success = true resp.Data = struct { Removed []types.ListReplaceItem `json:"removed,omitempty"` Replaced []types.ListReplaceItem `json:"replaced,omitempty"` }{ Removed: removed, Replaced: replaced, } return } // SetSetItem add members to set or remove from set func (b *browserService) SetSetItem(server string, db int, k any, remove bool, members []any) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(k) var added, removed []types.SetEntryItem var affected int64 if remove { for _, member := range members { if affected, _ = client.SRem(ctx, key, member).Result(); affected > 0 { removed = append(removed, types.SetEntryItem{ Value: member, }) } } } else { for _, member := range members { if affected, _ = client.SAdd(ctx, key, member).Result(); affected > 0 { added = append(added, types.SetEntryItem{ Value: member, DisplayValue: "", // TODO: convert to display value }) } } } if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = struct { Added []types.SetEntryItem `json:"added,omitempty"` Removed []types.SetEntryItem `json:"removed,omitempty"` Affected int64 `json:"affected"` }{ Added: added, Removed: removed, Affected: affected, } return } // UpdateSetItem replace member of set func (b *browserService) UpdateSetItem(param types.SetSetParam) (resp types.JSResp) { item, err := b.getRedisClient(param.Server, param.DB) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(param.Key) var added, removed []types.SetEntryItem var affect int64 // remove old value str := strutil.DecodeRedisKey(param.Value) if affect, _ = client.SRem(ctx, key, str).Result(); affect > 0 { removed = append(removed, types.SetEntryItem{ Value: str, }) } // insert new value str = strutil.DecodeRedisKey(param.NewValue) decoder := Preferences().GetDecoder() var saveStr string if saveStr, err = convutil.SaveAs(str, param.Format, param.Decode, decoder); err != nil { resp.Msg = fmt.Sprintf(`save to type "%s" fail: %s`, param.Format, err.Error()) return } if affect, _ = client.SAdd(ctx, key, saveStr).Result(); affect > 0 { // add new item var displayStr string if len(param.RetDecode) > 0 && len(param.RetFormat) > 0 { displayStr, _, _ = convutil.ConvertTo(saveStr, param.RetDecode, param.RetFormat, decoder) } added = append(added, types.SetEntryItem{ Value: saveStr, DisplayValue: displayStr, }) } if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = struct { Added []types.SetEntryItem `json:"added,omitempty"` Removed []types.SetEntryItem `json:"removed,omitempty"` }{ Added: added, Removed: removed, } return } // UpdateZSetValue update value of sorted set member func (b *browserService) UpdateZSetValue(param types.SetZSetParam) (resp types.JSResp) { item, err := b.getRedisClient(param.Server, param.DB) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(param.Key) val, newVal := strutil.DecodeRedisKey(param.Value), strutil.DecodeRedisKey(param.NewValue) var added, updated, removed []types.ZSetEntryItem var replaced []types.ZSetReplaceItem var affect int64 decoder := Preferences().GetDecoder() if len(newVal) <= 0 { // no new value, delete value if affect, err = client.ZRem(ctx, key, val).Result(); affect > 0 { //removed = append(removed, val) removed = append(removed, types.ZSetEntryItem{ Value: val, }) } } else { var saveVal string if saveVal, err = convutil.SaveAs(newVal, param.Format, param.Decode, decoder); err != nil { resp.Msg = fmt.Sprintf(`save to type "%s" fail: %s`, param.Format, err.Error()) return } if saveVal == val { affect, err = client.ZAdd(ctx, key, redis.Z{ Score: param.Score, Member: saveVal, }).Result() displayValue, _, _ := convutil.ConvertTo(val, param.RetDecode, param.RetFormat, decoder) if affect > 0 { // add new item added = append(added, types.ZSetEntryItem{ Score: param.Score, Value: val, DisplayValue: displayValue, }) } else { // update score only updated = append(updated, types.ZSetEntryItem{ Score: param.Score, Value: val, DisplayValue: displayValue, }) } } else { // remove old value and add new one _, err = client.ZRem(ctx, key, val).Result() if err != nil { resp.Msg = err.Error() return } affect, err = client.ZAdd(ctx, key, redis.Z{ Score: param.Score, Member: saveVal, }).Result() displayValue, _, _ := convutil.ConvertTo(saveVal, param.RetDecode, param.RetFormat, decoder) if affect <= 0 { // no new value added, just update exists item removed = append(removed, types.ZSetEntryItem{ Value: val, }) updated = append(updated, types.ZSetEntryItem{ Score: param.Score, Value: saveVal, DisplayValue: displayValue, }) } else { // add new field replaced = append(replaced, types.ZSetReplaceItem{ Score: param.Score, Value: val, NewValue: saveVal, DisplayValue: displayValue, }) } } } if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = struct { Added []types.ZSetEntryItem `json:"added,omitempty"` Updated []types.ZSetEntryItem `json:"updated,omitempty"` Replaced []types.ZSetReplaceItem `json:"replaced,omitempty"` Removed []types.ZSetEntryItem `json:"removed,omitempty"` }{ Added: added, Updated: updated, Replaced: replaced, Removed: removed, } return } // AddZSetValue add item to sorted set func (b *browserService) AddZSetValue(server string, db int, k any, action int, valueScore map[string]float64) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(k) var added, updated []types.ZSetEntryItem switch action { case 1: // ignore duplicated fields for m, s := range valueScore { if affect, _ := client.ZAddNX(ctx, key, redis.Z{Score: s, Member: m}).Result(); affect > 0 { added = append(added, types.ZSetEntryItem{ Score: s, Value: m, DisplayValue: "", // TODO: convert to display value }) } } default: // overwrite duplicated fields for m, s := range valueScore { if affect, _ := client.ZAdd(ctx, key, redis.Z{Score: s, Member: m}).Result(); affect > 0 { added = append(added, types.ZSetEntryItem{ Score: s, Value: m, DisplayValue: "", // TODO: convert to display value }) } else { updated = append(updated, types.ZSetEntryItem{ Score: s, Value: m, DisplayValue: "", // TODO: convert to display value }) } } } if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = struct { Added []types.ZSetEntryItem `json:"added,omitempty"` Updated []types.ZSetEntryItem `json:"updated,omitempty"` }{ Added: added, Updated: updated, } return } // AddStreamValue add stream field func (b *browserService) AddStreamValue(server string, db int, k any, ID string, fieldItems []any) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(k) var updateID string updateID, err = client.XAdd(ctx, &redis.XAddArgs{ Stream: key, ID: ID, Values: fieldItems, }).Result() if err != nil { resp.Msg = err.Error() return } updateValues := make(map[string]any, len(fieldItems)/2) for i := 0; i < len(fieldItems)/2; i += 2 { updateValues[fieldItems[i].(string)] = fieldItems[i+1] } vb, _ := json.Marshal(updateValues) displayValue, _, _ := convutil.ConvertTo(string(vb), types.DECODE_NONE, types.FORMAT_JSON, Preferences().GetDecoder()) resp.Success = true resp.Data = struct { Added []types.StreamEntryItem `json:"added,omitempty"` }{ Added: []types.StreamEntryItem{ { ID: updateID, Value: updateValues, DisplayValue: displayValue, // TODO: convert to display value }, }, } return } // RemoveStreamValues remove stream values by id func (b *browserService) RemoveStreamValues(server string, db int, k any, IDs []string) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(k) var affected int64 affected, err = client.XDel(ctx, key, IDs...).Result() if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = struct { Affected int64 `json:"affected"` }{ Affected: affected, } return } // SetKeyTTL set ttl of key func (b *browserService) SetKeyTTL(server string, db int, k any, ttl int64) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(k) if ttl < 0 { if err = client.Persist(ctx, key).Err(); err != nil { resp.Msg = err.Error() return } } else { expiration := time.Duration(ttl) * time.Second if err = client.Expire(ctx, key, expiration).Err(); err != nil { resp.Msg = err.Error() return } } resp.Success = true return } // BatchSetTTL batch set ttl func (b *browserService) BatchSetTTL(server string, db int, ks []any, ttl int64, serialNo string) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client := item.client ctx, cancelFunc := context.WithCancel(b.ctx) defer cancelFunc() //cancelEvent := "ttling:stop:" + serialNo //runtime.EventsOnce(ctx, cancelEvent, func(data ...any) { // cancelFunc() //}) //processEvent := "ttling:" + serialNo total := len(ks) var failed, updated atomic.Int64 var canceled bool expiration := time.Now().Add(time.Duration(ttl) * time.Second) del := func(ctx context.Context, cli redis.UniversalClient) error { startTime := time.Now().Add(-10 * time.Second) for i, k := range ks { // emit progress per second //param := map[string]any{ // "total": total, // "progress": i + 1, // "processing": k, //} if i >= total-1 || time.Now().Sub(startTime).Milliseconds() > 100 { startTime = time.Now() //EventsEmit(ctx, processEvent, param) // do some sleep to prevent blocking the Redis server time.Sleep(10 * time.Millisecond) } key := strutil.DecodeRedisKey(k) var expErr error if ttl < 0 { expErr = cli.Persist(ctx, key).Err() } else { expErr = cli.ExpireAt(ctx, key, expiration).Err() } if err != nil { failed.Add(1) } else { // save deleted key updated.Add(1) } if errors.Is(expErr, context.Canceled) || canceled { canceled = true break } } return nil } if cluster, ok := client.(*redis.ClusterClient); ok { // cluster mode err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { return del(ctx, cli) }) } else { err = del(ctx, client) } //runtime.EventsOff(ctx, cancelEvent) resp.Success = true resp.Data = struct { Canceled bool `json:"canceled"` Updated int64 `json:"updated"` Failed int64 `json:"failed"` }{ Canceled: canceled, Updated: updated.Load(), Failed: failed.Load(), } return } // DeleteKey remove redis key func (b *browserService) DeleteKey(server string, db int, k any, async bool) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(k) var deletedKeys []string if strings.HasSuffix(key, "*") { // delete by prefix var mutex sync.Mutex supportUnlink := true del := func(ctx context.Context, cli redis.UniversalClient) error { handleDel := func(ks []string) error { var delErr error if async && supportUnlink { if delErr = cli.Unlink(ctx, ks...).Err(); delErr != nil { supportUnlink = false // not support unlink? try del command delErr = cli.Del(ctx, ks...).Err() } } else { delErr = cli.Del(ctx, ks...).Err() } mutex.Lock() deletedKeys = append(deletedKeys, ks...) mutex.Unlock() return delErr } scanSize := int64(Preferences().GetScanSize()) iter := cli.Scan(ctx, 0, key, scanSize).Iterator() resultKeys := make([]string, 0, 100) for iter.Next(ctx) { resultKeys = append(resultKeys, iter.Val()) if len(resultKeys) >= 20 { handleDel(resultKeys) resultKeys = resultKeys[:0:cap(resultKeys)] } } if len(resultKeys) > 0 { handleDel(resultKeys) } return nil } if cluster, ok := client.(*redis.ClusterClient); ok { // cluster mode err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { return del(ctx, cli) }) } else { err = del(ctx, client) } if err != nil { resp.Msg = err.Error() return } } else { // delete key only if async { if err = client.Unlink(ctx, key).Err(); err != nil { if err = client.Del(ctx, key).Err(); err != nil { resp.Msg = err.Error() return } } } else { if err = client.Del(ctx, key).Err(); err != nil { resp.Msg = err.Error() return } } deletedKeys = append(deletedKeys, key) } resp.Success = true resp.Data = map[string]any{ "deleted": deletedKeys, "deleteCount": len(deletedKeys), } return } // DeleteOneKey delete one key func (b *browserService) DeleteOneKey(server string, db int, k any) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx key := strutil.DecodeRedisKey(k) if cluster, ok := client.(*redis.ClusterClient); ok { // cluster mode err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { return cli.Del(ctx, key).Err() }) } else { err = client.Del(ctx, key).Err() } if err != nil { resp.Msg = err.Error() return } resp.Success = true return } // DeleteKeys delete keys sync with notification func (b *browserService) DeleteKeys(server string, db int, ks []any, serialNo string) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client := item.client ctx, cancelFunc := context.WithCancel(b.ctx) defer cancelFunc() cancelEvent := "delete:stop:" + serialNo cancelStopEvent := EventsOnce(ctx, cancelEvent, func(data ...any) { cancelFunc() }) total := len(ks) var canceled bool var deletedKeys = make([]any, 0, total) var mutex sync.Mutex del := func(ctx context.Context, cli redis.UniversalClient) error { const batchSize = 1000 for i := 0; i < total; i += batchSize { pipe := cli.Pipeline() for j := 0; j < batchSize; j++ { if i+j < total { pipe.Del(ctx, strutil.DecodeRedisKey(ks[i+j])) } } cmders, delErr := pipe.Exec(ctx) for j, cmder := range cmders { if cmder.(*redis.IntCmd).Val() == 1 { // save deleted key mutex.Lock() deletedKeys = append(deletedKeys, ks[i+j]) mutex.Unlock() } } if errors.Is(delErr, context.Canceled) || canceled { canceled = true break } } return nil } if cluster, ok := client.(*redis.ClusterClient); ok { // cluster mode err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { return del(ctx, cli) }) } else { err = del(ctx, client) } cancelStopEvent() resp.Success = true resp.Data = struct { Canceled bool `json:"canceled"` Deleted any `json:"deleted"` Failed int `json:"failed"` }{ Canceled: canceled, Deleted: deletedKeys, Failed: len(ks) - len(deletedKeys), } return } // DeleteKeysByPattern delete keys by pattern func (b *browserService) DeleteKeysByPattern(server string, db int, pattern string) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client := item.client ctx, cancelFunc := context.WithCancel(b.ctx) defer cancelFunc() var ks []any ks, _, err = b.scanKeys(ctx, client, pattern, "", 0, 0) if err != nil { resp.Msg = err.Error() return } total := len(ks) var canceled bool var deletedKeys = make([]any, 0, total) var mutex sync.Mutex del := func(ctx context.Context, cli redis.UniversalClient) error { const batchSize = 1000 for i := 0; i < total; i += batchSize { pipe := cli.Pipeline() for j := 0; j < batchSize; j++ { if i+j < total { pipe.Del(ctx, strutil.DecodeRedisKey(ks[i+j])) } } cmders, delErr := pipe.Exec(ctx) for j, cmder := range cmders { if cmder.(*redis.IntCmd).Val() == 1 { // save deleted key mutex.Lock() deletedKeys = append(deletedKeys, ks[i+j]) mutex.Unlock() } } if errors.Is(delErr, context.Canceled) || canceled { canceled = true break } } return nil } if cluster, ok := client.(*redis.ClusterClient); ok { // cluster mode err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { return del(ctx, cli) }) } else { err = del(ctx, client) } resp.Success = true resp.Data = struct { Canceled bool `json:"canceled"` Deleted any `json:"deleted"` Failed int `json:"failed"` }{ Canceled: canceled, Deleted: deletedKeys, Failed: len(ks) - len(deletedKeys), } return } // ExportKey export keys func (b *browserService) ExportKey(server string, db int, ks []any, path string, includeExpire bool) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client := item.client ctx, cancelFunc := context.WithCancel(b.ctx) defer cancelFunc() file, err := os.Create(path) if err != nil { resp.Msg = err.Error() return } defer file.Close() writer := csv.NewWriter(file) defer writer.Flush() cancelStopEvent := EventsOnce(ctx, "export:stop:"+path, func(data ...any) { cancelFunc() }) processEvent := "exporting:" + path total := len(ks) var exported, failed int64 var canceled bool startTime := time.Now().Add(-10 * time.Second) for i, k := range ks { if i >= total-1 || time.Now().Sub(startTime).Milliseconds() > 100 { startTime = time.Now() param := map[string]any{ "total": total, "progress": i + 1, "processing": k, } EventsEmit(ctx, processEvent, param) } key := strutil.DecodeRedisKey(k) content, dumpErr := client.Dump(ctx, key).Bytes() if errors.Is(dumpErr, context.Canceled) || canceled { canceled = true break } record := []string{hex.EncodeToString([]byte(key)), hex.EncodeToString(content)} if includeExpire { if dur, ttlErr := client.PTTL(ctx, key).Result(); ttlErr == nil && dur > 0 { record = append(record, strconv.FormatInt(time.Now().Add(dur).UnixMilli(), 10)) } else { record = append(record, "-1") } } if err = writer.Write(record); err != nil { failed += 1 } else { exported += 1 } } cancelStopEvent() resp.Success = true resp.Data = struct { Canceled bool `json:"canceled"` Exported int64 `json:"exported"` Failed int64 `json:"failed"` }{ Canceled: canceled, Exported: exported, Failed: failed, } return } // ImportCSV import data from csv file func (b *browserService) ImportCSV(server string, db int, path string, conflict int, ttl int64) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client := item.client ctx, cancelFunc := context.WithCancel(b.ctx) defer cancelFunc() file, err := os.Open(path) if err != nil { resp.Msg = err.Error() return } defer file.Close() reader := csv.NewReader(file) cancelEvent := "import:stop:" + path cancelStopEvent := EventsOnce(ctx, cancelEvent, func(data ...any) { cancelFunc() }) processEvent := "importing:" + path var line []string var readErr error var key, value []byte var ttlValue time.Duration var imported, ignored int64 var canceled bool startTime := time.Now().Add(-10 * time.Second) for { readErr = nil ttlValue = redis.KeepTTL line, readErr = reader.Read() if readErr != nil { break } if len(line) < 1 { continue } if key, readErr = hex.DecodeString(line[0]); readErr != nil { continue } if value, readErr = hex.DecodeString(line[1]); readErr != nil { continue } // get ttl if ttl < 0 && len(line) > 2 { // use previous if expire, ttlErr := strconv.ParseInt(line[2], 10, 64); ttlErr == nil && expire > 0 { ttlValue = time.UnixMilli(expire).Sub(time.Now()) } } else if ttl > 0 { // custom ttl ttlValue = time.Duration(ttl) * time.Second } if conflict == 0 { readErr = client.RestoreReplace(ctx, string(key), ttlValue, string(value)).Err() } else { keyStr := string(key) // go-redis may crash when batch calling restore // use "exists" to filter first if n, _ := client.Exists(ctx, keyStr).Result(); n <= 0 { readErr = client.Restore(ctx, keyStr, ttlValue, string(value)).Err() } else { readErr = errors.New("key already existed") } } if readErr != nil { // restore fail ignored += 1 } else { imported += 1 } if errors.Is(readErr, context.Canceled) || canceled { canceled = true break } if time.Now().Sub(startTime).Milliseconds() > 100 { startTime = time.Now() param := map[string]any{ "imported": imported, "ignored": ignored, //"processing": string(key), } EventsEmit(ctx, processEvent, param) // do some sleep to prevent blocking the Redis server time.Sleep(10 * time.Millisecond) } } cancelStopEvent() resp.Success = true resp.Data = struct { Canceled bool `json:"canceled"` Imported int64 `json:"imported"` Ignored int64 `json:"ignored"` }{ Canceled: canceled, Imported: imported, Ignored: ignored, } return } // FlushDB flush database func (b *browserService) FlushDB(server string, db int, async bool) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } flush := func(ctx context.Context, cli redis.UniversalClient, async bool) error { _, e := cli.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Select(ctx, db) if async { pipe.FlushDBAsync(ctx) } else { pipe.FlushDB(ctx) } return nil }) return e } client, ctx := item.client, item.ctx if cluster, ok := client.(*redis.ClusterClient); ok { // cluster mode err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { return flush(ctx, cli, async) }) // try sync mode if error cause if err != nil && async { err = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { return flush(ctx, cli, false) }) } } else { if err = flush(ctx, client, async); err != nil && async { // try sync mode if error cause err = flush(ctx, client, false) } } if err != nil { resp.Msg = err.Error() return } resp.Success = true return } // RenameKey rename key func (b *browserService) RenameKey(server string, db int, key, newKey string) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } client, ctx := item.client, item.ctx if _, ok := client.(*redis.ClusterClient); ok { resp.Msg = "RENAME not support in cluster mode yet" return } if _, err = client.RenameNX(ctx, key, newKey).Result(); err != nil { resp.Msg = err.Error() return } resp.Success = true return } // GetCmdHistory get redis command history func (b *browserService) GetCmdHistory(pageNo, pageSize int) (resp types.JSResp) { resp.Success = true if pageSize <= 0 || pageNo <= 0 { // return all history resp.Data = map[string]any{ "list": b.cmdHistory, "pageNo": 1, "pageSize": -1, } } else { total := len(b.cmdHistory) startIndex := total / pageSize * (pageNo - 1) endIndex := min(startIndex+pageSize, total) resp.Data = map[string]any{ "list": b.cmdHistory[startIndex:endIndex], "pageNo": pageNo, "pageSize": pageSize, } } return } // CleanCmdHistory clean redis command history func (b *browserService) CleanCmdHistory() (resp types.JSResp) { b.cmdHistory = []cmdHistoryItem{} resp.Success = true return } // GetSlowLogs get slow log list func (b *browserService) GetSlowLogs(server string, num int64) (resp types.JSResp) { item, err := b.getRedisClient(server, -1) if err != nil { resp.Msg = err.Error() return } num = max(1, num) client, ctx := item.client, item.ctx var logs []redis.SlowLog if cluster, ok := client.(*redis.ClusterClient); ok { // cluster mode var mu sync.Mutex err = cluster.ForEachShard(ctx, func(ctx context.Context, cli *redis.Client) error { if subLogs, _ := client.SlowLogGet(ctx, num).Result(); len(subLogs) > 0 { mu.Lock() logs = append(logs, subLogs...) mu.Unlock() } return nil }) } else { logs, err = client.SlowLogGet(ctx, num).Result() } if err != nil { resp.Msg = err.Error() return } sort.Slice(logs, func(i, j int) bool { return logs[i].Time.UnixMilli() > logs[j].Time.UnixMilli() }) if len(logs) > int(num) { logs = logs[:num] } list := sliceutil.Map(logs, func(i int) slowLogItem { var name string var e error if name, e = url.QueryUnescape(logs[i].ClientName); e != nil { name = logs[i].ClientName } return slowLogItem{ Timestamp: logs[i].Time.UnixMilli(), Client: name, Addr: logs[i].ClientAddr, Cmd: sliceutil.JoinString(logs[i].Args, " "), Cost: logs[i].Duration.Milliseconds(), } }) resp.Success = true resp.Data = map[string]any{ "list": list, } return } // GetClientList get all connected client info func (b *browserService) GetClientList(server string) (resp types.JSResp) { item, err := b.getRedisClient(server, -1) if err != nil { resp.Msg = err.Error() return } parseContent := func(content string) []map[string]string { lines := strings.Split(content, "\n") list := make([]map[string]string, 0, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) if len(line) > 0 { items := strings.Split(line, " ") itemKV := map[string]string{} for _, it := range items { kv := strings.SplitN(it, "=", 2) if len(kv) > 1 { itemKV[kv[0]] = kv[1] } } list = append(list, itemKV) } } return list } client, ctx := item.client, item.ctx var fullList []map[string]string var mutex sync.Mutex if cluster, ok := client.(*redis.ClusterClient); ok { cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { mutex.Lock() defer mutex.Unlock() fullList = append(fullList, parseContent(cli.ClientList(ctx).Val())...) return nil }) } else { fullList = append(fullList, parseContent(client.ClientList(ctx).Val())...) } resp.Success = true resp.Data = map[string]any{ "list": fullList, } return } ================================================ FILE: backend/services/cli_service.go ================================================ package services import ( "context" "errors" "fmt" "strings" "sync" "tinyrdm/backend/types" sliceutil "tinyrdm/backend/utils/slice" strutil "tinyrdm/backend/utils/string" "github.com/redis/go-redis/v9" ) type cliService struct { ctx context.Context ctxCancel context.CancelFunc mutex sync.Mutex clients map[string]redis.UniversalClient selectedDB map[string]int } type cliOutput struct { Null bool `json:"null,omitempty"` // output content is null Content []string `json:"content,omitempty"` // output content Prompt string `json:"prompt,omitempty"` // new line prompt, empty if not ready to input } var cli *cliService var onceCli sync.Once func Cli() *cliService { if cli == nil { onceCli.Do(func() { cli = &cliService{ clients: map[string]redis.UniversalClient{}, selectedDB: map[string]int{}, } }) } return cli } func (c *cliService) runCommand(server, data string) { if cmds := strutil.SplitCmd(data); len(cmds) > 0 && len(cmds[0]) > 0 { if client, err := c.getRedisClient(server); err == nil { args := sliceutil.Map(cmds, func(i int) any { return cmds[i] }) if result, err := client.Do(c.ctx, args...).Result(); err == nil || errors.Is(err, redis.Nil) { if strings.ToLower(cmds[0]) == "select" { // switch database if db, ok := strutil.AnyToInt(cmds[1]); ok { c.selectedDB[server] = db } } c.echo(server, result, true) } else { c.echoError(server, err.Error()) } return } } c.echoReady(server) } func (c *cliService) echo(server string, data any, newLineReady bool) { var output cliOutput if data != nil { str := strutil.AnyToString(data, "", 0) output.Content = strings.Split(str, "\n") } if newLineReady { output.Prompt = fmt.Sprintf("%s:db%d> ", server, c.selectedDB[server]) } EventsEmit(c.ctx, "cmd:output:"+server, output) } func (c *cliService) echoReady(server string) { c.echo(server, "", true) } func (c *cliService) echoError(server, data string) { c.echo(server, "\x1b[31m"+data+"\x1b[0m", true) } func (c *cliService) getRedisClient(server string) (redis.UniversalClient, error) { c.mutex.Lock() defer c.mutex.Unlock() client, ok := c.clients[server] if !ok { var err error conf := Connection().getConnection(server) if conf == nil { return nil, fmt.Errorf("no connection profile named: %s", server) } if client, err = Connection().createRedisClient(conf.ConnectionConfig); err != nil { return nil, err } c.clients[server] = client } return client, nil } func (c *cliService) Start(ctx context.Context) { c.ctx, c.ctxCancel = context.WithCancel(ctx) } // StartCli start a cli session func (c *cliService) StartCli(server string, db int) (resp types.JSResp) { client, err := c.getRedisClient(server) if err != nil { resp.Msg = err.Error() return } client.Do(c.ctx, "select", db) c.selectedDB[server] = db // monitor input EventsOn(c.ctx, "cmd:input:"+server, func(data ...interface{}) { if len(data) > 0 { if str, ok := data[0].(string); ok { c.runCommand(server, str) return } } c.echoReady(server) }) // echo prefix c.echoReady(server) resp.Success = true return } // CloseCli close cli session func (c *cliService) CloseCli(server string) (resp types.JSResp) { c.mutex.Lock() defer c.mutex.Unlock() if client, ok := c.clients[server]; ok { client.Close() delete(c.clients, server) delete(c.selectedDB, server) } EventsOff(c.ctx, "cmd:input:"+server) resp.Success = true return } // CloseAll close all cli sessions func (c *cliService) CloseAll() { if c.ctxCancel != nil { c.ctxCancel() } for server := range c.clients { c.CloseCli(server) } } ================================================ FILE: backend/services/connection_service.go ================================================ package services import ( "context" "crypto/tls" "crypto/x509" "errors" "io" "net" "net/url" "os" "path" "strconv" "strings" "sync" "time" "tinyrdm/backend/consts" . "tinyrdm/backend/storage" "tinyrdm/backend/types" _ "tinyrdm/backend/utils/proxy" "github.com/klauspost/compress/zip" "github.com/redis/go-redis/v9" "github.com/vrischmann/userdir" sshagent "github.com/xanzy/ssh-agent" "golang.org/x/crypto/ssh" "golang.org/x/net/proxy" ) type cmdHistoryItem struct { Timestamp int64 `json:"timestamp"` Server string `json:"server"` Cmd string `json:"cmd"` Cost int64 `json:"cost"` } type connectionService struct { ctx context.Context conns *ConnectionsStorage } var connection *connectionService var onceConnection sync.Once func Connection() *connectionService { if connection == nil { onceConnection.Do(func() { connection = &connectionService{ conns: NewConnections(), } }) } return connection } func (c *connectionService) Start(ctx context.Context) { c.ctx = ctx } func (c *connectionService) buildOption(config types.ConnectionConfig) (*redis.Options, error) { var dialer proxy.Dialer var dialerErr error if config.Proxy.Type == 1 { // use system proxy dialer = proxy.FromEnvironment() } else if config.Proxy.Type == 2 { // use custom proxy proxyUrl := url.URL{ Host: net.JoinHostPort(config.Proxy.Addr, strconv.Itoa(config.Proxy.Port)), } if len(config.Proxy.Username) > 0 { proxyUrl.User = url.UserPassword(config.Proxy.Username, config.Proxy.Password) } switch config.Proxy.Schema { case "socks5", "socks5h", "http", "https": proxyUrl.Scheme = config.Proxy.Schema default: proxyUrl.Scheme = "http" } if dialer, dialerErr = proxy.FromURL(&proxyUrl, proxy.Direct); dialerErr != nil { return nil, dialerErr } } var sshConfig *ssh.ClientConfig var sshAddr string if config.SSH.Enable { sshConfig = &ssh.ClientConfig{ User: config.SSH.Username, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: time.Duration(config.ConnTimeout) * time.Second, } switch config.SSH.LoginType { case "pwd": sshConfig.Auth = []ssh.AuthMethod{ssh.Password(config.SSH.Password)} case "pkfile": key, err := os.ReadFile(config.SSH.PKFile) if err != nil { return nil, err } var signer ssh.Signer if len(config.SSH.Passphrase) > 0 { signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(config.SSH.Passphrase)) } else { signer, err = ssh.ParsePrivateKey(key) } if err != nil { return nil, err } sshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)} case "agent": agent, conn, err := sshagent.New() if err != nil { return nil, err } if conn != nil { defer conn.Close() } sshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeysCallback(agent.Signers)} default: return nil, errors.New("invalid login type") } sshAddr = net.JoinHostPort(config.SSH.Addr, strconv.Itoa(config.SSH.Port)) } var tlsConfig *tls.Config if config.SSL.Enable { // setup tls config var certs []tls.Certificate if len(config.SSL.CertFile) > 0 && len(config.SSL.KeyFile) > 0 { if cert, err := tls.LoadX509KeyPair(config.SSL.CertFile, config.SSL.KeyFile); err != nil { return nil, err } else { certs = []tls.Certificate{cert} } } var caCertPool *x509.CertPool if len(config.SSL.CAFile) > 0 { ca, err := os.ReadFile(config.SSL.CAFile) if err != nil { return nil, err } caCertPool = x509.NewCertPool() caCertPool.AppendCertsFromPEM(ca) } tlsConfig = &tls.Config{ RootCAs: caCertPool, InsecureSkipVerify: config.SSL.AllowInsecure, Certificates: certs, ServerName: strings.TrimSpace(config.SSL.SNI), } } option := &redis.Options{ Username: config.Username, Password: config.Password, DialTimeout: time.Duration(config.ConnTimeout) * time.Second, ReadTimeout: time.Duration(config.ExecTimeout) * time.Second, WriteTimeout: time.Duration(config.ExecTimeout) * time.Second, ConnMaxIdleTime: 0, TLSConfig: tlsConfig, DisableIdentity: true, IdentitySuffix: "tinyrdm_", Protocol: 2, } if config.Network == "unix" { option.Network = "unix" if len(config.Sock) <= 0 { option.Addr = "/tmp/redis.sock" } else { option.Addr = config.Sock } } else { option.Network = "tcp" port := 6379 if config.Port > 0 { port = config.Port } if len(config.Addr) <= 0 { option.Addr = net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) } else { option.Addr = net.JoinHostPort(config.Addr, strconv.Itoa(port)) } } if len(sshAddr) > 0 { if dialer != nil { // ssh with proxy conn, err := dialer.Dial("tcp", sshAddr) if err != nil { return nil, err } sc, chans, reqs, err := ssh.NewClientConn(conn, sshAddr, sshConfig) if err != nil { return nil, err } dialer = ssh.NewClient(sc, chans, reqs) } else { // ssh without proxy sshClient, err := ssh.Dial("tcp", sshAddr, sshConfig) if err != nil { return nil, err } dialer = sshClient } } if dialer != nil { dial := func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.Dial(network, addr) } if tlsConfig != nil { // use dialer with tls config option.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) { rawConn, err := dial(ctx, network, addr) if err != nil { rawConn.Close() return nil, err } tlsConn := tls.Client(rawConn, tlsConfig) if err = tlsConn.Handshake(); err != nil { rawConn.Close() return nil, err } return tlsConn, nil } } else { option.Dialer = dial } if config.SSH.Enable { option.ReadTimeout = -2 option.WriteTimeout = -2 } } return option, nil } func (c *connectionService) createRedisClient(config types.ConnectionConfig) (redis.UniversalClient, error) { option, err := c.buildOption(config) if err != nil { return nil, err } if config.Sentinel.Enable { // get master address via sentinel node sentinel := redis.NewSentinelClient(option) defer sentinel.Close() var addr []string addr, err = sentinel.GetMasterAddrByName(c.ctx, config.Sentinel.Master).Result() if err != nil { return nil, err } if len(addr) < 2 { return nil, errors.New("cannot get master address") } option.Addr = net.JoinHostPort(addr[0], addr[1]) option.Username = config.Sentinel.Username option.Password = config.Sentinel.Password if option.Dialer != nil && config.SSH.Enable { option.ReadTimeout = -2 option.WriteTimeout = -2 } } if config.LastDB > 0 { option.DB = config.LastDB } rdb := redis.NewClient(option) if config.Cluster.Enable { defer rdb.Close() // connect to cluster var slots []redis.ClusterSlot if slots, err = rdb.ClusterSlots(c.ctx).Result(); err == nil { clusterOptions := &redis.ClusterOptions{ //NewClient: nil, //MaxRedirects: 0, //RouteByLatency: false, //RouteRandomly: false, //ClusterSlots: nil, Dialer: option.Dialer, OnConnect: option.OnConnect, Protocol: option.Protocol, Username: option.Username, Password: option.Password, MaxRetries: option.MaxRetries, MinRetryBackoff: option.MinRetryBackoff, MaxRetryBackoff: option.MaxRetryBackoff, DialTimeout: option.DialTimeout, ContextTimeoutEnabled: option.ContextTimeoutEnabled, PoolFIFO: option.PoolFIFO, PoolSize: option.PoolSize, PoolTimeout: option.PoolTimeout, MinIdleConns: option.MinIdleConns, MaxIdleConns: option.MaxIdleConns, ConnMaxIdleTime: option.ConnMaxIdleTime, ConnMaxLifetime: option.ConnMaxLifetime, TLSConfig: option.TLSConfig, DisableIdentity: option.DisableIdentity, } if option.Dialer != nil && config.SSH.Enable { clusterOptions.Dialer = option.Dialer clusterOptions.ReadTimeout = -2 clusterOptions.WriteTimeout = -2 } var addrs []string for _, slot := range slots { for _, node := range slot.Nodes { addrs = append(addrs, node.Addr) } } clusterOptions.Addrs = addrs clusterClient := redis.NewClusterClient(clusterOptions) return clusterClient, nil } else { return nil, err } } return rdb, nil } // ListSentinelMasters list all master info by sentinel func (c *connectionService) ListSentinelMasters(config types.ConnectionConfig) (resp types.JSResp) { option, err := c.buildOption(config) if err != nil { resp.Msg = err.Error() return } if option.DialTimeout > 0 { option.DialTimeout = 10 * time.Second } sentinel := redis.NewSentinelClient(option) defer sentinel.Close() var retInfo []map[string]string masterInfos, err := sentinel.Masters(c.ctx).Result() if err != nil { resp.Msg = err.Error() return } for _, info := range masterInfos { if infoMap, ok := info.(map[any]any); ok { retInfo = append(retInfo, map[string]string{ "name": infoMap["name"].(string), "addr": net.JoinHostPort(infoMap["ip"].(string), infoMap["port"].(string)), }) } } resp.Data = retInfo resp.Success = true return } func (c *connectionService) TestConnection(config types.ConnectionConfig) (resp types.JSResp) { client, err := c.createRedisClient(config) if err != nil { resp.Msg = err.Error() return } defer client.Close() if _, err = client.Ping(c.ctx).Result(); err != nil && !errors.Is(err, redis.Nil) { resp.Msg = err.Error() } else { resp.Success = true } return } // ListConnection list all saved connection in local profile func (c *connectionService) ListConnection() (resp types.JSResp) { resp.Success = true resp.Data = c.conns.GetConnections() return } func (c *connectionService) getConnection(name string) *types.Connection { return c.conns.GetConnection(name) } // GetConnection get connection profile by name func (c *connectionService) GetConnection(name string) (resp types.JSResp) { conn := c.getConnection(name) resp.Success = conn != nil resp.Data = conn return } // SaveConnection save connection config to local profile func (c *connectionService) SaveConnection(name string, param types.ConnectionConfig) (resp types.JSResp) { var err error if strings.ContainsAny(param.Name, "/") { err = errors.New("connection name contains illegal characters") } else { if len(name) > 0 { // update connection err = c.conns.UpdateConnection(name, param) } else { err = c.conns.CreateConnection(param) } } if err != nil { resp.Msg = err.Error() } else { resp.Success = true } return } // DeleteConnection remove connection by name func (c *connectionService) DeleteConnection(name string) (resp types.JSResp) { err := c.conns.DeleteConnection(name) if err != nil { resp.Msg = err.Error() return } resp.Success = true return } // SaveSortedConnection save sorted connection after drag func (c *connectionService) SaveSortedConnection(sortedConns types.Connections) (resp types.JSResp) { err := c.conns.SaveSortedConnection(sortedConns) if err != nil { resp.Msg = err.Error() return } resp.Success = true return } // CreateGroup create a new group func (c *connectionService) CreateGroup(name string) (resp types.JSResp) { err := c.conns.CreateGroup(name) if err != nil { resp.Msg = err.Error() return } resp.Success = true return } // RenameGroup rename group func (c *connectionService) RenameGroup(name, newName string) (resp types.JSResp) { err := c.conns.RenameGroup(name, newName) if err != nil { resp.Msg = err.Error() return } resp.Success = true return } // DeleteGroup remove a group by name func (c *connectionService) DeleteGroup(name string, includeConn bool) (resp types.JSResp) { err := c.conns.DeleteGroup(name, includeConn) if err != nil { resp.Msg = err.Error() return } resp.Success = true return } // SaveLastDB save last selected database index func (c *connectionService) SaveLastDB(name string, db int) (resp types.JSResp) { param := c.conns.GetConnection(name) if param == nil { resp.Msg = "no connection named \"" + name + "\"" return } if param.LastDB != db { param.LastDB = db if err := c.conns.UpdateConnection(name, param.ConnectionConfig); err != nil { resp.Msg = "save connection fail:" + err.Error() return } } resp.Success = true return } // SaveRefreshInterval save auto refresh interval func (c *connectionService) SaveRefreshInterval(name string, interval int) (resp types.JSResp) { param := c.conns.GetConnection(name) if param == nil { resp.Msg = "no connection named \"" + name + "\"" return } if param.RefreshInterval != interval { param.RefreshInterval = interval if err := c.conns.UpdateConnection(name, param.ConnectionConfig); err != nil { resp.Msg = "save connection fail:" + err.Error() return } } resp.Success = true return } // ExportConnections export connections to zip file func (c *connectionService) ExportConnections() (resp types.JSResp) { defaultFileName := "connections_" + time.Now().Format("20060102150405") + ".zip" filepath, err := SaveFileDialog(c.ctx, SaveDialogOptions{ ShowHiddenFiles: true, DefaultFilename: defaultFileName, Filters: []FileFilter{ { Pattern: "*.zip", }, }, }) if err != nil { resp.Msg = err.Error() return } // compress the connections profile with zip const connectionFilename = "connections.yaml" inputFile, err := os.Open(path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, connectionFilename)) if err != nil { resp.Msg = err.Error() return } defer inputFile.Close() outputFile, err := os.Create(filepath) if err != nil { resp.Msg = err.Error() return } defer outputFile.Close() zipWriter := zip.NewWriter(outputFile) defer zipWriter.Close() headerWriter, err := zipWriter.CreateHeader(&zip.FileHeader{ Name: connectionFilename, Method: zip.Deflate, }) if err != nil { resp.Msg = err.Error() return } if _, err = io.Copy(headerWriter, inputFile); err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = struct { Path string `json:"path"` }{ Path: filepath, } return } // ImportConnections import connections from local zip file func (c *connectionService) ImportConnections() (resp types.JSResp) { filepath, err := OpenFileDialog(c.ctx, OpenDialogOptions{ ShowHiddenFiles: true, Filters: []FileFilter{ { Pattern: "*.zip", }, }, }) if err != nil { resp.Msg = err.Error() return } const connectionFilename = "connections.yaml" zipFile, err := zip.OpenReader(filepath) if err != nil { resp.Msg = err.Error() return } var file *zip.File for _, file = range zipFile.File { if file.Name == connectionFilename { break } } if file != nil { zippedFile, err := file.Open() if err != nil { resp.Msg = err.Error() return } defer zippedFile.Close() outputFile, err := os.Create(path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, connectionFilename)) if err != nil { resp.Msg = err.Error() return } defer outputFile.Close() if _, err = io.Copy(outputFile, zippedFile); err != nil { resp.Msg = err.Error() return } } resp.Success = true return } // ParseConnectURL parse connection url string func (c *connectionService) ParseConnectURL(url string) (resp types.JSResp) { urlOpt, err := redis.ParseURL(url) if err != nil { resp.Msg = err.Error() return } var network, addr string var port int if urlOpt.Network == "unix" { network = urlOpt.Network addr = urlOpt.Addr } else { network = "tcp" addrPart := strings.Split(urlOpt.Addr, ":") addr = addrPart[0] port = 6379 if len(addrPart) > 1 { port, _ = strconv.Atoi(addrPart[1]) } } var sslServerName string if urlOpt.TLSConfig != nil { sslServerName = urlOpt.TLSConfig.ServerName } resp.Success = true resp.Data = struct { Network string `json:"network"` Sock string `json:"sock"` Addr string `json:"addr"` Port int `json:"port"` Username string `json:"username"` Password string `json:"password"` ConnTimeout int64 `json:"connTimeout"` ExecTimeout int64 `json:"execTimeout"` SSLServerName string `json:"sslServerName,omitempty"` }{ Network: network, Addr: addr, Port: port, Username: urlOpt.Username, Password: urlOpt.Password, ConnTimeout: int64(urlOpt.DialTimeout.Seconds()), ExecTimeout: int64(urlOpt.ReadTimeout.Seconds()), SSLServerName: sslServerName, } return } ================================================ FILE: backend/services/connection_service_web.go ================================================ //go:build web package services import ( "bytes" "io" "os" "path" "time" "tinyrdm/backend/consts" "tinyrdm/backend/types" "github.com/klauspost/compress/zip" "github.com/vrischmann/userdir" ) // ExportConnectionsToBytes exports connections as zip bytes for web download func (c *connectionService) ExportConnectionsToBytes() ([]byte, string, error) { const connectionFilename = "connections.yaml" filename := "connections_" + time.Now().Format("20060102150405") + ".zip" inputFile, err := os.Open(path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, connectionFilename)) if err != nil { return nil, "", err } defer inputFile.Close() var buf bytes.Buffer zipWriter := zip.NewWriter(&buf) headerWriter, err := zipWriter.CreateHeader(&zip.FileHeader{ Name: connectionFilename, Method: zip.Deflate, }) if err != nil { return nil, "", err } if _, err = io.Copy(headerWriter, inputFile); err != nil { return nil, "", err } if err = zipWriter.Close(); err != nil { return nil, "", err } return buf.Bytes(), filename, nil } // ImportConnectionsFromBytes imports connections from uploaded zip bytes func (c *connectionService) ImportConnectionsFromBytes(data []byte) (resp types.JSResp) { const connectionFilename = "connections.yaml" reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { resp.Msg = "invalid zip file" return } var file *zip.File for _, f := range reader.File { if f.Name == connectionFilename { file = f break } } if file == nil { resp.Msg = "connections.yaml not found in zip" return } zippedFile, err := file.Open() if err != nil { resp.Msg = "failed to read zip content" return } defer zippedFile.Close() outputFile, err := os.Create(path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, connectionFilename)) if err != nil { resp.Msg = "failed to save connections" return } defer outputFile.Close() if _, err = io.Copy(outputFile, zippedFile); err != nil { resp.Msg = "failed to write connections" return } resp.Success = true return } ================================================ FILE: backend/services/ga_service.go ================================================ package services import ( "bytes" "encoding/json" "net/http" "runtime" "strings" "sync" "tinyrdm/backend/storage" "github.com/google/uuid" ) // google analytics service type gaService struct { measurementID string secretKey string clientID string } type GaDataItem struct { ClientID string `json:"client_id"` Events []GaEventItem `json:"events"` } type GaEventItem struct { Name string `json:"name"` Params map[string]any `json:"params"` } var ga *gaService var onceGA sync.Once func GA() *gaService { if ga == nil { onceGA.Do(func() { // get or create an unique user id st := storage.NewLocalStore("device.txt") uidByte, err := st.Load() if err != nil { uidByte = []byte(strings.ReplaceAll(uuid.NewString(), "-", "")) st.Store(uidByte) } ga = &gaService{ clientID: string(uidByte), } }) } return ga } func (a *gaService) SetSecretKey(measurementID, secretKey string) { a.measurementID = measurementID a.secretKey = secretKey } func (a *gaService) isValid() bool { return len(a.measurementID) > 0 && len(a.secretKey) > 0 } func (a *gaService) sendEvent(events ...GaEventItem) error { body, err := json.Marshal(GaDataItem{ ClientID: a.clientID, Events: events, }) if err != nil { return err } //url := "https://www.google-analytics.com/debug/mp/collect" url := "https://www.google-analytics.com/mp/collect" req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) if err != nil { return err } q := req.URL.Query() q.Add("measurement_id", a.measurementID) q.Add("api_secret", a.secretKey) req.URL.RawQuery = q.Encode() response, err := http.DefaultClient.Do(req) if err != nil { return err } defer response.Body.Close() //if dump, err := httputil.DumpResponse(response, true); err == nil { // log.Println(string(dump)) //} return nil } // Startup sends application startup event func (a *gaService) Startup(version string) { if !a.isValid() { return } go a.sendEvent(GaEventItem{ Name: "startup", Params: map[string]any{ "os": runtime.GOOS, "arch": runtime.GOARCH, "version": version, }, }) } ================================================ FILE: backend/services/monitor_service.go ================================================ package services import ( "bufio" "context" "errors" "fmt" "os" "strconv" "sync" "time" "tinyrdm/backend/types" "github.com/redis/go-redis/v9" ) type monitorItem struct { client *redis.Client cmd *redis.MonitorCmd mutex sync.Mutex ch chan string closeCh chan struct{} eventName string } type monitorService struct { ctx context.Context ctxCancel context.CancelFunc mutex sync.Mutex items map[string]*monitorItem } var monitor *monitorService var onceMonitor sync.Once func Monitor() *monitorService { if monitor == nil { onceMonitor.Do(func() { monitor = &monitorService{ items: map[string]*monitorItem{}, } }) } return monitor } func (c *monitorService) getItem(server string) (*monitorItem, error) { c.mutex.Lock() defer c.mutex.Unlock() item, ok := c.items[server] if !ok { var err error conf := Connection().getConnection(server) if conf == nil { return nil, fmt.Errorf("no connection profile named: %s", server) } var uniClient redis.UniversalClient if uniClient, err = Connection().createRedisClient(conf.ConnectionConfig); err != nil { return nil, err } var client *redis.Client if client, ok = uniClient.(*redis.Client); !ok { return nil, errors.New("create redis client fail") } item = &monitorItem{ client: client, } c.items[server] = item } return item, nil } func (c *monitorService) Start(ctx context.Context) { c.ctx, c.ctxCancel = context.WithCancel(ctx) } // StartMonitor start a monitor by server name func (c *monitorService) StartMonitor(server string) (resp types.JSResp) { item, err := c.getItem(server) if err != nil { resp.Msg = err.Error() return } item.ch = make(chan string) item.closeCh = make(chan struct{}) item.eventName = "monitor:" + strconv.Itoa(int(time.Now().Unix())) item.cmd = item.client.Monitor(c.ctx, item.ch) item.cmd.Start() go c.processMonitor(&item.mutex, item.ch, item.closeCh, item.cmd, item.eventName) resp.Success = true resp.Data = struct { EventName string `json:"eventName"` }{ EventName: item.eventName, } return } func (c *monitorService) processMonitor(mutex *sync.Mutex, ch <-chan string, closeCh <-chan struct{}, cmd *redis.MonitorCmd, eventName string) { cache := make([]string, 0, 1000) ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case data := <-ch: if data != "OK" { go func() { mutex.Lock() defer mutex.Unlock() cache = append(cache, data) if len(cache) > 300 { EventsEmit(c.ctx, eventName, cache) cache = cache[:0:cap(cache)] } }() } case <-ticker.C: func() { mutex.Lock() defer mutex.Unlock() if len(cache) > 0 { EventsEmit(c.ctx, eventName, cache) cache = cache[:0:cap(cache)] } }() case <-closeCh: // monitor stopped cmd.Stop() return } } } // StopMonitor stop monitor by server name func (c *monitorService) StopMonitor(server string) (resp types.JSResp) { c.mutex.Lock() defer c.mutex.Unlock() item, ok := c.items[server] if !ok || item.cmd == nil { resp.Success = true return } //close(item.ch) item.client.Close() close(item.closeCh) delete(c.items, server) resp.Success = true return } // StopAll stop all monitor func (c *monitorService) StopAll() { if c.ctxCancel != nil { c.ctxCancel() } for server := range c.items { c.StopMonitor(server) } } func (c *monitorService) ExportLog(logs []string) (resp types.JSResp) { filepath, err := SaveFileDialog(c.ctx, SaveDialogOptions{ ShowHiddenFiles: false, DefaultFilename: fmt.Sprintf("monitor_log_%s.txt", time.Now().Format("20060102150405")), Filters: []FileFilter{ {Pattern: "*.txt"}, }, }) if err != nil { resp.Msg = err.Error() return } file, err := os.Create(filepath) if err != nil { resp.Msg = err.Error() return } defer file.Close() writer := bufio.NewWriter(file) for _, line := range logs { _, _ = writer.WriteString(line + "\n") } writer.Flush() resp.Success = true return } ================================================ FILE: backend/services/platform_desktop.go ================================================ //go:build !web package services import ( "context" "github.com/wailsapp/wails/v2/pkg/runtime" ) // Type aliases for Wails runtime types type OpenDialogOptions = runtime.OpenDialogOptions type SaveDialogOptions = runtime.SaveDialogOptions type FileFilter = runtime.FileFilter type Screen = runtime.Screen // EventsEmit emits an event to the frontend (Wails desktop) func EventsEmit(ctx context.Context, event string, data ...any) { runtime.EventsEmit(ctx, event, data...) } // EventsOnce registers a one-time event listener (Wails desktop) func EventsOnce(ctx context.Context, event string, callback func(data ...any)) func() { return runtime.EventsOnce(ctx, event, callback) } // EventsOn registers an event listener (Wails desktop) func EventsOn(ctx context.Context, event string, callback func(data ...any)) { runtime.EventsOn(ctx, event, callback) } // EventsOff removes an event listener (Wails desktop) func EventsOff(ctx context.Context, event string) { runtime.EventsOff(ctx, event) } // OpenFileDialog opens a native file dialog (Wails desktop) func OpenFileDialog(ctx context.Context, opts OpenDialogOptions) (string, error) { return runtime.OpenFileDialog(ctx, opts) } // SaveFileDialog opens a native save dialog (Wails desktop) func SaveFileDialog(ctx context.Context, opts SaveDialogOptions) (string, error) { return runtime.SaveFileDialog(ctx, opts) } // ScreenGetAll gets all screens (Wails desktop) func ScreenGetAll(ctx context.Context) ([]Screen, error) { return runtime.ScreenGetAll(ctx) } // WindowMaximise maximises the window (Wails desktop) func WindowMaximise(ctx context.Context) { runtime.WindowMaximise(ctx) } // WindowIsFullscreen checks if window is fullscreen (Wails desktop) func WindowIsFullscreen(ctx context.Context) bool { return runtime.WindowIsFullscreen(ctx) } // WindowGetSize gets window size (Wails desktop) func WindowGetSize(ctx context.Context) (int, int) { return runtime.WindowGetSize(ctx) } // WindowIsMaximised checks if window is maximised (Wails desktop) func WindowIsMaximised(ctx context.Context) bool { return runtime.WindowIsMaximised(ctx) } // WindowIsMinimised checks if window is minimised (Wails desktop) func WindowIsMinimised(ctx context.Context) bool { return runtime.WindowIsMinimised(ctx) } // WindowIsNormal checks if window is normal (Wails desktop) func WindowIsNormal(ctx context.Context) bool { return runtime.WindowIsNormal(ctx) } // IsWeb returns false in desktop mode func IsWeb() bool { return false } // IsDesktop returns true in desktop mode func IsDesktop() bool { return true } ================================================ FILE: backend/services/platform_web.go ================================================ //go:build web package services import ( "context" ) // Callback functions - set by api package at startup to avoid import cycle var EmitEventFunc func(event string, data any) var RegisterHandlerFunc func(event string, handler func(data any)) // Stub types to replace Wails runtime types in web mode type OpenDialogOptions struct { Title string ShowHiddenFiles bool Filters []FileFilter } type SaveDialogOptions struct { Title string ShowHiddenFiles bool DefaultFilename string Filters []FileFilter } type FileFilter struct { Pattern string } type Screen struct { IsCurrent bool Size ScreenSize } type ScreenSize struct { Width int Height int } // EventsEmit emits an event via WebSocket (web mode) func EventsEmit(ctx context.Context, event string, data ...any) { if EmitEventFunc == nil { return } if len(data) == 1 { EmitEventFunc(event, data[0]) } else { EmitEventFunc(event, data) } } // EventsOnce registers a one-time event listener via WebSocket (web mode) func EventsOnce(ctx context.Context, event string, callback func(data ...any)) func() { if RegisterHandlerFunc == nil { return func() {} } RegisterHandlerFunc(event, func(data any) { RegisterHandlerFunc(event, func(data any) {}) callback(data) }) return func() { RegisterHandlerFunc(event, func(data any) {}) } } // EventsOn registers an event listener via WebSocket (web mode) func EventsOn(ctx context.Context, event string, callback func(data ...any)) { if RegisterHandlerFunc == nil { return } RegisterHandlerFunc(event, func(data any) { callback(data) }) } // EventsOff removes an event listener (web mode) func EventsOff(ctx context.Context, event string) { if RegisterHandlerFunc == nil { return } RegisterHandlerFunc(event, func(data any) {}) } // OpenFileDialog is a no-op in web mode func OpenFileDialog(ctx context.Context, opts OpenDialogOptions) (string, error) { return "", nil } // SaveFileDialog is a no-op in web mode func SaveFileDialog(ctx context.Context, opts SaveDialogOptions) (string, error) { return "", nil } // ScreenGetAll returns empty in web mode func ScreenGetAll(ctx context.Context) ([]Screen, error) { return nil, nil } // WindowMaximise is a no-op in web mode func WindowMaximise(ctx context.Context) {} // WindowIsFullscreen returns false in web mode func WindowIsFullscreen(ctx context.Context) bool { return false } // WindowGetSize returns defaults in web mode func WindowGetSize(ctx context.Context) (int, int) { return 1024, 768 } // WindowIsMaximised returns false in web mode func WindowIsMaximised(ctx context.Context) bool { return false } // WindowIsMinimised returns false in web mode func WindowIsMinimised(ctx context.Context) bool { return false } // WindowIsNormal returns true in web mode func WindowIsNormal(ctx context.Context) bool { return true } // IsWeb returns true in web mode func IsWeb() bool { return true } // IsDesktop returns false in web mode func IsDesktop() bool { return false } ================================================ FILE: backend/services/preferences_service.go ================================================ package services import ( "context" "encoding/json" "net/http" "os" "sort" "strings" "sync" "tinyrdm/backend/consts" storage2 "tinyrdm/backend/storage" "tinyrdm/backend/types" "tinyrdm/backend/utils/coll" convutil "tinyrdm/backend/utils/convert" sliceutil "tinyrdm/backend/utils/slice" "github.com/adrg/sysfont" ) type preferencesService struct { pref *storage2.PreferencesStorage clientVersion string } var preferences *preferencesService var oncePreferences sync.Once func Preferences() *preferencesService { if preferences == nil { oncePreferences.Do(func() { preferences = &preferencesService{ pref: storage2.NewPreferences(), clientVersion: "", } }) } return preferences } func (p *preferencesService) GetPreferences() (resp types.JSResp) { resp.Data = p.pref.GetPreferences() resp.Success = true return } func (p *preferencesService) SetPreferences(pf types.Preferences) (resp types.JSResp) { err := p.pref.SetPreferences(&pf) if err != nil { resp.Msg = err.Error() return } p.UpdateEnv() resp.Success = true return } func (p *preferencesService) UpdatePreferences(value map[string]any) (resp types.JSResp) { err := p.pref.UpdatePreferences(value) if err != nil { resp.Msg = err.Error() return } resp.Success = true return } func (p *preferencesService) RestorePreferences() (resp types.JSResp) { defaultPref := p.pref.RestoreDefault() resp.Data = map[string]any{ "pref": defaultPref, } resp.Success = true return } type FontItem struct { Name string `json:"name"` Path string `json:"path"` } func (p *preferencesService) GetFontList() (resp types.JSResp) { finder := sysfont.NewFinder(nil) fontSet := coll.NewSet[string]() var fontList []FontItem for _, font := range finder.List() { if len(font.Family) > 0 && !strings.HasPrefix(font.Family, ".") && fontSet.Add(font.Family) { fontList = append(fontList, FontItem{ Name: font.Family, Path: font.Filename, }) } } sort.Slice(fontList, func(i, j int) bool { return fontList[i].Name < fontList[j].Name }) resp.Data = map[string]any{ "fonts": fontList, } resp.Success = true return } func (p *preferencesService) GetBuildInDecoder() (resp types.JSResp) { buildinDecoder := make([]string, 0, len(convutil.BuildInDecoders)) for name, convert := range convutil.BuildInDecoders { if convert.Enable() { buildinDecoder = append(buildinDecoder, name) } } resp.Data = map[string]any{ "decoder": buildinDecoder, } resp.Success = true return } func (p *preferencesService) GetLanguage() string { pref := p.pref.GetPreferences() return pref.General.Language } func (p *preferencesService) SetAppVersion(ver string) { if !strings.HasPrefix(ver, "v") { p.clientVersion = "v" + ver } else { p.clientVersion = ver } } func (p *preferencesService) GetAppVersion() (resp types.JSResp) { resp.Success = true resp.Data = map[string]any{ "version": p.clientVersion, } return } func (p *preferencesService) SaveWindowSize(width, height int, maximised bool) { if maximised { // do not update window size if maximised state p.UpdatePreferences(map[string]any{ "behavior.windowMaximised": true, }) } else if width >= consts.MIN_WINDOW_WIDTH && height >= consts.MIN_WINDOW_HEIGHT { p.UpdatePreferences(map[string]any{ "behavior.windowWidth": width, "behavior.windowHeight": height, "behavior.windowMaximised": false, }) } } func (p *preferencesService) GetWindowSize() (width, height int, maximised bool) { data := p.pref.GetPreferences() width, height, maximised = data.Behavior.WindowWidth, data.Behavior.WindowHeight, data.Behavior.WindowMaximised if width <= 0 { width = consts.DEFAULT_WINDOW_WIDTH } if height <= 0 { height = consts.DEFAULT_WINDOW_HEIGHT } return } func (p *preferencesService) GetWindowPosition(ctx context.Context) (x, y int) { data := p.pref.GetPreferences() x, y = data.Behavior.WindowPosX, data.Behavior.WindowPosY width, height := data.Behavior.WindowWidth, data.Behavior.WindowHeight var screenWidth, screenHeight int if screens, err := ScreenGetAll(ctx); err == nil { for _, screen := range screens { if screen.IsCurrent { screenWidth, screenHeight = screen.Size.Width, screen.Size.Height break } } } if screenWidth <= 0 || screenHeight <= 0 { screenWidth, screenHeight = consts.DEFAULT_WINDOW_WIDTH, consts.DEFAULT_WINDOW_HEIGHT } if x <= 0 || x+width > screenWidth || y <= 0 || y+height > screenHeight { // out of screen, reset to center x, y = (screenWidth-width)/2, (screenHeight-height)/2 } return } func (p *preferencesService) SaveWindowPosition(x, y int) { if x > 0 || y > 0 { p.UpdatePreferences(map[string]any{ "behavior.windowPosX": x, "behavior.windowPosY": y, }) } } func (p *preferencesService) GetScanSize() int { data := p.pref.GetPreferences() size := data.General.ScanSize if size <= 0 { size = consts.DEFAULT_SCAN_SIZE } return size } func (p *preferencesService) GetDecoder() []convutil.CmdConvert { data := p.pref.GetPreferences() return sliceutil.FilterMap(data.Decoder, func(i int) (convutil.CmdConvert, bool) { //if !data.Decoder[i].Enable { // return convutil.CmdConvert{}, false //} return convutil.CmdConvert{ Name: data.Decoder[i].Name, Auto: data.Decoder[i].Auto, DecodePath: data.Decoder[i].DecodePath, DecodeArgs: data.Decoder[i].DecodeArgs, EncodePath: data.Decoder[i].EncodePath, EncodeArgs: data.Decoder[i].EncodeArgs, }, true }) } type sponsorItem struct { Name string `json:"name"` Link string `json:"link"` Region []string `json:"region"` } type upgradeInfo struct { Version string `json:"version"` Changelog map[string]string `json:"changelog"` Description map[string]string `json:"description"` DownloadURl map[string]string `json:"download_url"` DownloadPage map[string]string `json:"download_page"` Sponsor []sponsorItem `json:"sponsor,omitempty"` } func (p *preferencesService) CheckForUpdate() (resp types.JSResp) { // request latest version //res, err := http.Get("https://api.github.com/repos/tiny-craft/tiny-rdm/releases/latest") res, err := http.Get("https://tinyrdm.com/client_version.json") if err != nil || res.StatusCode != http.StatusOK { resp.Msg = "network error" return } var respObj upgradeInfo err = json.NewDecoder(res.Body).Decode(&respObj) if err != nil { resp.Msg = "invalid content" return } // compare with current version resp.Success = true resp.Data = map[string]any{ "version": p.clientVersion, "latest": respObj.Version, "description": respObj.Description, "download_page": respObj.DownloadPage, "sponsor": respObj.Sponsor, } return } // UpdateEnv Update System Environment func (p *preferencesService) UpdateEnv() { if p.GetLanguage() == "zh" { os.Setenv("LANG", "zh_CN.UTF-8") } else { os.Unsetenv("LANG") } } ================================================ FILE: backend/services/pubsub_service.go ================================================ package services import ( "context" "fmt" "strconv" "sync" "time" "tinyrdm/backend/types" "github.com/redis/go-redis/v9" ) type pubsubItem struct { client redis.UniversalClient pubsub *redis.PubSub mutex sync.Mutex closeCh chan struct{} eventName string } type subMessage struct { Timestamp int64 `json:"timestamp"` Channel string `json:"channel"` Message string `json:"message"` } type pubsubService struct { ctx context.Context ctxCancel context.CancelFunc mutex sync.Mutex items map[string]*pubsubItem } var pubsub *pubsubService var oncePubsub sync.Once func Pubsub() *pubsubService { if pubsub == nil { oncePubsub.Do(func() { pubsub = &pubsubService{ items: map[string]*pubsubItem{}, } }) } return pubsub } func (p *pubsubService) getItem(server string) (*pubsubItem, error) { p.mutex.Lock() defer p.mutex.Unlock() item, ok := p.items[server] if !ok { var err error conf := Connection().getConnection(server) if conf == nil { return nil, fmt.Errorf("no connection profile named: %s", server) } var uniClient redis.UniversalClient if uniClient, err = Connection().createRedisClient(conf.ConnectionConfig); err != nil { return nil, err } item = &pubsubItem{ client: uniClient, } p.items[server] = item } return item, nil } func (p *pubsubService) Start(ctx context.Context) { p.ctx, p.ctxCancel = context.WithCancel(ctx) } // Publish publish message to channel func (p *pubsubService) Publish(server, channel, payload string) (resp types.JSResp) { rdb, err := Browser().getRedisClient(server, -1) if err != nil { resp.Msg = err.Error() return } var received int64 received, err = rdb.client.Publish(p.ctx, channel, payload).Result() if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = struct { Received int64 `json:"received"` }{ Received: received, } return } // StartSubscribe start to subscribe a channel func (p *pubsubService) StartSubscribe(server string) (resp types.JSResp) { item, err := p.getItem(server) if err != nil { resp.Msg = err.Error() return } item.closeCh = make(chan struct{}) item.eventName = "sub:" + strconv.Itoa(int(time.Now().Unix())) item.pubsub = item.client.PSubscribe(p.ctx, "*") go p.processSubscribe(&item.mutex, item.pubsub.Channel(), item.closeCh, item.eventName) resp.Success = true resp.Data = struct { EventName string `json:"eventName"` }{ EventName: item.eventName, } return } func (p *pubsubService) processSubscribe(mutex *sync.Mutex, ch <-chan *redis.Message, closeCh <-chan struct{}, eventName string) { cache := make([]subMessage, 0, 1000) ticker := time.NewTicker(300 * time.Millisecond) defer ticker.Stop() for { select { case data := <-ch: go func() { timestamp := time.Now().UnixMilli() mutex.Lock() defer mutex.Unlock() cache = append(cache, subMessage{ Timestamp: timestamp, Channel: data.Channel, Message: data.Payload, }) if len(cache) > 300 { EventsEmit(p.ctx, eventName, cache) cache = cache[:0:cap(cache)] } }() case <-ticker.C: func() { mutex.Lock() defer mutex.Unlock() if len(cache) > 0 { EventsEmit(p.ctx, eventName, cache) cache = cache[:0:cap(cache)] } }() case <-closeCh: // subscribe stopped return } } } // StopSubscribe stop subscribe by server name func (p *pubsubService) StopSubscribe(server string) (resp types.JSResp) { p.mutex.Lock() defer p.mutex.Unlock() item, ok := p.items[server] if !ok || item.pubsub == nil { resp.Success = true return } //item.pubsub.Unsubscribe(p.ctx, "*") item.pubsub.Close() close(item.closeCh) delete(p.items, server) resp.Success = true return } // StopAll stop all subscribe func (p *pubsubService) StopAll() { if p.ctxCancel != nil { p.ctxCancel() } for server := range p.items { p.StopSubscribe(server) } } ================================================ FILE: backend/services/system_service.go ================================================ package services import ( "context" runtime2 "runtime" "sync" "time" "tinyrdm/backend/consts" "tinyrdm/backend/types" sliceutil "tinyrdm/backend/utils/slice" ) type systemService struct { ctx context.Context appVersion string } var system *systemService var onceSystem sync.Once func System() *systemService { if system == nil { onceSystem.Do(func() { system = &systemService{ appVersion: "0.0.0", } if IsDesktop() { go system.loopWindowEvent() } }) } return system } func (s *systemService) Start(ctx context.Context, version string) { if !IsDesktop() { return } s.ctx = ctx s.appVersion = version // maximize the window if screen size is lower than the minimum window size if screen, err := ScreenGetAll(ctx); err == nil && len(screen) > 0 { for _, sc := range screen { if sc.IsCurrent { if sc.Size.Width < consts.MIN_WINDOW_WIDTH || sc.Size.Height < consts.MIN_WINDOW_HEIGHT { WindowMaximise(ctx) break } } } } } func (s *systemService) Info() (resp types.JSResp) { resp.Success = true resp.Data = struct { OS string `json:"os"` Arch string `json:"arch"` Version string `json:"version"` }{ OS: runtime2.GOOS, Arch: runtime2.GOARCH, Version: s.appVersion, } return } // SelectFile open file dialog to select a file func (s *systemService) SelectFile(title string, extensions []string) (resp types.JSResp) { if !IsDesktop() { return } filters := sliceutil.Map(extensions, func(i int) FileFilter { return FileFilter{ Pattern: "*." + extensions[i], } }) filepath, err := OpenFileDialog(s.ctx, OpenDialogOptions{ Title: title, ShowHiddenFiles: true, Filters: filters, }) if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = map[string]any{ "path": filepath, } return } // SaveFile open file dialog to save a file func (s *systemService) SaveFile(title string, defaultName string, extensions []string) (resp types.JSResp) { if !IsDesktop() { return } filters := sliceutil.Map(extensions, func(i int) FileFilter { return FileFilter{ Pattern: "*." + extensions[i], } }) filepath, err := SaveFileDialog(s.ctx, SaveDialogOptions{ Title: title, ShowHiddenFiles: true, DefaultFilename: defaultName, Filters: filters, }) if err != nil { resp.Msg = err.Error() return } resp.Success = true resp.Data = map[string]any{ "path": filepath, } return } func (s *systemService) loopWindowEvent() { if !IsDesktop() { return } var fullscreen, maximised, minimised, normal bool var width, height int var dirty bool for { time.Sleep(300 * time.Millisecond) if s.ctx == nil { continue } dirty = false if f := WindowIsFullscreen(s.ctx); f != fullscreen { // full-screen switched fullscreen = f dirty = true } if w, h := WindowGetSize(s.ctx); w != width || h != height { // window size changed width, height = w, h dirty = true } if m := WindowIsMaximised(s.ctx); m != maximised { maximised = m dirty = true } if m := WindowIsMinimised(s.ctx); m != minimised { minimised = m dirty = true } if n := WindowIsNormal(s.ctx); n != normal { normal = n dirty = true } if dirty { EventsEmit(s.ctx, "window_changed", map[string]any{ "fullscreen": fullscreen, "width": width, "height": height, "maximised": maximised, "minimised": minimised, "normal": normal, }) if !fullscreen && !minimised { // save window size and position Preferences().SaveWindowSize(width, height, maximised) } } } } ================================================ FILE: backend/storage/connections.go ================================================ package storage import ( "errors" "slices" "sync" "tinyrdm/backend/consts" "tinyrdm/backend/types" "gopkg.in/yaml.v3" ) type ConnectionsStorage struct { storage *localStorage mutex sync.Mutex } func NewConnections() *ConnectionsStorage { return &ConnectionsStorage{ storage: NewLocalStore("connections.yaml"), } } func (c *ConnectionsStorage) defaultConnections() types.Connections { return types.Connections{} } func (c *ConnectionsStorage) defaultConnectionItem() types.ConnectionConfig { return types.ConnectionConfig{ Name: "", Network: "tcp", Addr: "127.0.0.1", Port: 6379, Username: "", Password: "", DefaultFilter: "*", KeySeparator: ":", ConnTimeout: 60, ExecTimeout: 60, DBFilterType: "none", DBFilterList: []int{}, LoadSize: consts.DEFAULT_LOAD_SIZE, MarkColor: "", RefreshInterval: 5, Sentinel: types.ConnectionSentinel{ Master: "mymaster", }, } } func (c *ConnectionsStorage) getConnections() (ret types.Connections) { b, err := c.storage.Load() ret = c.defaultConnections() if err != nil { return } if err = yaml.Unmarshal(b, &ret); err != nil { ret = c.defaultConnections() return } if len(ret) <= 0 { ret = c.defaultConnections() } //if !sliceutil.AnyMatch(ret, func(i int) bool { // return ret[i].GroupName == "" //}) { // ret = append(ret, c.defaultConnections()...) //} return } // GetConnections get all store connections from local func (c *ConnectionsStorage) GetConnections() (ret types.Connections) { return c.getConnections() } // GetConnectionsFlat get all store connections from local flat(exclude group level) func (c *ConnectionsStorage) GetConnectionsFlat() (ret types.Connections) { conns := c.getConnections() for _, conn := range conns { if conn.Type == "group" { ret = append(ret, conn.Connections...) } else { ret = append(ret, conn) } } return } // GetConnection get connection by name func (c *ConnectionsStorage) GetConnection(name string) *types.Connection { conns := c.getConnections() var findConn func(string, string, types.Connections) *types.Connection findConn = func(name, groupName string, conns types.Connections) *types.Connection { for i, conn := range conns { if conn.Type != "group" { if conn.Name == name { conns[i].Group = groupName return &conns[i] } } else { if ret := findConn(name, conn.Name, conn.Connections); ret != nil { return ret } } } return nil } return findConn(name, "", conns) } // GetGroup get one connection group by name func (c *ConnectionsStorage) GetGroup(name string) *types.Connection { conns := c.getConnections() for i, conn := range conns { if conn.Type == "group" && conn.Name == name { return &conns[i] } } return nil } func (c *ConnectionsStorage) saveConnections(conns types.Connections) error { b, err := yaml.Marshal(&conns) if err != nil { return err } if err = c.storage.Store(b); err != nil { return err } return nil } // CreateConnection create new connection func (c *ConnectionsStorage) CreateConnection(param types.ConnectionConfig) error { c.mutex.Lock() defer c.mutex.Unlock() conn := c.GetConnection(param.Name) if conn != nil { return errors.New("duplicated connection name") } conns := c.getConnections() var group *types.Connection if len(param.Group) > 0 { for i, conn := range conns { if conn.Type == "group" && conn.Name == param.Group { group = &conns[i] break } } } if group != nil { group.Connections = append(group.Connections, types.Connection{ ConnectionConfig: param, }) } else { if len(param.Group) > 0 { // no group matched, create new group conns = append(conns, types.Connection{ Type: "group", Connections: types.Connections{ types.Connection{ ConnectionConfig: param, }, }, }) } else { conns = append(conns, types.Connection{ ConnectionConfig: param, }) } } return c.saveConnections(conns) } // UpdateConnection update existing connection by name func (c *ConnectionsStorage) UpdateConnection(name string, param types.ConnectionConfig) error { c.mutex.Lock() defer c.mutex.Unlock() conns := c.getConnections() var updated bool var retrieve func(types.Connections, string, types.ConnectionConfig) error retrieve = func(conns types.Connections, name string, param types.ConnectionConfig) error { for i, conn := range conns { if conn.Type != "group" { if name != param.Name && conn.Name == param.Name { return errors.New("duplicated connection name") } else if conn.Name == name && !updated { conns[i] = types.Connection{ ConnectionConfig: param, } updated = true } } else { if err := retrieve(conn.Connections, name, param); err != nil { return err } } } return nil } err := retrieve(conns, name, param) if err != nil { return err } if !updated { return errors.New("connection not found") } return c.saveConnections(conns) } // DeleteConnection remove special connection func (c *ConnectionsStorage) DeleteConnection(name string) error { c.mutex.Lock() defer c.mutex.Unlock() conns := c.getConnections() var updated bool for i, conn := range conns { if conn.Type == "group" { for j, subConn := range conn.Connections { if subConn.Name == name { conns[i].Connections = append(conns[i].Connections[:j], conns[i].Connections[j+1:]...) updated = true break } } } else if conn.Name == name { conns = append(conns[:i], conns[i+1:]...) updated = true break } if updated { break } } if !updated { return errors.New("no match connection") } return c.saveConnections(conns) } // SaveSortedConnection save connection after sort func (c *ConnectionsStorage) SaveSortedConnection(sortedConns types.Connections) error { c.mutex.Lock() defer c.mutex.Unlock() conns := c.GetConnectionsFlat() takeConn := func(name string) (types.Connection, bool) { idx := slices.IndexFunc(conns, func(connection types.Connection) bool { return connection.Name == name }) if idx >= 0 { ret := conns[idx] conns = append(conns[:idx], conns[idx+1:]...) return ret, true } return types.Connection{}, false } var replaceConn func(connections types.Connections) types.Connections replaceConn = func(cons types.Connections) types.Connections { var newConns types.Connections for _, conn := range cons { if conn.Type == "group" { newConns = append(newConns, types.Connection{ ConnectionConfig: types.ConnectionConfig{ Name: conn.Name, }, Type: "group", Connections: replaceConn(conn.Connections), }) } else { if foundConn, ok := takeConn(conn.Name); ok { newConns = append(newConns, foundConn) } } } return newConns } conns = replaceConn(sortedConns) return c.saveConnections(conns) } // CreateGroup create a new group func (c *ConnectionsStorage) CreateGroup(name string) error { c.mutex.Lock() defer c.mutex.Unlock() conns := c.getConnections() for _, conn := range conns { if conn.Type == "group" && conn.Name == name { return errors.New("duplicated group name") } } conns = append(conns, types.Connection{ ConnectionConfig: types.ConnectionConfig{ Name: name, }, Type: "group", }) return c.saveConnections(conns) } // RenameGroup rename group func (c *ConnectionsStorage) RenameGroup(name, newName string) error { c.mutex.Lock() defer c.mutex.Unlock() groupIndex := -1 conns := c.getConnections() for i, conn := range conns { if conn.Type == "group" { if conn.Name == newName { return errors.New("duplicated group name") } else if conn.Name == name { groupIndex = i } } } if groupIndex == -1 { return errors.New("group not found") } conns[groupIndex].Name = newName return c.saveConnections(conns) } // DeleteGroup remove specified group, include all connections under it func (c *ConnectionsStorage) DeleteGroup(group string, includeConnection bool) error { c.mutex.Lock() defer c.mutex.Unlock() conns := c.getConnections() for i, conn := range conns { if conn.Type == "group" && conn.Name == group { conns = append(conns[:i], conns[i+1:]...) if includeConnection { conns = append(conns, conn.Connections...) } return c.saveConnections(conns) } } return errors.New("group not found") } ================================================ FILE: backend/storage/local_storage.go ================================================ package storage import ( "os" "path" "tinyrdm/backend/consts" "github.com/vrischmann/userdir" ) // localStorage provides reading and writing application data to the user's // configuration directory. type localStorage struct { ConfPath string } // NewLocalStore returns a localStore instance. func NewLocalStore(filename string) *localStorage { return &localStorage{ ConfPath: path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, filename), } } // Load reads the given file in the user's configuration directory and returns // its contents. func (l *localStorage) Load() ([]byte, error) { d, err := os.ReadFile(l.ConfPath) if err != nil { return nil, err } return d, err } // Store writes data to the user's configuration directory at the given // filename. func (l *localStorage) Store(data []byte) error { dir := path.Dir(l.ConfPath) if err := ensureDirExists(dir); err != nil { return err } if err := os.WriteFile(l.ConfPath, data, 0600); err != nil { return err } return nil } // ensureDirExists checks for the existence of the directory at the given path, // which is created if it does not exist. func ensureDirExists(path string) error { _, err := os.Stat(path) if os.IsNotExist(err) { if err = os.Mkdir(path, 0700); err != nil { return err } } return nil } ================================================ FILE: backend/storage/preferences.go ================================================ package storage import ( "fmt" "log" "reflect" "strings" "sync" "tinyrdm/backend/consts" "tinyrdm/backend/types" "gopkg.in/yaml.v3" ) type PreferencesStorage struct { storage *localStorage mutex sync.Mutex } func NewPreferences() *PreferencesStorage { storage := NewLocalStore("preferences.yaml") log.Printf("preferences path: %s\n", storage.ConfPath) return &PreferencesStorage{ storage: storage, } } func (p *PreferencesStorage) DefaultPreferences() types.Preferences { return types.NewPreferences() } func (p *PreferencesStorage) getPreferences() (ret types.Preferences) { ret = p.DefaultPreferences() b, err := p.storage.Load() if err != nil { return } if err = yaml.Unmarshal(b, &ret); err != nil { ret = p.DefaultPreferences() return } return } // GetPreferences Get preferences from local func (p *PreferencesStorage) GetPreferences() (ret types.Preferences) { p.mutex.Lock() defer p.mutex.Unlock() ret = p.getPreferences() if ret.General.ScanSize <= 0 { ret.General.ScanSize = consts.DEFAULT_SCAN_SIZE } ret.Behavior.AsideWidth = max(ret.Behavior.AsideWidth, consts.DEFAULT_ASIDE_WIDTH) ret.Behavior.WindowWidth = max(ret.Behavior.WindowWidth, consts.MIN_WINDOW_WIDTH) ret.Behavior.WindowHeight = max(ret.Behavior.WindowHeight, consts.MIN_WINDOW_HEIGHT) return } func (p *PreferencesStorage) setPreferences(pf *types.Preferences, key string, value any) error { parts := strings.Split(key, ".") if len(parts) > 0 { var reflectValue reflect.Value if reflect.TypeOf(pf).Kind() == reflect.Ptr { reflectValue = reflect.ValueOf(pf).Elem() } else { reflectValue = reflect.ValueOf(pf) } for i, part := range parts { part = strings.ToUpper(part[:1]) + part[1:] reflectValue = reflectValue.FieldByName(part) if reflectValue.IsValid() { if i == len(parts)-1 { reflectValue.Set(reflect.ValueOf(value)) return nil } } else { break } } } return fmt.Errorf("invalid key path(%s)", key) } func (p *PreferencesStorage) savePreferences(pf *types.Preferences) error { b, err := yaml.Marshal(pf) if err != nil { return err } if err = p.storage.Store(b); err != nil { return err } return nil } // SetPreferences replace preferences func (p *PreferencesStorage) SetPreferences(pf *types.Preferences) error { p.mutex.Lock() defer p.mutex.Unlock() return p.savePreferences(pf) } // UpdatePreferences update values by key paths, the key path use "." to indicate multiple level func (p *PreferencesStorage) UpdatePreferences(values map[string]any) error { p.mutex.Lock() defer p.mutex.Unlock() pf := p.getPreferences() for path, v := range values { if err := p.setPreferences(&pf, path, v); err != nil { return err } } log.Println("after save", pf) return p.savePreferences(&pf) } func (p *PreferencesStorage) RestoreDefault() types.Preferences { p.mutex.Lock() defer p.mutex.Unlock() pf := p.DefaultPreferences() p.savePreferences(&pf) return pf } ================================================ FILE: backend/types/connection.go ================================================ package types type ConnectionCategory int type ConnectionConfig struct { Name string `json:"name" yaml:"name"` Group string `json:"group,omitempty" yaml:"-"` LastDB int `json:"lastDB" yaml:"last_db"` Network string `json:"network,omitempty" yaml:"network,omitempty"` Sock string `json:"sock,omitempty" yaml:"sock,omitempty"` Addr string `json:"addr,omitempty" yaml:"addr,omitempty"` Port int `json:"port,omitempty" yaml:"port,omitempty"` Username string `json:"username,omitempty" yaml:"username,omitempty"` Password string `json:"password,omitempty" yaml:"password,omitempty"` DefaultFilter string `json:"defaultFilter,omitempty" yaml:"default_filter,omitempty"` KeySeparator string `json:"keySeparator,omitempty" yaml:"key_separator,omitempty"` ConnTimeout int `json:"connTimeout,omitempty" yaml:"conn_timeout,omitempty"` ExecTimeout int `json:"execTimeout,omitempty" yaml:"exec_timeout,omitempty"` DBFilterType string `json:"dbFilterType" yaml:"db_filter_type,omitempty"` DBFilterList []int `json:"dbFilterList" yaml:"db_filter_list,omitempty"` KeyView int `json:"keyView,omitempty" yaml:"key_view,omitempty"` LoadSize int `json:"loadSize,omitempty" yaml:"load_size,omitempty"` MarkColor string `json:"markColor,omitempty" yaml:"mark_color,omitempty"` RefreshInterval int `json:"refreshInterval,omitempty" yaml:"refresh_interval,omitempty"` Alias map[int]string `json:"alias,omitempty" yaml:"alias,omitempty"` SSL ConnectionSSL `json:"ssl,omitempty" yaml:"ssl,omitempty"` SSH ConnectionSSH `json:"ssh,omitempty" yaml:"ssh,omitempty"` Sentinel ConnectionSentinel `json:"sentinel,omitempty" yaml:"sentinel,omitempty"` Cluster ConnectionCluster `json:"cluster,omitempty" yaml:"cluster,omitempty"` Proxy ConnectionProxy `json:"proxy,omitempty" yaml:"proxy,omitempty"` } type Connection struct { ConnectionConfig `json:",inline" yaml:",inline"` Type string `json:"type,omitempty" yaml:"type,omitempty"` Connections []Connection `json:"connections,omitempty" yaml:"connections,omitempty"` } type Connections []Connection type ConnectionDB struct { Name string `json:"name"` Alias string `json:"alias,omitempty"` Index int `json:"index"` MaxKeys int `json:"maxKeys"` Expires int `json:"expires,omitempty"` AvgTTL int `json:"avgTtl,omitempty"` } type ConnectionSSL struct { Enable bool `json:"enable,omitempty" yaml:"enable,omitempty"` KeyFile string `json:"keyFile,omitempty" yaml:"keyfile,omitempty"` CertFile string `json:"certFile,omitempty" yaml:"certfile,omitempty"` CAFile string `json:"caFile,omitempty" yaml:"cafile,omitempty"` AllowInsecure bool `json:"allowInsecure,omitempty" yaml:"allow_insecure,omitempty"` SNI string `json:"sni,omitempty" yaml:"sni,omitempty"` } type ConnectionSSH struct { Enable bool `json:"enable,omitempty" yaml:"enable,omitempty"` Addr string `json:"addr,omitempty" yaml:"addr,omitempty"` Port int `json:"port,omitempty" yaml:"port,omitempty"` LoginType string `json:"loginType,omitempty" yaml:"login_type"` Username string `json:"username,omitempty" yaml:"username,omitempty"` Password string `json:"password,omitempty" yaml:"password,omitempty"` PKFile string `json:"pkFile,omitempty" yaml:"pk_file,omitempty"` Passphrase string `json:"passphrase,omitempty" yaml:"passphrase,omitempty"` } type ConnectionSentinel struct { Enable bool `json:"enable,omitempty" yaml:"enable,omitempty"` Master string `json:"master,omitempty" yaml:"master,omitempty"` Username string `json:"username,omitempty" yaml:"username,omitempty"` Password string `json:"password,omitempty" yaml:"password,omitempty"` } type ConnectionCluster struct { Enable bool `json:"enable,omitempty" yaml:"enable,omitempty"` } type ConnectionProxy struct { Type int `json:"type,omitempty" yaml:"type,omitempty"` Schema string `json:"schema,omitempty" yaml:"schema,omitempty"` Addr string `json:"addr,omitempty" yaml:"addr,omitempty"` Port int `json:"port,omitempty" yaml:"port,omitempty"` Username string `json:"username,omitempty" yaml:"username,omitempty"` Password string `json:"password,omitempty" yaml:"password,omitempty"` } ================================================ FILE: backend/types/js_resp.go ================================================ package types type JSResp struct { Success bool `json:"success"` Msg string `json:"msg"` Data any `json:"data,omitempty"` } type KeySummaryParam struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` } type KeySummary struct { Type string `json:"type"` TTL int64 `json:"ttl,omitempty"` Size int64 `json:"size,omitempty"` Length int64 `json:"length,omitempty"` } type KeyDetailParam struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` Format string `json:"format,omitempty"` Decode string `json:"decode,omitempty"` MatchPattern string `json:"matchPattern,omitempty"` Reset bool `json:"reset"` Full bool `json:"full"` } type KeyDetail struct { Value any `json:"value"` KeyType string `json:"key_type"` Length int64 `json:"length,omitempty"` Format string `json:"format,omitempty"` Decode string `json:"decode,omitempty"` Match string `json:"match,omitempty"` Reset bool `json:"reset"` End bool `json:"end"` } type SetKeyParam struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` KeyType string `json:"keyType"` Value any `json:"value"` TTL int64 `json:"ttl"` Format string `json:"format,omitempty"` Decode string `json:"decode,omitempty"` } type SetListParam struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` Index int `json:"index"` Value any `json:"value"` Format string `json:"format,omitempty"` Decode string `json:"decode,omitempty"` RetFormat string `json:"retFormat,omitempty"` RetDecode string `json:"retDecode,omitempty"` } type SetHashParam struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` Field string `json:"field,omitempty"` NewField string `json:"newField,omitempty"` Value any `json:"value"` Format string `json:"format,omitempty"` Decode string `json:"decode,omitempty"` RetFormat string `json:"retFormat,omitempty"` RetDecode string `json:"retDecode,omitempty"` } type SetSetParam struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` Value any `json:"value"` NewValue any `json:"newValue"` Format string `json:"format,omitempty"` Decode string `json:"decode,omitempty"` RetFormat string `json:"retFormat,omitempty"` RetDecode string `json:"retDecode,omitempty"` } type SetZSetParam struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` Value any `json:"value"` NewValue any `json:"newValue"` Score float64 `json:"score"` Format string `json:"format,omitempty"` Decode string `json:"decode,omitempty"` RetFormat string `json:"retFormat,omitempty"` RetDecode string `json:"retDecode,omitempty"` } type GetHashParam struct { Server string `json:"server"` DB int `json:"db"` Key any `json:"key"` Field string `json:"field,omitempty"` Format string `json:"format,omitempty"` Decode string `json:"decode,omitempty"` } ================================================ FILE: backend/types/preferences.go ================================================ package types import "tinyrdm/backend/consts" type Preferences struct { Behavior PreferencesBehavior `json:"behavior" yaml:"behavior"` General PreferencesGeneral `json:"general" yaml:"general"` Editor PreferencesEditor `json:"editor" yaml:"editor"` Cli PreferencesCli `json:"cli" yaml:"cli"` Decoder []PreferencesDecoder `json:"decoder" yaml:"decoder,omitempty"` } func NewPreferences() Preferences { return Preferences{ Behavior: PreferencesBehavior{ AsideWidth: consts.DEFAULT_ASIDE_WIDTH, WindowWidth: consts.DEFAULT_WINDOW_WIDTH, WindowHeight: consts.DEFAULT_WINDOW_HEIGHT, }, General: PreferencesGeneral{ Theme: "auto", Language: "auto", FontSize: consts.DEFAULT_FONT_SIZE, ScanSize: consts.DEFAULT_SCAN_SIZE, KeyIconStyle: 0, CheckUpdate: true, AllowTrack: true, }, Editor: PreferencesEditor{ FontSize: consts.DEFAULT_FONT_SIZE, ShowLineNum: true, ShowFolding: true, DropText: true, Links: true, EntryTextAlign: 0, }, Cli: PreferencesCli{ FontSize: consts.DEFAULT_FONT_SIZE, CursorStyle: "block", }, Decoder: []PreferencesDecoder{}, } } type PreferencesBehavior struct { Welcomed bool `json:"welcomed" yaml:"welcomed"` AsideWidth int `json:"asideWidth" yaml:"aside_width"` WindowWidth int `json:"windowWidth" yaml:"window_width"` WindowHeight int `json:"windowHeight" yaml:"window_height"` WindowMaximised bool `json:"windowMaximised" yaml:"window_maximised"` WindowPosX int `json:"windowPosX" yaml:"window_pos_x"` WindowPosY int `json:"windowPosY" yaml:"window_pos_y"` } type PreferencesGeneral struct { Theme string `json:"theme" yaml:"theme"` Language string `json:"language" yaml:"language"` Font string `json:"font" yaml:"font,omitempty"` FontFamily []string `json:"fontFamily" yaml:"font_family,omitempty"` FontSize int `json:"fontSize" yaml:"font_size"` ScanSize int `json:"scanSize" yaml:"scan_size"` KeyIconStyle int `json:"keyIconStyle" yaml:"key_icon_style"` UseSysProxy bool `json:"useSysProxy" yaml:"use_sys_proxy,omitempty"` UseSysProxyHttp bool `json:"useSysProxyHttp" yaml:"use_sys_proxy_http,omitempty"` CheckUpdate bool `json:"checkUpdate" yaml:"check_update"` SkipVersion string `json:"skipVersion" yaml:"skip_version,omitempty"` AllowTrack bool `json:"allowTrack" yaml:"allow_track"` } type PreferencesEditor struct { Font string `json:"font" yaml:"font,omitempty"` FontFamily []string `json:"fontFamily" yaml:"font_family,omitempty"` FontSize int `json:"fontSize" yaml:"font_size"` ShowLineNum bool `json:"showLineNum" yaml:"show_line_num"` ShowFolding bool `json:"showFolding" yaml:"show_folding"` DropText bool `json:"dropText" yaml:"drop_text"` Links bool `json:"links" yaml:"links"` EntryTextAlign int `json:"entryTextAlign" yaml:"entry_text_align"` } type PreferencesCli struct { FontFamily []string `json:"fontFamily" yaml:"font_family,omitempty"` FontSize int `json:"fontSize" yaml:"font_size"` CursorStyle string `json:"cursorStyle" yaml:"cursor_style,omitempty"` } type PreferencesDecoder struct { Name string `json:"name" yaml:"name"` Enable bool `json:"enable" yaml:"enable"` Auto bool `json:"auto" yaml:"auto"` DecodePath string `json:"decodePath" yaml:"decode_path"` DecodeArgs []string `json:"decodeArgs" yaml:"decode_args,omitempty"` EncodePath string `json:"encodePath" yaml:"encode_path"` EncodeArgs []string `json:"encodeArgs" yaml:"encode_args,omitempty"` } ================================================ FILE: backend/types/redis_wrapper.go ================================================ package types type ListEntryItem struct { Index int `json:"index"` Value any `json:"v"` DisplayValue string `json:"dv,omitempty"` } type ListReplaceItem struct { Index int `json:"index"` Value any `json:"v,omitempty"` DisplayValue string `json:"dv,omitempty"` } type HashEntryItem struct { Key string `json:"k"` Value any `json:"v"` DisplayValue string `json:"dv,omitempty"` } type HashReplaceItem struct { Key any `json:"k"` NewKey any `json:"nk"` Value any `json:"v"` DisplayValue string `json:"dv,omitempty"` } type SetEntryItem struct { Value any `json:"v"` DisplayValue string `json:"dv,omitempty"` } type ZSetEntryItem struct { Score float64 `json:"s"` ScoreStr string `json:"ss,omitempty"` Value any `json:"v"` DisplayValue string `json:"dv,omitempty"` } type ZSetReplaceItem struct { Score float64 `json:"s"` Value string `json:"v"` NewValue string `json:"nv"` DisplayValue string `json:"dv,omitempty"` } type StreamEntryItem struct { ID string `json:"id"` Value map[string]any `json:"v"` DisplayValue string `json:"dv,omitempty"` } ================================================ FILE: backend/types/view_type.go ================================================ package types const FORMAT_RAW = "Raw" const FORMAT_JSON = "JSON" const FORMAT_UNICODE_JSON = "Unicode JSON" const FORMAT_YAML = "YAML" const FORMAT_XML = "XML" const FORMAT_HEX = "Hex" const FORMAT_BINARY = "Binary" const FORMAT_BITSET = "BitSet" const DECODE_NONE = "None" const DECODE_BASE64 = "Base64" const DECODE_GZIP = "GZip" const DECODE_DEFLATE = "Deflate" const DECODE_ZSTD = "ZStd" const DECODE_LZ4 = "LZ4" const DECODE_BROTLI = "Brotli" const DECODE_MSGPACK = "Msgpack" const DECODE_PHP = "PHP" const DECODE_PICKLE = "Pickle" ================================================ FILE: backend/utils/coll/set.go ================================================ package coll import ( "encoding/json" "fmt" "sort" . "tinyrdm/backend/utils" ) type Void struct{} // Set 集合, 存放不重复的元素 type Set[T Hashable] map[T]Void // type Set[T Hashable] struct { // data map[T]Void // } func NewSet[T Hashable](elems ...T) Set[T] { if len(elems) > 0 { data := make(Set[T], len(elems)) for _, e := range elems { data[e] = Void{} } return data } else { return Set[T]{} } } // Add 添加元素 func (s Set[T]) Add(elem T) bool { if s == nil { return false } if _, exists := s[elem]; !exists { s[elem] = Void{} return true } return false } // AddN 添加多个元素 func (s Set[T]) AddN(elems ...T) int { if s == nil { return 0 } addCount := 0 var exists bool for _, elem := range elems { if _, exists = s[elem]; !exists { s[elem] = Void{} addCount += 1 } } return addCount } // Merge 合并其他集合 func (s Set[T]) Merge(other Set[T]) int { return s.AddN(other.ToSlice()...) } // Contains 判断是否存在指定元素 func (s Set[T]) Contains(elem T) bool { if s == nil { return false } _, exists := s[elem] return exists } // ContainAny 判断是否包含任意元素 func (s Set[T]) ContainAny(elems ...T) bool { if s == nil { return false } var exists bool for _, elem := range elems { if _, exists = s[elem]; exists { return true } } return false } // Equals 判断两个集合内元素是否一致 func (s Set[T]) Equals(other Set[T]) bool { if s.Size() != other.Size() { return false } for elem := range s { if !other.Contains(elem) { return false } } return true } // ContainAll 判断是否包含所有元素 func (s Set[T]) ContainAll(elems ...T) bool { if s == nil { return false } var exists bool for _, elem := range elems { if _, exists = s[elem]; !exists { return false } } return true } // Remove 移除元素 func (s Set[T]) Remove(elem T) bool { if s == nil { return false } if _, exists := s[elem]; exists { delete(s, elem) return true } return false } // RemoveN 移除多个元素 func (s Set[T]) RemoveN(elems ...T) int { if s == nil { return 0 } var exists bool removeCnt := 0 for _, elem := range elems { if _, exists = s[elem]; exists { delete(s, elem) removeCnt += 1 } } return removeCnt } // RemoveSub 移除子集 func (s Set[T]) RemoveSub(subSet Set[T]) int { if s == nil { return 0 } var exists bool removeCnt := 0 for elem := range subSet { if _, exists = s[elem]; exists { delete(s, elem) removeCnt += 1 } } return removeCnt } // Filter 根据条件筛出符合的元素 func (s Set[T]) Filter(filterFunc func(i T) bool) []T { ret := []T{} for v := range s { if filterFunc(v) { ret = append(ret, v) } } return ret } // Size 集合长度 func (s Set[T]) Size() int { return len(s) } // IsEmpty 判断是否为空 func (s Set[T]) IsEmpty() bool { return len(s) <= 0 } // Clear 清空集合 func (s Set[T]) Clear() { for elem := range s { delete(s, elem) } } // ToSlice 转为切片 func (s Set[T]) ToSlice() []T { size := len(s) if size <= 0 { return []T{} } ret := make([]T, 0, size) for elem := range s { ret = append(ret, elem) } return ret } // ToSortedSlice 转为排序好的切片 func (s Set[T]) ToSortedSlice(sortFunc func(v1, v2 T) bool) []T { list := s.ToSlice() sort.Slice(list, func(i, j int) bool { return sortFunc(list[i], list[j]) }) return list } // Each 遍历检索每个元素 func (s Set[T]) Each(eachFunc func(T)) { if len(s) <= 0 { return } for elem := range s { eachFunc(elem) } } // Clone 克隆 func (s Set[T]) Clone() Set[T] { if s == nil { return nil } other := NewSet[T]() for elem := range s { other[elem] = Void{} } return other } func (s Set[T]) String() string { arr := s.ToSlice() return fmt.Sprintf("%v", arr) } // MarshalJSON to output non base64 encoded []byte func (s Set[T]) MarshalJSON() ([]byte, error) { if s == nil { return []byte("null"), nil } t := s.ToSlice() return json.Marshal(t) } // UnmarshalJSON to deserialize []byte func (s *Set[T]) UnmarshalJSON(b []byte) error { t := []T{} err := json.Unmarshal(b, &t) if err != nil { *s = NewSet[T]() } else { *s = NewSet[T](t...) } return nil } // GormDataType gorm common data type func (s Set[T]) GormDataType() string { return "json" } ================================================ FILE: backend/utils/constraints.go ================================================ package utils type Hashable interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64 | ~string } type SignedNumber interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 } type UnsignedNumber interface { ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 } ================================================ FILE: backend/utils/convert/base64_convert.go ================================================ package convutil import ( "encoding/base64" strutil "tinyrdm/backend/utils/string" ) type Base64Convert struct{} func (Base64Convert) Enable() bool { return true } func (Base64Convert) Encode(str string) (string, bool) { return base64.StdEncoding.EncodeToString([]byte(str)), true } func (Base64Convert) Decode(str string) (string, bool) { if decodedStr, err := base64.StdEncoding.DecodeString(str); err == nil { if s := string(decodedStr); !strutil.ContainsBinary(s) { return s, true } } return str, false } ================================================ FILE: backend/utils/convert/binary_convert.go ================================================ package convutil import ( "fmt" "strconv" "strings" ) type BinaryConvert struct{} func (BinaryConvert) Enable() bool { return true } func (BinaryConvert) Encode(str string) (string, bool) { total := len(str) if total%8 != 0 { return str, false } var result strings.Builder for i := 0; i < total; i += 8 { b, err := strconv.ParseUint(str[i:i+8], 2, 8) if err != nil { return str, false } result.WriteByte(byte(b)) } return result.String(), true } func (BinaryConvert) Decode(str string) (string, bool) { var binary strings.Builder for _, char := range []byte(str) { binary.WriteString(fmt.Sprintf("%08b", int(char))) } return binary.String(), true } ================================================ FILE: backend/utils/convert/bitset_convert.go ================================================ package convutil import ( "fmt" "math" "strconv" "strings" ) type BitSetConvert struct{} func (BitSetConvert) Enable() bool { return true } func (BitSetConvert) Encode(str string) (string, bool) { var result strings.Builder str = strings.ReplaceAll(str, "\r\n", "\n") // CRLF → LF str = strings.ReplaceAll(str, "\r", "\n") // CR → LF lines := strings.Split(str, "\n") bytes := encodeToRedisBitset(lines) result.Write(bytes) return result.String(), true } func (BitSetConvert) Decode(str string) (string, bool) { bitset := getBitSet([]byte(str)) var binBuilder strings.Builder for pos, value := range bitset { if value { if binBuilder.Len() > 0 { binBuilder.WriteByte('\n') } binBuilder.WriteString(fmt.Sprintf("%d", pos)) } } return binBuilder.String(), true } // encodeToRedisBitset encodes a list of strings with integers (positions) into a Redis bitset byte array. // The bit at position 'n' will be set to 1 if n is in the input array. // The resulting byte slice can be stored in Redis using SET command. func encodeToRedisBitset(numbers []string) []byte { if len(numbers) == 0 { return []byte{} } // Find the maximum number to determine the required bit length and convert strings to numbers maxNum := uint64(0) var validNumbers []uint64 for _, s := range numbers { if s == "" { continue } num, err := strconv.ParseUint(s, 10, 64) if err != nil || num < 0 || num > math.MaxUint32 { fmt.Printf("Warning: skipping invalid number '%s': %v\n", s, err) continue } validNumbers = append(validNumbers, num) if num > maxNum { maxNum = num } } if len(validNumbers) == 0 { return []byte{} } // Calculate required byte length (8 bits per byte) byteLen := ((maxNum + 7) / 8) + 1 // Initialize byte array bitset := make([]byte, byteLen) // Set bits for each number for _, num := range validNumbers { byteIndex := num / 8 if byteIndex < byteLen { bitIndex := uint(num % 8) // Set the bit (big-endian bit order within byte) bitset[byteIndex] |= 1 << (7 - bitIndex) } } return bitset } func getBitSet(redisResponse []byte) []bool { bitset := make([]bool, len(redisResponse)*8) for i := range redisResponse { for j := 7; j >= 0; j-- { bitPos := uint(i*8 + (7 - j)) bitset[bitPos] = (redisResponse[i] & (1 << uint(j))) > 0 } } return bitset } ================================================ FILE: backend/utils/convert/brotli_convert.go ================================================ package convutil import ( "bytes" "io" "strings" "github.com/andybalholm/brotli" ) type BrotliConvert struct{} func (BrotliConvert) Enable() bool { return true } func (BrotliConvert) Encode(str string) (string, bool) { var compress = func(b []byte) (string, error) { var buf bytes.Buffer writer := brotli.NewWriter(&buf) if _, err := writer.Write([]byte(str)); err != nil { writer.Close() return "", err } writer.Close() return string(buf.Bytes()), nil } if brotliStr, err := compress([]byte(str)); err == nil { return brotliStr, true } return str, false } func (BrotliConvert) Decode(str string) (string, bool) { reader := brotli.NewReader(strings.NewReader(str)) if decompressed, err := io.ReadAll(reader); err == nil { return string(decompressed), true } return str, false } ================================================ FILE: backend/utils/convert/cmd_convert.go ================================================ package convutil import ( "encoding/base64" "strings" sliceutil "tinyrdm/backend/utils/slice" ) type CmdConvert struct { Name string Auto bool DecodePath string DecodeArgs []string EncodePath string EncodeArgs []string } const replaceholder = "{VALUE}" func (c CmdConvert) Enable() bool { return true } func (c CmdConvert) Encode(str string) (string, bool) { base64Content := base64.StdEncoding.EncodeToString([]byte(str)) var containHolder bool args := sliceutil.Map(c.EncodeArgs, func(i int) string { arg := strings.TrimSpace(c.EncodeArgs[i]) if strings.Contains(arg, replaceholder) { arg = strings.ReplaceAll(arg, replaceholder, base64Content) containHolder = true } return arg }) if len(args) <= 0 || !containHolder { args = append(args, base64Content) } output, err := runCommand(c.EncodePath, args...) if err != nil || len(output) <= 0 || string(output) == "[RDM-ERROR]" { return str, false } outputContent := make([]byte, base64.StdEncoding.DecodedLen(len(output))) n, err := base64.StdEncoding.Decode(outputContent, output) if err != nil { return str, false } return string(outputContent[:n]), true } func (c CmdConvert) Decode(str string) (string, bool) { base64Content := base64.StdEncoding.EncodeToString([]byte(str)) var containHolder bool args := sliceutil.Map(c.DecodeArgs, func(i int) string { arg := strings.TrimSpace(c.DecodeArgs[i]) if strings.Contains(arg, replaceholder) { arg = strings.ReplaceAll(arg, replaceholder, base64Content) containHolder = true } return arg }) if len(args) <= 0 || !containHolder { args = append(args, base64Content) } output, err := runCommand(c.DecodePath, args...) if err != nil || len(output) <= 0 || string(output) == "[RDM-ERROR]" { return str, false } outputContent := make([]byte, base64.StdEncoding.DecodedLen(len(output))) n, err := base64.StdEncoding.Decode(outputContent, output) if err != nil { return str, false } return string(outputContent[:n]), true } ================================================ FILE: backend/utils/convert/common.go ================================================ package convutil import ( "os" "path" "tinyrdm/backend/consts" "github.com/vrischmann/userdir" ) func writeExecuteFile(content []byte, filename string) (string, error) { filepath := path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, "decoder", filename) _ = os.Mkdir(path.Dir(filepath), 0777) err := os.WriteFile(filepath, content, 0777) if err != nil { return "", err } return filepath, nil } ================================================ FILE: backend/utils/convert/common_nonwindows.go ================================================ //go:build !windows package convutil import ( "os/exec" ) func runCommand(name string, arg ...string) ([]byte, error) { cmd := exec.Command(name, arg...) return cmd.Output() } ================================================ FILE: backend/utils/convert/common_windows.go ================================================ //go:build windows package convutil import ( "os/exec" "syscall" ) func runCommand(name string, arg ...string) ([]byte, error) { cmd := exec.Command(name, arg...) cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} return cmd.Output() } ================================================ FILE: backend/utils/convert/convert.go ================================================ package convutil import ( "errors" "regexp" "tinyrdm/backend/types" strutil "tinyrdm/backend/utils/string" ) type DataConvert interface { Enable() bool Encode(string) (string, bool) Decode(string) (string, bool) } var ( jsonConv JsonConvert uniJsonConv UnicodeJsonConvert yamlConv YamlConvert xmlConv XmlConvert base64Conv Base64Convert binaryConv BinaryConvert bitSetConv BitSetConvert hexConv HexConvert gzipConv GZipConvert deflateConv DeflateConvert zstdConv ZStdConvert lz4Conv LZ4Convert brotliConv BrotliConvert msgpackConv MsgpackConvert phpConv = NewPhpConvert() pickleConv = NewPickleConvert() ) var BuildInFormatters = map[string]DataConvert{ types.FORMAT_JSON: jsonConv, types.FORMAT_UNICODE_JSON: uniJsonConv, types.FORMAT_YAML: yamlConv, types.FORMAT_XML: xmlConv, types.FORMAT_HEX: hexConv, types.FORMAT_BINARY: binaryConv, types.FORMAT_BITSET: bitSetConv, } var BuildInDecoders = map[string]DataConvert{ types.DECODE_BASE64: base64Conv, types.DECODE_GZIP: gzipConv, types.DECODE_DEFLATE: deflateConv, types.DECODE_ZSTD: zstdConv, types.DECODE_LZ4: lz4Conv, types.DECODE_BROTLI: brotliConv, types.DECODE_MSGPACK: msgpackConv, types.DECODE_PHP: phpConv, types.DECODE_PICKLE: pickleConv, } // ConvertTo convert string to specified type // @param decodeType empty string indicates automatic detection // @param formatType empty string indicates automatic detection // @param custom decoder if any func ConvertTo(str, decodeType, formatType string, customDecoder []CmdConvert) (value, resultDecode, resultFormat string) { if len(str) <= 0 { // empty content if len(formatType) <= 0 { resultFormat = types.FORMAT_RAW } else { resultFormat = formatType } if len(decodeType) <= 0 { resultDecode = types.DECODE_NONE } else { resultDecode = decodeType } return } // decode first value, resultDecode = decodeWith(str, decodeType, customDecoder) // then format content if len(formatType) <= 0 { value, resultFormat = autoViewAs(value) } else { value, resultFormat = viewAs(value, formatType) } return } func decodeWith(str, decodeType string, customDecoder []CmdConvert) (value, resultDecode string) { if len(decodeType) > 0 { value = str if buildinDecoder, ok := BuildInDecoders[decodeType]; ok { if decodedStr, ok := buildinDecoder.Decode(str); ok { value = decodedStr } } else if decodeType != types.DECODE_NONE { for _, decoder := range customDecoder { if decoder.Name == decodeType { if decodedStr, ok := decoder.Decode(str); ok { value = decodedStr } break } } } resultDecode = decodeType return } value, resultDecode = autoDecode(str, customDecoder) return } // attempt try possible decode method // if no decode is possible, it will return the origin string value and "none" decode type func autoDecode(str string, customDecoder []CmdConvert) (value, resultDecode string) { if len(str) > 0 { // pure digit content may incorrect regard as some encoded type, skip decode if match, _ := regexp.MatchString(`^\d+$`, str); !match { var ok bool if len(str)%4 == 0 && len(str) >= 12 && !strutil.IsSameChar(str) { if value, ok = base64Conv.Decode(str); ok { resultDecode = types.DECODE_BASE64 return } } if value, ok = gzipConv.Decode(str); ok { resultDecode = types.DECODE_GZIP return } // FIXME: skip decompress with deflate due to incorrect format checking //if value, ok = decodeDeflate(str); ok { // resultDecode = types.DECODE_DEFLATE // return //} if value, ok = zstdConv.Decode(str); ok { resultDecode = types.DECODE_ZSTD return } if value, ok = lz4Conv.Decode(str); ok { resultDecode = types.DECODE_LZ4 return } // FIXME: skip decompress with brotli due to incorrect format checking //if value, ok = decodeBrotli(str); ok { // resultDecode = types.DECODE_BROTLI // return //} if value, ok = msgpackConv.Decode(str); ok { resultDecode = types.DECODE_MSGPACK return } if value, ok = phpConv.Decode(str); ok { resultDecode = types.DECODE_PHP return } if value, ok = pickleConv.Decode(str); ok { resultDecode = types.DECODE_PICKLE return } // try decode with custom decoder for _, decoder := range customDecoder { if decoder.Auto { if value, ok = decoder.Decode(str); ok { resultDecode = decoder.Name return } } } } } value = str resultDecode = types.DECODE_NONE return } func viewAs(str, formatType string) (value, resultFormat string) { if len(formatType) > 0 { value = str if buildinFormatter, ok := BuildInFormatters[formatType]; ok { if formattedStr, ok := buildinFormatter.Decode(str); ok { value = formattedStr } } resultFormat = formatType return } return } // attempt automatic convert to possible types // if no conversion is possible, it will return the origin string value and "plain text" type func autoViewAs(str string) (value, resultFormat string) { if len(str) > 0 { var ok bool if value, ok = jsonConv.Decode(str); ok { resultFormat = types.FORMAT_JSON return } if value, ok = yamlConv.Decode(str); ok { resultFormat = types.FORMAT_YAML return } if value, ok = xmlConv.Decode(str); ok { resultFormat = types.FORMAT_XML return } if strutil.ContainsBinary(str) { if value, ok = hexConv.Decode(str); ok { resultFormat = types.FORMAT_HEX return } } } value = str resultFormat = types.FORMAT_RAW return } func SaveAs(str, format, decode string, customDecoder []CmdConvert) (value string, err error) { value = str if buildingFormatter, ok := BuildInFormatters[format]; ok { if formattedStr, ok := buildingFormatter.Encode(str); ok { value = formattedStr } else { err = errors.New("invalid " + format + " data") return } } if buildinDecoder, ok := BuildInDecoders[decode]; ok { if encodedValue, ok := buildinDecoder.Encode(str); ok { value = encodedValue } else { err = errors.New("fail to build " + decode) } return } else if decode != types.DECODE_NONE { for _, decoder := range customDecoder { if decoder.Name == decode { if encodedStr, ok := decoder.Encode(str); ok { value = encodedStr } else { err = errors.New("fail to build " + decode) } return } } } return } ================================================ FILE: backend/utils/convert/deflate_convert.go ================================================ package convutil import ( "bytes" "io" "strings" "github.com/klauspost/compress/flate" ) type DeflateConvert struct{} func (d DeflateConvert) Enable() bool { return true } func (d DeflateConvert) Encode(str string) (string, bool) { var compress = func(b []byte) (string, error) { var buf bytes.Buffer writer, err := flate.NewWriter(&buf, flate.DefaultCompression) if err != nil { return "", err } if _, err = writer.Write([]byte(str)); err != nil { writer.Close() return "", err } writer.Close() return string(buf.Bytes()), nil } if deflateStr, err := compress([]byte(str)); err == nil { return deflateStr, true } return str, false } func (d DeflateConvert) Decode(str string) (string, bool) { reader := flate.NewReader(strings.NewReader(str)) defer reader.Close() if decompressed, err := io.ReadAll(reader); err == nil { return string(decompressed), true } return str, false } ================================================ FILE: backend/utils/convert/gzip_convert.go ================================================ package convutil import ( "bytes" "io" "strings" "github.com/klauspost/compress/gzip" ) type GZipConvert struct{} func (GZipConvert) Enable() bool { return true } func (GZipConvert) Encode(str string) (string, bool) { var compress = func(b []byte) (string, error) { var buf bytes.Buffer writer := gzip.NewWriter(&buf) if _, err := writer.Write([]byte(str)); err != nil { writer.Close() return "", err } writer.Close() return string(buf.Bytes()), nil } if gzipStr, err := compress([]byte(str)); err == nil { return gzipStr, true } return str, false } func (GZipConvert) Decode(str string) (string, bool) { if reader, err := gzip.NewReader(strings.NewReader(str)); err == nil { defer reader.Close() var decompressed []byte if decompressed, err = io.ReadAll(reader); err == nil { return string(decompressed), true } } return str, false } ================================================ FILE: backend/utils/convert/hex_convert.go ================================================ package convutil import ( "encoding/hex" "strings" ) type HexConvert struct{} func (HexConvert) Enable() bool { return true } func (HexConvert) Encode(str string) (string, bool) { hexStrArr := strings.Split(str, "\\x") hexStr := strings.Join(hexStrArr, "") if decodeStr, err := hex.DecodeString(hexStr); err == nil { return string(decodeStr), true } return str, false } func (HexConvert) Decode(str string) (string, bool) { decodeStr := hex.EncodeToString([]byte(str)) decodeStr = strings.ToUpper(decodeStr) var resultStr strings.Builder for i := 0; i < len(decodeStr); i += 2 { resultStr.WriteString("\\x") resultStr.WriteString(decodeStr[i : i+2]) } return resultStr.String(), true } ================================================ FILE: backend/utils/convert/json_convert.go ================================================ package convutil import ( "strings" strutil "tinyrdm/backend/utils/string" ) type JsonConvert struct{} func (JsonConvert) Enable() bool { return true } func (JsonConvert) Decode(str string) (string, bool) { trimedStr := strings.TrimSpace(str) if (strings.HasPrefix(trimedStr, "{") && strings.HasSuffix(trimedStr, "}")) || (strings.HasPrefix(trimedStr, "[") && strings.HasSuffix(trimedStr, "]")) { return strutil.JSONBeautify(trimedStr, " "), true } return str, false } func (JsonConvert) Encode(str string) (string, bool) { return strutil.JSONMinify(str), true } ================================================ FILE: backend/utils/convert/lz4_convert.go ================================================ package convutil import ( "bytes" "io" "github.com/pierrec/lz4/v4" ) type LZ4Convert struct{} func (LZ4Convert) Enable() bool { return true } func (LZ4Convert) Encode(str string) (string, bool) { var compress = func(b []byte) (string, error) { var buf bytes.Buffer writer := lz4.NewWriter(&buf) if _, err := writer.Write([]byte(str)); err != nil { writer.Close() return "", err } writer.Close() return string(buf.Bytes()), nil } if gzipStr, err := compress([]byte(str)); err == nil { return gzipStr, true } return str, false } func (LZ4Convert) Decode(str string) (string, bool) { reader := lz4.NewReader(bytes.NewReader([]byte(str))) if decompressed, err := io.ReadAll(reader); err == nil { return string(decompressed), true } return str, false } ================================================ FILE: backend/utils/convert/msgpack_convert.go ================================================ package convutil import ( "encoding/json" "github.com/vmihailenco/msgpack/v5" ) type MsgpackConvert struct{} func (MsgpackConvert) Enable() bool { return true } func (c MsgpackConvert) Encode(str string) (string, bool) { var obj map[string]any if err := json.Unmarshal([]byte(str), &obj); err == nil { for k, v := range obj { obj[k] = c.TryFloatToInt(v) } if b, err := msgpack.Marshal(obj); err == nil { return string(b), true } } if b, err := msgpack.Marshal(str); err != nil { return string(b), true } return str, false } func (MsgpackConvert) Decode(str string) (string, bool) { var decodedStr string if err := msgpack.Unmarshal([]byte(str), &decodedStr); err == nil { return decodedStr, true } var obj map[string]any if err := msgpack.Unmarshal([]byte(str), &obj); err == nil { if b, err := json.Marshal(obj); err == nil { if len(b) >= 10 { return string(b), true } } } var arr []any if err := msgpack.Unmarshal([]byte(str), &arr); err == nil { if b, err := json.Marshal(arr); err == nil { if len(b) >= 10 { return string(b), true } } } return str, false } func (c MsgpackConvert) TryFloatToInt(input any) any { switch val := input.(type) { case map[string]any: for k, v := range val { val[k] = c.TryFloatToInt(v) } return val case []any: for i, v := range val { val[i] = c.TryFloatToInt(v) } return val case float64: if val == float64(int(val)) { return int(val) } return val default: return val } } ================================================ FILE: backend/utils/convert/php_convert.go ================================================ package convutil import ( "os/exec" ) type PhpConvert struct { CmdConvert } const phpDecodeCode = ` = 3: action = sys.argv[1].lower() content = sys.argv[2] try: if action == 'decode': decoded = base64.b64decode(content) obj = pickle.loads(decoded) unserialized = json.dumps(obj, ensure_ascii=False, default=default_serializer) print(base64.b64encode(unserialized.encode('utf-8')).decode('utf-8')) elif action == 'encode': decoded = base64.b64decode(content) obj = json.loads(decoded, object_hook=object_hook) serialized = pickle.dumps(obj) print(base64.b64encode(serialized).decode('utf-8')) except: print('[RDM-ERROR]') else: print('[RDM-ERROR]') ` func NewPickleConvert() *PickleConvert { c := CmdConvert{ Name: "Pickle", Auto: true, } c.DecodePath, c.EncodePath = "python3", "python3" var err error if _, err = exec.LookPath(c.DecodePath); err != nil { c.DecodePath, c.EncodePath = "python", "python" if _, err = exec.LookPath(c.DecodePath); err != nil { return nil } } // check if pickle available if runtime.GOOS == "darwin" { // the xcode-select installation prompt may appear on macOS // so check it manually in advance if _, err = exec.LookPath("xcode-select"); err != nil { return nil } } if _, err = runCommand(c.DecodePath, "-c", "import pickle"); err != nil { return nil } var filepath string if filepath, err = writeExecuteFile([]byte(pickleDecodeCode), "pickle_decoder.py"); err != nil { return nil } c.DecodeArgs = []string{filepath, "decode"} c.EncodeArgs = []string{filepath, "encode"} return &PickleConvert{ CmdConvert: c, } } func (p *PickleConvert) Enable() bool { if p == nil { return false } return true } func (p *PickleConvert) Encode(str string) (string, bool) { if !p.Enable() { return str, false } return p.CmdConvert.Encode(str) } func (p *PickleConvert) Decode(str string) (string, bool) { if !p.Enable() { return str, false } return p.CmdConvert.Decode(str) } ================================================ FILE: backend/utils/convert/unicode_json_convert.go ================================================ package convutil import ( "bytes" "strconv" "strings" strutil "tinyrdm/backend/utils/string" "unicode" "unicode/utf16" "unicode/utf8" ) type UnicodeJsonConvert struct{} func (UnicodeJsonConvert) Enable() bool { return true } func (UnicodeJsonConvert) Decode(str string) (string, bool) { trimedStr := strings.TrimSpace(str) if (strings.HasPrefix(trimedStr, "{") && strings.HasSuffix(trimedStr, "}")) || (strings.HasPrefix(trimedStr, "[") && strings.HasSuffix(trimedStr, "]")) { resultStr := strutil.JSONBeautify(trimedStr, " ") if quoteStr, ok := UnquoteUnicodeJson([]byte(resultStr)); ok { return string(quoteStr), true } } return str, false } func (UnicodeJsonConvert) Encode(str string) (string, bool) { return strutil.JSONMinify(str), true } func UnquoteUnicodeJson(s []byte) ([]byte, bool) { var unquoted bytes.Buffer r := 0 ls := len(s) for r < ls { c := s[r] offset := 1 if c == '"' { // find next '"' for ; r+offset < ls; offset++ { if s[r+offset] == '"' && s[r+offset-1] != '\\' { offset += 1 if ub, ok := unquoteBytes(s[r : r+offset]); ok { unquoted.WriteString(strconv.Quote(string(ub))) } else { return nil, false } break } } // can not find close '"' until reach to the end of content if r+offset >= ls { return nil, false } } else { unquoted.WriteByte(c) } r += offset } return unquoted.Bytes(), true } func getu4(s []byte) rune { if len(s) < 6 || s[0] != '\\' || s[1] != 'u' { return -1 } var r rune for _, c := range s[2:6] { switch { case '0' <= c && c <= '9': c = c - '0' case 'a' <= c && c <= 'f': c = c - 'a' + 10 case 'A' <= c && c <= 'F': c = c - 'A' + 10 default: return -1 } r = r*16 + rune(c) } return r } func unquoteBytes(s []byte) (t []byte, ok bool) { if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' { return } s = s[1 : len(s)-1] // Check for unusual characters. If there are none, // then no unquoting is needed, so return a slice of the // original bytes. r := 0 for r < len(s) { c := s[r] if c == '\\' || c == '"' || c < ' ' { break } if c < utf8.RuneSelf { r++ continue } rr, size := utf8.DecodeRune(s[r:]) if rr == utf8.RuneError && size == 1 { break } r += size } if r == len(s) { return s, true } b := make([]byte, len(s)+2*utf8.UTFMax) w := copy(b, s[0:r]) for r < len(s) { // Out of room? Can only happen if s is full of // malformed UTF-8 and we're replacing each // byte with RuneError. if w >= len(b)-2*utf8.UTFMax { nb := make([]byte, (len(b)+utf8.UTFMax)*2) copy(nb, b[0:w]) b = nb } switch c := s[r]; { case c == '\\': r++ if r >= len(s) { return } switch s[r] { default: return case '"', '\\', '/', '\'': b[w] = s[r] r++ w++ case 'b': b[w] = '\b' r++ w++ case 'f': b[w] = '\f' r++ w++ case 'n': b[w] = '\n' r++ w++ case 'r': b[w] = '\r' r++ w++ case 't': b[w] = '\t' r++ w++ case 'u': r-- rr := getu4(s[r:]) if rr < 0 { return } r += 6 if utf16.IsSurrogate(rr) { rr1 := getu4(s[r:]) if dec := utf16.DecodeRune(rr, rr1); dec != unicode.ReplacementChar { // A valid pair; consume. r += 6 w += utf8.EncodeRune(b[w:], dec) break } // Invalid surrogate; fall back to replacement rune. rr = unicode.ReplacementChar } w += utf8.EncodeRune(b[w:], rr) } // Quote, control characters are invalid. case c == '"', c < ' ': return // ASCII case c < utf8.RuneSelf: b[w] = c r++ w++ // Coerce to well-formed UTF-8. default: rr, size := utf8.DecodeRune(s[r:]) r += size w += utf8.EncodeRune(b[w:], rr) } } return b[0:w], true } ================================================ FILE: backend/utils/convert/xml_convert.go ================================================ package convutil import ( "encoding/xml" "strings" ) type XmlConvert struct{} func (XmlConvert) Enable() bool { return true } func (XmlConvert) Encode(str string) (string, bool) { return str, true } func (XmlConvert) Decode(str string) (string, bool) { trimedStr := strings.TrimSpace(str) if !strings.HasPrefix(trimedStr, "<") && !strings.HasSuffix(trimedStr, ">") { return str, false } var obj any err := xml.Unmarshal([]byte(trimedStr), &obj) return str, err == nil } ================================================ FILE: backend/utils/convert/yaml_convert.go ================================================ package convutil import ( "gopkg.in/yaml.v3" ) type YamlConvert struct{} func (YamlConvert) Enable() bool { return true } func (YamlConvert) Encode(str string) (string, bool) { return str, true } func (YamlConvert) Decode(str string) (string, bool) { var obj map[string]any err := yaml.Unmarshal([]byte(str), &obj) return str, err == nil } ================================================ FILE: backend/utils/convert/zstd_convert.go ================================================ package convutil import ( "bytes" "io" "strings" "github.com/klauspost/compress/zstd" ) type ZStdConvert struct{} func (ZStdConvert) Enable() bool { return true } func (ZStdConvert) Encode(str string) (string, bool) { var compress = func(b []byte) (string, error) { var buf bytes.Buffer writer, err := zstd.NewWriter(&buf) if err != nil { return "", err } if _, err = writer.Write([]byte(str)); err != nil { writer.Close() return "", err } writer.Close() return string(buf.Bytes()), nil } if zstdStr, err := compress([]byte(str)); err == nil { return zstdStr, true } return str, false } func (ZStdConvert) Decode(str string) (string, bool) { if reader, err := zstd.NewReader(strings.NewReader(str)); err == nil { defer reader.Close() if decompressed, err := io.ReadAll(reader); err == nil { return string(decompressed), true } } return str, false } ================================================ FILE: backend/utils/map/map_util.go ================================================ package maputil import ( . "tinyrdm/backend/utils" "tinyrdm/backend/utils/coll" ) // Get 获取键值对指定键的值, 如果不存在则返回自定默认值 func Get[M ~map[K]V, K Hashable, V any](m M, key K, defaultVal V) V { if m != nil { if v, exists := m[key]; exists { return v } } return defaultVal } // ContainsKey 判断指定键是否存在 func ContainsKey[M ~map[K]V, K Hashable, V any](m M, key K) bool { if m == nil { return false } _, exists := m[key] return exists } // MustGet 获取键值对指定键的值, 如果不存在则调用给定的函数进行获取 func MustGet[M ~map[K]V, K Hashable, V any](m M, key K, getFunc func(K) V) V { if v, exists := m[key]; exists { return v } if getFunc != nil { return getFunc(key) } var defaultV V return defaultV } // Keys 获取键值对中所有键 func Keys[M ~map[K]V, K Hashable, V any](m M) []K { if len(m) <= 0 { return []K{} } keys := make([]K, len(m)) index := 0 for k := range m { keys[index] = k index += 1 } return keys } // KeySet 获取键值对中所有键集合 func KeySet[M ~map[K]V, K Hashable, V any](m M) coll.Set[K] { if len(m) <= 0 { return coll.NewSet[K]() } keySet := coll.NewSet[K]() for k := range m { keySet.Add(k) } return keySet } // Values 获取键值对中所有值 func Values[M ~map[K]V, K Hashable, V any](m M) []V { if len(m) <= 0 { return []V{} } values := make([]V, len(m)) index := 0 for _, v := range m { values[index] = v index += 1 } return values } // ValueSet 获取键值对中所有值集合 func ValueSet[M ~map[K]V, K Hashable, V Hashable](m M) coll.Set[V] { if len(m) <= 0 { return coll.NewSet[V]() } valueSet := coll.NewSet[V]() for _, v := range m { valueSet.Add(v) } return valueSet } // Fill 填充键值对 func Fill[M ~map[K]V, K Hashable, V any](dest M, src M) M { for k, v := range src { dest[k] = v } return dest } // Merge 合并键值对, 后续键值对有重复键的元素会覆盖旧元素 func Merge[M ~map[K]V, K Hashable, V any](mapArr ...M) M { result := make(M, len(mapArr)) for _, m := range mapArr { for k, v := range m { result[k] = v } } return result } // Omit 根据条件省略指定元素 func Omit[M ~map[K]V, K Hashable, V any](m M, omitFunc func(k K, v V) bool) (M, []K) { result := M{} var removedKeys []K for k, v := range m { if !omitFunc(k, v) { result[k] = v } else { removedKeys = append(removedKeys, k) } } return result, removedKeys } // OmitKeys 省略指定键的的元素 func OmitKeys[M ~map[K]V, K Hashable, V any](m M, keys ...K) M { omitKey := map[K]struct{}{} for _, k := range keys { omitKey[k] = struct{}{} } result := M{} var exists bool for k, v := range m { if _, exists = omitKey[k]; !exists { result[k] = v } } return result } // ContainsAnyKey 是否包含任意键 func ContainsAnyKey[M ~map[K]V, K Hashable, V any](m M, keys ...K) bool { var exists bool for _, key := range keys { if _, exists = m[key]; exists { return true } } return false } // ContainsAllKey 是否包含所有键 func ContainsAllKey[M ~map[K]V, K Hashable, V any](m M, keys ...K) bool { var exists bool for _, key := range keys { if _, exists = m[key]; !exists { return false } } return true } // AnyMatch 是否任意元素符合条件 func AnyMatch[M ~map[K]V, K Hashable, V any](m M, matchFunc func(k K, v V) bool) bool { for k, v := range m { if matchFunc(k, v) { return true } } return false } // AllMatch 是否所有元素符合条件 func AllMatch[M ~map[K]V, K Hashable, V any](m M, matchFunc func(k K, v V) bool) bool { for k, v := range m { if !matchFunc(k, v) { return false } } return true } // Reduce 累计 func Reduce[M ~map[K]V, K Hashable, V any, R any](m M, init R, reduceFunc func(R, K, V) R) R { result := init for k, v := range m { result = reduceFunc(result, k, v) } return result } // ToSlice 键值对转切片 func ToSlice[M ~map[K]V, K Hashable, V any, R any](m M, mapFunc func(k K) R) []R { ret := make([]R, 0, len(m)) for k := range m { ret = append(ret, mapFunc(k)) } return ret } // Filter 筛选出指定条件的所有元素 func Filter[M ~map[K]V, K Hashable, V any](m M, filterFunc func(k K) bool) M { ret := make(M, len(m)) for k, v := range m { if filterFunc(k) { ret[k] = v } } return ret } // FilterToSlice 键值对筛选并转切片 func FilterToSlice[M ~map[K]V, K Hashable, V any, R any](m M, mapFunc func(k K) (R, bool)) []R { ret := make([]R, 0, len(m)) for k := range m { if v, filter := mapFunc(k); filter { ret = append(ret, v) } } return ret } // FilterKey 筛选出指定条件的所有键 func FilterKey[M ~map[K]V, K Hashable, V any](m M, filterFunc func(k K) bool) []K { ret := make([]K, 0, len(m)) for k := range m { if filterFunc(k) { ret = append(ret, k) } } return ret } // Clone 复制键值对 func Clone[M ~map[K]V, K Hashable, V any](src M) M { dest := make(M, len(src)) for k, v := range src { dest[k] = v } return dest } // Reverse 键->值映射翻转为值->键映射(如果重复则覆盖最后的) func Reverse[M ~map[K]V, K Hashable, V Hashable](src M) map[V]K { dest := make(map[V]K, len(src)) for k, v := range src { dest[v] = k } return dest } // ReverseAll 键->值映射翻转为值->键列表映射 func ReverseAll[M ~map[K]V, K Hashable, V Hashable](src M) map[V][]K { dest := make(map[V][]K, len(src)) for k, v := range src { dest[v] = append(dest[v], k) } return dest } // RemoveIf 移除指定条件的键 func RemoveIf[M ~map[K]V, K Hashable, V any](src M, cond func(key K) bool) { for k := range src { if cond(k) { delete(src, k) } } } ================================================ FILE: backend/utils/math/math_util.go ================================================ package mathutil import ( "math" . "tinyrdm/backend/utils" ) // MaxWithIndex 查找所有元素中的最大值 func MaxWithIndex[T Hashable](items ...T) (T, int) { selIndex := -1 for i, t := range items { if selIndex < 0 { selIndex = i } else { if t > items[selIndex] { selIndex = i } } } return items[selIndex], selIndex } // MinWithIndex 查找所有元素中的最小值 func MinWithIndex[T Hashable](items ...T) (T, int) { selIndex := -1 for i, t := range items { if selIndex < 0 { selIndex = i } else { if t < items[selIndex] { selIndex = i } } } return items[selIndex], selIndex } // Clamp 返回限制在minVal和maxVal范围内的value func Clamp[T Hashable](value T, minVal T, maxVal T) T { if minVal > maxVal { minVal, maxVal = maxVal, minVal } if value < minVal { value = minVal } else if value > maxVal { value = maxVal } return value } // Abs 计算绝对值 func Abs[T SignedNumber](val T) T { return T(math.Abs(float64(val))) } // Floor 向下取整 func Floor[T SignedNumber | UnsignedNumber](val T) T { return T(math.Floor(float64(val))) } // Ceil 向上取整 func Ceil[T SignedNumber | UnsignedNumber](val T) T { return T(math.Ceil(float64(val))) } // Round 四舍五入取整 func Round[T SignedNumber | UnsignedNumber](val T) T { return T(math.Round(float64(val))) } // Sum 计算所有元素总和 func Sum[T SignedNumber | UnsignedNumber](items ...T) T { var sum T for _, item := range items { sum += item } return sum } // Average 计算所有元素的平均值 func Average[T SignedNumber | UnsignedNumber](items ...T) T { return Sum(items...) / T(len(items)) } ================================================ FILE: backend/utils/proxy/http.go ================================================ package proxy import ( "bufio" "fmt" "net" "net/http" "net/url" "time" "golang.org/x/net/proxy" ) type HttpProxy struct { scheme string // HTTP Proxy scheme host string // HTTP Proxy host or host:port auth *proxy.Auth // authentication forward proxy.Dialer // forwarding Dialer } func (p *HttpProxy) Dial(network, addr string) (net.Conn, error) { c, err := p.forward.Dial(network, p.host) if err != nil { return nil, err } err = c.SetDeadline(time.Now().Add(15 * time.Second)) if err != nil { return nil, err } reqUrl := &url.URL{ Scheme: "", Host: addr, } // create with CONNECT method req, err := http.NewRequest("CONNECT", reqUrl.String(), nil) if err != nil { c.Close() return nil, err } req.Close = false // authentication if p.auth != nil { req.SetBasicAuth(p.auth.User, p.auth.Password) req.Header.Add("Proxy-Authorization", req.Header.Get("Authorization")) } // send request err = req.Write(c) if err != nil { c.Close() return nil, err } res, err := http.ReadResponse(bufio.NewReader(c), req) if err != nil { res.Body.Close() c.Close() return nil, err } res.Body.Close() if res.StatusCode != http.StatusOK { c.Close() return nil, fmt.Errorf("proxy connection error: StatusCode[%d]", res.StatusCode) } return c, nil } func NewHttpProxyDialer(u *url.URL, forward proxy.Dialer) (proxy.Dialer, error) { var auth *proxy.Auth if u.User != nil { pwd, _ := u.User.Password() auth = &proxy.Auth{ User: u.User.Username(), Password: pwd, } } hp := &HttpProxy{ scheme: u.Scheme, host: u.Host, auth: auth, forward: forward, } return hp, nil } func init() { proxy.RegisterDialerType("http", NewHttpProxyDialer) proxy.RegisterDialerType("https", NewHttpProxyDialer) } ================================================ FILE: backend/utils/redis/log_hook.go ================================================ package redis import ( "context" "fmt" "log" "net" "strconv" "time" "github.com/redis/go-redis/v9" ) type execCallback func(string, int64) type LogHook struct { name string cmdExec execCallback } func NewHook(name string, cmdExec execCallback) *LogHook { return &LogHook{ name: name, cmdExec: cmdExec, } } func appendArg(b []byte, v interface{}) []byte { switch v := v.(type) { case nil: return append(b, ""...) case string: return append(b, []byte(v)...) case []byte: return append(b, v...) case int: return strconv.AppendInt(b, int64(v), 10) case int8: return strconv.AppendInt(b, int64(v), 10) case int16: return strconv.AppendInt(b, int64(v), 10) case int32: return strconv.AppendInt(b, int64(v), 10) case int64: return strconv.AppendInt(b, v, 10) case uint: return strconv.AppendUint(b, uint64(v), 10) case uint8: return strconv.AppendUint(b, uint64(v), 10) case uint16: return strconv.AppendUint(b, uint64(v), 10) case uint32: return strconv.AppendUint(b, uint64(v), 10) case uint64: return strconv.AppendUint(b, v, 10) case float32: return strconv.AppendFloat(b, float64(v), 'f', -1, 64) case float64: return strconv.AppendFloat(b, v, 'f', -1, 64) case bool: if v { return append(b, "true"...) } return append(b, "false"...) case time.Time: return v.AppendFormat(b, time.RFC3339Nano) default: return append(b, fmt.Sprint(v)...) } } func (l *LogHook) DialHook(next redis.DialHook) redis.DialHook { return func(ctx context.Context, network, addr string) (net.Conn, error) { return next(ctx, network, addr) } } func (l *LogHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook { return func(ctx context.Context, cmd redis.Cmder) error { t := time.Now() err := next(ctx, cmd) b := make([]byte, 0, 64) for i, arg := range cmd.Args() { if i > 0 { b = append(b, ' ') } b = appendArg(b, arg) } log.Println(string(b)) if l.cmdExec != nil { l.cmdExec(string(b), time.Since(t).Milliseconds()) } return err } } func (l *LogHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook { return func(ctx context.Context, cmds []redis.Cmder) error { t := time.Now() err := next(ctx, cmds) cost := time.Since(t).Milliseconds() b := make([]byte, 0, 64) for i, cmd := range cmds { log.Println("pipeline: ", cmd) if l.cmdExec != nil { for i, arg := range cmd.Args() { if i > 0 { b = append(b, ' ') } b = appendArg(b, arg) } if i != len(cmds) { b = append(b, '\n') } } } if l.cmdExec != nil { l.cmdExec(string(b), cost) } return err } } ================================================ FILE: backend/utils/slice/slice_util.go ================================================ package sliceutil import ( "strings" . "tinyrdm/backend/utils" ) // Map map items to new array func Map[S ~[]T, T any, R any](arr S, mappingFunc func(int) R) []R { total := len(arr) result := make([]R, total) for i := 0; i < total; i++ { result[i] = mappingFunc(i) } return result } // FilterMap filter and map items to new array func FilterMap[S ~[]T, T any, R any](arr S, mappingFunc func(int) (R, bool)) []R { total := len(arr) result := make([]R, 0, total) var filter bool var mapItem R for i := 0; i < total; i++ { if mapItem, filter = mappingFunc(i); filter { result = append(result, mapItem) } } return result } // Join join any array to a single string by custom function func Join[S ~[]T, T any](arr S, sep string, toStringFunc func(int) string) string { total := len(arr) if total <= 0 { return "" } if total == 1 { return toStringFunc(0) } sb := strings.Builder{} for i := 0; i < total; i++ { if i != 0 { sb.WriteString(sep) } sb.WriteString(toStringFunc(i)) } return sb.String() } // JoinString join string array to a single string func JoinString(arr []string, sep string) string { return Join(arr, sep, func(idx int) string { return arr[idx] }) } // Unique filter unique item func Unique[S ~[]T, T Hashable](arr S) S { result := make(S, 0, len(arr)) uniKeys := map[T]struct{}{} var exists bool for _, item := range arr { if _, exists = uniKeys[item]; !exists { uniKeys[item] = struct{}{} result = append(result, item) } } return result } ================================================ FILE: backend/utils/string/any_convert.go ================================================ package strutil import ( "encoding/json" "strconv" "strings" sliceutil "tinyrdm/backend/utils/slice" ) func AnyToString(value interface{}, prefix string, layer int) (s string) { if value == nil { return } switch value.(type) { case float64: ft := value.(float64) s = strconv.FormatFloat(ft, 'f', -1, 64) case float32: ft := value.(float32) s = strconv.FormatFloat(float64(ft), 'f', -1, 64) case int: it := value.(int) s = strconv.Itoa(it) case uint: it := value.(uint) s = strconv.Itoa(int(it)) case int8: it := value.(int8) s = strconv.Itoa(int(it)) case uint8: it := value.(uint8) s = strconv.Itoa(int(it)) case int16: it := value.(int16) s = strconv.Itoa(int(it)) case uint16: it := value.(uint16) s = strconv.Itoa(int(it)) case int32: it := value.(int32) s = strconv.Itoa(int(it)) case uint32: it := value.(uint32) s = strconv.Itoa(int(it)) case int64: it := value.(int64) s = strconv.FormatInt(it, 10) case uint64: it := value.(uint64) s = strconv.FormatUint(it, 10) case string: if layer > 0 { s = "\"" + value.(string) + "\"" } else { s = value.(string) } case bool: val, _ := value.(bool) if val { s = "True" } else { s = "False" } case []byte: s = prefix + string(value.([]byte)) case []string: ss := value.([]string) anyStr := sliceutil.Map(ss, func(i int) string { str := AnyToString(ss[i], prefix, layer+1) return prefix + strconv.Itoa(i+1) + ") " + str }) s = prefix + sliceutil.JoinString(anyStr, "\r\n") case []any: as := value.([]any) anyItems := sliceutil.Map(as, func(i int) string { str := AnyToString(as[i], prefix, layer+1) return prefix + strconv.Itoa(i+1) + ") " + str }) s = sliceutil.JoinString(anyItems, "\r\n") case map[any]any: am := value.(map[any]any) var items []string index := 0 for k, v := range am { kk := prefix + strconv.Itoa(index+1) + ") " + AnyToString(k, prefix, layer+1) vv := prefix + strconv.Itoa(index+2) + ") " + AnyToString(v, "\t", layer+1) if layer > 0 { indent := layer if index == 0 { indent -= 1 } for i := 0; i < indent; i++ { vv = " " + vv } } index += 2 items = append(items, kk, vv) } s = sliceutil.JoinString(items, "\r\n") default: b, _ := json.Marshal(value) s = prefix + string(b) } return } //func AnyToHex(val any) (string, bool) { // var src string // switch val.(type) { // case string: // src = val.(string) // case []byte: // src = string(val.([]byte)) // } // // if len(src) <= 0 { // return "", false // } // // var output strings.Builder // for i := range src { // if !utf8.ValidString(src[i : i+1]) { // output.WriteString(fmt.Sprintf("\\x%02x", src[i:i+1])) // } else { // output.WriteString(src[i : i+1]) // } // } // // return output.String(), true //} func SplitCmd(cmd string) []string { var result []string var curStr strings.Builder var preChar int32 var quotesChar int32 cmdRune := []rune(strings.TrimSpace(cmd)) for _, char := range cmdRune { if (char == '"' || char == '\'') && preChar != '\\' && (quotesChar == 0 || quotesChar == char) { if quotesChar != 0 { quotesChar = 0 } else { quotesChar = char } } else if char == ' ' && quotesChar == 0 { result = append(result, curStr.String()) curStr.Reset() } else { curStr.WriteRune(char) } preChar = char } if curStr.Len() > 0 { result = append(result, curStr.String()) } result = sliceutil.FilterMap(result, func(i int) (string, bool) { var part = result[i] if i == 0 && len(part) <= 0 { return "", false } if strings.Contains(part, "\\") { if unquotePart, e := strconv.Unquote(`"` + part + `"`); e == nil { return unquotePart, true } } return part, true }) return result } ================================================ FILE: backend/utils/string/common.go ================================================ package strutil import ( "unicode" ) func ContainsBinary(str string) bool { //buf := []byte(str) //size := 0 //for start := 0; start < len(buf); start += size { // var r rune // if r, size = utf8.DecodeRune(buf[start:]); r == utf8.RuneError { // return true // } //} rs := []rune(str) for _, r := range rs { if r == unicode.ReplacementChar { return true } if !unicode.IsPrint(r) && !unicode.IsSpace(r) { return true } } return false } func IsSameChar(str string) bool { if len(str) <= 0 { return false } rs := []rune(str) first := rs[0] for _, r := range rs { if r != first { return false } } return true } ================================================ FILE: backend/utils/string/json_formatter.go ================================================ package strutil import ( "strings" "unicode" ) // Convert from https://github.com/ObuchiYuki/SwiftJSONFormatter // ArrayIterator defines the iterator for an array type ArrayIterator[T any] struct { array []T head int } // NewArrayIterator initializes a new ArrayIterator with the given array func NewArrayIterator[T any](array []T) *ArrayIterator[T] { return &ArrayIterator[T]{ array: array, head: -1, } } // HasNext returns true if there are more elements to iterate over func (it *ArrayIterator[T]) HasNext() bool { return it.head+1 < len(it.array) } // PeekNext returns the next element without advancing the iterator func (it *ArrayIterator[T]) PeekNext() *T { if it.head+1 < len(it.array) { return &it.array[it.head+1] } return nil } // Next returns the next element and advances the iterator func (it *ArrayIterator[T]) Next() *T { defer func() { it.head++ }() return it.PeekNext() } // JSONBeautify formats a JSON string with indentation func JSONBeautify(value string, indent string) string { if len(indent) <= 0 { indent = " " } return format(value, indent, "\n", " ") } // JSONMinify formats a JSON string by removing all unnecessary whitespace func JSONMinify(value string) string { return format(value, "", "", "") } // format applies the specified formatting to a JSON string func format(value string, indent string, newLine string, separator string) string { var formatted strings.Builder chars := NewArrayIterator([]rune(value)) indentLevel := 0 for chars.HasNext() { if char := chars.Next(); char != nil { switch *char { case '{', '[': formatted.WriteRune(*char) consumeWhitespaces(chars) peeked := chars.PeekNext() if peeked != nil && (*peeked == '}' || *peeked == ']') { chars.Next() formatted.WriteRune(*peeked) } else { indentLevel++ formatted.WriteString(newLine) formatted.WriteString(strings.Repeat(indent, indentLevel)) } case '}', ']': indentLevel-- formatted.WriteString(newLine) formatted.WriteString(strings.Repeat(indent, max(0, indentLevel))) formatted.WriteRune(*char) case '"': str := consumeString(chars) //str = convertUnicodeString(str) formatted.WriteString(str) case ',': consumeWhitespaces(chars) formatted.WriteRune(',') peeked := chars.PeekNext() if peeked != nil && *peeked != '}' && *peeked != ']' { formatted.WriteString(newLine) formatted.WriteString(strings.Repeat(indent, max(0, indentLevel))) } case ':': formatted.WriteString(":" + separator) default: if !unicode.IsSpace(*char) { formatted.WriteRune(*char) } } } } return formatted.String() } // consumeWhitespaces advances the iterator past any whitespace characters func consumeWhitespaces(iter *ArrayIterator[rune]) { for iter.HasNext() { if peeked := iter.PeekNext(); peeked != nil && unicode.IsSpace(*peeked) { iter.Next() } else { break } } } // consumeString consumes a JSON string value from the iterator func consumeString(iter *ArrayIterator[rune]) string { var sb strings.Builder sb.WriteRune('"') escaping := false for iter.HasNext() { if char := iter.Next(); char != nil { if *char == '\n' { return sb.String() // Unterminated string } sb.WriteRune(*char) if escaping { escaping = false } else { if *char == '\\' { escaping = true } if *char == '"' { break } } } } return sb.String() } func convertUnicodeString(str string) string { // TODO: quote UTF-16 characters //if len(str) > 2 { // if unqStr, err := strconv.Unquote(str); err == nil { // return strconv.Quote(unqStr) // } //} return str } ================================================ FILE: backend/utils/string/key_convert.go ================================================ package strutil import ( "strconv" sliceutil "tinyrdm/backend/utils/slice" ) // EncodeRedisKey encode the redis key to integer array // if key contains binary which could not display on ui, convert the key to char array func EncodeRedisKey(key string) any { if ContainsBinary(key) { b := []byte(key) arr := make([]int, len(b)) for i, bb := range b { arr[i] = int(bb) } return arr } return key } // DecodeRedisKey decode redis key to readable string func DecodeRedisKey(key any) string { switch key.(type) { case string: return key.(string) case []any: arr := key.([]any) bytes := sliceutil.Map(arr, func(i int) byte { if c, ok := AnyToInt(arr[i]); ok { return byte(c) } return '0' }) return string(bytes) case []int: arr := key.([]int) b := make([]byte, len(arr)) for i, bb := range arr { b[i] = byte(bb) } return string(b) } return "" } // AnyToInt convert any value to int func AnyToInt(val any) (int, bool) { switch val.(type) { case string: num, err := strconv.Atoi(val.(string)) if err != nil { return 0, false } return num, true case float64: return int(val.(float64)), true case float32: return int(val.(float32)), true case int64: return int(val.(int64)), true case int32: return int(val.(int32)), true case int: return val.(int), true case bool: if val.(bool) { return 1, true } else { return 0, true } } return 0, false } ================================================ FILE: build/README.md ================================================ # Build Directory The build directory is used to house all the build files and assets for your application. The structure is: * bin - Output directory * darwin - macOS specific files * windows - Windows specific files ## Mac The `darwin` directory holds files specific to Mac builds. These may be customised and used as part of the build. To return these files to the default state, simply delete them and build with `wails build`. The directory contains the following files: - `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`. - `Info.dev.plist` - same as the main plist file but used when building using `wails dev`. ## Windows The `windows` directory contains the manifest and rc files used when building with `wails build`. These may be customised for your application. To return these files to the default state, simply delete them and build with `wails build`. - `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file will be created using the `appicon.png` file in the build directory. - `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`. - `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer, as well as the application itself (right click the exe -> properties -> details) - `wails.exe.manifest` - The main application manifest file. ================================================ FILE: build/darwin/Info.dev.plist ================================================ CFBundlePackageType APPL CFBundleName {{.Info.ProductName}} CFBundleExecutable {{.Info.ProductName}} CFBundleIdentifier com.tinycraft.{{.Name}} CFBundleVersion {{.Info.ProductVersion}} CFBundleGetInfoString {{.Info.Comments}} CFBundleShortVersionString {{.Info.ProductVersion}} CFBundleIconFile iconfile LSMinimumSystemVersion 11.7.0 NSHighResolutionCapable true NSHumanReadableCopyright {{.Info.Copyright}} NSAppTransportSecurity NSAllowsLocalNetworking ================================================ FILE: build/darwin/Info.plist ================================================ CFBundlePackageType APPL CFBundleName {{.Info.ProductName}} CFBundleExecutable {{.Info.ProductName}} CFBundleIdentifier com.tinycraft.{{.Name}} CFBundleVersion {{.Info.ProductVersion}} CFBundleGetInfoString {{.Info.Comments}} CFBundleShortVersionString {{.Info.ProductVersion}} CFBundleIconFile iconfile LSMinimumSystemVersion 11.7.0 NSHighResolutionCapable true NSHumanReadableCopyright {{.Info.Copyright}} ================================================ FILE: build/dmg/fix-app ================================================ #!/bin/bash clear BLACK="\033[0;30m" DARK_GRAY="\033[1;30m" BLUE="\033[0;34m" LIGHT_BLUE="\033[1;34m" GREEN="\033[0;32m" LIGHT_GREEN="\033[1;32m" CYAN="\033[0;36m" LIGHT_CYAN="\033[1;36m" RED="\033[0;31m" LIGHT_RED="\033[1;31m" PURPLE="\033[0;35m" LIGHT_PURPLE="\033[1;35m" BROWN="\033[0;33m" YELLOW="\033[0;33m" LIGHT_GRAY="\033[0;37m" WHITE="\033[1;37m" NC="\033[0m" parentPath=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) cd "$parentPath" appPath=$( find "$parentPath" -name '*.app' -maxdepth 1) appName=${appPath##*/} appBashName=${appName// /\ } appDIR="/Applications/${appBashName}" echo -e "This tool fix these situations: \"${appBashName}\" is damaged and can't not be opened." echo "" if [ ! -d "$appDIR" ];then echo "" echo -e "Execution result: ${RED}You haven't installed ${appBashName} yet, please install it first.${NC}" else echo -e "${YELLOW}Please enter your login password, and then press enter. (The password is invisible during input)${NC}" sudo spctl --master-disable sudo xattr -rd com.apple.quarantine /Applications/"$appBashName" sudo xattr -rc /Applications/"$appBashName" sudo codesign --sign - --force --deep /Applications/"$appBashName" echo -e "Execution result: ${GREEN}Already fixed! ${NC} ${appBashName} will work correctly.${NC}" fi echo -e "You can close this window now" ================================================ FILE: build/dmg/fix-app_zh ================================================ #!/bin/bash clear BLACK="\033[0;30m" DARK_GRAY="\033[1;30m" BLUE="\033[0;34m" LIGHT_BLUE="\033[1;34m" GREEN="\033[0;32m" LIGHT_GREEN="\033[1;32m" CYAN="\033[0;36m" LIGHT_CYAN="\033[1;36m" RED="\033[0;31m" LIGHT_RED="\033[1;31m" PURPLE="\033[0;35m" LIGHT_PURPLE="\033[1;35m" BROWN="\033[0;33m" YELLOW="\033[0;33m" LIGHT_GRAY="\033[0;37m" WHITE="\033[1;37m" NC="\033[0m" parentPath=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) cd "$parentPath" appPath=$( find "$parentPath" -name '*.app' -maxdepth 1) appName=${appPath##*/} appBashName=${appName// /\ } appDIR="/Applications/${appBashName}" echo -e "『${appBashName} 提示已损坏,无法打开/ 来自身份不明的开发者』等问题修复工具" echo "" # 未安装APP时提醒安装,已安装绕过公证 if [ ! -d "$appDIR" ];then echo "" echo -e "执行结果:${RED}您还未安装 ${appBashName} ,请先安装${NC}" else # 绕过公证 echo -e "${YELLOW}请输入开机密码,输入完成后按下回车键(输入过程中密码是看不见的)${NC}" sudo spctl --master-disable sudo xattr -rd com.apple.quarantine /Applications/"$appBashName" sudo xattr -rc /Applications/"$appBashName" sudo codesign --sign - --force --deep /Applications/"$appBashName" echo -e "执行结果:${GREEN}修复成功!${NC}您现在可以正常运行 ${appBashName} 了。${NC}" fi echo -e "本窗口可以关闭啦!" ================================================ FILE: build/linux/tiny-rdm_0.0.0_amd64/DEBIAN/control ================================================ Package: {{.Name}} Version: {{.Info.ProductVersion}} Section: base Priority: optional Architecture: amd64 Depends: {{.libwebkit2gtk.PackageName}} Maintainer: {{.Author.Name}} <{{.Author.Email}}> Homepage: https://tinyrdm.com/ Description: {{.Info.Comments}} ================================================ FILE: build/linux/tiny-rdm_0.0.0_amd64/usr/local/bin/.gitkeep ================================================ ================================================ FILE: build/linux/tiny-rdm_0.0.0_amd64/usr/share/applications/tiny-rdm.desktop ================================================ [Desktop Entry] Name={{.Info.ProductName}} Exec=/usr/local/bin/tiny-rdm %U Terminal=false Type=Application Icon=tiny-rdm StartupWMClass=tinyrdm Comment={{.Info.Comments}} MimeType=x-scheme-handler/tinyrdm; Categories=Office; ================================================ FILE: build/windows/info.json ================================================ { "fixed": { "file_version": "{{.Info.ProductVersion}}" }, "info": { "0000": { "ProductVersion": "{{.Info.ProductVersion}}", "CompanyName": "{{.Info.CompanyName}}", "FileDescription": "{{.Info.ProductName}}", "LegalCopyright": "{{.Info.Copyright}}", "ProductName": "{{.Info.ProductName}}", "Comments": "{{.Info.Comments}}" } } } ================================================ FILE: build/windows/installer/project.nsi ================================================ Unicode true #### ## Please note: Template replacements don't work in this file. They are provided with default defines like ## mentioned underneath. ## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo. ## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually ## from outside of Wails for debugging and development of the installer. ## ## For development first make a wails nsis build to populate the "wails_tools.nsh": ## > wails build --target windows/amd64 --nsis ## Then you can call makensis on this file with specifying the path to your binary: ## For a AMD64 only installer: ## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe ## For a ARM64 only installer: ## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe ## For a installer with both architectures: ## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe #### ## The following information is taken from the ProjectInfo file, but they can be overwritten here. #### ## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}" ## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}" ## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}" ## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}" ## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}" ### ## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" ## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" #### ## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html #### ## Include the wails tools #### !include "wails_tools.nsh" # The version information for this two must consist of 4 parts VIProductVersion "${INFO_PRODUCTVERSION}.0" VIFileVersion "${INFO_PRODUCTVERSION}.0" VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" !include "MUI.nsh" !define MUI_ICON "..\icon.ico" !define MUI_UNICON "..\icon.ico" # !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 !define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps !define MUI_ABORTWARNING # This will warn the user if they exit from the installer. !insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. # !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer !insertmacro MUI_PAGE_DIRECTORY # In which folder install page. !insertmacro MUI_PAGE_INSTFILES # Installing page. !insertmacro MUI_PAGE_FINISH # Finished installation page. !insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page !insertmacro MUI_LANGUAGE "English" # Set the Language of the installer ## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 #!uninstfinalize 'signtool --file "%1"' #!finalize 'signtool --file "%1"' Name "${INFO_PRODUCTNAME}" OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder). ShowInstDetails show # This will always show the installation details. Function .onInit !insertmacro wails.checkArchitecture FunctionEnd Section !insertmacro wails.setShellContext !insertmacro wails.webview2runtime SetOutPath $INSTDIR !insertmacro wails.files CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" !insertmacro wails.writeUninstaller SectionEnd Section "uninstall" !insertmacro wails.setShellContext RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath RMDir /r $INSTDIR Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" !insertmacro wails.deleteUninstaller SectionEnd ================================================ FILE: build/windows/installer/wails_tools.nsh ================================================ # DO NOT EDIT - Generated automatically by `wails build` !include "x64.nsh" !include "WinVer.nsh" !include "FileFunc.nsh" !ifndef INFO_PROJECTNAME !define INFO_PROJECTNAME "{{.Name}}" !endif !ifndef INFO_COMPANYNAME !define INFO_COMPANYNAME "{{.Info.CompanyName}}" !endif !ifndef INFO_PRODUCTNAME !define INFO_PRODUCTNAME "{{.Info.ProductName}}" !endif !ifndef INFO_PRODUCTVERSION !define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}" !endif !ifndef INFO_COPYRIGHT !define INFO_COPYRIGHT "{{.Info.Copyright}}" !endif !ifndef PRODUCT_EXECUTABLE !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" !endif !ifndef UNINST_KEY_NAME !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" !endif !define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" !ifndef REQUEST_EXECUTION_LEVEL !define REQUEST_EXECUTION_LEVEL "admin" !endif RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" !ifdef ARG_WAILS_AMD64_BINARY !define SUPPORTS_AMD64 !endif !ifdef ARG_WAILS_ARM64_BINARY !define SUPPORTS_ARM64 !endif !ifdef SUPPORTS_AMD64 !ifdef SUPPORTS_ARM64 !define ARCH "amd64_arm64" !else !define ARCH "amd64" !endif !else !ifdef SUPPORTS_ARM64 !define ARCH "arm64" !else !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" !endif !endif !macro wails.checkArchitecture !ifndef WAILS_WIN10_REQUIRED !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." !endif !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" !endif ${If} ${AtLeastWin10} !ifdef SUPPORTS_AMD64 ${if} ${IsNativeAMD64} Goto ok ${EndIf} !endif !ifdef SUPPORTS_ARM64 ${if} ${IsNativeARM64} Goto ok ${EndIf} !endif IfSilent silentArch notSilentArch silentArch: SetErrorLevel 65 Abort notSilentArch: MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" Quit ${else} IfSilent silentWin notSilentWin silentWin: SetErrorLevel 64 Abort notSilentWin: MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" Quit ${EndIf} ok: !macroend !macro wails.files !ifdef SUPPORTS_AMD64 ${if} ${IsNativeAMD64} File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" ${EndIf} !endif !ifdef SUPPORTS_ARM64 ${if} ${IsNativeARM64} File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" ${EndIf} !endif !macroend !macro wails.writeUninstaller WriteUninstaller "$INSTDIR\uninstall.exe" SetRegView 64 WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 IntFmt $0 "0x%08X" $0 WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" !macroend !macro wails.deleteUninstaller Delete "$INSTDIR\uninstall.exe" SetRegView 64 DeleteRegKey HKLM "${UNINST_KEY}" !macroend !macro wails.setShellContext ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" SetShellVarContext all ${else} SetShellVarContext current ${EndIf} !macroend # Install webview2 by launching the bootstrapper # See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment !macro wails.webview2runtime !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" !endif SetRegView 64 # If the admin key exists and is not empty then webview2 is already installed ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" ${If} $0 != "" Goto ok ${EndIf} ${If} ${REQUEST_EXECUTION_LEVEL} == "user" # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" ${If} $0 != "" Goto ok ${EndIf} ${EndIf} SetDetailsPrint both DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" SetDetailsPrint listonly InitPluginsDir CreateDirectory "$pluginsdir\webview2bootstrapper" SetOutPath "$pluginsdir\webview2bootstrapper" File "tmp\MicrosoftEdgeWebview2Setup.exe" ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' SetDetailsPrint both ok: !macroend ================================================ FILE: build/windows/wails.exe.manifest ================================================ true/pm permonitorv2,permonitor ================================================ FILE: docker/entrypoint.sh ================================================ #!/bin/sh set -e # Start nginx in background (serves frontend + reverse proxy) nginx # Start Go backend in foreground exec ./tinyrdm-server ================================================ FILE: docker/nginx.conf ================================================ server { listen 8086; server_name _; root /usr/share/nginx/html; index index.html; # SPA fallback location / { try_files $uri $uri/ /index.html; } # Proxy API to Go backend location /api/ { proxy_pass http://127.0.0.1:8088; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; } # Proxy WebSocket location /ws { proxy_pass http://127.0.0.1:8088; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host; } } ================================================ FILE: docker-compose.yml ================================================ services: tinyrdm: image: ghcr.io/tiny-craft/tiny-rdm:latest container_name: tinyrdm restart: unless-stopped ports: - "8086:8086" environment: - PORT=8088 - GIN_MODE=release - ADMIN_USERNAME=admin - ADMIN_PASSWORD=tinyrdm # - SESSION_TTL=24h volumes: - ./data:/app/tinyrdm ================================================ FILE: docs/index.html ================================================ Tiny RDM ================================================ FILE: frontend/.prettierrc ================================================ { "printWidth": 120, "tabWidth": 4, "singleQuote": true, "semi": false, "bracketSameLine": true, "endOfLine": "auto", "htmlWhitespaceSensitivity": "ignore" } ================================================ FILE: frontend/README.md ================================================ # Frontend of Tiny RDM Use Vue3 + Vite ================================================ FILE: frontend/index.html ================================================ Tiny RDM
================================================ FILE: frontend/package.json ================================================ { "name": "frontend", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "chart.js": "^4.5.1", "dayjs": "^1.11.19", "lodash": "^4.17.23", "monaco-editor": "^0.47.0", "pinia": "^3.0.4", "sass": "^1.97.3", "vue": "^3.5.29", "vue-chartjs": "^5.3.3", "vue-i18n": "^11.2.8", "wcwidth": "^1.0.1", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.4", "naive-ui": "^2.43.2", "prettier": "^3.8.1", "unplugin-auto-import": "^21.0.0", "unplugin-icons": "^23.0.1", "unplugin-vue-components": "^31.0.0", "vite": "^7.3.1" } } ================================================ FILE: frontend/src/App.vue ================================================ ================================================ FILE: frontend/src/AppContent.vue ================================================ ================================================ FILE: frontend/src/assets/fonts/OFL.txt ================================================ Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com), This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL ----------------------------------------------------------- SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ----------------------------------------------------------- PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. ================================================ FILE: frontend/src/components/LoginPage.vue ================================================ ================================================ FILE: frontend/src/components/common/AutoRefreshForm.vue ================================================ ================================================ FILE: frontend/src/components/common/DropdownSelector.vue ================================================ ================================================ FILE: frontend/src/components/common/EditableTableColumn.vue ================================================ ================================================ FILE: frontend/src/components/common/EditableTableRow.vue ================================================ ================================================ FILE: frontend/src/components/common/FileOpenInput.vue ================================================ ================================================ FILE: frontend/src/components/common/FileSaveInput.vue ================================================ ================================================ FILE: frontend/src/components/common/IconButton.vue ================================================ ================================================ FILE: frontend/src/components/common/RedisTypeSelector.vue ================================================ ================================================ FILE: frontend/src/components/common/RedisTypeTag.vue ================================================ ================================================ FILE: frontend/src/components/common/ResizeableWrapper.vue ================================================ ================================================ FILE: frontend/src/components/common/SwitchButton.vue ================================================ ================================================ FILE: frontend/src/components/common/ToolbarControlWidget.vue ================================================ ================================================ FILE: frontend/src/components/common/TtlInput.vue ================================================ ================================================ FILE: frontend/src/components/content/ContentLogPane.vue ================================================ ================================================ FILE: frontend/src/components/content/ContentPane.vue ================================================ ================================================ FILE: frontend/src/components/content/ContentServerPane.vue ================================================ ================================================ FILE: frontend/src/components/content/ContentValueTab.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentCli.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentEditor.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentEntryEditor.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentMonitor.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentPubsub.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentSearchInput.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentServerStatus.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentSlog.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentToolbar.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentValueHash.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentValueJson.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentValueList.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentValueSet.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentValueStream.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentValueString.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentValueWrapper.vue ================================================ ================================================ FILE: frontend/src/components/content_value/ContentValueZSet.vue ================================================ ================================================ FILE: frontend/src/components/content_value/FormatSelector.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/AboutDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/AddFieldsDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/ConnectionDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/DecoderDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/DeleteKeyDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/ExportKeyDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/FlushDbDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/GroupDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/ImportKeyDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/KeyFilterDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/NewKeyDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/PreferencesDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/RenameKeyDialog.vue ================================================ ================================================ FILE: frontend/src/components/dialogs/SetTtlDialog.vue ================================================ ================================================ FILE: frontend/src/components/icons/Add.vue ================================================ ================================================ FILE: frontend/src/components/icons/AddGroup.vue ================================================ ================================================ FILE: frontend/src/components/icons/AddLink.vue ================================================ ================================================ FILE: frontend/src/components/icons/AlignCenter.vue ================================================ ================================================ FILE: frontend/src/components/icons/AlignLeft.vue ================================================ ================================================ FILE: frontend/src/components/icons/Binary.vue ================================================ ================================================ FILE: frontend/src/components/icons/Bottom.vue ================================================ ================================================ FILE: frontend/src/components/icons/Checkbox.vue ================================================ ================================================ FILE: frontend/src/components/icons/Checked.vue ================================================ ================================================ FILE: frontend/src/components/icons/Clear.vue ================================================ ================================================ FILE: frontend/src/components/icons/Close.vue ================================================ ================================================ FILE: frontend/src/components/icons/Cluster.vue ================================================ ================================================ FILE: frontend/src/components/icons/Code.vue ================================================ ================================================ FILE: frontend/src/components/icons/Config.vue ================================================ ================================================ FILE: frontend/src/components/icons/Connect.vue ================================================ ================================================ FILE: frontend/src/components/icons/Conversion.vue ================================================ ================================================ FILE: frontend/src/components/icons/Copy.vue ================================================ ================================================ FILE: frontend/src/components/icons/CopyLink.vue ================================================ ================================================ FILE: frontend/src/components/icons/Database.vue ================================================ ================================================ FILE: frontend/src/components/icons/Delete.vue ================================================ ================================================ FILE: frontend/src/components/icons/Detail.vue ================================================ ================================================ FILE: frontend/src/components/icons/Down.vue ================================================ ================================================ FILE: frontend/src/components/icons/Edit.vue ================================================ ================================================ FILE: frontend/src/components/icons/EditFile.vue ================================================ ================================================ FILE: frontend/src/components/icons/Export.vue ================================================ ================================================ FILE: frontend/src/components/icons/Filter.vue ================================================ ================================================ FILE: frontend/src/components/icons/Folder.vue ================================================ ================================================ FILE: frontend/src/components/icons/FullScreen.vue ================================================ ================================================ FILE: frontend/src/components/icons/Github.vue ================================================ ================================================ FILE: frontend/src/components/icons/Help.vue ================================================ ================================================ FILE: frontend/src/components/icons/Import.vue ================================================ ================================================ FILE: frontend/src/components/icons/Key.vue ================================================ ================================================ FILE: frontend/src/components/icons/Lang.vue ================================================ ================================================ FILE: frontend/src/components/icons/Layer.vue ================================================ ================================================ FILE: frontend/src/components/icons/ListView.vue ================================================ ================================================ FILE: frontend/src/components/icons/LoadAll.vue ================================================ ================================================ FILE: frontend/src/components/icons/LoadList.vue ================================================ ================================================ FILE: frontend/src/components/icons/Loading.vue ================================================ ================================================ FILE: frontend/src/components/icons/Log.vue ================================================ ================================================ FILE: frontend/src/components/icons/Logout.vue ================================================ ================================================ FILE: frontend/src/components/icons/Monitor.vue ================================================ ================================================ FILE: frontend/src/components/icons/Moon.vue ================================================ ================================================ FILE: frontend/src/components/icons/More.vue ================================================ ================================================ FILE: frontend/src/components/icons/OffScreen.vue ================================================ ================================================ FILE: frontend/src/components/icons/Pause.vue ================================================ ================================================ FILE: frontend/src/components/icons/Pin.vue ================================================ ================================================ FILE: frontend/src/components/icons/Play.vue ================================================ ================================================ FILE: frontend/src/components/icons/Plus.vue ================================================ ================================================ FILE: frontend/src/components/icons/Publish.vue ================================================ ================================================ FILE: frontend/src/components/icons/QRCode.vue ================================================ ================================================ FILE: frontend/src/components/icons/Record.vue ================================================ ================================================ FILE: frontend/src/components/icons/Refresh.vue ================================================ ================================================ FILE: frontend/src/components/icons/Save.vue ================================================ ================================================ FILE: frontend/src/components/icons/Search.vue ================================================ ================================================ FILE: frontend/src/components/icons/Server.vue ================================================ ================================================ FILE: frontend/src/components/icons/Sort.vue ================================================ ================================================ FILE: frontend/src/components/icons/SpellCheck.vue ================================================ ================================================ FILE: frontend/src/components/icons/Status.vue ================================================ ================================================ FILE: frontend/src/components/icons/Structure.vue ================================================ ================================================ FILE: frontend/src/components/icons/Subscribe.vue ================================================ ================================================ FILE: frontend/src/components/icons/Sun.vue ================================================ ================================================ FILE: frontend/src/components/icons/Terminal.vue ================================================ ================================================ FILE: frontend/src/components/icons/ThemeAuto.vue ================================================ ================================================ FILE: frontend/src/components/icons/Timer.vue ================================================ ================================================ FILE: frontend/src/components/icons/TreeView.vue ================================================ ================================================ FILE: frontend/src/components/icons/Twitter.vue ================================================ ================================================ FILE: frontend/src/components/icons/Unlink.vue ================================================ ================================================ FILE: frontend/src/components/icons/Update.vue ================================================ ================================================ FILE: frontend/src/components/icons/WindowClose.vue ================================================ ================================================ FILE: frontend/src/components/icons/WindowMax.vue ================================================ ================================================ FILE: frontend/src/components/icons/WindowMin.vue ================================================ ================================================ FILE: frontend/src/components/icons/WindowRestore.vue ================================================ ================================================ FILE: frontend/src/components/new_value/AddHashValue.vue ================================================ ================================================ FILE: frontend/src/components/new_value/AddListValue.vue ================================================ ================================================ FILE: frontend/src/components/new_value/AddZSetValue.vue ================================================ ================================================ FILE: frontend/src/components/new_value/NewHashValue.vue ================================================ ================================================ FILE: frontend/src/components/new_value/NewJsonValue.vue ================================================ ================================================ FILE: frontend/src/components/new_value/NewListValue.vue ================================================ ================================================ FILE: frontend/src/components/new_value/NewSetValue.vue ================================================ ================================================ FILE: frontend/src/components/new_value/NewStreamValue.vue ================================================ ================================================ FILE: frontend/src/components/new_value/NewStringValue.vue ================================================ ================================================ FILE: frontend/src/components/new_value/NewZSetValue.vue ================================================ ================================================ FILE: frontend/src/components/sidebar/BrowserPane.vue ================================================ ================================================ FILE: frontend/src/components/sidebar/BrowserTree.vue ================================================ ================================================ FILE: frontend/src/components/sidebar/ConnectionPane.vue ================================================ ================================================ FILE: frontend/src/components/sidebar/ConnectionTree.vue ================================================ ================================================ FILE: frontend/src/components/sidebar/ConnectionTreeItem.vue ================================================ ================================================ FILE: frontend/src/components/sidebar/Ribbon.vue ================================================ ================================================ FILE: frontend/src/consts/browser_tab_type.js ================================================ /** * all types of Browser sub tabs * @enum {string} */ export const BrowserTabType = { Status: 'status', KeyDetail: 'key_detail', Cli: 'cli', SlowLog: 'slow_log', CmdMonitor: 'cmd_monitor', PubMessage: 'pub_message', } ================================================ FILE: frontend/src/consts/connection_type.js ================================================ /** * all types of connection item * @enum {number} */ export const ConnectionType = { Group: 0, Server: 1, RedisDB: 2, RedisKey: 3, RedisValue: 4, } ================================================ FILE: frontend/src/consts/key_view_type.js ================================================ /** * all types of redis key viewing * @enum {number} */ export const KeyViewType = { Tree: 0, List: 1, } ================================================ FILE: frontend/src/consts/localstorage_key.js ================================================ export const STORAGE_THEME_KEY = 'rdm_theme' export const STORAGE_LANG_KEY = 'rdm_lang' ================================================ FILE: frontend/src/consts/support_redis_type.js ================================================ /** * all redis type * @enum {string} */ export const types = { STRING: 'STRING', HASH: 'HASH', LIST: 'LIST', SET: 'SET', ZSET: 'ZSET', STREAM: 'STREAM', JSON: 'JSON', } export const typesShortName = { STRING: 'S', HASH: 'H', LIST: 'L', SET: 'E', ZSET: 'Z', STREAM: 'X', JSON: 'J', } /** * mark color for redis types * @enum {string} */ export const typesColor = { [types.STRING]: '#8B5CF6', [types.HASH]: '#3B82F6', [types.LIST]: '#10B981', [types.SET]: '#F59E0B', [types.ZSET]: '#EF4444', [types.STREAM]: '#EC4899', [types.JSON]: '#828766', } /** * background mark color for redis types * @enum {string} */ export const typesBgColor = { [types.STRING]: '#F2EDFB', [types.HASH]: '#E4F0FC', [types.LIST]: '#E3F3EB', [types.SET]: '#FDF1DF', [types.ZSET]: '#FAEAED', [types.STREAM]: '#FDE6F1', [types.JSON]: '#ECECD9', } // export const typesName = Object.fromEntries(Object.entries(types).map(([key, value]) => [key, value.name])) export const validType = (t) => { return types.hasOwnProperty(t) } /** * icon type in browser tree * @enum {string} */ export const typesIconStyle = { SHORT: 0, FULL: 1, POINT: 2, ICON: 3, } ================================================ FILE: frontend/src/consts/text_align_type.js ================================================ /** * all types of text alignment * @enum {number} */ export const TextAlignType = { Center: 0, Left: 1, } ================================================ FILE: frontend/src/consts/tree_context_menu.js ================================================ import { ConnectionType } from './connection_type.js' export const contextMenuKey = { [ConnectionType.Server]: { key: '', label: '', }, } ================================================ FILE: frontend/src/consts/value_view_type.js ================================================ /** * string format types * @enum {string} */ export const formatTypes = { RAW: 'Raw', JSON: 'JSON', UNICODE_JSON: 'Unicode JSON', YAML: 'YAML', XML: 'XML', HEX: 'Hex', BINARY: 'Binary', BITSET: 'BitSet', } /** * string decode types * @enum {string} */ export const decodeTypes = { NONE: 'None', BASE64: 'Base64', GZIP: 'GZip', DEFLATE: 'Deflate', ZSTD: 'ZStd', LZ4: 'LZ4', BROTLI: 'Brotli', MSGPACK: 'Msgpack', PHP: 'PHP', PICKLE: 'Pickle', // Java: 'Java', } ================================================ FILE: frontend/src/langs/en-us.json ================================================ { "name": "English", "common": { "confirm": "Confirm", "cancel": "Cancel", "success": "Success", "warning": "Warning", "error": "Error", "save": "Save", "update": "Update", "none": "None", "second": "Second(s)", "minute": "Minute(s)", "hour": "Hour(s)", "day": "Day(s)", "unit_day": "d", "unit_hour": "h", "unit_minute": "m", "unit_second": "s", "all": "All", "key": "Key", "value": "Value", "field": "Field", "score": "Score", "index": "Position" }, "preferences": { "name": "Preferences", "restore_defaults": "Restore Defaults", "font_tip": "Supports multi-selection. Manually input the font if it's not listed.", "general": { "name": "General", "theme": "Theme", "theme_light": "Light", "theme_dark": "Dark", "theme_auto": "Auto", "language": "Language", "system_lang": "Use System Language", "font": "Font", "font_tip": "Select or input font name", "font_size": "Font Size", "scan_size": "Default Size for SCAN", "scan_size_tip": "Default return number of elements for SCAN/HSCAN/SSCAN/ZSCAN", "key_icon_style": "Key Icon Style", "key_icon_style0": "Compact", "key_icon_style1": "Full Name", "key_icon_style2": "Dot", "key_icon_style3": "Common", "update": "Update", "auto_check_update": "Auto check for updates", "privacy": "Privacy", "allow_track": "Allows anonymous data to be collected" }, "editor": { "name": "Editor", "show_linenum": "Show Line Numbers", "show_folding": "Enable Code Folding", "drop_text": "Allow Drag & Drop Text", "links": "Support Links" }, "cli": { "name": "Command Line", "cursor_style": "Cursor Style", "cursor_style_block": "Block", "cursor_style_underline": "Underline", "cursor_style_bar": "Bar" }, "decoder": { "name": "Custom Decoder", "new": "New Decoder", "decoder_name": "Name", "cmd_preview": "Preview", "status": "Status", "auto_enabled": "Auto Decoding Enabled", "help": "Help" } }, "interface": { "new_conn": "Add Connection", "new_group": "Add Group", "disconnect_all": "Disconnect All", "status": "Status", "filter": "Filter", "sort_conn": "Sort Connections", "new_conn_title": "New Connection", "open_db": "Open Database", "close_db": "Close Database", "filter_key": "Filter Keys", "disconnect": "Disconnect", "dup_conn": "Duplicate Connection", "remove_conn": "Remove Connection", "edit_conn": "Edit Connection", "edit_conn_group": "Edit Group", "rename_conn_group": "Rename Group", "remove_conn_group": "Remove Group", "import_conn": "Import Connections...", "export_conn": "Export Connections...", "ttl": "TTL", "forever": "Forever", "rename_key": "Rename Key", "delete_key": "Delete Key", "batch_delete_key": "Batch Delete Keys", "import_key": "Import Keys", "flush_db": "Flush Database", "check_mode": "Check Mode", "quit_check_mode": "Exit Check Mode", "delete_checked": "Delete Checked", "export_checked": "Export Checked", "ttl_checked": "Update TTL for Checked", "copy_value": "Copy Value", "edit_value": "Edit Value", "save_update": "Save Changes", "score_filter_tip": "Support operators:\n= equal\n!= not equal\n> greater than\n>= greater than or equal\n< less than\n<= less than or equal\ne.g. >3 for scores greater than 3", "add_row": "Insert Row", "edit_row": "Edit Row", "delete_row": "Delete Row", "fullscreen": "Full Screen", "offscreen": "Exit Full Screen", "pin_edit": "Pin (Stay open after save)", "unpin_edit": "Unpin", "search": "Search", "full_search": "Full Text Search", "full_search_result": "Content matched '{pattern}'", "filter_field": "Filter Field", "filter_value": "Filter Value", "length": "Length", "entries": "Entries", "memory_usage": "Memory Usage", "text_align_left": "Text Align Left", "text_align_center": "Text Align Center", "view_as": "View As", "decode_with": "Decode / Decompress", "custom_decoder": "New Custom Decoder", "reload": "Reload", "reload_disable": "Reload after fully loaded", "auto_refresh": "Auto Refresh", "refresh_interval": "Refresh Interval", "open_connection": "Open Connection", "copy_path": "Copy Path", "copy_key": "Copy Key", "save_value_succ": "Value Saved!", "copy_succ": "Copied to Clipboard!", "binary_key": "Binary Key Name", "remove_key": "Remove Key", "new_key": "New Key", "load_more": "Load More Keys", "load_all": "Load Remaining Keys", "load_more_entries": "Load More", "load_all_entries": "Load All", "more_action": "More Actions", "nonexist_tab_content": "Selected key does not exist or none selected. Retry after refresh.", "empty_server_content": "Select and open a connection from the left panel", "empty_server_list": "No Redis server added", "action": "Action", "type": "Type", "cli_welcome": "Welcome to Tiny RDM Redis Console", "retrieving_version": "Checking for updates", "sub_tab": { "status": "Status", "key_detail": "Key Detail", "cli": "Console", "slow_log": "Slow Log", "cmd_monitor": "Monitor Commands", "pub_message": "Pub/Sub" } }, "ribbon": { "server": "Server", "browser": "Data Browser", "log": "Log", "wechat_official": "WeChat Official Account", "follow_x": "Follow \uD835\uDD4F", "github": "Github", "logout": "Sign Out" }, "dialogue": { "close_confirm": "Close this connection ({name})?", "edit_close_confirm": "Please close relevant connections before editing. Continue?", "opening_connection": "Opening Connection...", "interrupt_connection": "Cancel", "remove_tip": "{type} \"{name}\" will be deleted", "remove_group_tip": "Group \"{name}\" and all its connections will be deleted", "rename_binary_key_fail": "Renaming binary key is not supported", "handle_succ": "Success!", "handle_cancel": "Operation canceled.", "reload_succ": "Reloaded!", "field_required": "This field is required", "spec_field_required": "\"{key}\" is required", "illegal_characters": "Contains illegal characters", "connection": { "new_title": "New Connection", "edit_title": "Edit Connection", "general": "General", "no_group": "No Group", "group": "Group", "conn_name": "Name", "addr": "Address", "usr": "Username", "pwd": "Password", "name_tip": "Connection name", "addr_tip": "Redis server address", "sock_tip": "Redis unix socket file", "usr_tip": "(Optional) Auth username", "pwd_tip": "(Optional) Auth password (Redis > 6.0)", "test": "Test Connection", "test_succ": "Successfully connected to Redis server", "test_fail": "Connection failed", "parse_url_clipboard": "Parse URL from Clipboard", "parse_pass": "Redis URL parsed: {url}", "parse_fail": "Failed to parse Redis URL: {reason}", "advn": { "title": "Advanced", "filter": "Default Key Filter", "filter_tip": "Pattern to filter loaded keys", "separator": "Key Separator", "separator_tip": "Separator for key path segments", "conn_timeout": "Connection Timeout", "exec_timeout": "Execution Timeout", "dbfilter_type": "Database Filter", "dbfilter_all": "Show All", "dbfilter_show": "Show Selected", "dbfilter_hide": "Hide Selected", "dbfilter_show_title": "Databases to Show", "dbfilter_hide_title": "Databases to Hide", "dbfilter_input": "Input Database Index", "dbfilter_input_tip": "Press Enter to confirm", "key_view": "Default Key View", "key_view_tree": "Tree View", "key_view_list": "List View", "load_size": "Keys Per Load", "mark_color": "Mark Color" }, "alias": { "title": "Database Alias", "db": "Input Database Index", "value": "Input Database Alias" }, "ssl": { "title": "SSL/TLS", "enable": "Enable SSL/TLS", "allow_insecure": "Allow Insecure", "sni": "Server Name (SNI)", "sni_tip": "(Optional) Server name", "cert_file": "Public Key File", "key_file": "Private Key File", "ca_file": "CA File", "cert_file_tip": "Public Key File in PEM format(Cert)", "key_file_tip": "Private Key File in PEM format(Key)", "ca_file_tip": "Certificate Authority File in PEM format(CA)" }, "ssh": { "enable": "Enable SSH Tunnel", "title": "SSH Tunnel", "login_type": "Login Type", "agent": "SSH Agent", "pkfile": "Private Key File", "passphrase": "Passphrase", "addr_tip": "SSH Server Address", "usr_tip": "SSH Username", "pwd_tip": "SSH Password", "pkfile_tip": "SSH private key file path", "passphrase_tip": "(Optional) Passphrase for private key" }, "sentinel": { "title": "Sentinel", "enable": "As Sentinel Node", "master": "Master Group Name", "auto_discover": "Auto Discover", "password": "Master Password", "username": "Master Username", "pwd_tip": "(Optional) Master auth password (Redis > 6.0)", "usr_tip": "(Optional) Master auth username" }, "cluster": { "title": "Cluster", "enable": "As Cluster Node" }, "proxy": { "title": "Proxy", "type_none": "No Proxy", "type_system": "System Proxy", "type_custom": "Manual Proxy", "host": "Hostname", "auth": "Proxy Authentication", "usr_tip": "Proxy auth username", "pwd_tip": "Proxy auth password" } }, "group": { "name": "Group Name", "rename": "Rename Group", "new": "New Group" }, "key": { "new": "New Key", "new_name": "New Key Name", "server": "Connection", "db_index": "Database Index", "key_expression": "Key Pattern", "affected_key": "Affected Keys", "show_affected_key": "Show Affected Keys", "confirm_delete_key": "Confirm delete {num} key(s)", "direct_delete": "Delete match pattern directly", "confirm_delete": "Confirm Delete", "async_delete": "Async Execution", "async_delete_title": "Don't wait for result", "confirm_flush": "I know what I'm doing!", "confirm_flush_db": "Confirm flush database" }, "delete": { "success": "\"{key}\" deleted", "deleting": "Deleting", "doing": "Deleting key ({index}/{count})", "completed": "Deletion completed, {success} succeeded, {fail} failed" }, "field": { "new": "New Field", "new_item": "New Item", "conflict_handle": "On Field Conflict", "overwrite_field": "Overwrite", "ignore_field": "Ignore", "insert_type": "Insert Type", "append_item": "Append", "prepend_item": "Prepend", "enter_key": "Enter Key", "enter_value": "Enter Value", "enter_field": "Enter Field Name", "enter_elem": "Enter Element", "enter_member": "Enter Member", "enter_score": "Enter Score", "element": "Element", "reload_when_succ": "Reload immediately if success" }, "filter": { "set_key_filter": "Set Key Filter", "filter_pattern": "Pattern", "filter_pattern_tip": "Filter by directly input, and scan by press 'Enter'.\n\n* matches 0 or more chars, e.g. 'key*' \n? matches single char, e.g. 'key?'\n[] matches range, e.g. 'key[1-3]'\n\\ escapes special chars", "exact_match_tip": "Exact Match", "filter_type_not_support": "Type filtering is not supported for Redis 5.x and below." }, "export": { "name": "Export Data", "export_expire_title": "Expiration", "export_expire": "Include Expiration", "export": "Export", "save_file": "Export Path", "save_file_tip": "Select path to save exported file", "exporting": "Exporting keys ({index}/{count})", "export_completed": "Export completed, {success} succeeded, {fail} failed" }, "import": { "name": "Import Data", "import_expire_title": "Expiration", "import": "Import", "reload": "Reload After Import", "open_csv_file": "Import File", "open_csv_file_tip": "Select file to import", "conflict_handle": "On Key Conflict", "conflict_overwrite": "Overwrite", "conflict_ignore": "Ignore", "ttl_include": "Import From File", "ttl_ignore": "Do Not Set", "ttl_custom": "Custom", "importing": "Importing keys imported/overwritten:{imported} conflict/failed:{conflict}", "import_completed": "Import completed, {success} succeeded, {ignored} ignored" }, "ttl": { "title": "Update TTL", "title_batch": "Batch Update TTL ({count})", "quick_set": "Quick Set", "success": "TTL updated for all keys" }, "decoder": { "name": "New Decoder/Encoder", "edit_name": "Edit Decoder/Encoder", "new": "New", "decoder": "Decoder", "encoder": "Encoder", "decoder_name": "Name", "auto": "Auto Decode", "decode_path": "Decoder Path", "encode_path": "Encoder Path", "path_help": "Path to executable, or cli alias like 'sh/php/python'", "args": "Arguments", "args_help": "Use [VALUE] as placeholder for encoding/decoding content. The content will be appended to the end if no placeholder is provided." }, "upgrade": { "title": "New Version Available", "new_version_tip": "New version {ver} available, download now?", "no_update": "You're up-to-date", "download_now": "Download Now", "later": "Later", "skip": "Skip This Version" }, "welcome": { "title": "Welcome to Tiny RDM!", "content": "In order to provide a better user experience, Tiny RDM collects some anonymous data to help optimize the software and improve the user experience, please rest assured that this does not involve your personal privacy information.\n\nIf you have any concerns, you can turn off this data collection feature at any time by going to Preferences. If you have any questions, feel free to contact the developer. I hope Tiny RDM can become your helpful assistant!", "accept": "Help Improve", "reject": "Reject" }, "about": { "source": "Source Code", "website": "Official Website" } }, "login": { "username_placeholder": "Enter username", "password_placeholder": "Enter password", "submit": "Sign In", "too_many_attempts": "Too many attempts, please try later", "invalid_credentials": "Invalid credentials", "network_error": "Network error" }, "menu": { "minimise": "Minimise", "maximise": "Maximise", "restore": "Restore", "close": "Close", "preferences": "Preferences", "help": "Help", "user_guide": "User Guide", "check_update": "Check for Updates...", "report_bug": "Report Bug", "about": "About" }, "log": { "title": "Launch Log", "filter_server": "Filter Server", "filter_keyword": "Filter Keyword", "clean_log": "Clean Log", "confirm_clean_log": "Confirm clean launch log", "exec_time": "Exec Time", "server": "Server", "cmd": "Command", "cost_time": "Cost", "refresh": "Refresh" }, "status": { "uptime": "Uptime", "connected_clients": "Clients", "total_keys": "Keys", "memory_used": "Memory", "server_info": "Server Info", "activity_status": "Activity", "act_cmd": "Commands/Sec", "act_network_input": "Network Input", "act_network_output": "Network Output", "client": { "title": "Client List", "addr": "Client Address", "age": "Age (sec)", "idle": "Idle (sec)", "db": "Database" } }, "slog": { "title": "Slow Log", "limit": "Limit", "filter": "Filter", "exec_time": "Time", "client": "Client", "cmd": "Command", "cost_time": "Cost" }, "monitor": { "title": "Monitor Commands", "actions": "Actions", "warning": "Command monitoring may cause server blocking, use with caution on production servers.", "start": "Start", "stop": "Stop", "search": "Search", "copy_log": "Copy Log", "save_log": "Save Log", "clean_log": "Clean Log", "always_show_last": "Auto Scroll to Latest" }, "pubsub": { "title": "Pub/Sub", "publish": "Publish", "subscribe": "Subscribe", "unsubscribe": "Unsubscribe", "clear": "Clear Messages", "time": "Time", "filter": "Filter", "channel": "Channel", "message": "Message", "receive_message": "Received {total} messages", "always_show_last": "Auto Scroll to Latest" } } ================================================ FILE: frontend/src/langs/es-es.json ================================================ { "name": "Español", "common": { "confirm": "Confirmar", "cancel": "Cancelar", "success": "Éxito", "warning": "Advertencia", "error": "Error", "save": "Guardar", "update": "Actualizar", "none": "Ninguno", "second": "Segundo(s)", "minute": "Minuto(s)", "hour": "Hora(s)", "day": "Día(s)", "unit_day": "d", "unit_hour": "h", "unit_minute": "m", "unit_second": "s", "all": "Todos", "key": "Clave", "value": "Valor", "field": "Campo", "score": "Puntuación", "index": "Posición" }, "preferences": { "name": "Preferencias", "restore_defaults": "Restaurar valores predeterminados", "font_tip": "Admite selección múltiple. Ingrese manualmente la fuente si no está en la lista.", "general": { "name": "General", "theme": "Tema", "theme_light": "Claro", "theme_dark": "Oscuro", "theme_auto": "Automático", "language": "Idioma", "system_lang": "Usar el idioma del sistema", "font": "Fuente", "font_tip": "Seleccione o ingrese el nombre de la fuente", "font_size": "Tamaño de fuente", "scan_size": "Tamaño predeterminado para SCAN", "scan_size_tip": "Número de elementos devueltos por los comandos SCAN/HSCAN/SSCAN/ZSCAN", "key_icon_style": "Estilo de icono de clave", "key_icon_style0": "Compacto", "key_icon_style1": "Nombre completo", "key_icon_style2": "Punto", "key_icon_style3": "Común", "update": "Actualizar", "auto_check_update": "Buscar actualizaciones automáticamente", "privacy": "Política de Privacidad", "allow_track": "Permitir recopilar datos anónimos" }, "editor": { "name": "Editor", "show_linenum": "Mostrar números de línea", "show_folding": "Habilitar plegado de código", "drop_text": "Permitir arrastrar y soltar texto", "links": "Compatibilidad con enlaces" }, "cli": { "name": "Línea de comandos", "cursor_style": "Estilo del cursor", "cursor_style_block": "Bloque", "cursor_style_underline": "Subrayado", "cursor_style_bar": "Barra" }, "decoder": { "name": "Decodificador personalizado", "new": "Nuevo decodificador", "decoder_name": "Nombre", "cmd_preview": "Vista previa", "status": "Estado", "auto_enabled": "Decodificación automática habilitada", "help": "Ayuda" } }, "interface": { "new_conn": "Agregar conexión", "new_group": "Agregar grupo", "disconnect_all": "Desconectar todo", "status": "Estado", "filter": "Filtrar", "sort_conn": "Ordenar conexiones", "new_conn_title": "Nueva conexión", "open_db": "Abrir base de datos", "close_db": "Cerrar base de datos", "filter_key": "Filtrar claves", "disconnect": "Desconectar", "dup_conn": "Duplicar conexión", "remove_conn": "Eliminar conexión", "edit_conn": "Editar conexión", "edit_conn_group": "Editar grupo", "rename_conn_group": "Renombrar grupo", "remove_conn_group": "Eliminar grupo", "import_conn": "Importar conexiones...", "export_conn": "Exportar conexiones...", "ttl": "TTL", "forever": "Siempre", "rename_key": "Renombrar clave", "delete_key": "Eliminar clave", "batch_delete_key": "Eliminar claves en lote", "import_key": "Importar claves", "flush_db": "Vaciar base de datos", "check_mode": "Modo de selección", "quit_check_mode": "Salir del modo de selección", "delete_checked": "Eliminar seleccionados", "export_checked": "Exportar seleccionados", "ttl_checked": "Actualizar TTL para seleccionados", "copy_value": "Copiar valor", "edit_value": "Editar valor", "save_update": "Guardar cambios", "score_filter_tip": "Soporte para operadores:\n= igual\n!= distinto\n> mayor que\n>= mayor o igual\n< menor que\n<= menor o igual\ne.j. >3 para puntuaciones mayores que 3", "add_row": "Insertar fila", "edit_row": "Editar fila", "delete_row": "Eliminar fila", "fullscreen": "Pantalla completa", "offscreen": "Salir de pantalla completa", "pin_edit": "Fijar (permanece abierto después de guardar)", "unpin_edit": "Desfijar", "search": "Buscar", "full_search": "Búsqueda de texto completo", "full_search_result": "Contenido coincidente '{pattern}'", "filter_field": "Filtrar campo", "filter_value": "Filtrar valor", "length": "Longitud", "entries": "Entradas", "memory_usage": "Uso de memoria", "text_align_left": "Alinear a la izquierda", "text_align_center": "Centrar", "view_as": "Ver como", "decode_with": "Decodificar / Descomprimir", "custom_decoder": "Nuevo decodificador personalizado", "reload": "Recargar", "reload_disable": "Recargar después de cargar completamente", "auto_refresh": "Actualización automática", "refresh_interval": "Intervalo de actualización", "open_connection": "Abrir conexión", "copy_path": "Copiar ruta", "copy_key": "Copiar clave", "save_value_succ": "¡Valor guardado!", "copy_succ": "¡Copiado al portapapeles!", "binary_key": "Clave binaria", "remove_key": "Eliminar clave", "new_key": "Nueva clave", "load_more": "Cargar más claves", "load_all": "Cargar todas las claves restantes", "load_more_entries": "Cargar más", "load_all_entries": "Cargar todo", "more_action": "Más acciones", "nonexist_tab_content": "La clave seleccionada no existe o ninguna seleccionada. Intente nuevamente después de actualizar.", "empty_server_content": "Seleccione y abra una conexión desde el panel izquierdo", "empty_server_list": "No se ha agregado ningún servidor Redis", "action": "Acción", "type": "Tipo", "cli_welcome": "Bienvenido a la consola Redis de Tiny RDM", "retrieving_version": "Buscando actualizaciones", "sub_tab": { "status": "Estado", "key_detail": "Detalles de clave", "cli": "Consola", "slow_log": "Registro lento", "cmd_monitor": "Monitorear comandos", "pub_message": "Pub/Sub" } }, "ribbon": { "server": "Servidor", "browser": "Explorador de datos", "log": "Registro", "wechat_official": "Cuenta oficial de WeChat", "follow_x": "Seguir \uD835\uDD4F", "github": "Github", "logout": "Cerrar sesión" }, "dialogue": { "close_confirm": "¿Cerrar esta conexión ({name})?", "edit_close_confirm": "Cierre las conexiones relevantes antes de editar. ¿Continuar?", "opening_connection": "Abriendo conexión...", "interrupt_connection": "Cancelar", "remove_tip": "{type} \"{name}\" será eliminado", "remove_group_tip": "El grupo \"{name}\" y todas sus conexiones serán eliminados", "rename_binary_key_fail": "No se admite renombrar claves binarias", "handle_succ": "¡Éxito!", "handle_cancel": "Operación cancelada.", "reload_succ": "¡Recargado!", "field_required": "Este campo es obligatorio", "spec_field_required": "\"{key}\" es obligatorio", "illegal_characters": "Contiene caracteres ilegales", "connection": { "new_title": "Nueva conexión", "edit_title": "Editar conexión", "general": "General", "no_group": "Sin grupo", "group": "Grupo", "conn_name": "Nombre", "addr": "Dirección", "usr": "Usuario", "pwd": "Contraseña", "name_tip": "Nombre de la conexión", "addr_tip": "Dirección del servidor Redis", "sock_tip": "Archivo de socket Unix de Redis", "usr_tip": "(Opcional) Usuario de autenticación", "pwd_tip": "(Opcional) Contraseña de autenticación (Redis > 6.0)", "test": "Probar conexión", "test_succ": "Conectado con éxito al servidor Redis", "test_fail": "Falló la conexión", "parse_url_clipboard": "Analizar URL desde el portapapeles", "parse_pass": "URL de Redis analizada: {url}", "parse_fail": "Error al analizar la URL de Redis: {reason}", "advn": { "title": "Avanzado", "filter": "Filtro de clave predeterminado", "filter_tip": "Patrón para filtrar las claves cargadas", "separator": "Separador de clave", "separator_tip": "Separador para segmentos de ruta de clave", "conn_timeout": "Tiempo de espera de conexión", "exec_timeout": "Tiempo de espera de ejecución", "dbfilter_type": "Filtro de base de datos", "dbfilter_all": "Mostrar todo", "dbfilter_show": "Mostrar seleccionados", "dbfilter_hide": "Ocultar seleccionados", "dbfilter_show_title": "Bases de datos a mostrar", "dbfilter_hide_title": "Bases de datos a ocultar", "dbfilter_input": "Ingresar índice de base de datos", "dbfilter_input_tip": "Presione Enter para confirmar", "key_view": "Vista de clave predeterminada", "key_view_tree": "Vista de árbol", "key_view_list": "Vista de lista", "load_size": "Claves por carga", "mark_color": "Color de marca" }, "alias": { "title": "Alias de base de datos", "db": "Ingresar índice de base de datos", "value": "Ingresar alias de base de datos" }, "ssl": { "title": "SSL/TLS", "enable": "Habilitar SSL/TLS", "allow_insecure": "Permitir inseguro", "sni": "Nombre de servidor (SNI)", "sni_tip": "(Opcional) Nombre del servidor", "cert_file": "Archivo de clave pública", "key_file": "Archivo de clave privada", "ca_file": "Archivo CA", "cert_file_tip": "Archivo de clave pública en formato PEM (Cert)", "key_file_tip": "Archivo de clave privada en formato PEM (Key)", "ca_file_tip": "Archivo de autoridad de certificación en formato PEM (CA)" }, "ssh": { "enable": "Habilitar túnel SSH", "title": "Túnel SSH", "login_type": "Tipo de inicio de sesión", "agent": "Agente SSH", "pkfile": "Archivo de clave privada", "passphrase": "Frase de contraseña", "addr_tip": "Dirección del servidor SSH", "usr_tip": "Usuario SSH", "pwd_tip": "Contraseña SSH", "pkfile_tip": "Ruta del archivo de clave privada SSH", "passphrase_tip": "(Opcional) Frase de contraseña para la clave privada" }, "sentinel": { "title": "Centinela", "enable": "Como nodo centinela", "master": "Nombre del grupo maestro", "auto_discover": "Descubrimiento automático", "password": "Contraseña del maestro", "username": "Usuario del maestro", "pwd_tip": "(Opcional) Contraseña de autenticación del maestro (Redis > 6.0)", "usr_tip": "(Opcional) Usuario de autenticación del maestro" }, "cluster": { "title": "Clúster", "enable": "Como nodo de clúster" }, "proxy": { "title": "Proxy", "type_none": "Sin proxy", "type_system": "Proxy del sistema", "type_custom": "Proxy manual", "host": "Nombre de host", "auth": "Autenticación de proxy", "usr_tip": "Usuario de autenticación de proxy", "pwd_tip": "Contraseña de autenticación de proxy" } }, "group": { "name": "Nombre del grupo", "rename": "Renombrar grupo", "new": "Nuevo grupo" }, "key": { "new": "Nueva clave", "new_name": "Nuevo nombre de clave", "server": "Conexión", "db_index": "Índice de base de datos", "key_expression": "Patrón de clave", "affected_key": "Claves afectadas", "show_affected_key": "Mostrar claves afectadas", "confirm_delete_key": "Confirmar eliminar {num} clave(s)", "direct_delete": "Eliminar el patrón coincidente directamente", "confirm_delete": "Confirmar eliminación", "async_delete": "Ejecución asíncrona", "async_delete_title": "No esperar el resultado", "confirm_flush": "¡Sé lo que estoy haciendo!", "confirm_flush_db": "Confirmar vaciar la base de datos" }, "delete": { "success": "\"{key}\" eliminada", "deleting": "Eliminando", "doing": "Eliminando clave ({index}/{count})", "completed": "Eliminación completada, {success} tuvieron éxito, {fail} fallaron" }, "field": { "new": "Nuevo campo", "new_item": "Nuevo elemento", "conflict_handle": "En conflicto de campo", "overwrite_field": "Sobrescribir", "ignore_field": "Ignorar", "insert_type": "Tipo de inserción", "append_item": "Anexar", "prepend_item": "Anteponer", "enter_key": "Ingresar clave", "enter_value": "Ingresar valor", "enter_field": "Ingresar nombre de campo", "enter_elem": "Ingresar elemento", "enter_member": "Ingresar miembro", "enter_score": "Ingresar puntuación", "element": "Elemento", "reload_when_succ": "Recargar inmediatamente si tiene éxito" }, "filter": { "set_key_filter": "Establecer filtro de clave", "filter_pattern": "Patrón", "filter_pattern_tip": "Filtre la lista actual ingresando directamente, y escanee el servidor presionando 'Ingresar'.\n\n* coincide con 0 o más caracteres, ej. 'key*'\n? coincide con un carácter, ej. 'key?'\n[] coincide con un rango, ej. 'key[1-3]'\n\\ escapa caracteres especiales", "exact_match_tip": "Coincidencia exacta", "filter_type_not_support": "El filtrado por tipo no es compatible con Redis 5.x y versiones anteriores" }, "export": { "name": "Exportar datos", "export_expire_title": "Expiración", "export_expire": "Incluir expiración", "export": "Exportar", "save_file": "Ruta de exportación", "save_file_tip": "Seleccionar ruta para guardar archivo exportado", "exporting": "Exportando claves ({index}/{count})", "export_completed": "Exportación completada, {success} tuvieron éxito, {fail} fallaron" }, "import": { "name": "Importar datos", "import_expire_title": "Expiración", "import": "Importar", "reload": "Recargar después de importar", "open_csv_file": "Archivo de importación", "open_csv_file_tip": "Seleccionar archivo a importar", "conflict_handle": "En conflicto de clave", "conflict_overwrite": "Sobrescribir", "conflict_ignore": "Ignorar", "ttl_include": "Importar desde archivo", "ttl_ignore": "No establecer", "ttl_custom": "Personalizado", "importing": "Importando claves importadas/sobrescritas:{imported} conflicto/fallas:{conflict}", "import_completed": "Importación completada, {success} tuvieron éxito, {ignored} ignoradas" }, "ttl": { "title": "Actualizar TTL", "title_batch": "Actualizar TTL en lote ({count})", "quick_set": "Configurar rápidamente", "success": "TTL actualizado para todas las claves" }, "decoder": { "name": "Nuevo decodificador/codificador", "edit_name": "Editar decodificador/codificador", "new": "Nuevo", "decoder": "Decodificador", "encoder": "Codificador", "decoder_name": "Nombre", "auto": "Decodificar automáticamente", "decode_path": "Ruta del decodificador", "encode_path": "Ruta del codificador", "path_help": "Ruta al ejecutable, o alias de cli como 'sh/php/python'", "args": "Argumentos", "args_help": "Use [VALUE] como marcador de posición para codificar/decodificar contenido. El contenido se agregará al final si no se proporciona marcador de posición." }, "upgrade": { "title": "Nueva versión disponible", "new_version_tip": "Nueva versión {ver} disponible, ¿descargar ahora?", "no_update": "Está actualizado", "download_now": "Descargar ahora", "later": "Más tarde", "skip": "Omitir esta versión" }, "welcome": { "title": "¡Bienvenido a Tiny RDM!", "content": "Con el fin de brindar una mejor experiencia de usuario, Tiny RDM recopila algunos datos anónimos para ayudar a optimizar el software y mejorar la experiencia del usuario. Tenga la seguridad de que esto no involucrará su información de privacidad personal.\n\nSi tiene alguna inquietud, puede desactivar esta función de recopilación de datos en cualquier momento yendo a Preferencias. Si tiene alguna pregunta, no dude en ponerse en contacto con el desarrollador. ¡Espero que Tiny RDM pueda ser un buen asistente para usted!", "accept": "Ayudar a Mejorar", "reject": "Rechazar" }, "about": { "source": "Código fuente", "website": "Sitio web oficial" } }, "login": { "username_placeholder": "Ingrese usuario", "password_placeholder": "Ingrese contraseña", "submit": "Entrar", "too_many_attempts": "Demasiados intentos, intente más tarde", "invalid_credentials": "Credenciales inválidas", "network_error": "Error de red" }, "menu": { "minimise": "Minimizar", "maximise": "Maximizar", "restore": "Restaurar", "close": "Cerrar", "preferences": "Preferencias", "help": "Ayuda", "user_guide": "Guía de usuario", "check_update": "Buscar actualizaciones...", "report_bug": "Reportar error", "about": "Acerca de" }, "log": { "title": "Registro de lanzamiento", "filter_server": "Filtrar servidor", "filter_keyword": "Filtrar palabra clave", "clean_log": "Limpiar registro", "confirm_clean_log": "Confirmar limpiar registro de lanzamiento", "exec_time": "Tiempo de ejecución", "server": "Servidor", "cmd": "Comando", "cost_time": "Costo", "refresh": "Actualizar" }, "status": { "uptime": "Tiempo activo", "connected_clients": "Clientes", "total_keys": "Claves", "memory_used": "Memoria", "server_info": "Información del servidor", "activity_status": "Actividad", "act_cmd": "Comandos/Seg", "act_network_input": "Entrada de red", "act_network_output": "Salida de red", "client": { "title": "Lista de clientes", "addr": "Dirección del cliente", "age": "Edad (seg)", "idle": "Inactivo (seg)", "db": "Base de datos" } }, "slog": { "title": "Registro lento", "limit": "Límite", "filter": "Filtrar", "exec_time": "Tiempo", "client": "Cliente", "cmd": "Comando", "cost_time": "Costo" }, "monitor": { "title": "Monitorear comandos", "actions": "Acciones", "warning": "El monitoreo de comandos puede causar bloqueos en el servidor, úselo con precaución en servidores de producción.", "start": "Iniciar", "stop": "Detener", "search": "Buscar", "copy_log": "Copiar registro", "save_log": "Guardar registro", "clean_log": "Limpiar registro", "always_show_last": "Desplazamiento automático al último" }, "pubsub": { "title": "Pub/Sub", "publish": "Publicar", "subscribe": "Suscribir", "unsubscribe": "Cancelar suscripción", "clear": "Limpiar mensajes", "time": "Tiempo", "filter": "Filtrar", "channel": "Canal", "message": "Mensaje", "receive_message": "Recibidos {total} mensajes", "always_show_last": "Desplazamiento automático al último" } } ================================================ FILE: frontend/src/langs/fr-fr.json ================================================ { "name": "Français", "common": { "confirm": "Confirmer", "cancel": "Annuler", "success": "Succès", "warning": "Avertissement", "error": "Erreur", "save": "Enregistrer", "update": "Mettre à jour", "none": "Aucun", "second": "Seconde(s)", "minute": "Minute(s)", "hour": "Heure(s)", "day": "Jour(s)", "unit_day": "j", "unit_hour": "h", "unit_minute": "m", "unit_second": "s", "all": "Tous", "key": "Clé", "value": "Valeur", "field": "Champ", "score": "Score", "index": "Position" }, "preferences": { "name": "Préférences", "restore_defaults": "Restaurer les valeurs par défaut", "font_tip": "Supporte la sélection multiple. Saisir manuellement la police si elle n'est pas listée.", "general": { "name": "Général", "theme": "Thème", "theme_light": "Clair", "theme_dark": "Sombre", "theme_auto": "Automatique", "language": "Langue", "system_lang": "Utiliser la langue du système", "font": "Police", "font_tip": "Sélectionner ou saisir le nom de la police", "font_size": "Taille de la police", "scan_size": "Taille par défaut pour SCAN", "scan_size_tip": "Nombre d'éléments retournés par les commandes SCAN/HSCAN/SSCAN/ZSCAN", "key_icon_style": "Style d'icône de clé", "key_icon_style0": "Compact", "key_icon_style1": "Nom complet", "key_icon_style2": "Point", "key_icon_style3": "Commun", "update": "Mise à jour", "auto_check_update": "Vérifier automatiquement les mises à jour", "privacy": "Politique de confidentialité", "allow_track": "Autoriser la collecte de données anonymes" }, "editor": { "name": "Éditeur", "show_linenum": "Afficher les numéros de ligne", "show_folding": "Activer le repliage de code", "drop_text": "Autoriser le glisser-déposer de texte", "links": "Supporter les liens" }, "cli": { "name": "Ligne de commande", "cursor_style": "Style du curseur", "cursor_style_block": "Bloc", "cursor_style_underline": "Soulignement", "cursor_style_bar": "Barre" }, "decoder": { "name": "Décodeur personnalisé", "new": "Nouveau décodeur", "decoder_name": "Nom", "cmd_preview": "Aperçu", "status": "Statut", "auto_enabled": "Décodage automatique activé", "help": "Aide" } }, "interface": { "new_conn": "Ajouter une connexion", "new_group": "Ajouter un groupe", "disconnect_all": "Déconnecter tout", "status": "Statut", "filter": "Filtre", "sort_conn": "Trier les connexions", "new_conn_title": "Nouvelle connexion", "open_db": "Ouvrir la base de données", "close_db": "Fermer la base de données", "filter_key": "Filtrer les clés", "disconnect": "Déconnecter", "dup_conn": "Dupliquer la connexion", "remove_conn": "Supprimer la connexion", "edit_conn": "Éditer la connexion", "edit_conn_group": "Éditer le groupe", "rename_conn_group": "Renommer le groupe", "remove_conn_group": "Supprimer le groupe", "import_conn": "Importer des connexions...", "export_conn": "Exporter des connexions...", "ttl": "TTL", "forever": "Pour toujours", "rename_key": "Renommer la clé", "delete_key": "Supprimer la clé", "batch_delete_key": "Supprimer les clés par lot", "import_key": "Importer des clés", "flush_db": "Vider la base de données", "check_mode": "Mode de vérification", "quit_check_mode": "Quitter le mode de vérification", "delete_checked": "Supprimer les éléments cochés", "export_checked": "Exporter les éléments cochés", "ttl_checked": "Mettre à jour le TTL des éléments cochés", "copy_value": "Copier la valeur", "edit_value": "Éditer la valeur", "save_update": "Enregistrer les modifications", "score_filter_tip": "Supporte les opérateurs :\n= égal\n!= différent\n> supérieur à \n>= supérieur ou égal\n< inférieur à\n<= inférieur ou égal\nEx: >3 pour les scores supérieurs à 3", "add_row": "Insérer une ligne", "edit_row": "Éditer la ligne", "delete_row": "Supprimer la ligne", "fullscreen": "Plein écran", "offscreen": "Quitter le plein écran", "pin_edit": "Épingler (rester ouvert après enregistrement)", "unpin_edit": "Désépingler", "search": "Rechercher", "full_search": "Recherche en texte intégral", "full_search_result": "Contenu correspondant à '{pattern}'", "filter_field": "Filtrer le champ", "filter_value": "Valeur de filtrage", "length": "Longueur", "entries": "Entrées", "memory_usage": "Utilisation de la mémoire", "text_align_left": "Aligner à gauche", "text_align_center": "Centrer", "view_as": "Voir comme", "decode_with": "Décoder / Décompresser", "custom_decoder": "Nouveau décodeur personnalisé", "reload": "Recharger", "reload_disable": "Recharger après chargement complet", "auto_refresh": "Rafraîchissement automatique", "refresh_interval": "Intervalle de rafraîchissement", "open_connection": "Ouvrir la connexion", "copy_path": "Copier le chemin", "copy_key": "Copier la clé", "save_value_succ": "Valeur enregistrée !", "copy_succ": "Copié dans le presse-papiers !", "binary_key": "Clé binaire", "remove_key": "Supprimer la clé", "new_key": "Nouvelle clé", "load_more": "Charger plus de clés", "load_all": "Charger toutes les clés restantes", "load_more_entries": "Charger plus", "load_all_entries": "Charger tout", "more_action": "Plus d'actions", "nonexist_tab_content": "La clé sélectionnée n'existe pas ou aucune clé n'est sélectionnée. Réessayez après un rafraîchissement.", "empty_server_content": "Sélectionnez et ouvrez une connexion depuis le panneau de gauche", "empty_server_list": "Aucun serveur Redis ajouté", "action": "Action", "type": "Type", "cli_welcome": "Bienvenue dans la console Redis de Tiny RDM", "retrieving_version": "Vérification des mises à jour", "sub_tab": { "status": "Statut", "key_detail": "Détails de la clé", "cli": "Console", "slow_log": "Journal lent", "cmd_monitor": "Surveiller les commandes", "pub_message": "Pub/Sub" } }, "ribbon": { "server": "Serveur", "browser": "Navigateur de données", "log": "Journal", "wechat_official": "Compte officiel WeChat", "follow_x": "Suivre \uD835\uDD4F", "github": "Github", "logout": "Se déconnecter" }, "dialogue": { "close_confirm": "Fermer cette connexion ({name}) ?", "edit_close_confirm": "Veuillez fermer les connexions appropriées avant l'édition. Continuer ?", "opening_connection": "Ouverture de la connexion...", "interrupt_connection": "Annuler", "remove_tip": "{type} \"{name}\" sera supprimé", "remove_group_tip": "Le groupe \"{name}\" et toutes ses connexions seront supprimés", "rename_binary_key_fail": "Le renommage des clés binaires n'est pas pris en charge", "handle_succ": "Succès !", "handle_cancel": "Opération annulée.", "reload_succ": "Rechargé !", "field_required": "Ce champ est obligatoire", "spec_field_required": "\"{key}\" est requis", "illegal_characters": "Contient des caractères illégaux", "connection": { "new_title": "Nouvelle connexion", "edit_title": "Éditer la connexion", "general": "Général", "no_group": "Aucun groupe", "group": "Groupe", "conn_name": "Nom", "addr": "Adresse", "usr": "Nom d'utilisateur", "pwd": "Mot de passe", "name_tip": "Nom de la connexion", "addr_tip": "Adresse du serveur Redis", "sock_tip": "Fichier de socket Unix Redis", "usr_tip": "(Optionnel) Nom d'utilisateur d'authentification", "pwd_tip": "(Optionnel) Mot de passe d'authentification (Redis > 6.0)", "test": "Tester la connexion", "test_succ": "Connecté avec succès au serveur Redis", "test_fail": "Échec de la connexion", "parse_url_clipboard": "Analyser l'URL depuis le presse-papiers", "parse_pass": "URL Redis analysée : {url}", "parse_fail": "Échec de l'analyse de l'URL Redis : {reason}", "advn": { "title": "Avancé", "filter": "Filtre de clé par défaut", "filter_tip": "Modèle pour filtrer les clés chargées", "separator": "Séparateur de clé", "separator_tip": "Séparateur pour les segments de chemin de clé", "conn_timeout": "Délai d'expiration de la connexion", "exec_timeout": "Délai d'exécution", "dbfilter_type": "Filtre de base de données", "dbfilter_all": "Tout afficher", "dbfilter_show": "Afficher la sélection", "dbfilter_hide": "Masquer la sélection", "dbfilter_show_title": "Bases de données à afficher", "dbfilter_hide_title": "Bases de données à masquer", "dbfilter_input": "Saisir l'index de la base de données", "dbfilter_input_tip": "Appuyer sur Entrée pour confirmer", "key_view": "Vue de clé par défaut", "key_view_tree": "Vue arborescente", "key_view_list": "Vue liste", "load_size": "Clés par chargement", "mark_color": "Couleur de marquage" }, "alias": { "title": "Alias de base de données", "db": "Saisir l'index de la base de données", "value": "Saisir l'alias de la base de données" }, "ssl": { "title": "SSL/TLS", "enable": "Activer SSL/TLS", "allow_insecure": "Autoriser les connexions non sécurisées", "sni": "Nom du serveur (SNI)", "sni_tip": "(Optionnel) Nom du serveur", "cert_file": "Fichier de clé publique", "key_file": "Fichier de clé privée", "ca_file": "Fichier CA", "cert_file_tip": "Fichier de clé publique au format PEM(Cert)", "key_file_tip": "Fichier de clé privée au format PEM(Key)", "ca_file_tip": "Fichier d'autorité de certification au format PEM(CA)" }, "ssh": { "enable": "Activer le tunnel SSH", "title": "Tunnel SSH", "login_type": "Type de connexion", "agent": "Agent SSH", "pkfile": "Fichier de clé privée", "passphrase": "Phrase secrète", "addr_tip": "Adresse SSH", "usr_tip": "Nom d'utilisateur SSH", "pwd_tip": "Mot de passe SSH", "pkfile_tip": "Chemin du fichier de clé privée SSH", "passphrase_tip": "(Optionnel) Phrase secrète pour la clé privée" }, "sentinel": { "title": "Sentinelle", "enable": "En tant que noeud sentinelle", "master": "Nom du groupe principal", "auto_discover": "Découverte automatique", "password": "Mot de passe principal", "username": "Nom d'utilisateur principal", "pwd_tip": "(Optionnel) Mot de passe d'authentification principal (Redis > 6.0)", "usr_tip": "(Optionnel) Nom d'utilisateur d'authentification principal" }, "cluster": { "title": "Cluster", "enable": "En tant que noeud de cluster" }, "proxy": { "title": "Proxy", "type_none": "Pas de proxy", "type_system": "Proxy système", "type_custom": "Proxy manuel", "host": "Nom d'hôte", "auth": "Authentification proxy", "usr_tip": "Nom d'utilisateur d'authentification proxy", "pwd_tip": "Mot de passe d'authentification proxy" } }, "group": { "name": "Nom du groupe", "rename": "Renommer le groupe", "new": "Nouveau groupe" }, "key": { "new": "Nouvelle clé", "new_name": "Nouveau nom de clé", "server": "Connexion", "db_index": "Index de la base de données", "key_expression": "Modèle de clé", "affected_key": "Clés affectées", "show_affected_key": "Afficher les clés affectées", "confirm_delete_key": "Confirmer la suppression de {num} clé(s)", "direct_delete": "Supprimer le modèle correspondant directement", "confirm_delete": "Confirmer la suppression", "async_delete": "Exécution asynchrone", "async_delete_title": "Ne pas attendre le résultat", "confirm_flush": "Je sais ce que je fais !", "confirm_flush_db": "Confirmer le vidage de la base de données" }, "delete": { "success": "\"{key}\" supprimé", "deleting": "Suppression en cours", "doing": "Suppression de la clé ({index}/{count})", "completed": "Suppression terminée, {success} réussies, {fail} échouées" }, "field": { "new": "Nouveau champ", "new_item": "Nouvel élément", "conflict_handle": "En cas de conflit de champ", "overwrite_field": "Écraser", "ignore_field": "Ignorer", "insert_type": "Type d'insertion", "append_item": "Ajouter", "prepend_item": "Insérer en tête", "enter_key": "Saisir la clé", "enter_value": "Saisir la valeur", "enter_field": "Saisir le nom du champ", "enter_elem": "Saisir l'élément", "enter_member": "Saisir le membre", "enter_score": "Saisir le score", "element": "Élément", "reload_when_succ": "Recharger immédiatement en cas de réussite" }, "filter": { "set_key_filter": "Définir le filtre de clé", "filter_pattern": "Modèle", "filter_pattern_tip": "Filtrez la liste actuelle en saisissant directement, et scannez le serveur en appuyant sur 'Entrée'.\n\n* correspond à 0 ou plusieurs caractères, ex : 'key*'\n? correspond à un seul caractère, ex : 'key?'\n[] correspond à une plage, ex : 'key[1-3]' échappe les caractères spéciaux", "exact_match_tip": "Correspondance exacte", "filter_type_not_support": "Le filtrage par type n’est pas pris en charge pour Redis 5.x et les versions antérieures" }, "export": { "name": "Exporter les données", "export_expire_title": "Expiration", "export_expire": "Inclure l'expiration", "export": "Exporter", "save_file": "Chemin d'exportation", "save_file_tip": "Sélectionner le chemin pour enregistrer le fichier exporté", "exporting": "Exportation des clés ({index}/{count})", "export_completed": "Exportation terminée, {success} réussies, {fail} échouées" }, "import": { "name": "Importer des données", "import_expire_title": "Expiration", "reload": "Recharger après l'importation", "import": "Importer", "open_csv_file": "Fichier d'importation", "open_csv_file_tip": "Sélectionner le fichier à importer", "conflict_handle": "En cas de conflit de clé", "conflict_overwrite": "Écraser", "conflict_ignore": "Ignorer", "ttl_include": "Importer depuis le fichier", "ttl_ignore": "Ne pas définir", "ttl_custom": "Personnalisé", "importing": "Importation des clés importées/écrasées:{imported} conflit/échouées:{conflict}", "import_completed": "Importation terminée, {success} réussies, {ignored} ignorées" }, "ttl": { "title": "Mettre à jour le TTL", "title_batch": "Mise à jour par lot du TTL ({count})", "quick_set": "Définition rapide", "success": "TTL mis à jour pour toutes les clés" }, "decoder": { "name": "Nouveau décodeur/encodeur", "edit_name": "Éditer le décodeur/encodeur", "new": "Nouveau", "decoder": "Décodeur", "encoder": "Encodeur", "decoder_name": "Nom", "auto": "Décodage automatique", "decode_path": "Chemin du décodeur", "encode_path": "Chemin de l'encodeur", "path_help": "Chemin de l'exécutable, ou alias cli comme 'sh/php/python'", "args": "Arguments", "args_help": "Utiliser [VALUE] comme espace réservé pour le contenu de codage/décodage. Le contenu sera ajouté à la fin si aucun espace réservé n'est fourni." }, "upgrade": { "title": "Nouvelle version disponible", "new_version_tip": "Nouvelle version {ver} disponible, télécharger maintenant ?", "no_update": "Vous êtes à jour", "download_now": "Télécharger maintenant", "later": "Plus tard", "skip": "Ignorer cette version" }, "welcome": { "title": "Bienvenue dans Tiny RDM!", "content": "Afin d'offrir une meilleure expérience utilisateur, Tiny RDM collecte certaines données anonymes pour aider à optimiser le logiciel et améliorer l'expérience utilisateur. Soyez assuré que cela n'implique pas vos informations personnelles et privées.\n\nSi vous avez des inquiétudes, vous pouvez désactiver cette fonction de collecte de données à tout moment en allant dans les Préférences. Si vous avez des questions, n'hésitez pas à contacter le développeur. J'espère que Tiny RDM pourra être un bon assistant pour vous!", "accept": "Aider à Améliorer", "reject": "Rejeter" }, "about": { "source": "Code source", "website": "Site officiel" } }, "login": { "username_placeholder": "Entrez le nom d'utilisateur", "password_placeholder": "Entrez le mot de passe", "submit": "Se connecter", "too_many_attempts": "Trop de tentatives, réessayez plus tard", "invalid_credentials": "Identifiants invalides", "network_error": "Erreur réseau" }, "menu": { "minimise": "Minimiser", "maximise": "Maximiser", "restore": "Restaurer", "close": "Fermer", "preferences": "Préférences", "help": "Aide", "user_guide": "Guide de l'utilisateur", "check_update": "Vérifier les mises à jour...", "report_bug": "Signaler un bug", "about": "À propos" }, "log": { "title": "Journal de lancement", "filter_server": "Filtrer le serveur", "filter_keyword": "Filtrer les mots-clés", "clean_log": "Nettoyer le journal", "confirm_clean_log": "Confirmer le nettoyage du journal de lancement", "exec_time": "Heure d'exécution", "server": "Serveur", "cmd": "Commande", "cost_time": "Coût", "refresh": "Rafraîchir" }, "status": { "uptime": "Temps de fonctionnement", "connected_clients": "Clients connectés", "total_keys": "Nombre total de clés", "memory_used": "Mémoire utilisée", "server_info": "Informations serveur", "activity_status": "Activité", "act_cmd": "Commandes/Sec", "act_network_input": "Entrée réseau", "act_network_output": "Sortie réseau", "client": { "title": "Liste des clients", "addr": "Adresse client", "age": "Âge (sec)", "idle": "Inactif (sec)", "db": "Base de données" } }, "slog": { "title": "Journal lent", "limit": "Limite", "filter": "Filtre", "exec_time": "Heure", "client": "Client", "cmd": "Commande", "cost_time": "Coût" }, "monitor": { "title": "Surveiller les commandes", "actions": "Actions", "warning": "La surveillance des commandes peut bloquer le serveur, à utiliser avec prudence sur les serveurs de production.", "start": "Démarrer", "stop": "Arrêter", "search": "Rechercher", "copy_log": "Copier le journal", "save_log": "Enregistrer le journal", "clean_log": "Nettoyer le journal", "always_show_last": "Défilement automatique vers le dernier message" }, "pubsub": { "title": "Pub/Sub", "publish": "Publier", "subscribe": "S'abonner", "unsubscribe": "Se désabonner", "clear": "Effacer les messages", "time": "Heure", "filter": "Filtre", "channel": "Canal", "message": "Message", "receive_message": "{total} messages reçus", "always_show_last": "Défilement automatique vers le dernier message" } } ================================================ FILE: frontend/src/langs/index.js ================================================ import en from './en-us' import pt from './pt-br' import zh from './zh-cn' import tw from './zh-tw' import ko from './ko-kr' import ja from './ja-jp' import es from './es-es' import fr from './fr-fr' import ru from './ru-ru' import tr from './tr-tr' export const lang = { en, es, fr, ja, ko, pt, ru, zh, tw, tr, } ================================================ FILE: frontend/src/langs/ja-jp.json ================================================ { "name": "日本語", "common": { "confirm": "確認", "cancel": "キャンセル", "success": "成功", "warning": "警告", "error": "エラー", "save": "保存", "update": "更新", "none": "なし", "second": "秒", "minute": "分", "hour": "時間", "day": "日", "unit_day": "日", "unit_hour": "時間", "unit_minute": "分", "unit_second": "秒", "all": "すべて", "key": "キー", "value": "値", "field": "フィールド", "score": "スコア", "index": "位置" }, "preferences": { "name": "設定", "restore_defaults": "デフォルトに戻す", "font_tip": "複数選択可能、インストール済みのフォントがリストにない場合は手動で入力できます", "general": { "name": "一般設定", "theme": "テーマ", "theme_light": "ライトモード", "theme_dark": "ダークモード", "theme_auto": "自動", "language": "言語", "system_lang": "システム言語を使用", "font": "フォント", "font_tip": "フォント名を選択または入力してください", "font_size": "フォントサイズ", "scan_size": "SCANコマンドのデフォルトサイズ", "scan_size_tip": "SCAN/HSCAN/SSCAN/ZSCAN コマンドで1回に返される要素の数", "key_icon_style": "キーアイコンのスタイル", "key_icon_style0": "コンパクトタイプ", "key_icon_style1": "フルネームタイプ", "key_icon_style2": "ドットタイプ", "key_icon_style3": "共通アイコン", "update": "更新", "auto_check_update": "自動でアップデートを確認", "privacy": "プライバシーポリシー", "allow_track": "匿名データの収集を許可する" }, "editor": { "name": "エディター", "show_linenum": "行番号を表示", "show_folding": "コード折りたたみを有効化", "drop_text": "テキストのドラッグ&ドロップを許可", "links": "リンクをサポート" }, "cli": { "name": "コマンドライン", "cursor_style": "カーソルスタイル", "cursor_style_block": "ブロック", "cursor_style_underline": "アンダーライン", "cursor_style_bar": "バー" }, "decoder": { "name": "カスタムデコーダー", "new": "新しいデコーダー", "decoder_name": "名前", "cmd_preview": "プレビュー", "status": "ステータス", "auto_enabled": "自動デコーディングが有効化されました", "help": "ヘルプ" } }, "interface": { "new_conn": "新しい接続を追加", "new_group": "新しいグループを追加", "disconnect_all": "すべての接続を切断", "status": "ステータス", "filter": "フィルター", "sort_conn": "接続を並べ替え", "new_conn_title": "新しい接続", "open_db": "データベースを開く", "close_db": "データベースを閉じる", "filter_key": "キーをフィルター", "disconnect": "切断", "dup_conn": "接続を複製", "remove_conn": "接続を削除", "edit_conn": "接続設定を編集", "edit_conn_group": "グループを編集", "rename_conn_group": "グループ名を変更", "remove_conn_group": "グループを削除", "import_conn": "接続をインポート...", "export_conn": "接続をエクスポート...", "ttl": "TTL", "forever": "永久", "rename_key": "キー名を変更", "delete_key": "キーを削除", "batch_delete_key": "キーを一括削除", "import_key": "キーをインポート", "flush_db": "データベースをフラッシュ", "check_mode": "チェックモード", "quit_check_mode": "チェックモードを終了", "delete_checked": "チェックされたものを削除", "export_checked": "チェックされたものをエクスポート", "ttl_checked": "チェックされたもののTTLを更新", "copy_value": "値をコピー", "edit_value": "値を編集", "save_update": "変更を保存", "score_filter_tip": "以下の演算子を使って範囲をフィルターできます\n=:等しい\n!=:等しくない\n>:より大きい\n>=:以上\n<:より小さい\n<=:以下\n例)スコアが3よりも大きいものを検索する場合は、>3と入力します", "add_row": "行を挿入", "edit_row": "行を編集", "delete_row": "行を削除", "fullscreen": "全画面表示", "offscreen": "全画面表示を終了", "pin_edit": "編集ボックスを固定(保存後も閉じない)", "unpin_edit": "ピン留めを解除", "search": "検索", "full_search": "全文検索", "full_search_result": "コンテンツが'{pattern}'にマッチしました", "filter_field": "フィールドをフィルター", "filter_value": "値をフィルター", "length": "長さ", "entries": "エントリ", "memory_usage": "メモリ使用量", "text_align_left": "左揃え", "text_align_center": "中央揃え", "view_as": "表示形式", "decode_with": "デコード/解凍", "custom_decoder": "新しいカスタムデコーダー", "reload": "再読み込み", "reload_disable": "すべて読み込んだ後に再読み込みできます", "auto_refresh": "自動更新", "refresh_interval": "更新間隔", "open_connection": "接続を開く", "copy_path": "パスをコピー", "copy_key": "キーをコピー", "save_value_succ": "値を保存しました!", "copy_succ": "クリップボードにコピーしました!", "binary_key": "バイナリキー名", "remove_key": "キーを削除", "new_key": "新しいキー", "load_more": "キーをさらに読み込む", "load_all": "残りのすべてのキーを読み込む", "load_more_entries": "さらに読み込む", "load_all_entries": "すべて読み込む", "more_action": "その他の操作", "nonexist_tab_content": "選択したキーが存在しないか、キーが選択されていません。更新後に再試行してください。", "empty_server_content": "左のパネルから接続を選択して開いてください", "empty_server_list": "Redisサーバーが追加されていません", "action": "アクション", "type": "タイプ", "cli_welcome": "Tiny RDMのRedisコンソールへようこそ", "retrieving_version": "新しいバージョンを確認しています", "sub_tab": { "status": "ステータス", "key_detail": "キーの詳細", "cli": "コンソール", "slow_log": "スロー ログ", "cmd_monitor": "コマンドのモニタリング", "pub_message": "パブリッシュ/サブスクライブ" } }, "ribbon": { "server": "サーバー", "browser": "データ ブラウザ", "log": "ログ", "wechat_official": "Wechat 公式アカウント", "follow_x": "私の \uD835\uDD4F をフォローする", "github": "Github", "logout": "ログアウト" }, "dialogue": { "close_confirm": "この接続({name})を閉じますか?", "edit_close_confirm": "編集する前に関連する接続を閉じる必要があります。続行しますか?", "opening_connection": "接続を開いています...", "interrupt_connection": "キャンセル", "remove_tip": "{type} \"{name}\" が削除されます", "remove_group_tip": "グループ \"{name}\" とそのすべての接続が削除されます", "rename_binary_key_fail": "バイナリキーの名前は変更できません", "handle_succ": "成功しました!", "handle_cancel": "操作がキャンセルされました。", "reload_succ": "再読み込みしました!", "field_required": "この項目は必須です", "spec_field_required": "\"{key}\" は必須です", "illegal_characters": "不正な文字が含まれています", "connection": { "new_title": "新しい接続", "edit_title": "接続を編集", "general": "一般", "no_group": "グループなし", "group": "グループ", "conn_name": "名前", "addr": "アドレス", "usr": "ユーザー名", "pwd": "パスワード", "name_tip": "接続名", "addr_tip": "Redisサーバーのアドレス", "sock_tip": "Redisのunixソケットファイル", "usr_tip": "(オプション)認証ユーザー名", "pwd_tip": "(オプション)認証パスワード (Redis > 6.0)", "test": "接続をテスト", "test_succ": "Redisサーバーに正常に接続しました", "test_fail": "接続に失敗しました", "parse_url_clipboard": "クリップボードからURLを解析", "parse_pass": "RedisのURLを解析しました: {url}", "parse_fail": "RedisのURLを解析できませんでした: {reason}", "advn": { "title": "高度な設定", "filter": "デフォルトのキーフィルター", "filter_tip": "読み込むキーのパターン", "separator": "キーセパレーター", "separator_tip": "キーパスのセグメントの区切り文字", "conn_timeout": "接続タイムアウト", "exec_timeout": "実行タイムアウト", "dbfilter_type": "データベースフィルター", "dbfilter_all": "すべて表示", "dbfilter_show": "選択したものを表示", "dbfilter_hide": "選択したものを非表示", "dbfilter_show_title": "表示するデータベース", "dbfilter_hide_title": "非表示にするデータベース", "dbfilter_input": "データベースインデックスを入力", "dbfilter_input_tip": "Enterキーで確定", "key_view": "デフォルトのキービュー", "key_view_tree": "ツリービュー", "key_view_list": "リストビュー", "load_size": "1回の読み込みキー数", "mark_color": "マーク色" }, "alias": { "title": "データベースエイリアス", "db": "データベースインデックスを入力", "value": "エイリアスを入力" }, "ssl": { "title": "SSL/TLS", "enable": "SSL/TLSを有効化", "allow_insecure": "安全でない接続を許可", "sni": "サーバー名(SNI)", "sni_tip": "(オプション)サーバー名", "cert_file": "公開鍵ファイル", "key_file": "秘密鍵ファイル", "ca_file": "CAファイル", "cert_file_tip": "PEM形式の公開鍵ファイル(Cert)", "key_file_tip": "PEM形式の秘密鍵ファイル(Key)", "ca_file_tip": "PEM形式のCA証明書ファイル(CA)" }, "ssh": { "enable": "SSHトンネルを有効化", "title": "SSHトンネル", "login_type": "ログイン方式", "agent": "SSHエージェント", "pkfile": "秘密鍵ファイル", "passphrase": "パスフレーズ", "addr_tip": "SSHサーバーのアドレス", "usr_tip": "SSHユーザー名", "pwd_tip": "SSHパスワード", "pkfile_tip": "SSHの秘密鍵ファイルのパス", "passphrase_tip": "(オプション)秘密鍵のパスフレーズ" }, "sentinel": { "title": "センチネルモード", "enable": "センチネルノードとして", "master": "マスターグループ名", "auto_discover": "自動検出", "password": "マスターパスワード", "username": "マスターユーザー名", "pwd_tip": "(オプション)マスター認証パスワード (Redis > 6.0)", "usr_tip": "(オプション)マスター認証,ユーザー名" }, "cluster": { "title": "クラスターモード", "enable": "クラスターノードとして" }, "proxy": { "title": "プロキシ", "type_none": "プロキシなし", "type_system": "システムプロキシ設定を使用", "type_custom": "手動でプロキシを設定", "host": "ホスト名", "auth": "プロキシ認証を使用", "usr_tip": "プロキシ認証ユーザー名", "pwd_tip": "プロキシ認証パスワード" } }, "group": { "name": "グループ名", "rename": "グループ名を変更", "new": "新しいグループ" }, "key": { "new": "新しいキー", "new_name": "新しいキー名", "server": "接続", "db_index": "データベースインデックス", "key_expression": "キーパターン", "affected_key": "影響を受けるキー", "show_affected_key": "影響を受けるキーを表示", "confirm_delete_key": "{num}個のキーを削除することを確認", "direct_delete": "一致するパターンを直接削除", "confirm_delete": "削除を確認", "async_delete": "非同期実行", "async_delete_title": "結果を待たない", "confirm_flush": "自分が実行しようとしている操作を理解しています!", "confirm_flush_db": "データベースをフラッシュすることを確認" }, "delete": { "success": "\"{key}\" を削除しました", "deleting": "削除中", "doing": "キーを削除中 ({index}/{count})", "completed": "削除が完了しました。成功: {success}個、失敗: {fail}個" }, "field": { "new": "新しいフィールド", "new_item": "新しい項目", "conflict_handle": "フィールドが競合した場合", "overwrite_field": "上書き", "ignore_field": "無視", "insert_type": "挿入タイプ", "append_item": "末尾に追加", "prepend_item": "先頭に挿入", "enter_key": "キー名を入力", "enter_value": "値を入力", "enter_field": "フィールド名を入力", "enter_elem": "新しい要素を入力", "enter_member": "メンバーを入力", "enter_score": "スコアを入力", "element": "要素", "reload_when_succ": "成功したら即座に再読み込み" }, "filter": { "set_key_filter": "キーフィルターを設定", "filter_pattern": "パターン", "filter_pattern_tip": "直接入力して現在のリストをフィルタリングし、Enterキーを押すとサーバーをスキャンできます。\n\n*:0文字以上にマッチ。例:\"key*\"は\"key\"で始まるすべてのキーにマッチ\n?:1文字にマッチ。例:\"key?\"は\"key1\"、\"key2\"にマッチ\n[ ]:指定範囲の1文字にマッチ。例:\"key[1-3]\"は\"key1\"、\"key2\"、\"key3\"にマッチ\n\\:エスケープ文字。*、?、[、]をリテラルとして解釈したい場合は\"\\ \"をつける", "exact_match_tip": "完全一致", "filter_type_not_support": "タイプフィルタリングは、Redis 5.x 以前のバージョンには対応していません" }, "export": { "name": "データをエクスポート", "export_expire_title": "有効期限", "export_expire": "有効期限を含める", "export": "エクスポート", "save_file": "エクスポート先", "save_file_tip": "エクスポートファイルの保存先を選択", "exporting": "キーをエクスポート中 ({index}/{count})", "export_completed": "エクスポートが完了しました。成功: {success}個、失敗: {fail}個" }, "import": { "name": "データをインポート", "import_expire_title": "有効期限", "import": "インポート", "reload": "インポート後に再読み込み", "open_csv_file": "インポートファイル", "open_csv_file_tip": "インポートするファイルを選択", "conflict_handle": "キーが競合した場合", "conflict_overwrite": "上書き", "conflict_ignore": "無視", "ttl_include": "ファイルから読み込む", "ttl_ignore": "設定しない", "ttl_custom": "カスタム", "importing": "キーをインポート中 インポート/上書き:{imported} 競合/失敗:{conflict}", "import_completed": "インポートが完了しました。成功: {success}個、無視: {ignored}個" }, "ttl": { "title": "TTLを更新", "title_batch": "TTLを一括更新 ({count})", "quick_set": "クイック設定", "success": "すべてのキーのTTLが更新されました" }, "decoder": { "name": "新しいデコーダー/エンコーダー", "edit_name": "デコーダー/エンコーダーを編集", "new": "新規", "decoder": "デコーダー", "encoder": "エンコーダー", "decoder_name": "名前", "auto": "自動デコード", "decode_path": "デコーダーのパス", "encode_path": "エンコーダーのパス", "path_help": "実行ファイルのパスか、'sh/php/python'のようなCLIエイリアス", "args": "引数", "args_help": "エンコード/デコードするコンテンツの場所には[VALUE]を使ってください。プレースホルダーを指定しない場合は、コンテンツが最後に付加されます。" }, "upgrade": { "title": "新しいバージョンが利用可能です", "new_version_tip": "新しいバージョン{ver}が利用可能です。今すぐダウンロードしますか?", "no_update": "最新バージョンです", "download_now": "今すぐダウンロード", "later": "後で", "skip": "このバージョンをスキップ" }, "welcome": { "title": "Tiny RDMをご利用いただきありがとうございます!", "content": "ユーザーエクスペリエンスを改善するために、Tiny RDMは一部の匿名データを収集し、ソフトウェアの最適化とユーザーエクスペリエンスの向上に役立てています。個人プライバシー情報は含まれませんのでご安心ください。\n\nご不安な点がありましたら、いつでも「設定」から当該機能をオフにすることができます。ご不明な点がございましたら、開発者までお問い合わせください。Tiny RDMがお役に立てることを願っております。", "accept": "改善の支援", "reject": "拒否する" }, "about": { "source": "ソースコード", "website": "公式ウェブサイト" } }, "login": { "username_placeholder": "ユーザー名を入力", "password_placeholder": "パスワードを入力", "submit": "ログイン", "too_many_attempts": "試行回数が多すぎます", "invalid_credentials": "ユーザー名またはパスワードが正しくありません", "network_error": "ネットワークエラー" }, "menu": { "minimise": "最小化", "maximise": "最大化", "restore": "元に戻す", "close": "閉じる", "preferences": "設定", "help": "ヘルプ", "user_guide": "ユーザーガイド", "check_update": "アップデートを確認...", "report_bug": "バグを報告", "about": "このソフトについて" }, "log": { "title": "実行ログ", "filter_server": "サーバーをフィルター", "filter_keyword": "キーワードでフィルター", "clean_log": "ログをクリア", "confirm_clean_log": "実行ログをクリアしてよろしいですか?", "exec_time": "実行時間", "server": "サーバー", "cmd": "コマンド", "cost_time": "所要時間", "refresh": "すぐに更新" }, "status": { "uptime": "稼働時間", "connected_clients": "接続クライアント数", "total_keys": "合計キー数", "memory_used": "メモリ使用量", "server_info": "サーバー情報", "activity_status": "アクティビティ状況", "act_cmd": "コマンド実行数(/秒)", "act_network_input": "ネットワーク入力", "act_network_output": "ネットワーク出力", "client": { "title": "クライアント一覧", "addr": "クライアントアドレス", "age": "接続時間(秒)", "idle": "アイドル時間(秒)", "db": "データベース" } }, "slog": { "title": "スローログ", "limit": "上限", "filter": "フィルター", "exec_time": "実行時間", "client": "クライアント", "cmd": "コマンド", "cost_time": "所要時間" }, "monitor": { "title": "コマンドのモニタリング", "actions": "アクション", "warning": "コマンドのモニタリングは、サーバーをブロックする可能性があるため、運用環境のサーバーでは注意して使用してください。", "start": "開始", "stop": "停止", "search": "検索", "copy_log": "ログをコピー", "save_log": "ログを保存", "clean_log": "ログをクリア", "always_show_last": "最新に自動スクロール" }, "pubsub": { "title": "パブ/サブ", "publish": "パブリッシュ", "subscribe": "サブスクライブ開始", "unsubscribe": "サブスクライブ解除", "clear": "メッセージをクリア", "time": "時間", "filter": "フィルター", "channel": "チャンネル", "message": "メッセージ", "receive_message": "{total}件のメッセージを受信しました", "always_show_last": "最新に自動スクロール" } } ================================================ FILE: frontend/src/langs/ko-kr.json ================================================ { "name": "한국어", "common": { "confirm": "확인", "cancel": "취소", "success": "성공", "warning": "경고", "error": "오류", "save": "저장", "update": "업데이트", "none": "없음", "second": "초", "minute": "분", "hour": "시간", "day": "일", "unit_day": "일", "unit_hour": "시간", "unit_minute": "분", "unit_second": "초", "all": "전체", "key": "키", "value": "값", "field": "필드", "score": "점수", "index": "위치" }, "preferences": { "name": "설정", "restore_defaults": "기본값 복원", "font_tip": "다중 선택 지원, 목록에 없는 폰트는 직접 입력하세요", "general": { "name": "일반", "theme": "테마", "theme_light": "밝은 테마", "theme_dark": "어두운 테마", "theme_auto": "자동", "language": "언어", "system_lang": "시스템 언어 사용", "font": "폰트", "font_tip": "폰트 선택 또는 이름 입력", "font_size": "폰트 크기", "scan_size": "SCAN 기본 크기", "scan_size_tip": "SCAN/HSCAN/SSCAN/ZSCAN 명령에서 한 번에 반환되는 요소 수", "key_icon_style": "키 아이콘 스타일", "key_icon_style0": "간략한 타입", "key_icon_style1": "전체 이름", "key_icon_style2": "점 타입", "key_icon_style3": "일반 아이콘", "update": "업데이트", "auto_check_update": "자동 업데이트 확인", "privacy": "개인 정보 보호 정책", "allow_track": "익명 데이터 수집 허용" }, "editor": { "name": "에디터", "show_linenum": "줄번호 표시", "show_folding": "코드 폴딩 활성화", "drop_text": "텍스트 드래그 앤 드롭 허용", "links": "링크 지원" }, "cli": { "name": "명령줄", "cursor_style": "커서 스타일", "cursor_style_block": "블록", "cursor_style_underline": "밑줄", "cursor_style_bar": "바" }, "decoder": { "name": "사용자 정의 디코더", "new": "새 디코더", "decoder_name": "이름", "cmd_preview": "미리보기", "status": "상태", "auto_enabled": "자동 디코딩 활성화", "help": "도움말" } }, "interface": { "new_conn": "새 연결 추가", "new_group": "새 그룹 추가", "disconnect_all": "모든 연결 끊기", "status": "상태", "filter": "필터", "sort_conn": "연결 정렬", "new_conn_title": "새 연결", "open_db": "데이터베이스 열기", "close_db": "데이터베이스 닫기", "filter_key": "키 필터링", "disconnect": "연결 끊기", "dup_conn": "연결 복제", "remove_conn": "연결 제거", "edit_conn": "연결 편집", "edit_conn_group": "그룹 편집", "rename_conn_group": "그룹 이름 변경", "remove_conn_group": "그룹 제거", "import_conn": "연결 가져오기...", "export_conn": "연결 내보내기...", "ttl": "TTL", "forever": "영구", "rename_key": "키 이름 변경", "delete_key": "키 삭제", "batch_delete_key": "키 일괄 삭제", "import_key": "키 가져오기", "flush_db": "데이터베이스 플러시", "check_mode": "선택 모드", "quit_check_mode": "선택 모드 종료", "delete_checked": "선택 항목 삭제", "export_checked": "선택 항목 내보내기", "ttl_checked": "선택 항목 TTL 업데이트", "copy_value": "값 복사", "edit_value": "값 편집", "save_update": "변경사항 저장", "score_filter_tip": "다음 연산자로 범위 일치 가능\n=: 같음\n!=: 다름\n>: 큼\n<: 작음\n>=: 크거나 같음\n<=: 작거나 같음\n예) 점수가 3 이상인 결과: >3", "add_row": "행 삽입", "edit_row": "행 편집", "delete_row": "행 삭제", "fullscreen": "전체화면", "offscreen": "전체화면 종료", "pin_edit": "편집기 고정(저장 후 열린 상태 유지)", "unpin_edit": "고정 해제", "search": "검색", "full_search": "전체 텍스트 검색", "full_search_result": "'{pattern}'와 일치하는 내용", "filter_field": "필드 필터링", "filter_value": "값 필터링", "length": "길이", "entries": "항목 수", "memory_usage": "메모리 사용량", "text_align_left": "텍스트 왼쪽 정렬", "text_align_center": "텍스트 가운데 정렬", "view_as": "보기", "decode_with": "디코딩/압축 해제", "custom_decoder": "새 사용자 정의 디코더", "reload": "새로고침", "reload_disable": "전체 로드 후 새로고침 가능", "auto_refresh": "자동 새로고침", "refresh_interval": "새로고침 간격", "open_connection": "연결 열기", "copy_path": "경로 복사", "copy_key": "키 복사", "save_value_succ": "값 저장됨!", "copy_succ": "클립보드에 복사됨!", "binary_key": "바이너리 키 이름", "remove_key": "키 제거", "new_key": "새 키", "load_more": "더 많은 키 불러오기", "load_all": "남은 모든 키 불러오기", "load_more_entries": "더 불러오기", "load_all_entries": "모두 불러오기", "more_action": "더 많은 작업", "nonexist_tab_content": "선택한 키가 없거나 존재하지 않습니다. 새로고침 후 다시 시도하세요.", "empty_server_content": "왼쪽 패널에서 연결을 선택하고 열기", "empty_server_list": "추가된 Redis 서버 없음", "action": "작업", "type": "유형", "cli_welcome": "Tiny RDM Redis 콘솔 환영합니다", "retrieving_version": "업데이트 확인 중", "sub_tab": { "status": "상태", "key_detail": "키 상세정보", "cli": "콘솔", "slow_log": "슬로우 로그", "cmd_monitor": "명령 모니터링", "pub_message": "Pub/Sub" } }, "ribbon": { "server": "서버", "browser": "데이터 브라우저", "log": "로그", "wechat_official": "공식 계정", "follow_x": "팔로우 \uD835\uDD4F", "github": "Github", "logout": "로그아웃" }, "dialogue": { "close_confirm": "이 연결({name})을 종료하시겠습니까?", "edit_close_confirm": "편집 전에 관련 연결을 종료해야 합니다. 계속하시겠습니까?", "opening_connection": "연결 중...", "interrupt_connection": "취소", "remove_tip": "{type} \"{name}\"가 삭제됩니다", "remove_group_tip": "그룹 \"{name}\"과 그 안의 모든 연결이 삭제됩니다", "rename_binary_key_fail": "바이너리 키 이름 변경은 지원되지 않습니다", "handle_succ": "성공!", "handle_cancel": "작업이 취소되었습니다.", "reload_succ": "새로고침 완료!", "field_required": "이 필드는 필수입니다", "spec_field_required": "\"{key}\"는 필수입니다", "illegal_characters": "잘못된 문자가 포함되어 있습니다", "connection": { "new_title": "새 연결", "edit_title": "연결 편집", "general": "일반", "no_group": "그룹 없음", "group": "그룹", "conn_name": "이름", "addr": "주소", "usr": "사용자 이름", "pwd": "비밀번호", "name_tip": "연결 이름", "addr_tip": "Redis 서버 주소", "sock_tip": "Redis 유닉스 소켓 파일", "usr_tip": "(선택) 인증 사용자 이름", "pwd_tip": "(선택) 인증 비밀번호 (Redis > 6.0)", "test": "연결 테스트", "test_succ": "Redis 서버에 성공적으로 연결되었습니다", "test_fail": "연결 실패", "parse_url_clipboard": "클립보드에서 URL 분석", "parse_pass": "Redis URL이 분석되었습니다: {url}", "parse_fail": "Redis URL 분석 실패: {reason}", "advn": { "title": "고급", "filter": "기본 키 필터", "filter_tip": "로드할 키 패턴", "separator": "키 구분 기호", "separator_tip": "키 경로 구분 기호", "conn_timeout": "연결 시간 초과", "exec_timeout": "실행 시간 초과", "dbfilter_type": "데이터베이스 필터", "dbfilter_all": "모두 표시", "dbfilter_show": "선택 항목 표시", "dbfilter_hide": "선택 항목 숨기기", "dbfilter_show_title": "표시할 데이터베이스", "dbfilter_hide_title": "숨길 데이터베이스", "dbfilter_input": "데이터베이스 인덱스 입력", "dbfilter_input_tip": "Enter를 눌러 확인", "key_view": "기본 키 보기", "key_view_tree": "트리 보기", "key_view_list": "목록 보기", "load_size": "불러올 키 수", "mark_color": "표시 색상" }, "alias": { "title": "데이터베이스 별칭", "db": "데이터베이스 인덱스 입력", "value": "별칭 입력" }, "ssl": { "title": "SSL/TLS", "enable": "SSL/TLS 활성화", "allow_insecure": "안전하지 않은 연결 허용", "sni": "서버 이름(SNI)", "sni_tip": "(선택) 서버 이름", "cert_file": "공개 키 파일", "key_file": "개인 키 파일", "ca_file": "CA 파일", "cert_file_tip": "PEM 형식 공개 키 파일(Cert)", "key_file_tip": "PEM 형식 개인 키 파일(Key)", "ca_file_tip": "PEM 형식 CA 파일(CA)" }, "ssh": { "enable": "SSH 터널 활성화", "title": "SSH 터널", "login_type": "로그인 유형", "agent": "SSH 에이전트", "pkfile": "개인 키 파일", "passphrase": "암호구문", "addr_tip": "SSH 주소", "usr_tip": "SSH 사용자 이름", "pwd_tip": "SSH 비밀번호", "pkfile_tip": "SSH 개인 키 파일 경로", "passphrase_tip": "(선택) SSH 개인 키 암호구문" }, "sentinel": { "title": "센티널 모드", "enable": "현재 센티널 노드", "master": "마스터 그룹 이름", "auto_discover": "자동 탐색", "password": "마스터 비밀번호", "username": "마스터 사용자 이름", "pwd_tip": "(선택) 마스터 인증 비밀번호 (Redis > 6.0)", "usr_tip": "(선택) 마스터 인증 사용자 이름" }, "cluster": { "title": "클러스터 모드", "enable": "현재 클러스터 노드" }, "proxy": { "title": "프록시", "type_none": "프록시 사용 안함", "type_system": "시스템 프록시 설정 사용", "type_custom": "프록시 수동 설정", "host": "호스트명", "auth": "인증 사용", "usr_tip": "프록시 인증 사용자 이름", "pwd_tip": "프록시 인증 비밀번호" } }, "group": { "name": "그룹 이름", "rename": "그룹 이름 변경", "new": "새 그룹" }, "key": { "new": "새 키", "new_name": "새 키 이름", "server": "연결", "db_index": "데이터베이스 인덱스", "key_expression": "키 패턴", "affected_key": "영향받는 키", "show_affected_key": "영향받는 키 표시", "confirm_delete_key": "{num}개의 키를 삭제하시겠습니까?", "direct_delete": "일치하는 패턴 직접 삭제", "confirm_delete": "삭제 확인", "async_delete": "비동기 실행", "async_delete_title": "결과를 기다리지 않음", "confirm_flush": "진행 중인 작업을 알고 있습니다!", "confirm_flush_db": "데이터베이스 플러시 확인" }, "delete": { "success": "\"{key}\"가 삭제되었습니다", "deleting": "삭제 중", "doing": "키 삭제 중 ({index}/{count})", "completed": "삭제가 완료되었습니다. 성공: {success}개, 실패: {fail}개" }, "field": { "new": "새 필드", "new_item": "새 항목", "conflict_handle": "필드 충돌 시", "overwrite_field": "덮어쓰기", "ignore_field": "무시", "insert_type": "삽입 유형", "append_item": "추가", "prepend_item": "앞에 추가", "enter_key": "키 입력", "enter_value": "값 입력", "enter_field": "필드 이름 입력", "enter_elem": "요소 입력", "enter_member": "멤버 입력", "enter_score": "점수 입력", "element": "요소", "reload_when_succ": "성공하면 즉시 새로고침" }, "filter": { "set_key_filter": "키 필터 설정", "filter_pattern": "패턴", "filter_pattern_tip": "직접 입력하여 현재 목록을 필터링하고, Enter키를 누르면 서버를 스캔할 수 있습니다.\n\n* 0개 이상의 문자 일치, 예) 'key*'\n? 단일 문자 일치, 예) 'key?'\n[] 범위 일치, 예) 'key[1-3]'\n\\ 특수문자 이스케이프", "exact_match_tip": "완전 일치", "filter_type_not_support": "타입 필터링은 Redis 5.x 및 이전 버전을 지원하지 않습니다" }, "export": { "name": "데이터 내보내기", "export_expire_title": "만료 시간", "export_expire": "만료 시간 포함", "export": "내보내기", "save_file": "내보내기 경로", "save_file_tip": "내보낼 파일 저장 경로 선택", "exporting": "키 내보내는 중 ({index}/{count})", "export_completed": "내보내기가 완료되었습니다. 성공: {success}개, 실패: {fail}개" }, "import": { "name": "데이터 가져오기", "import_expire_title": "만료 시간", "import": "가져오기", "reload": "가져오기 후 새로고침", "open_csv_file": "가져올 파일", "open_csv_file_tip": "가져올 파일 선택", "conflict_handle": "키 충돌 시", "conflict_overwrite": "덮어쓰기", "conflict_ignore": "무시", "ttl_include": "파일에서 가져오기", "ttl_ignore": "설정 안함", "ttl_custom": "직접 설정", "importing": "키 가져오는 중 가져오기/덮어쓰기:{imported} 충돌/실패:{conflict}", "import_completed": "가져오기가 완료되었습니다. 성공: {success}개, 무시: {ignored}개" }, "ttl": { "title": "TTL 업데이트", "title_batch": "TTL 일괄 업데이트 ({count})", "quick_set": "빠른 설정", "success": "모든 키의 TTL이 업데이트되었습니다" }, "decoder": { "name": "새 디코더/인코더", "edit_name": "디코더/인코더 편집", "new": "새로 만들기", "decoder": "디코더", "encoder": "인코더", "decoder_name": "이름", "auto": "자동 디코딩", "decode_path": "디코더 경로", "encode_path": "인코더 경로", "path_help": "실행 파일 경로 또는 sh/php/python과 같은 CLI 별칭", "args": "인수", "args_help": "[VALUE]를 인코딩/디코딩 내용 자리 표시자로 사용하세요. 자리 표시자가 없으면 끝에 추가됩니다." }, "upgrade": { "title": "새 버전 사용 가능", "new_version_tip": "새 버전 {ver}이 있습니다. 지금 다운로드하시겠습니까?", "no_update": "최신 버전입니다", "download_now": "지금 다운로드", "later": "나중에", "skip": "이 버전 건너뛰기" }, "welcome": { "title": "Tiny RDM에 오신 것을 환영합니다!", "content": "더 나은 사용자 경험을 제공하기 위해 Tiny RDM은 일부 익명 데이터를 수집하여 소프트웨어를 최적화하고 사용자 경험을 개선하는 데 사용합니다. 이는 개인 정보와는 무관함을 알려드립니다.\n\n만약 우려되는 점이 있다면 설정에서 이 데이터 수집 기능을 언제든 끌 수 있습니다. 문의 사항이 있으면 개발자에게 연락하시기 바랍니다. Tiny RDM이 좋은 도우미가 되길 바랍니다!", "accept": "개선에 동의", "reject": "거부" }, "about": { "source": "소스 코드", "website": "공식 웹사이트" } }, "login": { "username_placeholder": "사용자 이름 입력", "password_placeholder": "비밀번호 입력", "submit": "로그인", "too_many_attempts": "시도 횟수 초과, 잠시 후 다시 시도하세요", "invalid_credentials": "사용자 이름 또는 비밀번호가 올바르지 않습니다", "network_error": "네트워크 오류" }, "menu": { "minimise": "최소화", "maximise": "최대화", "restore": "복원", "close": "닫기", "preferences": "설정", "help": "도움말", "user_guide": "사용자 가이드", "check_update": "업데이트 확인...", "report_bug": "버그 신고", "about": "정보" }, "log": { "title": "실행 로그", "filter_server": "서버 필터링", "filter_keyword": "키워드 필터링", "clean_log": "로그 지우기", "confirm_clean_log": "실행 로그를 지우시겠습니까?", "exec_time": "실행 시간", "server": "서버", "cmd": "명령", "cost_time": "소요 시간", "refresh": "새로고침" }, "status": { "uptime": "가동 시간", "connected_clients": "클라이언트 수", "total_keys": "키 수", "memory_used": "메모리 사용량", "server_info": "서버 정보", "activity_status": "활동 현황", "act_cmd": "명령/초", "act_network_input": "네트워크 입력", "act_network_output": "네트워크 출력", "client": { "title": "클라이언트 목록", "addr": "클라이언트 주소", "age": "시간(초)", "idle": "유휴 시간(초)", "db": "데이터베이스" } }, "slog": { "title": "슬로우 로그", "limit": "제한", "filter": "필터", "exec_time": "시간", "client": "클라이언트", "cmd": "명령", "cost_time": "소요 시간" }, "monitor": { "title": "명령 모니터링", "actions": "작업", "warning": "명령 모니터링은 서버 차단을 유발할 수 있으므로 실서버에서는 주의해서 사용하세요.", "start": "시작", "stop": "정지", "search": "검색", "copy_log": "로그 복사", "save_log": "로그 저장", "clean_log": "로그 지우기", "always_show_last": "최신 내용으로 자동 스크롤" }, "pubsub": { "title": "Pub/Sub", "publish": "발행", "subscribe": "구독", "unsubscribe": "구독 취소", "clear": "메시지 지우기", "time": "시간", "filter": "필터", "channel": "채널", "message": "메시지", "receive_message": "{total}개의 메시지를 받았습니다", "always_show_last": "최신 내용으로 자동 스크롤" } } ================================================ FILE: frontend/src/langs/pt-br.json ================================================ { "name": "Português", "common": { "confirm": "Confirmar", "cancel": "Cancelar", "success": "Sucesso", "warning": "Aviso", "error": "Erro", "save": "Salvar", "update": "Atualizar", "none": "Nenhum", "second": "Segundo(s)", "minute": "Minuto(s)", "hour": "Hora(s)", "day": "Dia(s)", "unit_day": "d", "unit_hour": "h", "unit_minute": "m", "unit_second": "s", "all": "Tudo", "key": "Chave", "value": "Valor", "field": "Campo", "score": "Pontuação", "index": "Posição" }, "preferences": { "name": "Preferências", "restore_defaults": "Restaurar Padrões", "font_tip": "Suporta seleção múltipla. Digite manualmente a fonte se ela não estiver listada.", "general": { "name": "Geral", "theme": "Tema", "theme_light": "Claro", "theme_dark": "Escuro", "theme_auto": "Automático", "language": "Idioma", "system_lang": "Usar Idioma do Sistema", "font": "Fonte", "font_tip": "Selecione ou digite o nome da fonte", "font_size": "Tamanho da Fonte", "scan_size": "Tamanho Padrão para Comando SCAN", "scan_size_tip": "Número de elementos retornados por vez pelos comandos SCAN/HSCAN/SSCAN/ZSCAN", "key_icon_style": "Estilo do Ícone de Chave", "key_icon_style0": "Compacto", "key_icon_style1": "Nome Completo", "key_icon_style2": "Ponto", "key_icon_style3": "Comum", "update": "Atualizar", "auto_check_update": "Verificar atualizações automaticamente", "privacy": "Política de Privacidade", "allow_track": "Permitir a coleta de dados anônimos" }, "editor": { "name": "Editor", "show_linenum": "Mostrar Números de Linha", "show_folding": "Habilitar Dobra de Código", "drop_text": "Permitir Arrastar e Soltar Texto", "links": "Suportar Links" }, "cli": { "name": "Linha de Comando", "cursor_style": "Estilo do Cursor", "cursor_style_block": "Bloco", "cursor_style_underline": "Sublinhado", "cursor_style_bar": "Barra" }, "decoder": { "name": "Decodificador Personalizado", "new": "Novo Decodificador", "decoder_name": "Nome", "cmd_preview": "Visualizar", "status": "Status", "auto_enabled": "Decodificação Automática Habilitada", "help": "Ajuda" } }, "interface": { "new_conn": "Adicionar Nova Conexão", "new_group": "Adicionar Novo Grupo", "disconnect_all": "Desconectar Tudo", "status": "Status", "filter": "Filtro", "sort_conn": "Ordenar Conexões", "new_conn_title": "Nova Conexão", "open_db": "Abrir Banco de Dados", "close_db": "Fechar Banco de Dados", "filter_key": "Filtrar Chave", "disconnect": "Desconectar", "dup_conn": "Duplicar Conexão", "remove_conn": "Excluir Conexão", "edit_conn": "Editar Configuração da Conexão", "edit_conn_group": "Editar Grupo de Conexão", "rename_conn_group": "Renomear Grupo de Conexão", "remove_conn_group": "Excluir Grupo de Conexão", "import_conn": "Importar Conexões...", "export_conn": "Exportar Conexões...", "ttl": "TTL", "forever": "Para Sempre", "rename_key": "Renomear Chave", "delete_key": "Excluir Chave", "batch_delete_key": "Excluir Lotes de Chaves", "import_key": "Importar Chaves", "flush_db": "Limpar Banco de Dados", "check_mode": "Modo de Seleção", "quit_check_mode": "Sair do Modo de Seleção", "delete_checked": "Excluir Selecionados", "export_checked": "Exportar Selecionados", "ttl_checked": "Atualizar TTL para Selecionados", "copy_value": "Copiar Valor", "edit_value": "Editar Valor", "save_update": "Salvar Alterações", "score_filter_tip": "Lista de operadores suportados abaixo:\n= igual\n!= diferente\n> maior que\n>= maior ou igual a\n< menor que\n<= menor ou igual a\nPor exemplo, se você deseja filtrar resultados maiores que 3, insira: >3", "add_row": "Adicionar Linha", "edit_row": "Editar Linha", "delete_row": "Excluir Linha", "fullscreen": "Tela Cheia", "offscreen": "Sair da Tela Cheia", "pin_edit": "Fixar (Permanecer aberto após salvar)", "unpin_edit": "Desafixar", "search": "Buscar", "full_search": "Busca Completa", "full_search_result": "Conteúdo correspondente '{pattern}'", "filter_field": "Filtrar Campo", "filter_value": "Filtrar Valor", "length": "Tamanho", "entries": "Entradas", "memory_usage": "Uso de Memória", "text_align_left": "Alinhar à esquerda", "text_align_center": "Centralizar", "view_as": "Visualizar Como", "decode_with": "Decodificar / Descompressão", "custom_decoder": "Novo Decodificador Personalizado", "reload": "Recarregar", "reload_disable": "Recarregar após carregar completamente", "auto_refresh": "Atualização Automática", "refresh_interval": "Intervalo de Atualização", "open_connection": "Abrir Conexão", "copy_path": "Copiar Caminho", "copy_key": "Copiar Chave", "save_value_succ": "Valor Salvo!", "copy_succ": "Copiado para a Área de Transferência!", "binary_key": "Nome da Chave Binária", "remove_key": "Remover Chave", "new_key": "Adicionar Chave", "load_more": "Carregar Mais Chaves", "load_all": "Carregar Todas as Chaves Restantes", "load_more_entries": "Carregar Mais", "load_all_entries": "Carregar Tudo", "more_action": "Mais Ação", "nonexist_tab_content": "A chave selecionada não existe ou nenhuma chave está selecionada. Tente novamente após atualizar.", "empty_server_content": "Selecione e abra uma conexão à esquerda", "empty_server_list": "Nenhum servidor Redis adicionado", "action": "Ação", "type": "Tipo", "cli_welcome": "Bem-vindo ao Console Redis Tiny RDM", "retrieving_version": "Verificando atualizações", "sub_tab": { "status": "Status", "key_detail": "Detalhes da Chave", "cli": "Console", "slow_log": "Log Lento", "cmd_monitor": "Monitorar Comandos", "pub_message": "Pub/Sub" } }, "ribbon": { "server": "Servidor", "browser": "Navegador de Dados", "log": "Log", "wechat_official": "Conta Oficial do WeChat", "follow_x": "Siga \uD835\uDD4F", "github": "Github", "logout": "Sair" }, "dialogue": { "close_confirm": "Fechar esta conexão ({name})?", "edit_close_confirm": "Por favor, feche as conexões relevantes antes de editar. Deseja continuar?", "opening_connection": "Abrindo Conexão...", "interrupt_connection": "Cancelar", "remove_tip": "{type} \"{name}\" será excluído", "remove_group_tip": "O grupo \"{name}\" e todas as conexões nele serão excluídos", "rename_binary_key_fail": "Renomear nome de chave binária não é suportado", "handle_succ": "Sucesso!", "handle_cancel": "Operação cancelada.", "reload_succ": "Recarregado!", "field_required": "Este campo é obrigatório", "spec_field_required": "\"{key}\" é obrigatório", "illegal_characters": "Contém caracteres ilegais", "connection": { "new_title": "Nova Conexão", "edit_title": "Editar Conexão", "general": "Geral", "no_group": "Sem Grupo", "group": "Grupo", "conn_name": "Nome", "addr": "Endereço", "usr": "Nome de Usuário", "pwd": "Senha", "name_tip": "Nome da Conexão", "addr_tip": "Endereço do servidor Redis", "sock_tip": "Arquivo de socket unix do Redis", "usr_tip": "(Opcional) Nome de usuário para autenticação", "pwd_tip": "(Opcional) Senha de autenticação (Redis > 6.0)", "test": "Testar Conexão", "test_succ": "Conectado com sucesso ao servidor Redis", "test_fail": "Falha na Conexão", "parse_url_clipboard": "Analisar URL da Área de Transferência", "parse_pass": "URL Redis analisada: {url}", "parse_fail": "Falha ao analisar URL Redis: {reason}", "advn": { "title": "Avançado", "filter": "Filtro Padrão de Chave", "filter_tip": "Padrão que define as chaves carregadas do servidor Redis", "separator": "Separador de Chave", "separator_tip": "Separador para segmento do caminho da chave", "conn_timeout": "Tempo Limite de Conexão", "exec_timeout": "Tempo Limite de Execução", "dbfilter_type": "Filtro de Banco de Dados", "dbfilter_all": "Mostrar Todos", "dbfilter_show": "Mostrar Selecionados", "dbfilter_hide": "Ocultar Selecionados", "dbfilter_show_title": "Bancos de Dados a Mostrar", "dbfilter_hide_title": "Bancos de Dados a Ocultar", "dbfilter_input": "Índice do Banco de Dados de Entrada", "dbfilter_input_tip": "Pressione Enter para confirmar", "key_view": "Visualização Padrão de Chave", "key_view_tree": "Visualização em Árvore", "key_view_list": "Visualização em Lista", "load_size": "Chaves Por Carga", "mark_color": "Cor de Marcação" }, "alias": { "title": "Alias do Banco de Dados", "db": "Índice do Banco de Dados de Entrada", "value": "Alias do Banco de Dados de Entrada" }, "ssl": { "title": "SSL/TLS", "enable": "Habilitar SSL/TLS", "allow_insecure": "Permitir Inseguro", "sni": "Nome do Servidor (SNI)", "sni_tip": "(Opcional) Nome do servidor", "cert_file": "Arquivo de Chave Pública", "key_file": "Arquivo de Chave Privada", "ca_file": "Arquivo CA", "cert_file_tip": "Arquivo de Chave Pública no formato PEM (Cert)", "key_file_tip": "Arquivo de Chave Privada no formato PEM (Chave)", "ca_file_tip": "Arquivo de Autoridade de Certificação no formato PEM (CA)" }, "ssh": { "enable": "Habilitar Túnel SSH", "title": "Túnel SSH", "login_type": "Tipo de Login", "agent": "Agente SSH", "pkfile": "Arquivo de Chave Privada", "passphrase": "Frase de Senha", "addr_tip": "Endereço do Servidor SSH", "usr_tip": "Nome de Usuário SSH", "pwd_tip": "Senha SSH", "pkfile_tip": "Caminho do Arquivo de Chave Privada SSH", "passphrase_tip": "(Opcional) Frase de Senha para Chave Privada" }, "sentinel": { "title": "Sentinela", "enable": "Atuar como Nó Sentinela", "master": "Nome do Grupo Master", "auto_discover": "Auto Descoberta", "password": "Senha para Nó Master", "username": "Nome de Usuário para Nó Master", "pwd_tip": "(Opcional) Senha de autenticação no nó master (Redis > 6.0)", "usr_tip": "(Opcional) Nome de usuário para autenticação no nó master" }, "cluster": { "title": "Cluster", "enable": "Atuar como Nó Cluster" }, "proxy": { "title": "Proxy", "type_none": "Sem Proxy", "type_system": "Proxy do Sistema", "type_custom": "Proxy Manual", "host": "Nome do Host", "auth": "Autenticação de Proxy", "usr_tip": "Nome de usuário para autenticação de proxy", "pwd_tip": "Senha para autenticação de proxy" } }, "group": { "name": "Nome do Grupo", "rename": "Renomear Grupo", "new": "Novo Grupo" }, "key": { "new": "Nova Chave", "new_name": "Novo Nome da Chave", "server": "Conexão", "db_index": "Índice do Banco de Dados", "key_expression": "Expressão da Chave", "affected_key": "Chaves Afetadas", "show_affected_key": "Mostrar Chaves Afetadas", "confirm_delete_key": "Confirmar Exclusão de {num} Chave(s)", "direct_delete": "Excluir padrão correspondente diretamente", "confirm_delete": "Confirmar exclusão", "async_delete": "Execução Assíncrona", "async_delete_title": "Não esperar pelo resultado da operação", "confirm_flush": "Eu sei o que estou fazendo!", "confirm_flush_db": "Confirmar Limpar Banco de Dados" }, "delete": { "success": "\"{key}\" excluída", "deleting": "Excluindo", "doing": "Excluindo chave ({index}/{count})", "completed": "Exclusão concluída, {success} realizadas com sucesso, {fail} falharam" }, "field": { "new": "Novo Campo", "new_item": "Novo Item", "conflict_handle": "Em Conflito de Campo", "overwrite_field": "Sobrescrever", "ignore_field": "Ignorar", "insert_type": "Tipo de Inserção", "append_item": "Anexar", "prepend_item": "Inserir no Início", "enter_key": "Digite a Chave", "enter_value": "Digite o Valor", "enter_field": "Digite o Nome do Campo", "enter_elem": "Digite o Elemento", "enter_member": "Digite o Membro", "enter_score": "Digite a Pontuação", "element": "Elemento", "reload_when_succ": "Recarregar imediatamente após o sucesso" }, "filter": { "set_key_filter": "Definir Filtro de Chave", "filter_pattern": "Padrão", "filter_pattern_tip": "Filtre a lista atual inserindo diretamente, e escaneie o servidor pressionando 'Enter'.\n\n* corresponde a 0 ou mais caracteres, ex: 'chave*'\n? corresponde a um único caractere, ex: 'chave?'\n[] corresponde a um intervalo, ex: 'chave[1-3]'\n\\ escapa caracteres especiais", "exact_match_tip": "Correspondência Exata", "filter_type_not_support": "A filtragem por tipo não é suportada para Redis 5.x e versões anteriores" }, "export": { "name": "Exportar Dados", "export_expire_title": "Expiração", "export_expire": "Incluir Expiração", "export": "Exportar", "save_file": "Caminho de Exportação", "save_file_tip": "Selecione o caminho para salvar o arquivo exportado", "exporting": "Exportando chaves ({index}/{count})", "export_completed": "Exportação concluída, {success} realizadas com sucesso, {fail} falharam" }, "import": { "name": "Importar Dados", "import_expire_title": "Expiração", "import": "Importar", "reload": "Recarregar Após Importar", "open_csv_file": "Arquivo de Importação", "open_csv_file_tip": "Selecione o arquivo para importar", "conflict_handle": "Em Conflito de Chave", "conflict_overwrite": "Sobrescrever", "conflict_ignore": "Ignorar", "ttl_include": "Importar Do Arquivo", "ttl_ignore": "Não Definir", "ttl_custom": "Personalizado", "importing": "Importando chaves importadas/sobrescritas:{imported} conflito/falha:{conflict}", "import_completed": "Importação concluída, {success} realizadas com sucesso, {ignored} ignoradas" }, "ttl": { "title": "Atualizar TTL", "title_batch": "Atualização em Lote de TTL ({count})", "quick_set": "Definir Rapidamente", "success": "TTL atualizado para todas as chaves" }, "decoder": { "name": "Novo Decodificador/Codificador", "edit_name": "Editar Decodificador/Codificador", "new": "Novo", "decoder": "Decodificador", "encoder": "Codificador", "decoder_name": "Nome", "auto": "Decodificação Automática", "decode_path": "Caminho do Decodificador", "encode_path": "Caminho do Codificador", "path_help": "Caminho para executável ou alias de cli como 'sh/php/python'", "args": "Argumentos", "args_help": "Use [VALUE] como espaço reservado para conteúdo de codificação/decodificação. O conteúdo será anexado ao final se nenhum espaço reservado for fornecido." }, "upgrade": { "title": "Nova Versão Disponível", "new_version_tip": "Nova versão {ver} disponível, baixar agora?", "no_update": "Você está atualizado", "download_now": "Baixar Agora", "later": "Depois", "skip": "Ignorar Esta Versão" }, "welcome": { "title": "Bem-vindo ao Tiny RDM!", "content": "Para fornecer uma melhor experiência ao usuário, o Tiny RDM coleta alguns dados anônimos para ajudar a otimizar o software e melhorar a experiência do usuário. Fique tranquilo, isso não envolve suas informações de privacidade pessoal.\n\nSe você tiver alguma preocupação, pode desativar esse recurso de coleta de dados a qualquer momento indo em Preferências. Se tiver alguma dúvida, sinta-se à vontade para entrar em contato com o desenvolvedor. Espero que o Tiny RDM possa se tornar um assistente útil para você!", "accept": "Ajudar a Melhorar", "reject": "Rejeitar" }, "about": { "source": "Código Fonte", "website": "Site Oficial" } }, "login": { "username_placeholder": "Digite o usuário", "password_placeholder": "Digite a senha", "submit": "Entrar", "too_many_attempts": "Muitas tentativas, tente novamente mais tarde", "invalid_credentials": "Credenciais inválidas", "network_error": "Erro de rede" }, "menu": { "minimise": "Minimizar", "maximise": "Maximizar", "restore": "Restaurar", "close": "Fechar", "preferences": "Preferências", "help": "Ajuda", "user_guide": "Guia do Usuário", "check_update": "Verificar Atualizações...", "report_bug": "Reportar Erro", "about": "Sobre" }, "log": { "title": "Log de Inicialização", "filter_server": "Filtrar Servidor", "filter_keyword": "Filtrar Palavra-chave", "clean_log": "Limpar Log", "confirm_clean_log": "Confirmar limpar log de inicialização", "exec_time": "Tempo de Execução", "server": "Servidor", "cmd": "Comando", "cost_time": "Custo", "refresh": "Atualizar" }, "status": { "uptime": "Tempo de Atividade", "connected_clients": "Clientes Conectados", "total_keys": "Total de Chaves", "memory_used": "Memória Usada", "server_info": "Informações do Servidor", "activity_status": "Status da Atividade", "act_cmd": "Comandos/Seg", "act_network_input": "Entrada de Rede", "act_network_output": "Saída de Rede", "client": { "title": "Lista de Clientes", "addr": "Endereço do Cliente", "age": "Idade (seg)", "idle": "Ocioso (seg)", "db": "Banco de Dados" } }, "slog": { "title": "Log Lento", "limit": "Limite", "filter": "Filtrar", "exec_time": "Tempo", "client": "Cliente", "cmd": "Comando", "cost_time": "Custo" }, "monitor": { "title": "Monitorar Comandos", "actions": "Ações", "warning": "O monitoramento de comandos pode causar bloqueio do servidor, use com cuidado em servidores de produção.", "start": "Iniciar", "stop": "Parar", "search": "Buscar", "copy_log": "Copiar Log", "save_log": "Salvar Log", "clean_log": "Limpar Log", "always_show_last": "Rolar automaticamente para o mais recente" }, "pubsub": { "title": "Pub/Sub", "publish": "Publicar", "subscribe": "Inscrever", "unsubscribe": "Cancelar Inscrição", "clear": "Limpar Mensagens", "time": "Tempo", "filter": "Filtrar", "channel": "Canal", "message": "Mensagem", "receive_message": "Recebidas {total} mensagens", "always_show_last": "Rolar automaticamente para o mais recente" } } ================================================ FILE: frontend/src/langs/ru-ru.json ================================================ { "name": "Русский", "common": { "confirm": "Подтвердить", "cancel": "Отменить", "success": "Успех", "warning": "Предупреждение", "error": "Ошибка", "save": "Сохранить", "update": "Обновить", "none": "Нет", "second": "Секунда(ы)", "minute": "Минута(ы)", "hour": "Час(ы)", "day": "День(и)", "unit_day": "д", "unit_hour": "ч", "unit_minute": "м", "unit_second": "с", "all": "Все", "key": "Ключ", "value": "Значение", "field": "Поле", "score": "Счёт", "index": "Позиция" }, "preferences": { "name": "Настройки", "restore_defaults": "Восстановить настройки по умолчанию", "font_tip": "Поддерживается множественный выбор. Если установленный шрифт не указан в списке, введите его вручную.", "general": { "name": "Общие", "theme": "Тема", "theme_light": "Светлая", "theme_dark": "Тёмная", "theme_auto": "Авто", "language": "Язык", "system_lang": "Использовать язык системы", "font": "Шрифт", "font_tip": "Выберите или введите название шрифта", "font_size": "Размер шрифта", "scan_size": "Размер по умолчанию для SCAN", "scan_size_tip": "Количество элементов, возвращаемых за один раз командами SCAN/HSCAN/SSCAN/ZSCAN", "key_icon_style": "Стиль значка ключа", "key_icon_style0": "Компактный", "key_icon_style1": "Полное название", "key_icon_style2": "Точка", "key_icon_style3": "Обычный", "update": "Обновить", "auto_check_update": "Автоматически проверять обновления", "privacy": "Конфиденциальность", "allow_track": "Разрешить сбор анонимных данных" }, "editor": { "name": "Редактор", "show_linenum": "Показывать номера строк", "show_folding": "Включить сворачивание кода", "drop_text": "Разрешить перетаскивание текста", "links": "Поддержка ссылок" }, "cli": { "name": "Командная строка", "cursor_style": "Стиль курсора", "cursor_style_block": "Блок", "cursor_style_underline": "Подчёркнутый", "cursor_style_bar": "Линия" }, "decoder": { "name": "Пользовательский декодер", "new": "Новый декодер", "decoder_name": "Название", "cmd_preview": "Предпросмотр", "status": "Статус", "auto_enabled": "Автодекодирование включено", "help": "Помощь" } }, "interface": { "new_conn": "Добавить соединение", "new_group": "Добавить группу", "disconnect_all": "Отключить все", "status": "Статус", "filter": "Фильтр", "sort_conn": "Сортировать соединения", "new_conn_title": "Новое соединение", "open_db": "Открыть базу данных", "close_db": "Закрыть базу данных", "filter_key": "Фильтр ключей", "disconnect": "Отключить", "dup_conn": "Дублировать соединение", "remove_conn": "Удалить соединение", "edit_conn": "Редактировать соединение", "edit_conn_group": "Редактировать группу", "rename_conn_group": "Переименовать группу", "remove_conn_group": "Удалить группу", "import_conn": "Импортировать соединения...", "export_conn": "Экспортировать соединения...", "ttl": "TTL", "forever": "Навсегда", "rename_key": "Переименовать ключ", "delete_key": "Удалить ключ", "batch_delete_key": "Пакетное удаление ключей", "import_key": "Импортировать ключи", "flush_db": "Очистить базу данных", "check_mode": "Режим выбора", "quit_check_mode": "Выйти из режима выбора", "delete_checked": "Удалить выбранные", "export_checked": "Экспортировать выбранные", "ttl_checked": "Обновить TTL для выбранных", "copy_value": "Копировать значение", "edit_value": "Редактировать значение", "save_update": "Сохранить изменения", "score_filter_tip": "Поддерживаются операторы: \n= равно\n!= не равно\n> больше\n>= больше или равно\n< меньше\n<= меньше или равно\nНапример, >3 для значений больше 3", "add_row": "Вставить строку", "edit_row": "Редактировать строку", "delete_row": "Удалить строку", "fullscreen": "Полноэкранный режим", "offscreen": "Выйти из полноэкранного режима", "pin_edit": "Закрепить (не закрывать после сохранения)", "unpin_edit": "Открепить", "search": "Поиск", "full_search": "Полнотекстовый поиск", "full_search_result": "Содержимое соответствует '{pattern}'", "filter_field": "Фильтр полей", "filter_value": "Фильтр значений", "length": "Длина", "entries": "Записи", "memory_usage": "Использование памяти", "text_align_left": "Выравнивание по левому краю", "text_align_center": "Выравнивание по центру", "view_as": "Вид", "decode_with": "Декодировать/Распаковать", "custom_decoder": "Новый пользовательский декодер", "reload": "Перезагрузить", "reload_disable": "Перезагрузить после полной загрузки", "auto_refresh": "Автообновление", "refresh_interval": "Интервал обновления", "open_connection": "Открыть соединение", "copy_path": "Копировать путь", "copy_key": "Копировать ключ", "save_value_succ": "Значение сохранено!", "copy_succ": "Скопировано в буфер обмена!", "binary_key": "Двоичное имя ключа", "remove_key": "Удалить ключ", "new_key": "Новый ключ", "load_more": "Загрузить больше ключей", "load_all": "Загрузить все оставшиеся ключи", "load_more_entries": "Загрузить больше", "load_all_entries": "Загрузить все", "more_action": "Больше действий", "nonexist_tab_content": "Выбранный ключ не существует или не выбран. Попробуйте обновить.", "empty_server_content": "Выберите и откройте соединение с левой панели", "empty_server_list": "Нет добавленных серверов Redis", "action": "Действие", "type": "Тип", "cli_welcome": "Добро пожаловать в консоль Redis Tiny RDM", "retrieving_version": "Проверка обновлений", "sub_tab": { "status": "Статус", "key_detail": "Детали ключа", "cli": "Консоль", "slow_log": "Медленный лог", "cmd_monitor": "Мониторинг команд", "pub_message": "Публикация/Подписка" } }, "ribbon": { "server": "Сервер", "browser": "Браузер данных", "log": "Лог", "wechat_official": "Официальный аккаунт WeChat", "follow_x": "Подписаться на \uD835\uDD4F", "github": "Github", "logout": "Выйти" }, "dialogue": { "close_confirm": "Закрыть это соединение ({name})?", "edit_close_confirm": "Перед редактированием закройте соответствующие соединения. Продолжить?", "opening_connection": "Открытие соединения...", "interrupt_connection": "Отменить", "remove_tip": "{type} \"{name}\" будет удален(а/о)", "remove_group_tip": "Группа \"{name}\" и все её соединения будут удалены", "rename_binary_key_fail": "Переименование двоичного ключа не поддерживается", "handle_succ": "Успешно!", "handle_cancel": "Операция отменена.", "reload_succ": "Перезагружено!", "field_required": "Это поле обязательно для заполнения", "spec_field_required": "\"{key}\" требуется", "illegal_characters": "Содержит недопустимые символы", "connection": { "new_title": "Новое соединение", "edit_title": "Редактировать соединение", "general": "Общие", "no_group": "Без группы", "group": "Группа", "conn_name": "Название", "addr": "Адрес", "usr": "Имя пользователя", "pwd": "Пароль", "name_tip": "Название соединения", "addr_tip": "Адрес сервера Redis", "sock_tip": "Unix-сокет файл Redis", "usr_tip": "(Опционально) Имя пользователя для авторизации", "pwd_tip": "(Опционально) Пароль для авторизации (Redis > 6.0)", "test": "Проверить соединение", "test_succ": "Успешно подключено к серверу Redis", "test_fail": "Не удалось подключиться", "parse_url_clipboard": "Распарсить URL из буфера обмена", "parse_pass": "Redis URL распарсен: {url}", "parse_fail": "Не удалось распарсить Redis URL: {reason}", "advn": { "title": "Дополнительно", "filter": "Фильтр ключей по умолчанию", "filter_tip": "Шаблон для фильтрации загруженных ключей", "separator": "Разделитель ключей", "separator_tip": "Разделитель сегментов пути ключа", "conn_timeout": "Тайм-аут соединения", "exec_timeout": "Тайм-аут выполнения", "dbfilter_type": "Фильтр баз данных", "dbfilter_all": "Показать все", "dbfilter_show": "Показать выбранные", "dbfilter_hide": "Скрыть выбранные", "dbfilter_show_title": "Базы данных для показа", "dbfilter_hide_title": "Базы данных для скрытия", "dbfilter_input": "Введите индекс базы данных", "dbfilter_input_tip": "Нажмите Enter для подтверждения", "key_view": "Вид ключей по умолчанию", "key_view_tree": "Древовидный", "key_view_list": "Списком", "load_size": "Ключей за загрузку", "mark_color": "Цвет маркера" }, "alias": { "title": "Псевдонимы баз данных", "db": "Введите индекс базы данных", "value": "Введите псевдоним" }, "ssl": { "title": "SSL/TLS", "enable": "Включить SSL/TLS", "allow_insecure": "Разрешить небезопасные соединения", "sni": "Имя сервера (SNI)", "sni_tip": "(Опционально) Имя сервера", "cert_file": "Файл открытого ключа", "key_file": "Файл закрытого ключа", "ca_file": "Файл CA", "cert_file_tip": "Файл открытого ключа в формате PEM (Cert)", "key_file_tip": "Файл закрытого ключа в формате PEM (Key)", "ca_file_tip": "Файл авторитета сертификации в формате PEM (CA)" }, "ssh": { "enable": "Включить SSH-туннель", "title": "SSH-туннель", "login_type": "Тип входа", "agent": "SSH-агент", "pkfile": "Файл закрытого ключа", "passphrase": "Парольная фраза", "addr_tip": "Адрес SSH-сервера", "usr_tip": "Имя пользователя SSH", "pwd_tip": "Пароль SSH", "pkfile_tip": "Путь к файлу закрытого ключа SSH", "passphrase_tip": "(Опционально) Парольная фраза для закрытого ключа" }, "sentinel": { "title": "Сентинель", "enable": "В качестве узла Сентинеля", "master": "Имя группы мастера", "auto_discover": "Автоопределение", "password": "Пароль мастера", "username": "Имя пользователя мастера", "pwd_tip": "(Опционально) Пароль мастера для авторизации (Redis > 6.0)", "usr_tip": "(Опционально) Имя пользователя мастера для авторизации" }, "cluster": { "title": "Кластер", "enable": "В качестве узла кластера" }, "proxy": { "title": "Прокси", "type_none": "Без прокси", "type_system": "Прокси системы", "type_custom": "Ручная настройка прокси", "host": "Имя хоста", "auth": "Авторизация прокси", "usr_tip": "Имя пользователя для авторизации прокси", "pwd_tip": "Пароль для авторизации прокси" } }, "group": { "name": "Имя группы", "rename": "Переименовать группу", "new": "Новая группа" }, "key": { "new": "Новый ключ", "new_name": "Новое имя ключа", "server": "Соединение", "db_index": "Индекс базы данных", "key_expression": "Шаблон ключей", "affected_key": "Затронутые ключи", "show_affected_key": "Показать затронутые ключи", "confirm_delete_key": "Подтвердить удаление {num} ключ(ей/ей)", "direct_delete": "Удалить совпадающий шаблон напрямую", "confirm_delete": "Подтвердить удаление", "async_delete": "Асинхронное выполнение", "async_delete_title": "Не ждать результата", "confirm_flush": "Я знаю, что делаю!", "confirm_flush_db": "Подтвердить очистку базы данных" }, "delete": { "success": "\"{key}\" удален(а/о)", "deleting": "Удаление", "doing": "Удаление ключа ({index}/{count})", "completed": "Удаление завершено, {success} успешно, {fail} с ошибкой" }, "field": { "new": "Новое поле", "new_item": "Новый элемент", "conflict_handle": "При конфликте полей", "overwrite_field": "Перезаписать", "ignore_field": "Пропустить", "insert_type": "Тип вставки", "append_item": "Добавить в конец", "prepend_item": "Добавить в начало", "enter_key": "Введите ключ", "enter_value": "Введите значение", "enter_field": "Введите имя поля", "enter_elem": "Введите элемент", "enter_member": "Введите элемент", "enter_score": "Введите счёт", "element": "Элемент", "reload_when_succ": "Перезагрузить сразу после успеха" }, "filter": { "set_key_filter": "Установить фильтр ключей", "filter_pattern": "Шаблон", "filter_pattern_tip": "Отфильтруйте текущий список, введя напрямую, и выполните сканирование сервера, нажав 'Enter'.\n\n* соответствует 0 или более символов, напр. 'key*'\n? соответствует одному символу, напр. 'key?'\n[] соответствует диапазону, напр. 'key[1-3]'\n\\ экранирует спецсимволы", "exact_match_tip": "Точное совпадение", "filter_type_not_support": "Фильтрация по типу не поддерживается для Redis версии 5.x и ниже" }, "export": { "name": "Экспорт данных", "export_expire_title": "Срок истечения", "export_expire": "Включить срок истечения", "export": "Экспорт", "save_file": "Путь для экспорта", "save_file_tip": "Выберите путь для сохранения экспортируемого файла", "exporting": "Экспорт ключей ({index}/{count})", "export_completed": "Экспорт завершен, {success} успешно, {fail} с ошибкой" }, "import": { "name": "Импорт данных", "import_expire_title": "Срок истечения", "reload": "Перезагрузить после импорта", "import": "Импорт", "open_csv_file": "Импортировать файл", "open_csv_file_tip": "Выберите файл для импорта", "conflict_handle": "При конфликте ключей", "conflict_overwrite": "Перезаписать", "conflict_ignore": "Пропустить", "ttl_include": "Импортировать из файла", "ttl_ignore": "Не устанавливать", "ttl_custom": "Пользовательское", "importing": "Импорт ключей импортировано/перезаписано:{imported} конфликтов/ошибок:{conflict}", "import_completed": "Импорт завершен, {success} успешно, {ignored} пропущено" }, "ttl": { "title": "Обновить TTL", "title_batch": "Пакетное обновление TTL ({count})", "quick_set": "Быстрая установка", "success": "TTL обновлен для всех ключей" }, "decoder": { "name": "Новый декодер/энкодер", "edit_name": "Редактировать декодер/энкодер", "new": "Новый", "decoder": "Декодер", "encoder": "Энкодер", "decoder_name": "Название", "auto": "Автодекодирование", "decode_path": "Путь декодера", "encode_path": "Путь энкодера", "path_help": "Путь к исполняемому файлу или алиасу cli, например 'sh/php/python'", "args": "Аргументы", "args_help": "Используйте [VALUE] в качестве заменителя для кодирования/декодирования. Если заменитель не указан, содержимое будет добавлено в конец." }, "upgrade": { "title": "Доступна новая версия", "new_version_tip": "Доступна новая версия {ver}, загрузить сейчас?", "no_update": "У вас установлена последняя версия", "download_now": "Загрузить сейчас", "later": "Позже", "skip": "Пропустить эту версию" }, "welcome": { "title": "Добро пожаловать в Tiny RDM!", "content": "Для предоставления лучшего пользовательского опыта Tiny RDM собирает некоторые анонимные данные, чтобы помочь оптимизировать программное обеспечение и улучшить пользовательский опыт. Не беспокойтесь, это не будет затрагивать вашу личную конфиденциальную информацию.\n\nЕсли у вас есть какие-либо опасения, вы можете в любое время отключить сбор данных, перейдя в «Настройки». Если у вас есть какие-либо вопросы, обращайтесь к разработчику. Надеюсь, Tiny RDM станет вашим полезным помощником!", "accept": "Помочь улучшить", "reject": "Отклонить" }, "about": { "source": "Исходный код", "website": "Официальный сайт" } }, "login": { "username_placeholder": "Введите имя пользователя", "password_placeholder": "Введите пароль", "submit": "Войти", "too_many_attempts": "Слишком много попыток, попробуйте позже", "invalid_credentials": "Неверные учётные данные", "network_error": "Ошибка сети" }, "menu": { "minimise": "Свернуть", "maximise": "Развернуть", "restore": "Восстановить", "close": "Закрыть", "preferences": "Настройки", "help": "Помощь", "user_guide": "Руководство пользователя", "check_update": "Проверить обновления...", "report_bug": "Сообщить об ошибке", "about": "О программе" }, "log": { "title": "Журнал запуска", "filter_server": "Фильтр сервера", "filter_keyword": "Фильтр ключевых слов", "clean_log": "Очистить журнал", "confirm_clean_log": "Подтвердите очистку журнала запуска", "exec_time": "Время выполнения", "server": "Сервер", "cmd": "Команда", "cost_time": "Затраченное время", "refresh": "Обновить" }, "status": { "uptime": "Uptime", "connected_clients": "Клиенты", "total_keys": "Ключи", "memory_used": "Память", "server_info": "Информация о сервере", "activity_status": "Активность", "act_cmd": "Команд/сек", "act_network_input": "Входящий трафик", "act_network_output": "Исходящий трафик", "client": { "title": "Список клиентов", "addr": "Адрес клиента", "age": "Время (сек)", "idle": "Простой (сек)", "db": "База данных" } }, "slog": { "title": "Медленный журнал", "limit": "Лимит", "filter": "Фильтр", "exec_time": "Время", "client": "Клиент", "cmd": "Команда", "cost_time": "Затраченное время" }, "monitor": { "title": "Мониторинг команд", "actions": "Действия", "warning": "Мониторинг команд может вызвать блокировку сервера, используйте с осторожностью на производственных серверах.", "start": "Старт", "stop": "Стоп", "search": "Поиск", "copy_log": "Копировать журнал", "save_log": "Сохранить журнал", "clean_log": "Очистить журнал", "always_show_last": "Автоматическая прокрутка к последнему" }, "pubsub": { "title": "Публикация/Подписка", "publish": "Опубликовать", "subscribe": "Подписаться", "unsubscribe": "Отписаться", "clear": "Очистить сообщения", "time": "Время", "filter": "Фильтр", "channel": "Канал", "message": "Сообщение", "receive_message": "Получено сообщений: {total}", "always_show_last": "Автоматическая прокрутка к последнему" } } ================================================ FILE: frontend/src/langs/tr-tr.json ================================================ { "name": "Türkçe", "common": { "confirm": "Onayla", "cancel": "İptal", "success": "Başarılı", "warning": "Uyarı", "error": "Hata", "save": "Kaydet", "update": "Güncelle", "none": "Yok", "second": "Saniye", "minute": "Dakika", "hour": "Saat", "day": "Gün", "unit_day": "g", "unit_hour": "sa", "unit_minute": "dk", "unit_second": "sn", "all": "Tümü", "key": "Anahtar", "value": "Değer", "field": "Alan", "score": "Puan", "index": "Konum" }, "preferences": { "name": "Tercihler", "restore_defaults": "Varsayılanlara Dön", "font_tip": "Çoklu seçimi destekler. Listede yoksa yazı tipini manuel girin.", "general": { "name": "Genel", "theme": "Tema", "theme_light": "Açık", "theme_dark": "Koyu", "theme_auto": "Otomatik", "language": "Dil", "system_lang": "Sistem Dilini Kullan", "font": "Yazı Tipi", "font_tip": "Yazı tipi adını seçin veya girin", "font_size": "Yazı Boyutu", "scan_size": "SCAN için Varsayılan Boyut", "scan_size_tip": "SCAN/HSCAN/SSCAN/ZSCAN için varsayılan döndürülecek eleman sayısı", "key_icon_style": "Anahtar İkon Stili", "key_icon_style0": "Kompakt", "key_icon_style1": "Tam Ad", "key_icon_style2": "Nokta", "key_icon_style3": "Ortak", "update": "Güncelle", "auto_check_update": "Güncellemeleri otomatik kontrol et", "privacy": "Gizlilik", "allow_track": "Anonim veri toplanmasına izin ver" }, "editor": { "name": "Editör", "show_linenum": "Satır Numaralarını Göster", "show_folding": "Kod Katlamayı Etkinleştir", "drop_text": "Sürükle ve Bırak Metnine İzin Ver", "links": "Linkleri Destekle" }, "cli": { "name": "Komut Satırı", "cursor_style": "İmleç Stili", "cursor_style_block": "Blok", "cursor_style_underline": "Alt Çizgi", "cursor_style_bar": "Çubuk" }, "decoder": { "name": "Özel Kod Çözücü", "new": "Yeni Kod Çözücü", "decoder_name": "Ad", "cmd_preview": "Önizleme", "status": "Durum", "auto_enabled": "Otomatik Kod Çözme Etkin", "help": "Yardım" } }, "interface": { "new_conn": "Bağlantı Ekle", "new_group": "Grup Ekle", "disconnect_all": "Tümünün Bağlantısını Kes", "status": "Durum", "filter": "Filtre", "sort_conn": "Bağlantıları Sırala", "new_conn_title": "Yeni Bağlantı", "open_db": "Veritabanını Aç", "close_db": "Veritabanını Kapat", "filter_key": "Anahtarları Filtrele", "disconnect": "Bağlantıyı Kes", "dup_conn": "Bağlantıyı Çoğalt", "remove_conn": "Bağlantıyı Kaldır", "edit_conn": "Bağlantıyı Düzenle", "edit_conn_group": "Grubu Düzenle", "rename_conn_group": "Grubu Yeniden Adlandır", "remove_conn_group": "Grubu Kaldır", "import_conn": "Bağlantıları İçe Aktar...", "export_conn": "Bağlantıları Dışa Aktar...", "ttl": "TTL", "forever": "Süresiz", "rename_key": "Anahtarı Yeniden Adlandır", "delete_key": "Anahtarı Sil", "batch_delete_key": "Toplu Anahtar Sil", "import_key": "Anahtarları İçe Aktar", "flush_db": "Veritabanını Temizle", "check_mode": "Seçim Modu", "quit_check_mode": "Seçim Modundan Çık", "delete_checked": "Seçilenleri Sil", "export_checked": "Seçilenleri Dışa Aktar", "ttl_checked": "Seçilenler için TTL Güncelle", "copy_value": "Değeri Kopyala", "edit_value": "Değeri Düzenle", "save_update": "Değişiklikleri Kaydet", "score_filter_tip": "Operatörleri destekler:\n= eşit\n!= eşit değil\n> büyüktür\n>= büyük eşittir\n< küçüktür\n<= küçük eşittir\nörn. >3 üçten büyük puanlar için", "add_row": "Satır Ekle", "edit_row": "Satırı Düzenle", "delete_row": "Satırı Sil", "fullscreen": "Tam Ekran", "offscreen": "Tam Ekrandan Çık", "pin_edit": "Sabitle (Kaydedildikten sonra açık kal)", "unpin_edit": "Sabitlemeyi Kaldır", "search": "Ara", "full_search": "Tam Metin Arama", "full_search_result": "İçerik '{pattern}' ile eşleşti", "filter_field": "Alan Filtrele", "filter_value": "Değer Filtrele", "length": "Uzunluk", "entries": "Girişler", "memory_usage": "Bellek Kullanımı", "text_align_left": "Metni Sola Hizala", "text_align_center": "Metni Ortala", "view_as": "Farklı Görüntüle", "decode_with": "Kod Çöz / Sıkıştırmayı Aç", "custom_decoder": "Yeni Özel Kod Çözücü", "reload": "Yeniden Yükle", "reload_disable": "Tamamen yüklendikten sonra yeniden yükle", "auto_refresh": "Otomatik Yenile", "refresh_interval": "Yenileme Aralığı", "open_connection": "Bağlantıyı Aç", "copy_path": "Yolu Kopyala", "copy_key": "Anahtarı Kopyala", "save_value_succ": "Değer Kaydedildi!", "copy_succ": "Panoya Kopyalandı!", "binary_key": "İkili Anahtar Adı", "remove_key": "Anahtarı Kaldır", "new_key": "Yeni Anahtar", "load_more": "Daha Fazla Anahtar Yükle", "load_all": "Kalan Anahtarları Yükle", "load_more_entries": "Daha Fazla Yükle", "load_all_entries": "Tümünü Yükle", "more_action": "Daha Fazla İşlem", "nonexist_tab_content": "Seçilen anahtar mevcut değil veya hiç seçilmedi. Yeniledikten sonra tekrar deneyin.", "empty_server_content": "Sol panelden bir bağlantı seçin ve açın", "empty_server_list": "Redis sunucusu eklenmedi", "action": "İşlem", "type": "Tür", "cli_welcome": "Tiny RDM Redis Konsoluna Hoş Geldiniz", "retrieving_version": "Güncellemeler kontrol ediliyor", "sub_tab": { "status": "Durum", "key_detail": "Anahtar Detayı", "cli": "Konsol", "slow_log": "Yavaş Log", "cmd_monitor": "Komutları İzle", "pub_message": "Pub/Sub" } }, "ribbon": { "server": "Sunucu", "browser": "Veri Tarayıcı", "log": "Log", "wechat_official": "WeChat Resmi Hesap", "follow_x": "𝕏'i Takip Et", "github": "Github", "logout": "Çıkış Yap" }, "dialogue": { "close_confirm": "Bu bağlantı kapatılsın mı ({name})?", "edit_close_confirm": "Düzenlemeden önce lütfen ilgili bağlantıları kapatın. Devam edilsin mi?", "opening_connection": "Bağlantı Açılıyor...", "interrupt_connection": "İptal", "remove_tip": "{type} \"{name}\" silinecek", "remove_group_tip": "Grup \"{name}\" ve tüm bağlantıları silinecek", "rename_binary_key_fail": "İkili anahtarı yeniden adlandırma desteklenmiyor", "handle_succ": "Başarılı!", "handle_cancel": "İşlem iptal edildi.", "reload_succ": "Yeniden yüklendi!", "field_required": "Bu alan zorunludur", "spec_field_required": "\"{key}\" zorunludur", "illegal_characters": "Geçersiz karakterler içeriyor", "connection": { "new_title": "Yeni Bağlantı", "edit_title": "Bağlantıyı Düzenle", "general": "Genel", "no_group": "Grup Yok", "group": "Grup", "conn_name": "Ad", "addr": "Adres", "usr": "Kullanıcı Adı", "pwd": "Şifre", "name_tip": "Bağlantı adı", "addr_tip": "Redis sunucu adresi", "sock_tip": "Redis unix socket dosyası", "usr_tip": "(İsteğe bağlı) Kimlik doğrulama kullanıcı adı", "pwd_tip": "(İsteğe bağlı) Kimlik doğrulama şifresi (Redis > 6.0)", "test": "Bağlantıyı Test Et", "test_succ": "Redis sunucusuna başarıyla bağlanıldı", "test_fail": "Bağlantı başarısız", "parse_url_clipboard": "Panodan URL'yi Ayrıştır", "parse_pass": "Redis URL'si ayrıştırıldı: {url}", "parse_fail": "Redis URL'si ayrıştırılamadı: {reason}", "advn": { "title": "Gelişmiş", "filter": "Varsayılan Anahtar Filtresi", "filter_tip": "Yüklenen anahtarları filtrelemek için desen", "separator": "Anahtar Ayırıcı", "separator_tip": "Anahtar yol segmentleri için ayırıcı", "conn_timeout": "Bağlantı Zaman Aşımı", "exec_timeout": "Çalıştırma Zaman Aşımı", "dbfilter_type": "Veritabanı Filtresi", "dbfilter_all": "Tümünü Göster", "dbfilter_show": "Seçilenleri Göster", "dbfilter_hide": "Seçilenleri Gizle", "dbfilter_show_title": "Gösterilecek Veritabanları", "dbfilter_hide_title": "Gizlenecek Veritabanları", "dbfilter_input": "Veritabanı İndeksini Girin", "dbfilter_input_tip": "Onaylamak için Enter'a basın", "key_view": "Varsayılan Anahtar Görünümü", "key_view_tree": "Ağaç Görünümü", "key_view_list": "Liste Görünümü", "load_size": "Her Yüklemede Anahtar Sayısı", "mark_color": "İşaret Rengi" }, "alias": { "title": "Veritabanı Takma Adı", "db": "Veritabanı İndeksini Girin", "value": "Veritabanı Takma Adını Girin" }, "ssl": { "title": "SSL/TLS", "enable": "SSL/TLS'yi Etkinleştir", "allow_insecure": "Güvensiz Bağlantılara İzin Ver", "sni": "Sunucu Adı (SNI)", "sni_tip": "(İsteğe bağlı) Sunucu adı", "cert_file": "Genel Anahtar Dosyası", "key_file": "Özel Anahtar Dosyası", "ca_file": "CA Dosyası", "cert_file_tip": "PEM formatında Genel Anahtar Dosyası (Cert)", "key_file_tip": "PEM formatında Özel Anahtar Dosyası (Key)", "ca_file_tip": "PEM formatında Sertifika Yetkilisi Dosyası (CA)" }, "ssh": { "enable": "SSH Tünelini Etkinleştir", "title": "SSH Tüneli", "login_type": "Giriş Türü", "agent": "SSH Ajanı", "pkfile": "Özel Anahtar Dosyası", "passphrase": "Parola", "addr_tip": "SSH Sunucu Adresi", "usr_tip": "SSH Kullanıcı Adı", "pwd_tip": "SSH Şifresi", "pkfile_tip": "SSH özel anahtar dosya yolu", "passphrase_tip": "(İsteğe bağlı) Özel anahtar için parola" }, "sentinel": { "title": "Sentinel", "enable": "Sentinel Düğümü Olarak", "master": "Master Grup Adı", "auto_discover": "Otomatik Keşfet", "password": "Master Şifresi", "username": "Master Kullanıcı Adı", "pwd_tip": "(İsteğe bağlı) Master kimlik doğrulama şifresi (Redis > 6.0)", "usr_tip": "(İsteğe bağlı) Master kimlik doğrulama kullanıcı adı" }, "cluster": { "title": "Küme", "enable": "Küme Düğümü Olarak" }, "proxy": { "title": "Proxy", "type_none": "Proxy Yok", "type_system": "Sistem Proxy", "type_custom": "Manuel Proxy", "host": "Host Adı", "auth": "Proxy Kimlik Doğrulama", "usr_tip": "Proxy kimlik doğrulama kullanıcı adı", "pwd_tip": "Proxy kimlik doğrulama şifresi" } }, "group": { "name": "Grup Adı", "rename": "Grubu Yeniden Adlandır", "new": "Yeni Grup" }, "key": { "new": "Yeni Anahtar", "new_name": "Yeni Anahtar Adı", "server": "Bağlantı", "db_index": "Veritabanı İndeksi", "key_expression": "Anahtar Deseni", "affected_key": "Etkilenen Anahtarlar", "show_affected_key": "Etkilenen Anahtarları Göster", "confirm_delete_key": "{num} anahtarın silinmesini onaylayın", "direct_delete": "Eşleşen deseni doğrudan sil", "confirm_delete": "Silmeyi Onayla", "async_delete": "Asenkron Çalıştırma", "async_delete_title": "Sonucu bekleme", "confirm_flush": "Ne yaptığımı biliyorum!", "confirm_flush_db": "Veritabanı temizlemeyi onayla" }, "delete": { "success": "\"{key}\" silindi", "deleting": "Siliniyor", "doing": "Anahtar siliniyor ({index}/{count})", "completed": "Silme tamamlandı, {success} başarılı, {fail} başarısız" }, "field": { "new": "Yeni Alan", "new_item": "Yeni Öğe", "conflict_handle": "Alan Çakışmasında", "overwrite_field": "Üzerine Yaz", "ignore_field": "Yoksay", "insert_type": "Ekleme Türü", "append_item": "Sona Ekle", "prepend_item": "Başa Ekle", "enter_key": "Anahtar Girin", "enter_value": "Değer Girin", "enter_field": "Alan Adı Girin", "enter_elem": "Eleman Girin", "enter_member": "Üye Girin", "enter_score": "Puan Girin", "element": "Eleman", "reload_when_succ": "Başarılı olursa hemen yeniden yükle" }, "filter": { "set_key_filter": "Anahtar Filtresi Ayarla", "filter_pattern": "Desen", "filter_pattern_tip": "Doğrudan girerek filtrele ve taramak için 'Enter'a basın.\n\n* 0 veya daha fazla karakterle eşleşir, örn. 'anahtar*' \n? tek karakterle eşleşir, örn. 'anahtar?'\n[] aralıkla eşleşir, örn. 'anahtar[1-3]'\n\\ özel karakterlerden kaçar", "exact_match_tip": "Tam Eşleşme", "filter_type_not_support": "Tür filtreleme Redis 5.x ve altı için desteklenmiyor." }, "export": { "name": "Veriyi Dışa Aktar", "export_expire_title": "Son Kullanma", "export_expire": "Son Kullanmayı Dahil Et", "export": "Dışa Aktar", "save_file": "Dışa Aktarma Yolu", "save_file_tip": "Dışa aktarılan dosyayı kaydetmek için yol seçin", "exporting": "Anahtarlar dışa aktarılıyor ({index}/{count})", "export_completed": "Dışa aktarma tamamlandı, {success} başarılı, {fail} başarısız" }, "import": { "name": "Veriyi İçe Aktar", "import_expire_title": "Son Kullanma", "import": "İçe Aktar", "reload": "İçe Aktardıktan Sonra Yeniden Yükle", "open_csv_file": "İçe Aktarma Dosyası", "open_csv_file_tip": "İçe aktarılacak dosyayı seçin", "conflict_handle": "Anahtar Çakışmasında", "conflict_overwrite": "Üzerine Yaz", "conflict_ignore": "Yoksay", "ttl_include": "Dosyadan İçe Aktar", "ttl_ignore": "Ayarlama", "ttl_custom": "Özel", "importing": "Anahtarlar içe aktarılıyor/üzerine yazılıyor:{imported} çakışma/başarısız:{conflict}", "import_completed": "İçe aktarma tamamlandı, {success} başarılı, {ignored} yoksayıldı" }, "ttl": { "title": "TTL Güncelle", "title_batch": "Toplu TTL Güncelle ({count})", "quick_set": "Hızlı Ayarla", "success": "Tüm anahtarlar için TTL güncellendi" }, "decoder": { "name": "Yeni Kod Çözücü/Kodlayıcı", "edit_name": "Kod Çözücü/Kodlayıcıyı Düzenle", "new": "Yeni", "decoder": "Kod Çözücü", "encoder": "Kodlayıcı", "decoder_name": "Ad", "auto": "Otomatik Kod Çöz", "decode_path": "Kod Çözücü Yolu", "encode_path": "Kodlayıcı Yolu", "path_help": "Çalıştırılabilir dosya yolu veya 'sh/php/python' gibi cli takma adı", "args": "Argümanlar", "args_help": "Kodlama/kod çözme içeriği için [VALUE] yer tutucusu kullanın. Yer tutucu sağlanmazsa içerik sonuna eklenecektir." }, "upgrade": { "title": "Yeni Sürüm Mevcut", "new_version_tip": "Yeni {ver} sürümü mevcut, şimdi indirilsin mi?", "no_update": "Güncelsiniz", "download_now": "Şimdi İndir", "later": "Sonra", "skip": "Bu Sürümü Atla" }, "welcome": { "title": "Tiny RDM'e Hoş Geldiniz!", "content": "Daha iyi bir kullanıcı deneyimi sağlamak için Tiny RDM, yazılımı optimize etmeye ve kullanıcı deneyimini iyileştirmeye yardımcı olmak için bazı anonim veriler toplamaktadır. Bunun kişisel gizlilik bilgilerinizi içermediğinden emin olabilirsiniz.\n\nHerhangi bir endişeniz varsa, Tercihler'e giderek bu veri toplama özelliğini istediğiniz zaman kapatabilirsiniz. Herhangi bir sorunuz varsa, geliştiriciye ulaşmaktan çekinmeyin. Umarım Tiny RDM yararlı asistanınız olur!", "accept": "İyileştirmeye Yardım Et", "reject": "Reddet" }, "about": { "source": "Kaynak Kod", "website": "Resmi Web Sitesi" } }, "login": { "username_placeholder": "Kullanıcı adını girin", "password_placeholder": "Şifreyi girin", "submit": "Giriş Yap", "too_many_attempts": "Çok fazla deneme, lütfen daha sonra tekrar deneyin", "invalid_credentials": "Geçersiz kimlik bilgileri", "network_error": "Ağ hatası" }, "menu": { "minimise": "Küçült", "maximise": "Büyüt", "restore": "Geri Yükle", "close": "Kapat", "preferences": "Tercihler", "help": "Yardım", "user_guide": "Kullanıcı Kılavuzu", "check_update": "Güncellemeleri Kontrol Et...", "report_bug": "Hata Bildir", "about": "Hakkında" }, "log": { "title": "Başlatma Logu", "filter_server": "Sunucu Filtrele", "filter_keyword": "Anahtar Kelime Filtrele", "clean_log": "Logu Temizle", "confirm_clean_log": "Başlatma logunu temizlemeyi onayla", "exec_time": "Çalışma Zamanı", "server": "Sunucu", "cmd": "Komut", "cost_time": "Süre", "refresh": "Yenile" }, "status": { "uptime": "Çalışma Süresi", "connected_clients": "İstemciler", "total_keys": "Anahtarlar", "memory_used": "Bellek", "server_info": "Sunucu Bilgisi", "activity_status": "Aktivite", "act_cmd": "Komut/Saniye", "act_network_input": "Ağ Girişi", "act_network_output": "Ağ Çıkışı", "client": { "title": "İstemci Listesi", "addr": "İstemci Adresi", "age": "Yaş (saniye)", "idle": "Boşta (saniye)", "db": "Veritabanı" } }, "slog": { "title": "Yavaş Log", "limit": "Limit", "filter": "Filtre", "exec_time": "Zaman", "client": "İstemci", "cmd": "Komut", "cost_time": "Süre" }, "monitor": { "title": "Komutları İzle", "actions": "İşlemler", "warning": "Komut izleme sunucu bloklarına neden olabilir, üretim sunucularında dikkatli kullanın.", "start": "Başlat", "stop": "Durdur", "search": "Ara", "copy_log": "Logu Kopyala", "save_log": "Logu Kaydet", "clean_log": "Logu Temizle", "always_show_last": "Son Mesaja Otomatik Kaydır" }, "pubsub": { "title": "Pub/Sub", "publish": "Yayınla", "subscribe": "Abone Ol", "unsubscribe": "Abonelikten Çık", "clear": "Mesajları Temizle", "time": "Zaman", "filter": "Filtre", "channel": "Kanal", "message": "Mesaj", "receive_message": "{total} mesaj alındı", "always_show_last": "Son Mesaja Otomatik Kaydır" } } ================================================ FILE: frontend/src/langs/zh-cn.json ================================================ { "name": "简体中文", "common": { "confirm": "确认", "cancel": "取消", "success": "成功", "warning": "警告", "error": "错误", "save": "保存", "update": "更新", "none": "无", "second": "秒", "minute": "分", "hour": "小时", "day": "天", "unit_day": "天", "unit_hour": "小时", "unit_minute": "分钟", "unit_second": "秒", "all": "全部", "key": "键", "value": "值", "field": "字段", "score": "分值", "index": "位置" }, "preferences": { "name": "偏好设置", "restore_defaults": "重置为默认", "font_tip": "支持多选,如列表没有已安装的字体,可自行手动输入", "general": { "name": "常规配置", "theme": "主题", "theme_light": "浅色", "theme_dark": "深色", "theme_auto": "自动", "language": "语言", "system_lang": "使用系统语言", "font": "字体", "font_tip": "请选择或手动输入字体名", "font_size": "字体尺寸", "scan_size": "SCAN命令默认数量", "scan_size_tip": "SCAN/HSCAN/SSCAN/ZSCAN 命令每次返回数量", "key_icon_style": "键图标样式", "key_icon_style0": "紧凑类型", "key_icon_style1": "全称类型", "key_icon_style2": "圆点类型", "key_icon_style3": "通用图标", "update": "更新", "auto_check_update": "自动检查更新", "privacy": "隐私策略", "allow_track": "允许收集匿名数据" }, "editor": { "name": "编辑器", "show_linenum": "显示行号", "show_folding": "启用代码折叠", "drop_text": "允许拖放文本", "links": "支持链接跳转" }, "cli": { "name": "命令行", "cursor_style": "光标样式", "cursor_style_block": "方块", "cursor_style_underline": "下划线", "cursor_style_bar": "竖线" }, "decoder": { "name": "自定义解码", "new": "新增自定义解码", "decoder_name": "解码器名称", "cmd_preview": "命令预览", "status": "状态", "auto_enabled": "已加入自动解码", "help": "帮助" } }, "interface": { "new_conn": "添加新连接", "new_group": "添加新分组", "disconnect_all": "断开所有连接", "status": "状态", "filter": "筛选", "sort_conn": "调整连接顺序", "new_conn_title": "新建连接", "open_db": "打开数据库", "close_db": "关闭数据库", "filter_key": "过滤键", "disconnect": "断开连接", "dup_conn": "复制连接", "remove_conn": "删除连接", "edit_conn": "编辑连接配置", "edit_conn_group": "编辑分组", "rename_conn_group": "重命名分组", "remove_conn_group": "删除分组", "import_conn": "导入连接...", "export_conn": "导出连接...", "ttl": "TTL", "forever": "永久", "rename_key": "重命名键", "delete_key": "删除键", "batch_delete_key": "批量删除键", "import_key": "导入数据", "flush_db": "清空数据库", "check_mode": "勾选模式", "quit_check_mode": "退出勾选模式", "delete_checked": "删除所选项", "export_checked": "导出所选项", "ttl_checked": "为所选项更新TTL", "copy_value": "复制值", "edit_value": "修改值", "save_update": "保存修改", "score_filter_tip": "支持如下运算符比较匹配范围\n=:等于\n!=:不等于\n>:大于\n<:小于\n>=:大于等于\n<=:小于等于\n如查询分值大于3的结果,则输入:>3", "add_row": "插入行", "edit_row": "编辑行", "delete_row": "删除行", "fullscreen": "全屏显示", "offscreen": "退出全屏显示", "pin_edit": "固定编辑框(保存后不关闭)", "unpin_edit": "取消固定", "search": "搜索", "full_search": "全文匹配", "full_search_result": "内容已匹配为 {pattern}", "filter_field": "筛选字段", "filter_value": "筛选值", "length": "长度", "entries": "条目", "memory_usage": "内存占用", "text_align_left": "文本居左", "text_align_center": "文本居中", "view_as": "查看方式", "decode_with": "解码/解压方式", "custom_decoder": "添加自定义解码", "reload": "重新载入", "reload_disable": "全量加载后可重新载入", "auto_refresh": "自动刷新", "refresh_interval": "刷新间隔", "open_connection": "打开连接", "copy_path": "复制路径", "copy_key": "复制键名", "save_value_succ": "已保存值", "copy_succ": "已复制到剪切板", "binary_key": "二进制键名", "remove_key": "删除键", "new_key": "添加新键", "load_more": "加载更多键", "load_all": "加载剩余所有键", "load_more_entries": "加载更多", "load_all_entries": "加载全部", "more_action": "更多操作", "nonexist_tab_content": "所选键不存在或未选中任何键,请尝试刷新后重试", "empty_server_content": "可以从左边选择并打开连接", "empty_server_list": "还没添加Redis服务器", "action": "操作", "type": "类型", "cli_welcome": "欢迎使用Tiny RDM的Redis命令行控制台", "retrieving_version": "正在检索新版本", "sub_tab": { "status": "状态", "key_detail": "键详情", "cli": "命令行", "slow_log": "慢日志", "cmd_monitor": "监控命令", "pub_message": "发布/订阅" } }, "ribbon": { "server": "服务器", "browser": "数据浏览", "log": "日志", "wechat_official": "微信公众号", "follow_x": "关注我的\uD835\uDD4F", "github": "Github", "logout": "退出登录" }, "dialogue": { "close_confirm": "是否关闭此连接({name})", "edit_close_confirm": "编辑前需要关闭相关连接,是否继续", "opening_connection": "正在打开连接...", "interrupt_connection": "中断连接", "remove_tip": "{type} \"{name}\" 将会被删除", "remove_group_tip": "分组 \"{name}\"及其所有连接将会被删除", "rename_binary_key_fail": "不支持重命名二进制键名", "handle_succ": "操作成功", "handle_cancel": "操作已取消", "reload_succ": "已重新载入", "field_required": "此项不能为空", "spec_field_required": "{key} 不能为空", "illegal_characters": "包含非法字符", "connection": { "new_title": "新建连接", "edit_title": "编辑连接", "general": "常规配置", "no_group": "无分组", "group": "分组", "conn_name": "连接名", "addr": "连接地址", "usr": "用户名", "pwd": "密码", "name_tip": "连接名", "addr_tip": "Redis服务地址", "sock_tip": "Redis套接字文件", "usr_tip": "(可选)Redis服务授权用户名", "pwd_tip": "(可选)Redis服务授权密码 (Redis > 6.0)", "test": "测试连接", "test_succ": "成功连接到Redis服务器", "test_fail": "连接失败", "parse_url_clipboard": "解析剪切板中的URL", "parse_pass": "解析Redis URL完成: {url}", "parse_fail": "解析Redis URL失败: {reason}", "advn": { "title": "高级配置", "filter": "默认键过滤表达式", "filter_tip": "需要加载的键名表达式", "separator": "键分隔符", "separator_tip": "键名路径分隔符", "conn_timeout": "连接超时", "exec_timeout": "执行超时", "dbfilter_type": "数据库过滤方式", "dbfilter_all": "显示所有", "dbfilter_show": "显示指定", "dbfilter_hide": "隐藏指定", "dbfilter_show_title": "需要显示的数据库", "dbfilter_hide_title": "需要隐藏的数据库", "dbfilter_input": "输入数据库索引", "dbfilter_input_tip": "按回车确认", "key_view": "默认键视图", "key_view_tree": "树形列表", "key_view_list": "平铺列表", "load_size": "单次加载键数量", "mark_color": "标记颜色" }, "alias": { "title": "数据库别名", "db": "输入数据库索引", "value": "输入别名" }, "ssl": { "title": "SSL/TLS", "enable": "启用SSL", "allow_insecure": "允许不安全连接", "sni": "服务器名(SNI)", "sni_tip": "(可选)服务器名", "cert_file": "公钥文件", "key_file": "私钥文件", "ca_file": "授权文件", "cert_file_tip": "PEM格式公钥文件(Cert)", "key_file_tip": "PEM格式私钥文件(Key)", "ca_file_tip": "PEM格式授权文件(CA)" }, "ssh": { "enable": "启用SSH隧道", "title": "SSH隧道", "login_type": "登录类型", "agent": "SSH代理", "pkfile": "私钥文件", "passphrase": "私钥密码", "addr_tip": "SSH地址", "usr_tip": "SSH登录用户名", "pwd_tip": "SSH登录密码", "pkfile_tip": "SSH私钥文件路径", "passphrase_tip": "(可选)SSH私钥密码" }, "sentinel": { "title": "哨兵模式", "enable": "当前为哨兵节点", "master": "主节点组名", "auto_discover": "自动查询组名", "password": "主节点密码", "username": "主节点用户名", "pwd_tip": "(可选)主节点服务授权密码 (Redis > 6.0)", "usr_tip": "(可选)主节点服务授权用户名" }, "cluster": { "title": "集群模式", "enable": "当前为集群节点" }, "proxy": { "title": "网络代理", "type_none": "不使用代理", "type_system": "使用系统代理设置", "type_custom": "手动配置代理", "host": "主机名", "auth": "使用身份验证", "usr_tip": "代理授权用户名", "pwd_tip": "代理授权密码" } }, "group": { "name": "分组名", "rename": "重命名分组", "new": "添加新分组" }, "key": { "new": "添加新键", "new_name": "新键名", "server": "所属连接", "db_index": "数据库编号", "key_expression": "键名表达式", "affected_key": "受影响的键名", "show_affected_key": "查看受影响的键名", "confirm_delete_key": "确认删除{num}个键", "direct_delete": "直接匹配删除", "confirm_delete": "确认删除", "async_delete": "异步执行", "async_delete_title": "不等待操作结果", "confirm_flush": "我知道我正在执行的操作!", "confirm_flush_db": "确认清空数据库" }, "delete": { "success": "{key} 已被删除", "deleting": "正在删除", "doing": "正在删除键({index}/{count})", "completed": "已完成删除操作,成功{success}个,失败{fail}个" }, "field": { "new": "添加新字段", "new_item": "添加新元素", "conflict_handle": "字段冲突处理", "overwrite_field": "覆盖", "ignore_field": "忽略", "insert_type": "插入类型", "append_item": "尾部追加", "prepend_item": "插入头部", "enter_key": "输入键名", "enter_value": "输入值", "enter_field": "输入字段名", "enter_elem": "输入新元素", "enter_member": "输入成员", "enter_score": "输入分值", "element": "元素", "reload_when_succ": "操作成功后立即重新加载" }, "filter": { "set_key_filter": "设置键过滤器", "filter_pattern": "过滤表达式", "filter_pattern_tip": "直接输入筛选当前列表,回车后可对服务器进行扫描。\n\n *:匹配零个或多个字符。例如:\"key*\"匹配到以\"key\"开头的所有键\n?:匹配单个字符。例如:\"key?\"匹配\"key1\"、\"key2\"\n[ ]:匹配指定范围内的单个字符。例如:\"key[1-3]\"可以匹配类似于 \"key1\"、\"key2\"、\"key3\" 的键\n\\:转义字符。如果想要匹配 *、?、[、或],需要使用反斜杠\"\\\"进行转义", "exact_match_tip": "完全匹配", "filter_type_not_support": "类型筛选不支持 Redis 5.x 及以下版本" }, "export": { "name": "导出数据", "export_expire_title": "过期时间", "export_expire": "同时导出过期时间", "export": "确认导出", "save_file": "导出路径", "save_file_tip": "选择导出文件保存路径", "exporting": "正在导出键({index}/{count})", "export_completed": "已完成导出操作,成功{success}个,失败{fail}个" }, "import": { "name": "导入数据", "import_expire_title": "过期时间", "reload": "导入完成后重新载入", "import": "确认导入", "open_csv_file": "导入文件路径", "open_csv_file_tip": "选择需要导入的文件", "conflict_handle": "键冲突处理", "conflict_overwrite": "覆盖", "conflict_ignore": "忽略", "ttl_include": "尝试导入", "ttl_ignore": "不设置", "ttl_custom": "自定义", "importing": "正在导入数据 已导入/覆盖:{imported} 冲突/失败:{conflict}", "import_completed": "已完成导入操作,成功{success}个,忽略{ignored}个" }, "ttl": { "title": "设置键存活时间", "title_batch": "批量设置键存活时间({count})", "quick_set": "快捷设置", "success": "已全部更新TTL" }, "decoder": { "name": "新增解码/编码器", "edit_name": "编辑解码/编码器", "new": "新增", "decoder": "解码器", "encoder": "编码器", "decoder_name": "解码器名称", "auto": "自动解码", "decode_path": "解码器执行路径", "encode_path": "编码器执行路径", "path_help": "执行文件路径,也可以直接填写命令行接口,如sh/php/python", "args": "运行参数", "args_help": "使用[VALUE]代替编码/解码内容占位符,如果不填内容占位则默认放最后" }, "upgrade": { "title": "有可用新版本", "new_version_tip": "新版本({ver}),是否立即下载", "no_update": "当前已是最新版", "download_now": "立即下载", "later": "稍后提醒我", "skip": "忽略本次更新" }, "welcome": { "title": "欢迎使用Tiny RDM!", "content": "为了提供更好的用户体验,Tiny RDM会收集一些匿名的数据,以帮助优化软件和改进用户体验,请放心这不会涉及到您的个人隐私信息。\n\n如果您对此有任何顾虑,可以随时前往\"偏好设置\"中关闭此项数据收集功能。有任何问题可联系开发者,希望Tiny RDM可以成为您的好帮手!", "accept": "帮助改进", "reject": "不接受" }, "about": { "source": "源码地址", "website": "官方网站" } }, "login": { "username_placeholder": "请输入用户名", "password_placeholder": "请输入密码", "submit": "登录", "too_many_attempts": "尝试次数过多,请稍后再试", "invalid_credentials": "用户名或密码错误", "network_error": "网络错误" }, "menu": { "minimise": "最小化", "maximise": "最大化", "restore": "还原", "close": "关闭", "preferences": "偏好设置", "help": "帮助", "user_guide": "使用指南", "check_update": "检查更新...", "report_bug": "报告错误", "about": "关于" }, "log": { "title": "运行日志", "filter_server": "筛选服务器", "filter_keyword": "筛选关键字", "clean_log": "清空运行日志", "confirm_clean_log": "确定清空运行日志", "exec_time": "执行时间", "server": "服务器", "cmd": "命令", "cost_time": "耗时", "refresh": "立即刷新" }, "status": { "uptime": "运行时间", "connected_clients": "已连客户端", "total_keys": "键总数", "memory_used": "内存使用", "server_info": "状态信息", "activity_status": "活动状态", "act_cmd": "命令执行数/秒", "act_network_input": "网络输入", "act_network_output": "网络输出", "client": { "title": "所有客户端列表", "addr": "客户端地址", "age": "连接时长(秒)", "idle": "空闲时长(秒)", "db": "数据库" } }, "slog": { "title": "慢日志", "limit": "条数", "filter": "筛选", "exec_time": "执行时间", "client": "客户端", "cmd": "命令", "cost_time": "耗时" }, "monitor": { "title": "监控命令", "actions": "操作", "warning": "命令监控可能会造成服务端堵塞,请谨慎在生产环境的服务器使用", "start": "开启监控", "stop": "停止监控", "search": "搜索", "copy_log": "复制日志", "save_log": "保存日志", "clean_log": "清空日志", "always_show_last": "自动滚到最新" }, "pubsub": { "title": "发布订阅", "publish": "发布", "subscribe": "开启订阅", "unsubscribe": "取消订阅", "clear": "清空消息", "time": "时间", "filter": "筛选", "channel": "频道", "message": "消息", "receive_message": "已接收消息 {total} 条", "always_show_last": "自动滚到最新" } } ================================================ FILE: frontend/src/langs/zh-tw.json ================================================ { "name": "繁體中文", "common": { "confirm": "確認", "cancel": "取消", "success": "成功", "warning": "警告", "error": "錯誤", "save": "儲存", "update": "更新", "none": "無", "second": "秒", "minute": "分", "hour": "小時", "day": "天", "unit_day": "天", "unit_hour": "小時", "unit_minute": "分鐘", "unit_second": "秒", "all": "全部", "key": "鍵", "value": "值", "field": "欄位", "score": "分數", "index": "位置" }, "preferences": { "name": "偏好設定", "restore_defaults": "重設為預設值", "font_tip": "支援多選,如列表沒有已安裝的字型,可自行手動輸入", "general": { "name": "一般設定", "theme": "主題", "theme_light": "淺色", "theme_dark": "深色", "theme_auto": "自動", "language": "語言", "system_lang": "使用系統語言", "font": "字型", "font_tip": "請選擇或手動輸入字型名稱", "font_size": "字型大小", "scan_size": "SCAN命令預設數量", "scan_size_tip": "SCAN/HSCAN/SSCAN/ZSCAN 命令每次返回的元素數量", "key_icon_style": "鍵圖示樣式", "key_icon_style0": "緊湊類型", "key_icon_style1": "全稱類型", "key_icon_style2": "點類型", "key_icon_style3": "通用圖示", "update": "更新", "auto_check_update": "自動檢查更新", "privacy": "隱私權政策", "allow_track": "允許收集匿名數據" }, "editor": { "name": "編輯器", "show_linenum": "顯示行號", "show_folding": "啟用代碼折疊", "drop_text": "允許拖放文字", "links": "支援連結跳轉" }, "cli": { "name": "命令列", "cursor_style": "游標樣式", "cursor_style_block": "方塊", "cursor_style_underline": "底線", "cursor_style_bar": "直線" }, "decoder": { "name": "自定義解碼", "new": "新增自定義解碼", "decoder_name": "解碼器名稱", "cmd_preview": "命令預覽", "status": "狀態", "auto_enabled": "已加入自動解碼", "help": "説明" } }, "interface": { "new_conn": "新增連線", "new_group": "新增群組", "disconnect_all": "斷開所有連線", "status": "狀態", "filter": "篩選", "sort_conn": "調整連線順序", "new_conn_title": "新建連線", "open_db": "開啟資料庫", "close_db": "關閉資料庫", "filter_key": "過濾鍵", "disconnect": "斷開連線", "dup_conn": "複製連線", "remove_conn": "移除連線", "edit_conn": "編輯連線設定", "edit_conn_group": "編輯群組", "rename_conn_group": "重新命名群組", "remove_conn_group": "刪除群組", "import_conn": "匯入連線...", "export_conn": "匯出連線...", "ttl": "TTL", "forever": "永久", "rename_key": "重新命名鍵", "delete_key": "刪除鍵", "batch_delete_key": "批量刪除鍵", "import_key": "匯入資料", "flush_db": "清空資料庫", "check_mode": "勾選模式", "quit_check_mode": "退出勾選模式", "delete_checked": "刪除所選項目", "export_checked": "匯出所選項目", "ttl_checked": "為所選項目更新TTL", "copy_value": "複製值", "edit_value": "修改值", "save_update": "儲存修改", "score_filter_tip": "支援以下運算子比較範圍\n=:等於\n!=:不等於\n>:大於\n<:小於\n>=:大於等於\n<=:小於等於\n如查詢分數大於3的結果,則輸入:>3", "add_row": "插入行", "edit_row": "編輯行", "delete_row": "刪除行", "fullscreen": "全屏顯示", "offscreen": "退出全屏顯示", "pin_edit": "固定編輯框(儲存後不關閉)", "unpin_edit": "取消固定", "search": "搜尋", "full_search": "全文匹配", "full_search_result": "內容已匹配為 {pattern}", "filter_field": "篩選欄位", "filter_value": "篩選值", "length": "長度", "entries": "條目", "memory_usage": "記憶體使用量", "text_align_left": "文字靠左", "text_align_center": "文字置中", "view_as": "檢視方式", "decode_with": "解碼/解壓方式", "custom_decoder": "新增自定義解碼", "reload": "重新載入", "reload_disable": "全量加載後可重新載入", "auto_refresh": "自動重新整理", "refresh_interval": "重新整理間隔", "open_connection": "開啟連線", "copy_path": "複製路徑", "copy_key": "複製鍵名", "save_value_succ": "已儲存值", "copy_succ": "已複製到剪貼簿", "binary_key": "二進位鍵名", "remove_key": "移除鍵", "new_key": "新增鍵", "load_more": "載入更多鍵", "load_all": "載入剩餘所有鍵", "load_more_entries": "載入更多", "load_all_entries": "載入全部", "more_action": "更多操作", "nonexist_tab_content": "所選鍵不存在或未選中任何鍵,請嘗試重新整理後重試", "empty_server_content": "可以從左邊選擇並開啟連線", "empty_server_list": "還沒新增Redis伺服器", "action": "操作", "type": "類型", "cli_welcome": "歡迎使用Tiny RDM的Redis命令列控制台", "retrieving_version": "正在檢索新版本", "sub_tab": { "status": "狀態", "key_detail": "鍵詳情", "cli": "命令列", "slow_log": "慢日誌", "cmd_monitor": "監控命令", "pub_message": "發佈/訂閱" } }, "ribbon": { "server": "伺服器", "browser": "資料瀏覽器", "log": "日誌", "wechat_official": "微信公眾號", "follow_x": "關注我的\uD835\uDD4F", "github": "Github", "logout": "登出" }, "dialogue": { "close_confirm": "是否關閉此連線({name})", "edit_close_confirm": "編輯前需要關閉相關連線,是否繼續", "opening_connection": "正在開啟連線...", "interrupt_connection": "中斷連線", "remove_tip": "{type} \"{name}\"將會被刪除", "remove_group_tip": "群組 \"{name}\"及其所有連線將會被刪除", "rename_binary_key_fail": "不支援重新命名二進位鍵名", "handle_succ": "操作成功", "handle_cancel": "操作已取消", "reload_succ": "已重新載入", "field_required": "此項不能為空", "spec_field_required": "{key} 不能為空", "illegal_characters": "包含非法字元", "connection": { "new_title": "新建連線", "edit_title": "編輯連線", "general": "一般設定", "no_group": "無群組", "group": "群組", "conn_name": "連線名", "addr": "連線位址", "usr": "使用者名稱", "pwd": "密碼", "name_tip": "連線名", "addr_tip": "Redis伺服器位址", "sock_tip": "Redis Socket文件", "usr_tip": "(可選)Redis伺服器授權使用者名稱", "pwd_tip": "(可選)Redis伺服器授權密碼 (Redis > 6.0)", "test": "測試連線", "test_succ": "成功連線到Redis伺服器", "test_fail": "連線失敗", "parse_url_clipboard": "解析剪貼簿中的URL", "parse_pass": "解析Redis URL完成: {url}", "parse_fail": "解析Redis URL失敗: {reason}", "advn": { "title": "進階設定", "filter": "預設鍵過濾表示式", "filter_tip": "需要載入的鍵名表示式", "separator": "鍵分隔符", "separator_tip": "鍵名路徑分隔符", "conn_timeout": "連線逾時", "exec_timeout": "執行逾時", "dbfilter_type": "資料庫過濾方式", "dbfilter_all": "顯示所有", "dbfilter_show": "顯示指定", "dbfilter_hide": "隱藏指定", "dbfilter_show_title": "需要顯示的資料庫", "dbfilter_hide_title": "需要隱藏的資料庫", "dbfilter_input": "輸入資料庫索引", "dbfilter_input_tip": "按Enter確認", "key_view": "預設鍵檢視", "key_view_tree": "樹形列表", "key_view_list": "平鋪列表", "load_size": "單次載入鍵數量", "mark_color": "標記顏色" }, "alias": { "title": "資料庫別名", "db": "輸入資料庫索引", "value": "輸入別名" }, "ssl": { "title": "SSL/TLS", "enable": "啟用SSL", "allow_insecure": "允許不安全連線", "sni": "伺服器名(SNI)", "sni_tip": "(可選)伺服器名", "cert_file": "公鑰文件", "key_file": "私鑰文件", "ca_file": "授權文件", "cert_file_tip": "PEM格式公鑰文件(Cert)", "key_file_tip": "PEM格式私鑰文件(Key)", "ca_file_tip": "PEM格式授權文件(CA)" }, "ssh": { "enable": "啟用SSH隧道", "title": "SSH隧道", "login_type": "登入類型", "agent": "SSH代理", "pkfile": "私鑰文件", "passphrase": "私鑰密碼", "addr_tip": "SSH位址", "usr_tip": "SSH登入使用者名稱", "pwd_tip": "SSH登入密碼", "pkfile_tip": "SSH私鑰文件路徑", "passphrase_tip": "(可選)SSH私鑰密碼" }, "sentinel": { "title": "哨兵模式", "enable": "目前為哨兵節點", "master": "主節點組名", "auto_discover": "自動查詢組名", "password": "主節點密碼", "username": "主節點使用者名稱", "pwd_tip": "(可選)主節點伺服器授權密碼 (Redis > 6.0)", "usr_tip": "(可選)主節點伺服器授權使用者名稱" }, "cluster": { "title": "集群模式", "enable": "目前為集群節點" }, "proxy": { "title": "網路代理", "type_none": "不使用代理", "type_system": "使用系統代理設定", "type_custom": "手動設定代理", "host": "主機名稱", "auth": "使用身份驗證", "usr_tip": "代理授權使用者名稱", "pwd_tip": "代理授權密碼" } }, "group": { "name": "群組名稱", "rename": "重新命名群組", "new": "新增群組" }, "key": { "new": "新增鍵", "new_name": "新鍵名", "server": "所屬連線", "db_index": "資料庫編號", "key_expression": "鍵名表示式", "affected_key": "受影響的鍵名", "show_affected_key": "檢視受影響的鍵名", "confirm_delete_key": "確認刪除{num}個鍵", "direct_delete": "直接匹配刪除", "confirm_delete": "確認刪除", "async_delete": "異步執行", "async_delete_title": "不等待操作結果", "confirm_flush": "我知道我正在執行的操作!", "confirm_flush_db": "確認清空資料庫" }, "delete": { "success": "\"{key}\" 已被刪除", "deleting": "正在刪除", "doing": "正在刪除鍵({index}/{count})", "completed": "已完成刪除操作,成功{success}個,失敗{fail}個" }, "field": { "new": "新增欄位", "new_item": "新增元素", "conflict_handle": "欄位衝突處理", "overwrite_field": "覆蓋", "ignore_field": "忽略", "insert_type": "插入類型", "append_item": "尾部附加", "prepend_item": "插入頭部", "enter_key": "輸入鍵名", "enter_value": "輸入值", "enter_field": "輸入欄位名", "enter_elem": "輸入新元素", "enter_member": "輸入成員", "enter_score": "輸入分數", "element": "元素", "reload_when_succ": "操作成功後立即重新載入" }, "filter": { "set_key_filter": "設定鍵過濾器", "filter_pattern": "過濾表示式", "filter_pattern_tip": "直接鍵入篩選目前清單,按Enter鍵後可對伺服器進行掃描。\n\n*:匹配零個或多個字元。例如:\"key*\"匹配到以\"key\"開頭的所有鍵\n?:匹配單個字元。例如:\"key?\"匹配\"key1\", \"key2\"\n[ ]:匹配指定範圍內的單個字元。例如:\"key[1-3]\"可以匹配類似於 \"key1\", \"key2\", \"key3\" 的鍵\n\\:轉義字元。如果想要匹配 *, ?, [, 或],需要使用反斜線\"\\\"進行轉義", "exact_match_tip": "精準匹配", "filter_type_not_support": "類型篩選不支援 Redis 5.x 以下版本" }, "export": { "name": "匯出資料", "export_expire_title": "過期時間", "export_expire": "同時匯出過期時間", "export": "確認匯出", "save_file": "匯出路徑", "save_file_tip": "選擇匯出文件儲存路徑", "exporting": "正在匯出鍵({index}/{count})", "export_completed": "已完成匯出操作,成功{success}個,失敗{fail}個" }, "import": { "name": "匯入資料", "import_expire_title": "過期時間", "reload": "匯入完成後重新載入", "import": "確認匯入", "open_csv_file": "匯入文件路徑", "open_csv_file_tip": "選擇需要匯入的文件", "conflict_handle": "鍵衝突處理", "conflict_overwrite": "覆蓋", "conflict_ignore": "忽略", "ttl_include": "嘗試匯入", "ttl_ignore": "不設定", "ttl_custom": "自定義", "importing": "正在匯入資料 已匯入/覆蓋:{imported} 衝突/失敗:{conflict}", "import_completed": "已完成匯入操作,成功{success}個,忽略{ignored}個" }, "ttl": { "title": "設定鍵存活時間", "title_batch": "批量設定鍵存活時間({count})", "quick_set": "快速設定", "success": "已全部更新TTL" }, "decoder": { "name": "新增解碼/編碼器", "edit_name": "編輯解碼/編碼器", "new": "新增", "decoder": "解碼器", "encoder": "編碼器", "decoder_name": "解碼器名稱", "auto": "自動解碼", "decode_path": "解碼器執行路徑", "encode_path": "編碼器執行路徑", "path_help": "執行文件路徑,也可以直接填寫命令列介面,如sh/php/python", "args": "運行參數", "args_help": "使用[VALUE]代替編碼/解碼內容佔位符,如果不填內容佔位則默認放最後" }, "upgrade": { "title": "有可用新版本", "new_version_tip": "新版本({ver}),是否立即下載", "no_update": "目前已是最新版", "download_now": "立即下載", "later": "稍後提醒我", "skip": "忽略本次更新" }, "welcome": { "title": "歡迎使用Tiny RDM!", "content": "為了提供更好的使用者體驗,Tiny RDM 會收集一些匿名的數據,以幫助最佳化軟體和改進使用者體驗,請放心這不會涉及到您的個人隱私資訊。\n\n如果您對此有任何顧慮,可以隨時前往\"偏好設定\"中關閉此項數據收集功能。有任何問題可聯繫開發者,希望Tiny RDM可以成為您的好幫手!", "accept": "幫助改進", "reject": "不接受" }, "about": { "source": "源碼地址", "website": "官方網站" } }, "login": { "username_placeholder": "請輸入使用者名稱", "password_placeholder": "請輸入密碼", "submit": "登入", "too_many_attempts": "嘗試次數過多,請稍後再試", "invalid_credentials": "使用者名稱或密碼錯誤", "network_error": "網路錯誤" }, "menu": { "minimise": "最小化", "maximise": "最大化", "restore": "還原", "close": "關閉", "preferences": "偏好設定", "help": "説明", "user_guide": "使用指南", "check_update": "檢查更新...", "report_bug": "回報錯誤", "about": "關於" }, "log": { "title": "運行日誌", "filter_server": "篩選伺服器", "filter_keyword": "篩選關鍵字", "clean_log": "清空運行日誌", "confirm_clean_log": "確定清空運行日誌", "exec_time": "執行時間", "server": "伺服器", "cmd": "命令", "cost_time": "耗時", "refresh": "立即重新整理" }, "status": { "uptime": "運行時間", "connected_clients": "已連線客戶端", "total_keys": "鍵總數", "memory_used": "記憶體使用量", "server_info": "狀態資訊", "activity_status": "活動狀態", "act_cmd": "命令執行數/秒", "act_network_input": "網路輸入", "act_network_output": "網路輸出", "client": { "title": "所有客戶端列表", "addr": "客戶端位址", "age": "連線時長(秒)", "idle": "空閒時長(秒)", "db": "資料庫" } }, "slog": { "title": "慢日誌", "limit": "條數", "filter": "篩選", "exec_time": "執行時間", "client": "客戶端", "cmd": "命令", "cost_time": "耗時" }, "monitor": { "title": "監控命令", "actions": "操作", "warning": "命令監控可能會造成伺服器阻塞,請謹慎在生產環境的伺服器使用", "start": "開啟監控", "stop": "停止監控", "search": "搜尋", "copy_log": "複製日誌", "save_log": "儲存日誌", "clean_log": "清空日誌", "always_show_last": "自動捲動到最新" }, "pubsub": { "title": "發佈訂閱", "publish": "發佈", "subscribe": "開啟訂閱", "unsubscribe": "取消訂閱", "clear": "清空訊息", "time": "時間", "filter": "篩選", "channel": "頻道", "message": "訊息", "receive_message": "已接收訊息 {total} 條", "always_show_last": "自動捲動到最新" } } ================================================ FILE: frontend/src/main.js ================================================ import { createPinia } from 'pinia' import { createApp, nextTick } from 'vue' import App from './App.vue' import './styles/style.scss' import dayjs from 'dayjs' import duration from 'dayjs/plugin/duration' import relativeTime from 'dayjs/plugin/relativeTime' import { i18n } from '@/utils/i18n.js' import { setupDiscreteApi } from '@/utils/discrete.js' import usePreferencesStore from 'stores/preferences.js' import { loadEnvironment } from '@/utils/platform.js' import { setupMonaco } from '@/utils/monaco.js' import { setupChart } from '@/utils/chart.js' import { isWeb } from './utils/platform.js' dayjs.extend(duration) dayjs.extend(relativeTime) async function setupApp() { const app = createApp(App) app.use(i18n) app.use(createPinia()) await loadEnvironment() setupMonaco() setupChart() const prefStore = usePreferencesStore() if (isWeb()) { await prefStore.loadAppVersion() } await prefStore.loadPreferences() await setupDiscreteApi() app.config.errorHandler = (err, instance, info) => { // TODO: add "send error message to author" later nextTick().then(() => { try { const content = err.toString() $notification.error(content, { title: i18n.global.t('common.error'), meta: 'Please see console output for more detail', }) console.error(err) } catch (e) {} }) } // app.config.warnHandler = (message) => { // console.warn(message) // } app.mount('#app') } setupApp() ================================================ FILE: frontend/src/objects/redisDatabaseItem.js ================================================ /** * redis database item */ export class RedisDatabaseItem { constructor({ db = 0, alias = '', keyCount = 0, maxKeys = 0 }) { this.db = db this.alias = alias this.keyCount = keyCount this.maxKeys = maxKeys } } ================================================ FILE: frontend/src/objects/redisNodeItem.js ================================================ import { isEmpty, remove, size, sumBy } from 'lodash' import { ConnectionType } from '@/consts/connection_type.js' /** * redis node item in tree view */ export class RedisNodeItem { /** * * @param {string} key - tree node unique key * @param {string} label * @param {string} [name] - server name, type != ConnectionType.Group only * @param {ConnectionType} type * @param {number} [db] - database index, type == ConnectionType.RedisDB only * @param {string} [redisKey] - redis key, type == ConnectionType.RedisKey || type == ConnectionType.RedisValue only * @param {number[]} [redisKeyCode] - redis key char code array, optional for redis key which contains binary data * @param {number} [keyCount] - children key count * @param {number} [maxKeys] - max key count for database * @param {boolean} [isLeaf] * @param {boolean} [opened] - redis db is opened, type == ConnectionType.RedisDB only * @param {boolean} [expanded] - current node is expanded * @param {RedisNodeItem[]} [children] * @param {string} [redisType] - redis type name, 'loading' indicate that is in loading progress */ constructor({ key, label, name, type, db = 0, redisKey, redisKeyCode, keyCount = 0, maxKeys = 0, isLeaf = false, opened = false, expanded = false, children, redisType, }) { this.key = key this.label = label this.name = name this.type = type this.db = db this.redisKey = redisKey this.redisKeyCode = redisKeyCode this.keyCount = keyCount this.maxKeys = maxKeys this.isLeaf = isLeaf this.opened = opened this.expanded = expanded this.children = children this.redisType = redisType } /** * sort node list * @param {RedisNodeItem[]} nodeList * @private */ _sortNodes(nodeList) { if (nodeList == null) { return } nodeList.sort((a, b) => { return a.key > b.key ? 1 : -1 }) } /** * compare two items to determine the sort order * @param {*} a * @param {*} b * @return {number} */ _sortingCompare(a, b) { if (a.type !== b.type) { return a.type - b.type } const isANum = isNaN(a.label) const isBNum = isNaN(b.label) if (!isANum && !isBNum) { return parseInt(a.label, 10) - parseInt(b.label, 10) } else if (!isANum) { return -1 } else if (!isBNum) { return 1 } return a.label.localeCompare(b.label) } /** * calculate insert sorted index * @param {[]} arr * @param {*} item * @return {number} */ _sortedIndex(arr, item) { for (let i = 0; i < arr.length; i++) { const cmpResult = this._sortingCompare(arr[i], item) if (cmpResult > 0) { return i } else if (cmpResult === 0) { return i + 1 } } return arr.length } /** * sort all node item's children and calculate keys count * @param skipSort skip sorting children * @returns {boolean} return whether key count changed */ tidy(skipSort) { if (this.type === ConnectionType.RedisValue) { this.keyCount = 1 } else if (this.type === ConnectionType.RedisKey || this.type === ConnectionType.RedisDB) { let keyCount = 0 if (!isEmpty(this.children)) { if (!!!skipSort) { this.sortChildren() } for (const child of this.children) { child.tidy(skipSort) keyCount += child.keyCount } } else { keyCount = 0 } if (this.keyCount !== keyCount) { this.keyCount = keyCount return true } } return false } sortChildren() { this.children.sort(this._sortingCompare) } /** * * @param {RedisNodeItem} child * @param {boolean} [sorted] */ addChild(child, sorted) { if (!!!sorted) { this.children.push(child) } else { const idx = this._sortedIndex(this.children, child) this.children.splice(idx, 0, child) } } /** * * @param {{}} predicate * @return {number} */ removeChild(predicate) { if (this.type !== ConnectionType.RedisKey && this.type !== ConnectionType.RedisDB) { return 0 } const removed = remove(this.children, predicate) return size(removed) } getChildren() { return this.children } reCalcKeyCount() { if (this.type === ConnectionType.RedisValue) { this.keyCount = 1 } this.keyCount = sumBy(this.children, (c) => c.keyCount) return this.keyCount } } ================================================ FILE: frontend/src/objects/redisServerState.js ================================================ import { get, initial, isEmpty, join, last, mapValues, size, slice, sortBy, split, toUpper } from 'lodash' import useConnectionStore from 'stores/connections.js' import { ConnectionType } from '@/consts/connection_type.js' import { RedisDatabaseItem } from '@/objects/redisDatabaseItem.js' import { KeyViewType } from '@/consts/key_view_type.js' import { RedisNodeItem } from '@/objects/redisNodeItem.js' import { decodeRedisKey, nativeRedisKey } from '@/utils/key_convert.js' /** * server connection state */ export class RedisServerState { /** * @typedef {Object} LoadingState * @property {boolean} loading indicated that is loading children now * @property {boolean} fullLoaded indicated that all children already loaded */ /** * @param {string} name server name * @param {number} db current opened database * @param {number} reloadKey try to reload when changed * @param {{}} stats current server status info * @param {Object.} databases database list * @param {string|null} patternFilter pattern filter * @param {string|null} typeFilter redis type filter * @param {boolean} exactFilter exact match filter keyword * @param {LoadingState} loadingState all loading state in opened connections map by server and LoadingState * @param {KeyViewType} viewType view type selection for all opened connections group by 'server' * @param {Map} nodeMap map nodes by "type#key" * @param {string} version redis server version */ constructor({ name, db = 0, stats = {}, databases = {}, patternFilter = null, typeFilter = null, exactFilter = false, loadingState = {}, viewType = KeyViewType.Tree, nodeMap = new Map(), version = '', }) { this.name = name this.db = db this.reloadKey = Date.now() this.stats = stats this.databases = databases this.patternFilter = patternFilter this.typeFilter = typeFilter this.exactFilter = exactFilter this.loadingState = loadingState this.viewType = viewType this.nodeMap = nodeMap this.version = version this.decodeHistory = new Map() this.decodeHistoryLimit = 100 this.getRoot() const connStore = useConnectionStore() const keySeparator = connStore.getDefaultSeparator(name) this.separator = isEmpty(keySeparator) ? ':' : keySeparator } dispose() { this.stats = {} this.patternFilter = null this.typeFilter = null this.exactFilter = false this.nodeMap.clear() } closeDatabase() { this.patternFilter = null this.typeFilter = null this.nodeMap.clear() } setDatabaseKeyCount(db, maxKeys) { const dbInst = this.databases[db] if (dbInst == null) { this.databases[db] = new RedisDatabaseItem({ db, maxKeys }) } else { dbInst.maxKeys = maxKeys } return dbInst } /** * update max key by increase/decrease value * @param {number} db * @param {number} updateVal */ updateDBKeyCount(db, updateVal) { const dbInst = this.databases[db] if (dbInst != null) { dbInst.maxKeys = Math.max(0, dbInst.maxKeys + updateVal) } } /** * set db max keys value * @param {number} db * @param {number} count */ setDBKeyCount(db, count) { const dbInst = this.databases[db] if (dbInst != null) { dbInst.maxKeys = Math.max(0, count) } } /** * get tree root item * @returns {RedisNodeItem} */ getRoot() { const rootKey = `${ConnectionType.RedisDB}` let root = this.nodeMap.get(rootKey) if (root == null) { // create root node root = new RedisNodeItem({ key: rootKey, label: `db${this.db}`, type: ConnectionType.RedisDB, children: [], }) this.nodeMap.set(rootKey, root) } return root } /** * get database list sort by db asc * @return {RedisDatabaseItem[]} */ getDatabase() { return sortBy(mapValues(this.databases), 'db') } /** * * @param {ConnectionType} type * @param {string} keyPath * @param {RedisNodeItem} node */ addNode(type, keyPath, node) { this.nodeMap.set(`${type}/${keyPath}`, node) } /** * add keys to current opened database * @param {Array|Set} keys * @param {boolean} [sortInsert] * @return {{newKey: number, newLayer: number, success: boolean, replaceKey: number}} */ addKeyNodes(keys, sortInsert) { const result = { success: false, newLayer: 0, newKey: 0, replaceKey: 0, } const root = this.getRoot() if (this.viewType === KeyViewType.List) { // construct list view data for (const key of keys) { const k = decodeRedisKey(key) const isBinaryKey = k !== key const nodeKey = `${ConnectionType.RedisValue}/${nativeRedisKey(key)}` const replaceKey = this.nodeMap.has(nodeKey) const selectedNode = new RedisNodeItem({ key: `${this.name}/db${this.db}#${nodeKey}`, label: k, db: this.db, keyCount: 0, redisKey: k, redisKeyCode: isBinaryKey ? key : undefined, redisKeyType: undefined, type: ConnectionType.RedisValue, isLeaf: true, }) this.nodeMap.set(nodeKey, selectedNode) if (!replaceKey) { root.addChild(selectedNode, sortInsert) result.newKey += 1 } else { result.replaceKey += 1 } } } else { // construct tree view data for (const key of keys) { const k = decodeRedisKey(key) const isBinaryKey = k !== key const keyParts = isBinaryKey ? [nativeRedisKey(key)] : split(k, this.separator) const len = size(keyParts) const lastIdx = len - 1 let handlePath = '' let node = root for (let i = 0; i < len; i++) { handlePath += keyParts[i] if (i !== lastIdx) { // layer const nodeKey = `${ConnectionType.RedisKey}/${handlePath}` let selectedNode = this.nodeMap.get(nodeKey) if (selectedNode == null) { selectedNode = new RedisNodeItem({ key: `${this.name}/db${this.db}#${nodeKey}`, label: keyParts[i], db: this.db, keyCount: 0, redisKey: handlePath, type: ConnectionType.RedisKey, isLeaf: false, children: [], }) this.nodeMap.set(nodeKey, selectedNode) node.addChild(selectedNode, sortInsert) result.newLayer += 1 } node = selectedNode handlePath += this.separator } else { // key const nodeKey = `${ConnectionType.RedisValue}/${handlePath}` const replaceKey = this.nodeMap.has(nodeKey) const selectedNode = new RedisNodeItem({ key: `${this.name}/db${this.db}#${nodeKey}`, label: isBinaryKey ? k : keyParts[i], db: this.db, keyCount: 0, redisKey: handlePath, redisKeyCode: isBinaryKey ? key : undefined, redisKeyType: undefined, type: ConnectionType.RedisValue, isLeaf: true, }) this.nodeMap.set(nodeKey, selectedNode) if (!replaceKey) { node.addChild(selectedNode, sortInsert) result.newKey += 1 } else { result.replaceKey += 1 } } } } } return result } /** * rename key to a new name * @param {string} key * @param {string} newKey */ renameKey(key, newKey) { const oldLayer = initial(key.split(this.separator)).join(this.separator) const newLayer = initial(newKey.split(this.separator)).join(this.separator) if (oldLayer !== newLayer) { // also change layer this.removeKeyNode(key, false) const { success } = this.addKeyNodes([newKey], true) if (success) { this.tidyNode(newLayer) } } else { // change key name only const oldNodeKeyName = `${ConnectionType.RedisValue}/${key}` const newNodeKeyName = `${ConnectionType.RedisValue}/${newKey}` const keyNode = this.nodeMap.get(oldNodeKeyName) keyNode.key = `${this.name}/db${this.db}#${newNodeKeyName}` if (this.viewType === KeyViewType.Tree) { keyNode.label = last(split(newKey, this.separator)) } else { keyNode.label = newKey } keyNode.redisKey = newKey // not support rename binary key name yet // keyNode.redisKeyCode = [] this.nodeMap.set(newNodeKeyName, keyNode) this.nodeMap.delete(oldNodeKeyName) } } /** * remove key node by key name * @param {string} [key] * @param {boolean} [isLayer] * @return {boolean} */ removeKeyNode(key, isLayer) { if (isLayer === true) { this.deleteChildrenKeyNodes(key) } else { const nodeKey = `${ConnectionType.RedisValue}/${key}` this.nodeMap.delete(nodeKey) } const dbRoot = this.getRoot() if (isEmpty(key)) { // clear all key nodes this.nodeMap.clear() this.getRoot() const dbInst = this.databases[this.db] if (dbInst != null) { dbInst.maxKeys = 0 dbInst.keyCount = 0 } } else { const keyParts = split(key, this.separator) const totalParts = size(keyParts) // remove from parent in tree node const parentKey = slice(keyParts, 0, totalParts - 1) let parentNode if (isEmpty(parentKey)) { parentNode = dbRoot } else { parentNode = this.nodeMap.get(`${ConnectionType.RedisKey}/${join(parentKey, this.separator)}`) } // not found parent node if (parentNode == null) { return false } parentNode.removeChild({ type: isLayer ? ConnectionType.RedisKey : ConnectionType.RedisValue, redisKey: key, }) // // check and remove empty layer node // let i = totalParts - 1 // for (; i >= 0; i--) { // const anceKey = join(slice(keyParts, 0, i), this.separator) // if (i > 0) { // const anceNode = this.nodeMap.get(`${ConnectionType.RedisKey}/${anceKey}`) // const redisKey = join(slice(keyParts, 0, i + 1), this.separator) // anceNode.removeChild({ type: ConnectionType.RedisKey, redisKey }) // // if (isEmpty(anceNode.children)) { // this.nodeMap.delete(`${ConnectionType.RedisKey}/${anceKey}`) // } else { // break // } // } else { // // last one, remove from db node // dbRoot.removeChild({ type: ConnectionType.RedisKey, redisKey: keyParts[0] }) // this.nodeMap.delete(`${ConnectionType.RedisValue}/${keyParts[0]}`) // } // } } return true } /** * tidy node by key * @param {string} [key] * @param {boolean} [skipResort] * @return */ tidyNode(key, skipResort) { const rootNode = this.getRoot() const keyParts = split(key, this.separator) const totalParts = size(keyParts) let node // find last exists ancestor key let i = totalParts - 1 for (; i > 0; i--) { const parentKey = join(slice(keyParts, 0, i), this.separator) node = this.nodeMap.get(`${ConnectionType.RedisKey}/${parentKey}`) if (node != null) { break } } if (node == null) { node = rootNode } const keyCountUpdated = node.tidy(skipResort) if (keyCountUpdated) { // update key count of parent and above for (; i > 0; i--) { const parentKey = join(slice(keyParts, 0, i), this.separator) const parentNode = this.nodeMap.get(`${ConnectionType.RedisKey}/${parentKey}`) if (parentNode == null) { break } const count = parentNode.reCalcKeyCount() if (count <= 0) { let anceKeyNode = rootNode // remove from ancestor node if (i > 1) { const anceKey = join(slice(keyParts, 0, i - 1), this.separator) anceKeyNode = this.nodeMap.get(`${ConnectionType.RedisKey}/${anceKey}`) } if (anceKeyNode != null) { anceKeyNode.removeChild({ type: ConnectionType.RedisKey, redisKey: parentKey }) } } } // update key count of db const dbInst = this.databases[this.db] if (dbInst != null) { dbInst.keyCount = rootNode.reCalcKeyCount() } } } /** * add keys to current opened database * @param {ConnectionType} type * @param {string} keyPath * @return {RedisNodeItem|null} */ getNode(type, keyPath) { return this.nodeMap.get(`${type}/${keyPath}`) || null } /** * delete node and all it's children from nodeMap * @param {string} [key] clean nodeMap if key is empty * @private */ deleteChildrenKeyNodes(key) { if (isEmpty(key)) { this.nodeMap.clear() this.getRoot() } else { const nodeKey = `${ConnectionType.RedisKey}/${key}` const node = this.nodeMap.get(nodeKey) const children = node.children || [] for (const child of children) { if (child.type === ConnectionType.RedisValue) { if (!this.nodeMap.delete(`${ConnectionType.RedisValue}/${child.redisKey}`)) { console.warn('delete:', `${ConnectionType.RedisValue}/${child.redisKey}`) } } else if (child.type === ConnectionType.RedisKey) { this.deleteChildrenKeyNodes(child.redisKey) } } if (!this.nodeMap.delete(nodeKey)) { console.warn('delete map key', nodeKey) } } } getFilter() { let pattern = this.patternFilter if (isEmpty(pattern)) { const conn = useConnectionStore() pattern = conn.getDefaultKeyFilter(this.name) } return { match: pattern, type: toUpper(this.typeFilter), exact: this.exactFilter === true, } } /** * set key filter * @param {string} [pattern] * @param {string} [type] * @param {boolean} [exact] */ setFilter({ pattern, type, exact = false }) { this.patternFilter = pattern === null ? this.patternFilter : pattern this.typeFilter = type === null ? this.typeFilter : type this.exactFilter = exact === true } /** * add manually selected decode type to history * @param {string} key * @param {number} db * @param {string} format * @param {string} decode */ addDecodeHistory(key, db, format = '', decode = '') { const decodeKey = `${key}#${db}` this.decodeHistory.delete(decodeKey) if (isEmpty(format) && isEmpty(decode)) { // reset to default, remove from history return } this.decodeHistory.set(decodeKey, [format, decode]) while (this.decodeHistory.size > this.decodeHistoryLimit) { const k = this.decodeHistory.keys().next().value this.decodeHistory.delete(k) } } /** * get manually selected decode type from history * @param {string|number[]} key * @param {number} db * @return {[]} */ getDecodeHistory(key, db) { const h = this.decodeHistory.get(`${nativeRedisKey(key)}#${db}`) || [] return [get(h, 0, ''), get(h, 1, '')] } } ================================================ FILE: frontend/src/objects/tabItem.js ================================================ /** * tab item */ export class TabItem { /** * @typedef {Object} CheckedKey * @property {string} key * @property {string} [redisKey] */ /** * * @param {string} name connection name * @param {string} title tab title * @param {boolean} blank is blank tab * @param {string} subTab secondary tab value * @param {string} [title] tab title * @param {string} [icon] tab icon * @param {string} [activatedKey] current activated key on displaying * @param {string[]} expandedKeys * @param {string[]} selectedKeys * @param {CheckedKey[]} checkedKeys * @param {string} [type] key type * @param {*} [value] key value * @param {string} [server] server name * @param {int} [db] database index * @param {string} [key] current key name * @param {number[]|null|undefined} [keyCode] current key name as char array * @param {number} [size] memory usage * @param {number} [length] length of content or entries * @param {int} [ttl] ttl of current key * @param {string} [decode] * @param {string} [format] * @param {string} [matchPattern] * @param {boolean} [end] * @param {boolean} [loading] */ constructor({ name, title, blank, subTab, icon, expandedKeys = [], selectedKeys = [], checkedKeys = [], type, value, server, db = 0, key, keyCode, size = 0, length = 0, ttl = 0, decode = '', format = '', matchPattern = '', end = false, loading = false, }) { this.name = name this.title = title this.blank = blank this.subTab = subTab this.icon = icon this.activatedKey = '' this.expandedKeys = expandedKeys this.selectedKeys = selectedKeys this.checkedKeys = checkedKeys this.type = type this.value = value this.server = server this.db = db this.key = key this.keyCode = keyCode this.size = size this.length = length this.ttl = ttl this.decode = decode this.format = format this.matchPattern = matchPattern this.end = end this.loading = loading } } ================================================ FILE: frontend/src/stores/browser.js ================================================ import { defineStore } from 'pinia' import { endsWith, get, isEmpty, join, map, now, size, slice, split, startsWith } from 'lodash' import { AddHashField, AddListItem, AddStreamValue, AddZSetValue, BatchSetTTL, CleanCmdHistory, CloseConnection, ConvertValue, DeleteKey, DeleteKeys, DeleteKeysByPattern, ExportKey, FlushDB, GetClientList, GetCmdHistory, GetHashValue, GetKeyDetail, GetKeySummary, GetKeyType, GetSlowLogs, ImportCSV, LoadAllKeys, LoadNextAllKeys, LoadNextKeys, OpenConnection, OpenDatabase, RemoveStreamValues, RenameKey, ServerInfo, SetHashValue, SetKeyTTL, SetKeyValue, SetListItem, SetSetItem, UpdateSetItem, UpdateZSetValue, } from 'wailsjs/go/services/browserService.js' import useTabStore from 'stores/tab.js' import { nativeRedisKey } from '@/utils/key_convert.js' import { BrowserTabType } from '@/consts/browser_tab_type.js' import { KeyViewType } from '@/consts/key_view_type.js' import { ConnectionType } from '@/consts/connection_type.js' import useConnectionStore from 'stores/connections.js' import { decodeTypes, formatTypes } from '@/consts/value_view_type.js' import { isRedisGlob } from '@/utils/glob_pattern.js' import { i18nGlobal } from '@/utils/i18n.js' import { EventsEmit, EventsOn } from 'wailsjs/runtime/runtime.js' import { RedisNodeItem } from '@/objects/redisNodeItem.js' import { RedisServerState } from '@/objects/redisServerState.js' import { RedisDatabaseItem } from '@/objects/redisDatabaseItem.js' import { timeout } from '@/utils/promise.js' const useBrowserStore = defineStore('browser', { /** * @typedef {Object} FilterItem * @property {string} pattern key pattern filter * @property {string} type type filter */ /** * @typedef {Object} HistoryItem * @property {string} time * @property {string} server * @property {string} cmd * @property {number} cost */ /** * @typedef {Object} BrowserState * @property {Object.} servers */ /** * * @returns {BrowserState} */ state: () => ({ servers: {}, }), getters: { anyConnectionOpened() { return !isEmpty(this.servers) }, }, actions: { /** * check if connection is connected * @param name * @returns {boolean} */ isConnected(name) { return this.servers.hasOwnProperty(name) }, /** * close all connections * @returns {Promise} */ async closeAllConnection() { for (const serverName in this.servers) { await CloseConnection(serverName) this.servers[serverName].dispose() } const tabStore = useTabStore() tabStore.removeAllTab() }, /** * get database info list * @param {string} server * @return {RedisDatabaseItem[]} */ getDBList(server) { const serverInst = this.servers[server] if (serverInst != null) { return serverInst.getDatabase() } return [] }, /** * get server version * @param {string} server */ getServerVersion(server) { const serverInst = this.servers[server] if (serverInst != null) { return serverInst.version } return '1.0.0' }, /** * get database by server name and database index * @param {string} server * @param {number} db * @return {RedisDatabaseItem|null} */ getDatabase(server, db) { /** @type {RedisServerState} **/ const serverInst = this.servers[server] if (serverInst != null) { return serverInst.databases[db] || null } return null }, /** * get current selection database by server * @param server * @return {number} */ getSelectedDB(server) { /** @type {RedisServerState} **/ const serverInst = this.servers[server] if (serverInst != null) { return serverInst.db } return 0 }, /** * get key struct in current database * @param {string} server * @param {boolean} [includeRoot] * @return {RedisNodeItem[]} */ getKeyStruct(server, includeRoot) { /** @type {RedisServerState} **/ const serverInst = this.servers[server] let rootNode = null if (serverInst != null) { rootNode = serverInst.getRoot() } if (includeRoot === true) { return [rootNode] } return get(rootNode, 'children', []) }, getReloadKey(server) { /** @type {RedisServerState} **/ const serverInst = this.servers[server] return serverInst != null ? serverInst.reloadKey : 0 }, reloadServer(server) { /** @type {RedisServerState} **/ const serverInst = this.servers[server] if (serverInst != null) { serverInst.reloadKey = Date.now() } }, /** * switch key view * @param {string} connName * @param {number} viewType */ // async switchKeyView(connName, viewType) { // if (viewType !== KeyViewType.Tree && viewType !== KeyViewType.List) { // return // } // // const t = get(this.viewType, connName, KeyViewType.Tree) // if (t === viewType) { // return // } // // this.viewType[connName] = viewType // const dbs = get(this.databases, connName, []) // for (const dbItem of dbs) { // if (!dbItem.opened) { // continue // } // // dbItem.children = undefined // dbItem.keyCount = 0 // const { db = 0 } = dbItem // this._getNodeMap(connName, db).clear() // this._addKeyNodes(connName, db, keys) // this._tidyNode(connName, db, '') // } // }, /** * open connection * @param {string} name * @param {boolean} [reload] * @returns {Promise} */ async openConnection(name, reload) { if (this.isConnected(name)) { if (reload !== true) { return } else { // reload mode, try close connection first await CloseConnection(name) } } const { data, success, msg } = await OpenConnection(name) if (!success) { throw new Error(msg) } // append to db node to current connection // const connNode = this.getConnection(name) // if (connNode == null) { // throw new Error('no such connection') // } const { db, view = KeyViewType.Tree, lastDB, version } = data if (isEmpty(db)) { throw new Error('no db loaded') } const serverInst = new RedisServerState({ name, separator: this.getSeparator(name), db: -1, viewType: view, version, }) /** @type {Object.} **/ const databases = {} for (const dbItem of db) { databases[dbItem.index] = new RedisDatabaseItem({ db: dbItem.index, alias: dbItem.alias, maxKeys: dbItem.maxKeys, }) if (dbItem.index === lastDB) { // set last opened database as default serverInst.db = dbItem.index } else if (serverInst.db === -1) { // set the first database as default serverInst.db = dbItem.index } } serverInst.databases = databases this.servers[name] = serverInst }, /** * close connection * @param {string} name * @returns {Promise} */ async closeConnection(name) { const { success, msg } = await CloseConnection(name) if (!success) { // throw new Error(msg) return false } delete this.servers[name] const tabStore = useTabStore() tabStore.removeTabByName(name) return true }, /** * open database and load all keys * @param server * @param db * @returns {Promise} */ async openDatabase(server, db) { const { data, success, msg } = await OpenDatabase(server, db) if (!success) { throw new Error(msg) } const { keys = [], end = false, maxKeys = 0 } = data /** @type {RedisServerState} **/ const serverInst = this.servers[server] if (serverInst == null) { return } serverInst.db = db serverInst.setDatabaseKeyCount(db, maxKeys) serverInst.loadingState.fullLoaded = end if (isEmpty(keys)) { serverInst.nodeMap.clear() } else { // append db node to current connection's children serverInst.addKeyNodes(keys) } serverInst.tidyNode('', false) }, /** * close database * @param server * @param db */ closeDatabase(server, db) { /** @type {RedisServerState} **/ const serverInst = this.servers[server] if (serverInst == null) { return } if (serverInst.db !== db) { return } serverInst.closeDatabase() /** @type {RedisDatabaseItem} **/ const selDB = this.getDatabase(server, db) if (selDB == null) { return } selDB.keyCount = 0 }, /** * * @param {string} server * @param {boolean} mute * @returns {Promise<{}>} */ async getServerInfo(server, mute) { try { const { success, data, msg } = await ServerInfo(server) if (success) { /** @type {RedisServerState} **/ const serverInst = this.servers[server] if (serverInst != null) { serverInst.stats = data } return data } else if (!isEmpty(msg) && mute !== true) { $message.warning(msg) } } finally { } return {} }, /** * load key summary info * @param {string} server * @param {number} db * @param {string|number[]} [key] null or blank indicate that update tab to display normal content (blank content or server status) * @param {boolean} [clearValue] * @param {boolean} [redirect] redirect to key detail tab * @return {Promise} */ async loadKeySummary({ server, db, key, clearValue, redirect = true }) { try { const tab = useTabStore() if (!isEmpty(key)) { const { data, success, msg } = await GetKeySummary({ server, db, key, }) if (success) { const { type, ttl, size, length } = data const k = nativeRedisKey(key) const binaryKey = k !== key tab.upsertTab({ subTab: redirect === false ? null : BrowserTabType.KeyDetail, server, db, type, ttl, keyCode: binaryKey ? key : undefined, key: k, size, length, clearValue, }) return } else { if (!isEmpty(msg)) { $message.error('load key summary fail: ' + msg) } // its danger to delete "non-exists" key, just remove from tree view // await this.deleteKey(server, db, key, true) // TODO: show key not found page or check exists on server first? } } tab.upsertTab({ subTab: BrowserTabType.Status, server, db, type: 'none', ttl: -1, key: null, keyCode: null, size: 0, length: 0, clearValue, }) } catch (e) { $message.error(e.message || 'unknown error') } finally { } }, /** * load key type * @param {string} server * @param {number} db * @param {string|number[]} key * @param {number[]} keyCode * @return {Promise} */ async loadKeyType({ server, db, key }) { /** @type {RedisServerState} **/ const serverInst = this.servers[server] if (serverInst == null) { return } const node = serverInst.getNode(ConnectionType.RedisValue, nativeRedisKey(key)) if (node == null || !isEmpty(node.redisType)) { return } try { node.redisType = 'loading' const { data, success, msg } = await GetKeyType({ server, db, key }) if (success) { const { type } = data || {} node.redisType = type } else { node.redisType = 'NONE' } } catch (e) { node.redisType = 'NONE' } finally { } }, /** * reload key * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} [decode] * @param {string} [format] * @param {string} [matchPattern] * @param {boolean} [showLoading] * @return {Promise} */ async reloadKey({ server, db, key, decode, format, matchPattern, showLoading = true }) { const tab = useTabStore() try { if (showLoading) { tab.updateLoading({ server, db, loading: true }) } await this.loadKeySummary({ server, db, key, clearValue: true, redirect: false }) await this.loadKeyDetail({ server, db, key, decode, format, matchPattern, reset: true, showLoading: false, }) } finally { if (showLoading) { tab.updateLoading({ server, db, loading: false }) } } }, /** * load key content * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} [format] * @param {string} [decode] * @param {string} [matchPattern] * @param {boolean} [reset] * @param {boolean} [full] * @param {boolean} [showLoading] * @return {Promise} */ async loadKeyDetail({ server, db, key, format, decode, matchPattern, reset, full, showLoading = true }) { const tab = useTabStore() const serverInst = this.servers[server] if (serverInst == null) { return } try { if (showLoading) { tab.updateLoading({ server, db, loading: true }) } const [storeFormat, storeDecode] = serverInst.getDecodeHistory(key, db) const { data, success, msg } = await GetKeyDetail({ server, db, key, format: isEmpty(format) ? storeFormat : format, decode: isEmpty(decode) ? storeDecode : decode, matchPattern, full: full === true, reset, lite: true, }) if (success) { const { value, keyType, decode: retDecode, format: retFormat, match: retMatch, reset: retReset, end, } = data tab.updateValue({ server, db, key: nativeRedisKey(key), value, decode: retDecode || storeDecode, format: retFormat || storeFormat, reset: retReset, matchPattern: retMatch || '', end, }) } else { $message.error('load key detail fail:' + msg) } } finally { if (showLoading) { tab.updateLoading({ server, db, loading: false }) } } }, /** * convert value by decode type or format * @param {string|number[]} value * @param {string} [decode] * @param {string} [format] * @return {Promise<{[format]: string, [decode]: string, value: string}>} */ async convertValue({ value, decode, format }) { try { const { data, success } = await ConvertValue(value, decode, format) if (success) { const { value: retVal, decode: retDecode, format: retFormat } = data return { value: retVal, decode: retDecode, format: retFormat } } } catch (e) {} return { value, decode, format } }, /** * scan keys with prefix * @param {string} server * @param {number} db * @param {string} match * @param {boolean} exact * @param {string} [matchType] * @param {number} [loadType] 0.load next; 1.load next full; 2.reload load all * @returns {Promise<{keys: string[], maxKeys: number, end: boolean}>} */ async scanKeys({ server, db, match = '*', exact = false, matchType = '', loadType = 0 }) { let resp switch (loadType) { case 0: default: resp = await LoadNextKeys(server, db, match, matchType, exact) break case 1: resp = await LoadNextAllKeys(server, db, match, matchType, exact) break case 2: resp = await LoadAllKeys(server, db, match, matchType, exact) break } const { data, success, msg } = resp || {} if (!success) { throw new Error(msg) } const { keys = [], maxKeys, end } = data return { keys, end, maxKeys, success } }, /** * * @param {string} server * @param {number} db * @param {string|null} match * @param {boolean} exact * @param {string|null} matchType * @param {boolean} [all] * @return {Promise<{keys: Array, maxKeys: number, end: boolean}>} * @private */ async _loadKeys({ server, db, match, exact, matchType, all }) { if (isEmpty(match)) { match = '*' } if (!isRedisGlob(match) && !exact) { if (!startsWith(match, '*')) { match = '*' + match } if (!endsWith(match, '*')) { match = match + '*' } } return this.scanKeys({ server, db, match, exact, matchType, loadType: all ? 1 : 0 }) }, /** * load more keys within the database * @param {string} server * @param {number} db * @return {Promise} */ async loadMoreKeys(server, db) { const { match, type: keyType, exact } = this.getKeyFilter(server) const { keys, maxKeys, end } = await this._loadKeys({ server, db, match, exact, matchType: keyType, all: false, }) /** @type RedisServerState **/ const serverInst = this.servers[server] if (serverInst != null) { serverInst.setDBKeyCount(db, maxKeys) // remove current keys below prefix serverInst.addKeyNodes(keys) serverInst.tidyNode('') } return end }, /** * load all left keys within the database * @param {string} server * @param {number} db * @return {Promise} */ async loadAllKeys(server, db) { const { match, type: keyType, exact } = this.getKeyFilter(server) const { keys, maxKeys } = await this._loadKeys({ server, db, match, exact, matchType: keyType, all: true }) /** @type RedisServerState **/ const serverInst = this.servers[server] if (serverInst != null) { serverInst.setDBKeyCount(db, maxKeys) serverInst.addKeyNodes(keys) serverInst.tidyNode('') } }, /** * reload keys under layer * @param {string} server * @param {number} db * @param {string} prefix * @return {Promise} */ async reloadLayer(server, db, prefix) { if (isEmpty(prefix)) { return } let match = prefix const separator = this.getSeparator(server) if (!isEmpty(match)) { if (!endsWith(match, separator)) { match += separator + '*' } else { match += '*' } } // FIXME: ignore original match pattern due to redis not support combination matching const { match: originMatch, type: keyType, exact } = this.getKeyFilter(server) const { keys, maxKeys, success } = await this._loadKeys({ server, db, match: match || originMatch, exact: false, matchType: keyType, all: true, }) if (!success) { return } /** @type RedisServerState **/ const serverInst = this.servers[server] if (serverInst != null) { serverInst.setDBKeyCount(db, maxKeys) // remove current keys below prefix serverInst.removeKeyNode(prefix, true) serverInst.addKeyNodes(keys) serverInst.tidyNode(prefix) } }, /** * get custom separator of connection * @param server * @returns {string} * @private */ getSeparator(server) { const connStore = useConnectionStore() const { keySeparator } = connStore.getDefaultSeparator(server) if (isEmpty(keySeparator)) { return ':' } return keySeparator }, /** * get tree node by key name * @param {string} key format `/#/` * @return {RedisNodeItem|null} */ getNode(key) { const match = key.match(/db\d+(?=#)/) if (!match) { return null } let idx = match.index + match[0].length if (idx < 0) { idx = size(key) } const dbPart = key.substring(0, idx) // parse server and db index const idx2 = dbPart.lastIndexOf('/db') if (idx2 < 0) { return null } const server = dbPart.substring(0, idx2) /** @type {RedisServerState} **/ const serverInst = this.servers[server] if (serverInst == null) { return null } const db = parseInt(dbPart.substring(idx2 + 3)) if (isNaN(db)) { return null } if (size(key) <= idx + 1) { return null } // contains redis key const keyPart = key.substring(idx + 1) return serverInst.nodeMap.get(keyPart) }, /** * get parent tree node by key name * @param {string} key * @return {RedisNodeItem|null} */ getParentNode(key) { const i = key.indexOf('#') if (i < 0) { return null } const [server, db] = split(key.substring(0, i), '/') if (isEmpty(server) || isEmpty(db)) { return null } /** @type {RedisServerState} **/ const serverInst = this.servers[server] if (serverInst == null) { return null } const separator = this.getSeparator(server) const keyPart = key.substring(i) const keyStartIdx = keyPart.indexOf('/') const redisKey = keyPart.substring(keyStartIdx + 1) const redisKeyParts = split(redisKey, separator) const parentKey = slice(redisKeyParts, 0, size(redisKeyParts) - 1) if (isEmpty(parentKey)) { return serverInst.getRoot() } return serverInst.nodeMap.get(`${ConnectionType.RedisKey}/${join(parentKey, separator)}`) }, /** * set redis key * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} keyType * @param {any} value * @param {number} ttl * @param {string} [format] * @param {string} [decode] * @returns {Promise<{[msg]: string, success: boolean, [nodeKey]: {string}}>} */ async setKey({ server, db, key, keyType, value, ttl, format = formatTypes.RAW, decode = decodeTypes.NONE }) { try { const { data, success, msg } = await SetKeyValue({ server, db, key, keyType, value, ttl, format, decode, }) if (success) { /** @type RedisServerState **/ const serverInst = this.servers[server] if (serverInst != null && serverInst.db === db) { // const { value } = data // update tree view data const { newKey = 0 } = serverInst.addKeyNodes([key], true) if (newKey > 0) { serverInst.tidyNode(key) serverInst.updateDBKeyCount(db, newKey) } const { value: updatedValue } = data if (updatedValue != null) { const tab = useTabStore() tab.updateValue({ server, db, key, value: updatedValue }) } } // this.loadKeySummary({ server, db, key }) return { success, nodeKey: `${server}/db${db}#${ConnectionType.RedisValue}/${key}`, updatedValue: value, } } else { return { success, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * update hash entry * when field is set, newField is null, delete field * when field is null, newField is set, add new field * when both field and newField are set, and field === newField, update field * when both field and newField are set, and field !== newField, delete field and add newField * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} field * @param {string} [newField] * @param {string} [value] * @param {decodeTypes} [decode] * @param {formatTypes} [format] * @param {decodeTypes} [retDecode] * @param {formatTypes} [retFormat] * @param {boolean} [refresh] * @param {number} [index] index for retrieve affect entries quickly * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean, [updated]: {}}>} */ async setHash({ server, db, key, field, newField = '', value = '', decode = decodeTypes.NONE, format = formatTypes.RAW, retDecode, retFormat, index, reload, }) { try { const { data, success, msg } = await SetHashValue({ server, db, key, field, newField, value, decode, format, retDecode, retFormat, }) if (success) { /** * @type {{updated: HashEntryItem[], removed: HashEntryItem[], updated: HashEntryItem[], replaced: HashReplaceItem[]}} */ const { updated = [], removed = [], added = [], replaced = [] } = data const tab = useTabStore() if (!isEmpty(removed)) { const removedKeys = map(removed, 'k') tab.removeValueEntries({ server, db, key, type: 'hash', entries: removedKeys }) } if (!isEmpty(updated)) { tab.updateValueEntries({ server, db, key, type: 'hash', entries: updated }) } if (!isEmpty(added)) { tab.insertValueEntries({ server, db, key, type: 'hash', entries: added }) } if (!isEmpty(replaced)) { tab.replaceValueEntries({ server, db, key, type: 'hash', entries: replaced, index: [index], }) } if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } return { success, updated } } else { return { success, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * insert or update hash field item * @param {string} server * @param {number} db * @param {string|number[]} key * @param {number }action 0:ignore duplicated fields 1:overwrite duplicated fields * @param {string[]} fieldItems field1, value1, filed2, value2... * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean, [updated]: [], [added]: []}>} */ async addHashField({ server, db, key, action, fieldItems, reload }) { try { const { data, success, msg } = await AddHashField(server, db, key, action, fieldItems) if (success) { const { updated = [], added = [] } = data const tab = useTabStore() if (!isEmpty(updated)) { tab.updateValueEntries({ server, db, key, type: 'hash', entries: updated }) } if (!isEmpty(added)) { tab.insertValueEntries({ server, db, key, type: 'hash', entries: added }) } if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } return { success, updated, added } } else { return { success: false, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * get hash field * @param {string} server * @param {number} db * @param {string} key * @param {string} field * @param {decodeTypes} [decode] * @param {formatTypes} [format] * @return {Promise<{{msg: string, success: boolean, updated: HashEntryItem[]}>} */ async getHashField({ server, db, key, field, decode = decodeTypes.NONE, format = formatTypes.RAW }) { try { const { data, success, msg } = await GetHashValue({ server, db, key, field, decode, format }) if (success && !isEmpty(data)) { const tab = useTabStore() tab.updateValueEntries({ server, db, key, type: 'hash', entries: [data] }) return { success, updated: data } } else { return { success: false, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * remove hash field * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} field * @param {boolean} reload * @returns {Promise<{[msg]: {}, success: boolean, [removed]: string[]}>} */ async removeHashField({ server, db, key, field, reload }) { try { const { data, success, msg } = await SetHashValue({ server, db, key, field, newField: '' }) if (success) { const { removed = [] } = data // if (!isEmpty(removed)) { // const tab = useTabStore() // tab.removeValueEntries({ server, db, key, type: 'hash', entries: removed }) // } if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } return { success, removed } } else { return { success, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * prepend item to head of list * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string[]} values * @param {boolean} reload * @returns {Promise<{[msg]: string, success: boolean, [item]: []}>} */ async prependListItem({ server, db, key, values, reload }) { try { const { data, success, msg } = await AddListItem(server, db, key, 0, values) if (success) { const { left = [] } = data if (!isEmpty(left)) { const tab = useTabStore() tab.insertValueEntries({ server: server, db, key, type: 'list', entries: left, prepend: true, }) if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } } return { success, item: left } } else { return { success: false, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * append item to tail of list * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string[]} values * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean, [item]: any[]}>} */ async appendListItem({ server, db, key, values, reload }) { try { const { data, success, msg } = await AddListItem(server, db, key, 1, values) if (success) { const { right = [] } = data // FIXME: do not append items if not all items loaded if (!isEmpty(right)) { const tab = useTabStore() tab.insertValueEntries({ server: server, db, key, type: 'list', entries: right, prepend: false, }) if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } } return { success, item: right } } else { return { success: false, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * update value of list item by index * @param {string} server * @param {number} db * @param {string|number[]} key * @param {number} index * @param {string|number[]} value * @param {decodeTypes} decode * @param {formatTypes} format * @param {decodeTypes} [retDecode] * @param {formatTypes} [retFormat] * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean}>} */ async updateListItem({ server, db, key, index, value, decode = decodeTypes.NONE, format = formatTypes.RAW, retDecode, retFormat, reload, }) { try { const { data, success, msg } = await SetListItem({ server, db, key, index, value, decode, format, retDecode, retFormat, }) if (success) { /** @type {{replaced: ListReplaceItem[]}} **/ const { replaced = [], removed = [] } = data const tab = useTabStore() if (!isEmpty(replaced)) { tab.replaceValueEntries({ server, db, key, type: 'list', entries: replaced, }) } if (!isEmpty(removed)) { const removedIndex = map(removed, 'index') tab.removeValueEntries({ server, db, key, type: 'list', entries: removedIndex, }) } if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } return { success } } else { return { success, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * remove list item * @param {string} server * @param {number} db * @param {string|number[]} key * @param {number} index * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean, [removed]: string[]}>} */ async removeListItem({ server, db, key, index, reload }) { try { const { data, success, msg } = await SetListItem({ server, db, key, index }) if (success) { const { removed = [] } = data const tab = useTabStore() if (!isEmpty(removed)) { const removedIndexes = map(removed, 'index') tab.removeValueEntries({ server, db, key, type: 'list', entries: removedIndexes, }) if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } } return { success, removed } } else { return { success, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * add item to set * @param {string} server * @param {number} db * @param {string|number} key * @param {string|string[]} value * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean}>} */ async addSetItem({ server, db, key, value, reload }) { try { if ((!value) instanceof Array) { value = [value] } const { data, success, msg } = await SetSetItem(server, db, key, false, value) if (success) { const { added } = data if (!isEmpty(added)) { const tab = useTabStore() tab.insertValueEntries({ server, db, key, type: 'set', entries: added }) } if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } return { success } } else { return { success, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * update value of set item * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string|number[]} value * @param {string|number[]} newValue * @param {decodeTypes} [decode] * @param {formatTypes} [format] * @param {decodeTypes} [retDecode] * @param {formatTypes} [retFormat] * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean}>} */ async updateSetItem({ server, db, key, value, newValue, decode = decodeTypes.NONE, format = formatTypes.RAW, retDecode, retFormat, reload, }) { try { const { data, success, msg } = await UpdateSetItem({ server, db, key, value, newValue, decode, format, retDecode, retFormat, }) if (success) { const { added, removed } = data const tab = useTabStore() if (!isEmpty(removed)) { const removedValues = map(removed, 'v') tab.removeValueEntries({ server, db, key, type: 'set', entries: removedValues }) } if (!isEmpty(added)) { tab.insertValueEntries({ server, db, key, type: 'set', entries: added }) } if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } return { success } } else { return { success: false, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * remove item from set * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} value * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean}>} */ async removeSetItem({ server, db, key, value, reload }) { try { const { data, success, msg } = await SetSetItem(server, db, key, true, [value]) if (success) { const { removed } = data const tab = useTabStore() if (!isEmpty(removed)) { const removedValues = map(removed, 'v') tab.removeValueEntries({ server, db, key, type: 'set', entries: removedValues }) } if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } return { success } } else { return { success, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * add item to sorted set * @param {string} server * @param {number} db * @param {string|number[]} key * @param {number} action * @param {Object.} vs value: score * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean}>} */ async addZSetItem({ server, db, key, action, vs, reload }) { try { const { data, success, msg } = await AddZSetValue(server, db, key, action, vs) if (success) { const { added, updated } = data const tab = useTabStore() if (!isEmpty(added)) { tab.insertValueEntries({ server, db, key, type: 'zset', entries: added }) } if (!isEmpty(updated)) { tab.updateValueEntries({ server, db, key, type: 'zset', entries: updated }) } if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } return { success } } else { return { success, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * update item of sorted set * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} value * @param {string} newValue * @param {number} score * @param {decodeTypes} decode * @param {formatTypes} format * @param {decodeTypes} [retDecode] * @param {formatTypes} [retFormat] * @param {number} [index] index for retrieve affect entries quickly * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean}>} */ async updateZSetItem({ server, db, key, value = '', newValue, score, decode = decodeTypes.NONE, format = formatTypes.RAW, retDecode, retFormat, index, reload, }) { try { const { data, success, msg } = await UpdateZSetValue({ server, db, key, value, newValue, score, decode, format, retDecode, retFormat, }) if (success) { const { updated = [], added = [], removed = [], replaced = [] } = data const tab = useTabStore() if (!isEmpty(removed)) { const removedValues = map(removed, 'v') tab.removeValueEntries({ server, db, key, type: 'zset', entries: removedValues }) } if (!isEmpty(updated)) { tab.updateValueEntries({ server, db, key, type: 'zset', entries: updated }) } if (!isEmpty(added)) { tab.insertValueEntries({ server, db, key, type: 'zset', entries: added }) } if (!isEmpty(replaced)) { tab.replaceValueEntries({ server, db, key, type: 'zset', entries: replaced, index: [index] }) } if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } return { success, updated, removed } } else { return { success, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * remove item from sorted set * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} value * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean, [removed]: []}>} */ async removeZSetItem({ server, db, key, value, reload }) { try { const { data, success, msg } = await UpdateZSetValue({ server, db, key, value, newValue: '', score: 0 }) if (success) { const { removed } = data const tab = useTabStore() if (!isEmpty(removed)) { const removeValues = map(removed, 'v') tab.removeValueEntries({ server, db, key, type: 'zset', entries: removeValues }) } if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } return { success, removed } } else { return { success, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * insert new stream field item * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} id * @param {string[]} values field1, value1, filed2, value2... * @param {boolean} [reload] * @returns {Promise<{[msg]: string, success: boolean}>} */ async addStreamValue({ server, db, key, id, values, reload }) { try { const { data = {}, success, msg } = await AddStreamValue(server, db, key, id, values) if (success) { const { added = [] } = data if (!isEmpty(added)) { const tab = useTabStore() tab.insertValueEntries({ server, db, key, type: 'stream', entries: added, }) if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } } return { success } } else { return { success: false, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * remove stream field * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string[]|string} ids * @param {boolean} [reload] * @returns {Promise<{[msg]: {}, success: boolean}>} */ async removeStreamValues({ server, db, key, ids, reload }) { if (typeof ids === 'string') { ids = [ids] } try { const { data = {}, success, msg } = await RemoveStreamValues(server, db, key, ids) if (success) { const tab = useTabStore() tab.removeValueEntries({ server, db, key, type: 'stream', entries: ids }) if (reload === true) { this.reloadKey({ server, db, key }) } else { // reload summary only this.loadKeySummary({ server, db, key }) } return { success } } else { return { success, msg } } } catch (e) { return { success: false, msg: e.message } } }, /** * reset key's ttl * @param {string} server * @param {number} db * @param {string|number[]} key * @param {number} ttl * @returns {Promise} */ async setTTL(server, db, key, ttl) { try { const { success, msg } = await SetKeyTTL(server, db, key, ttl) if (success) { const tabStore = useTabStore() tabStore.updateTTL({ server, db, key: nativeRedisKey(key), ttl, }) } return success === true } catch (e) { return false } }, async setTTLs(server, db, keys, ttl) { // const msgRef = $message.loading('', { duration: 0, closable: true }) // let updated = [] // let failCount = 0 // let canceled = false const serialNo = Date.now().valueOf().toString() // const eventName = 'ttling:' + serialNo // const cancelEvent = 'ttling:stop:' + serialNo try { // let maxProgress = 0 // EventsOn(eventName, ({ total, progress, processing }) => { // // update delete progress // if (progress > maxProgress) { // maxProgress = progress // } // const k = decodeRedisKey(processing) // msgRef.content = i18nGlobal.t('dialogue.delete.doing', { // key: k, // index: maxProgress, // count: total, // }) // }) // msgRef.onClose = () => { // EventsEmit(cancelEvent) // } const { data, success, msg } = await BatchSetTTL(server, db, keys, ttl, serialNo) if (success) { // canceled = get(data, 'canceled', false) // updated = get(data, 'updated', []) // failCount = get(data, 'failed', 0) } else { $message.error(msg) } } finally { // msgRef.destroy() // EventsOff(eventName) } $message.success(i18nGlobal.t('dialogue.ttl.success')) // const deletedCount = size(updated) // if (canceled) { // $message.info(i18nGlobal.t('dialogue.handle_cancel')) // } else if (failCount <= 0) { // // no fail // $message.success(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount })) // } else if (failCount >= deletedCount) { // // all fail // $message.error(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount })) // } else { // // some fail // $message.warning(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount })) // } }, /** * delete redis key * @param {string} server * @param {number} db * @param {string|number[]} key * @param {boolean} [soft] do not try to remove from redis if true, just remove from tree data * @returns {Promise} */ async deleteKey(server, db, key, soft) { try { let deleteCount = 1 if (soft !== true) { const { data } = await DeleteKey(server, db, key) deleteCount = get(data, 'deleteCount', 0) } const k = nativeRedisKey(key) // update tree view data /** @type RedisServerState **/ const serverInst = this.servers[server] if (serverInst != null) { serverInst.removeKeyNode(k) serverInst.tidyNode(k, true) serverInst.updateDBKeyCount(db, -deleteCount) } // set tab content empty const tab = useTabStore() tab.emptyTab(server) tab.setSelectedKeys(server) tab.setCheckedKeys(server) return true } finally { } return false }, /** * delete multiple keys * @param {string} server * @param {number} db * @param {string[]|number[][]} keys * @return {Promise} */ async deleteKeys(server, db, keys) { const msgRef = $message.loading(i18nGlobal.t('dialogue.delete.deleting'), { duration: 0, closable: true }) let deleted = [] let failCount = 0 let canceled = false const serialNo = Date.now().valueOf().toString() msgRef.onClose = () => { EventsEmit('delete:stop:' + serialNo) } try { const { success, msg, data } = await DeleteKeys(server, db, keys, serialNo) if (success) { canceled = get(data, 'canceled', false) deleted = get(data, 'deleted', []) failCount = get(data, 'failed', 0) } else { $message.error(msg) } } finally { msgRef.destroy() // clear checked keys const tab = useTabStore() tab.setCheckedKeys(server) } // refresh model data const deletedCount = size(deleted) if (canceled) { $message.info(i18nGlobal.t('dialogue.handle_cancel')) } else if (failCount <= 0) { // no fail $message.success(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount })) } else if (failCount >= deletedCount) { // all fail $message.error(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount })) } else { // some fail $message.warning(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount })) } // update ui timeout(100).then(async () => { /** @type RedisServerState **/ const serverInst = this.servers[server] if (serverInst != null) { let start = now() for (let i = 0; i < deleted.length; i++) { serverInst.removeKeyNode(deleted[i], false) if (now() - start > 300) { await timeout(100) start = now() } } serverInst.tidyNode('', true) serverInst.updateDBKeyCount(db, -deletedCount) } }) }, /** * delete multiple keys by pattern * @param server * @param db * @param pattern * @return {Promise} */ async deleteByPattern(server, db, pattern) { const msgRef = $message.loading(i18nGlobal.t('dialogue.delete.deleting'), { duration: 0, closable: true }) let deleted = [] let failCount = 0 let canceled = false try { const { success, msg, data } = await DeleteKeysByPattern(server, db, pattern) if (success) { canceled = get(data, 'canceled', false) deleted = get(data, 'deleted', []) failCount = get(data, 'failed', 0) } else { $message.error(msg) } } finally { msgRef.destroy() // clear checked keys const tab = useTabStore() tab.setCheckedKeys(server) } // refresh model data const deletedCount = size(deleted) if (canceled) { $message.info(i18nGlobal.t('dialogue.handle_cancel')) } else if (failCount <= 0) { // no fail $message.success(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount })) } else if (failCount >= deletedCount) { // all fail $message.error(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount })) } else { // some fail $message.warning(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount })) } // update ui timeout(100).then(async () => { /** @type RedisServerState **/ const serverInst = this.servers[server] if (serverInst != null) { let start = now() for (let i = 0; i < deleted.length; i++) { serverInst.removeKeyNode(deleted[i], false) if (now() - start > 300) { await timeout(100) start = now() } } serverInst.tidyNode('', true) serverInst.updateDBKeyCount(db, -deletedCount) } }) }, /** * export multiple keys * @param {string} server * @param {number} db * @param {string[]|number[][]} keys * @param {string} path * @param {boolean} [expire] * @returns {Promise} */ async exportKeys(server, db, keys, path, expire) { const msgRef = $message.loading('', { duration: 0, closable: true }) let exported = 0 let failCount = 0 let canceled = false const cancelEventFn = EventsOn('exporting:' + path, ({ total, progress, processing }) => { // update export progress msgRef.content = i18nGlobal.t('dialogue.export.exporting', { // key: decodeRedisKey(processing), index: progress, count: total, }) }) msgRef.onClose = () => { EventsEmit('export:stop:' + path) } try { const { data, success, msg } = await ExportKey(server, db, keys, path, expire) if (success) { canceled = get(data, 'canceled', false) exported = get(data, 'exported', 0) failCount = get(data, 'failed', 0) } else { $message.error(msg) } } finally { msgRef.destroy() cancelEventFn() } if (canceled) { $message.info(i18nGlobal.t('dialogue.handle_cancel')) } else if (failCount <= 0) { // no fail $message.success( i18nGlobal.t('dialogue.export.export_completed', { success: exported, fail: failCount }), ) } else if (failCount >= exported) { // all fail $message.error(i18nGlobal.t('dialogue.export.export_completed', { success: exported, fail: failCount })) } else { // some fail $message.warning( i18nGlobal.t('dialogue.export.export_completed', { success: exported, fail: failCount, }), ) } }, /** * import multiple keys from csv file * @param {string} server * @param {number} db * @param {string} path * @param {number} conflict * @param {number} [ttl] <0:use previous; ==0: persist; >0: custom ttl * @param {boolean} [reload] * @return {Promise} */ async importKeysFromCSVFile(server, db, path, conflict, ttl, reload) { const msgRef = $message.loading('', { duration: 0, closable: true }) let imported = 0 let ignored = 0 let canceled = false const cancelEventFn = EventsOn('importing:' + path, ({ imported = 0, ignored = 0 }) => { // update export progress msgRef.content = i18nGlobal.t('dialogue.import.importing', { // key: decodeRedisKey(processing), imported, conflict: ignored, }) }) msgRef.onClose = () => { EventsEmit('import:stop:' + path) } try { const { data, success, msg } = await ImportCSV(server, db, path, conflict, ttl) if (success) { canceled = get(data, 'canceled', false) imported = get(data, 'imported', 0) ignored = get(data, 'ignored', 0) } else { $message.error(msg) } } finally { cancelEventFn() msgRef.destroy() } if (canceled) { $message.info(i18nGlobal.t('dialogue.handle_cancel')) } else { // finish $message.success(i18nGlobal.t('dialogue.import.import_completed', { success: imported, ignored })) if (reload) { this.reloadServer(server) } } }, /** * flush database * @param {string} server * @param {number} db * @param {boolean} async * @return {Promise} */ async flushDatabase(server, db, async) { try { const { success = false } = await FlushDB(server, db, async) if (success === true) { /** @type RedisServerState **/ const serverInst = this.servers[server] if (serverInst != null) { // update tree view data serverInst.removeKeyNode() } // set tab content empty const tab = useTabStore() tab.emptyTab(server) tab.setSelectedKeys(server) tab.setCheckedKeys(server) tab.setExpandedKeys(server) return true } } finally { } return true }, /** * rename key * @param {string} server * @param {number} db * @param {string} key * @param {string} newKey * @returns {Promise<{[msg]: string, success: boolean, [nodeKey]: string}>} */ async renameKey(server, db, key, newKey) { const { success = false, msg } = await RenameKey(server, db, key, newKey) if (success) { // delete old key and add new key struct /** @type RedisServerState **/ const serverInst = this.servers[server] if (serverInst != null) { serverInst.renameKey(key, newKey) } return { success: true, nodeKey: `${server}/db${db}#${ConnectionType.RedisValue}/${newKey}` } } else { return { success: false, msg } } }, /** * get command history * @param {number} [pageNo] * @param {number} [pageSize] * @returns {Promise} */ async getCmdHistory(pageNo, pageSize) { if (pageNo === undefined || pageSize === undefined) { pageNo = -1 pageSize = -1 } try { const { success, data = { list: [] } } = await GetCmdHistory(pageNo, pageSize) const { list } = data return list } catch { return [] } }, /** * clean cmd history * @return {Promise} */ async cleanCmdHistory() { try { const { success } = await CleanCmdHistory() return success === true } catch { return false } }, /** * get client list info * @param {string} server * @return {Promise<{idle: number, name: string, addr: string, age: number, db: number}[]>} */ async getClientList(server) { const { success, msg, data } = await GetClientList(server) if (success) { const { list = [] } = data return map(list, (item) => ({ addr: item['addr'], name: item['name'], age: item['age'] || 0, idle: item['idle'] || 0, db: item['db'] || 0, })) } return [] }, /** * get slow log list * @param {string} server * @param {number} num * @return {Promise<[]>} */ async getSlowLog(server, num) { try { const { success, data = { list: [] } } = await GetSlowLogs(server, num) const { list } = data return list } catch { return [] } }, /** * get key filter pattern and filter type * @param {string} server * @returns {{match: string, type: string, exact: boolean}} */ getKeyFilter(server) { let serverInst = this.servers[server] if (serverInst == null) { serverInst = new RedisServerState({ name: server, separator: this.getSeparator(server), }) } return serverInst.getFilter() }, /** * * @param {string} server * @param {string} [pattern] * @param {string} [type] * @param {boolean} [exact] */ setKeyFilter(server, { pattern, type, exact = false }) { const serverInst = this.servers[server] if (serverInst != null) { serverInst.setFilter({ pattern, type, exact }) } }, /** * * @param {string} server * @param {string} key * @param {number} db * @param {string} format * @param {string} decode */ setSelectedFormat(server, key, db, format, decode) { const serverInst = this.servers[server] if (serverInst == null) { return } serverInst.addDecodeHistory(key, db, format, decode) }, }, }) export default useBrowserStore ================================================ FILE: frontend/src/stores/connections.js ================================================ import { defineStore } from 'pinia' import { get, isEmpty, isObject, union, uniq } from 'lodash' import { CreateGroup, DeleteConnection, DeleteGroup, ExportConnections, GetConnection, ImportConnections, ListConnection, ParseConnectURL, RenameGroup, SaveConnection, SaveLastDB, SaveRefreshInterval, SaveSortedConnection } from 'wailsjs/go/services/connectionService.js' import { ConnectionType } from '@/consts/connection_type.js' import { KeyViewType } from '@/consts/key_view_type.js' import useBrowserStore from 'stores/browser.js' import { i18nGlobal } from '@/utils/i18n.js' import { ClipboardGetText } from 'wailsjs/runtime/runtime.js' const useConnectionStore = defineStore('connections', { /** * @typedef {Object} ConnectionItem * @property {string} key * @property {string} label display label * @property {string} name database name * @property {number} type * @property {boolean} cluster is cluster node * @property {ConnectionItem[]} children */ /** * @typedef {Object} ConnectionProfile * @property {string} defaultFilter * @property {string} keySeparator * @property {string} markColor * @property {number} refreshInterval */ /** * @typedef {Object} ConnectionState * @property {string[]} groups * @property {ConnectionItem[]} connections * @property {Object.} serverProfile */ /** * * @returns {ConnectionState} */ state: () => ({ groups: [], // all group name set connections: [], // all connections serverProfile: {}, // all server profile in flat list }), getters: {}, actions: { /** * load all store connections struct from local profile * @param {boolean} [force] * @returns {Promise} */ async initConnections(force) { if (!force && !isEmpty(this.connections)) { return } const conns = [] const groups = [] const profiles = {} const { data = [{ groupName: '', connections: [], refreshInterval: 5 }] } = await ListConnection() for (const conn of data) { if (conn.type !== 'group') { // top level conns.push({ key: '/' + conn.name, label: conn.name, name: conn.name, type: ConnectionType.Server, cluster: get(conn, 'cluster.enable', false), // isLeaf: false, }) profiles[conn.name] = { defaultFilter: conn.defaultFilter, keySeparator: conn.keySeparator, markColor: conn.markColor, refreshInterval: conn.refreshInterval, } } else { // custom group groups.push(conn.name) const subConns = get(conn, 'connections', []) const children = [] for (const item of subConns) { const value = conn.name + '/' + item.name children.push({ key: value, label: item.name, name: item.name, type: ConnectionType.Server, cluster: get(item, 'cluster.enable', false), // isLeaf: false, }) profiles[item.name] = { defaultFilter: item.defaultFilter, keySeparator: item.keySeparator, markColor: item.markColor, refreshInterval: item.refreshInterval, } } conns.push({ key: conn.name + '/', label: conn.name, type: ConnectionType.Group, children, }) } } this.connections = conns this.serverProfile = profiles this.groups = uniq(groups) }, /** * get connection by name from local profile * @param name * @returns {Promise} */ async getConnectionProfile(name) { try { const { data, success } = await GetConnection(name) if (success) { this.serverProfile[name] = { defaultFilter: data.defaultFilter, keySeparator: data.keySeparator, markColor: data.markColor, } return data } } finally { } return null }, /** * create a new default connection * @param {string} [name] * @returns {{}} */ newDefaultConnection(name) { return { group: '', name: name || '', network: 'tcp', sock: '/tmp/redis.sock', addr: '127.0.0.1', port: 6379, username: '', password: '', defaultFilter: '*', keySeparator: ':', connTimeout: 60, execTimeout: 60, dbFilterType: 'none', dbFilterList: [], keyView: KeyViewType.Tree, loadSize: 10000, markColor: '', alias: {}, ssl: { enable: false, allowInsecure: true, sni: '', certFile: '', keyFile: '', caFile: '', }, ssh: { enable: false, addr: '', port: 22, loginType: 'pwd', username: '', password: '', pkFile: '', passphrase: '', }, sentinel: { enable: false, master: 'mymaster', username: '', password: '', }, cluster: { enable: false, }, proxy: { type: 0, schema: 'http', addr: '', port: 0, auth: false, username: '', password: '', }, } }, mergeConnectionProfile(dest, src) { const mergeObj = (destObj, srcObj) => { const keys = union(Object.keys(destObj), Object.keys(srcObj)) for (const k of keys) { const t = typeof srcObj[k] if (t === 'string') { destObj[k] = srcObj[k] || destObj[k] || '' } else if (t === 'number') { destObj[k] = srcObj[k] || destObj[k] || 0 } else if (t === 'object') { mergeObj(destObj[k], srcObj[k] || {}) } else { destObj[k] = srcObj[k] } } return destObj } return mergeObj(dest, src) }, /** * get database server by name * @param name * @returns {ConnectionItem|null} */ getConnection(name) { const conns = this.connections for (let i = 0; i < conns.length; i++) { if (conns[i].type === ConnectionType.Server && conns[i].key === name) { return conns[i] } else if (conns[i].type === ConnectionType.Group) { const children = conns[i].children for (let j = 0; j < children.length; j++) { if (children[j].type === ConnectionType.Server && conns[i].key === name) { return children[j] } } } } return null }, /** * create a new connection or update current connection profile * @param {string} name set null if create a new connection * @param {{}} param * @returns {Promise<{success: boolean, [msg]: string}>} */ async saveConnection(name, param) { const { success, msg } = await SaveConnection(name, param) if (!success) { return { success: false, msg } } // reload connection list await this.initConnections(true) return { success: true } }, /** * save connection after sort * @returns {Promise} */ async saveConnectionSorted() { const mapToList = (conns) => { const list = [] for (const conn of conns) { if (conn.type === ConnectionType.Group) { const children = mapToList(conn.children) list.push({ name: conn.label, type: 'group', connections: children, }) } else if (conn.type === ConnectionType.Server) { list.push({ name: conn.name, }) } } return list } const s = mapToList(this.connections) SaveSortedConnection(s) }, /** * remove connection * @param name * @returns {Promise<{success: boolean, [msg]: string}>} */ async deleteConnection(name) { // close connection first const browser = useBrowserStore() await browser.closeConnection(name) const { success, msg } = await DeleteConnection(name) if (!success) { return { success: false, msg } } await this.initConnections(true) return { success: true } }, /** * create a connection group * @param name * @returns {Promise<{success: boolean, [msg]: string}>} */ async createGroup(name) { const { success, msg } = await CreateGroup(name) if (!success) { return { success: false, msg } } await this.initConnections(true) return { success: true } }, /** * rename connection group * @param name * @param newName * @returns {Promise<{success: boolean, [msg]: string}>} */ async renameGroup(name, newName) { if (name === newName) { return { success: true } } const { success, msg } = await RenameGroup(name, newName) if (!success) { return { success: false, msg } } await this.initConnections(true) return { success: true } }, /** * delete group by name * @param {string} name * @param {boolean} [includeConn] * @returns {Promise<{success: boolean, [msg]: string}>} */ async deleteGroup(name, includeConn) { const { success, msg } = await DeleteGroup(name, includeConn === true) if (!success) { return { success: false, msg } } await this.initConnections(true) return { success: true } }, /** * save last selected database * @param {string} name * @param {number} db * @return {Promise<{success: boolean, [msg]: string}>} */ async saveLastDB(name, db) { const { success, msg } = await SaveLastDB(name, db) if (!success) { return { success: false, msg } } return { success: true } }, /** * get default key filter pattern by server name * @param name * @return {string} */ getDefaultKeyFilter(name) { const { defaultFilter = '*' } = this.serverProfile[name] || {} return defaultFilter }, /** * get default key separator by server name * @param name * @return {string} */ getDefaultSeparator(name) { const { keySeparator = ':' } = this.serverProfile[name] || {} return keySeparator }, /** * get default status refresh interval by server name * @param {string} name * @return {number} */ getRefreshInterval(name) { const { refreshInterval = 5 } = this.serverProfile[name] || {} return refreshInterval }, /** * set and save default refresh interval * @param {string} name * @param {number} interval * @return {Promise<{success: boolean}|{msg: undefined, success: boolean}>} */ async saveRefreshInterval(name, interval) { const profile = this.serverProfile[name] || {} profile.refreshInterval = interval const { success, msg } = await SaveRefreshInterval(name, interval) if (!success) { return { success: false, msg } } return { success: true } }, /** * export connections to zip * @return {Promise} */ async exportConnections() { const { success, msg, data: { path = '' }, } = await ExportConnections() if (!success) { if (!isEmpty(msg)) { $message.error(msg) } return } $message.success(i18nGlobal.t('dialogue.handle_succ')) }, /** * import connections from zip * @return {Promise} */ async importConnections() { const { success, msg } = await ImportConnections() if (!success) { if (!isEmpty(msg)) { $message.error(msg) } return } $message.success(i18nGlobal.t('dialogue.handle_succ')) }, /** * parse redis url from text in clipboard * @return {Promise<{}>} */ async parseUrlFromClipboard() { const urlString = await ClipboardGetText() if (isEmpty(urlString)) { throw new Error('no text in clipboard') } const { success, msg, data } = await ParseConnectURL(urlString) if (!success || !isObject(data)) { throw new Error(msg || 'unknown') } data.url = urlString return data }, }, }) export default useConnectionStore ================================================ FILE: frontend/src/stores/dialog.js ================================================ import { defineStore } from 'pinia' import useConnectionStore from './connections.js' /** * connection dialog type * @enum {number} */ export const ConnDialogType = { NEW: 0, EDIT: 1, } const useDialogStore = defineStore('dialog', { state: () => ({ connDialogVisible: false, /** @type {ConnDialogType} **/ connType: ConnDialogType.NEW, connParam: null, groupDialogVisible: false, editGroup: '', /** * @property {string} prefix * @property {string} server * @property {int} db */ newKeyParam: { prefix: '', server: '', db: 0, }, newKeyDialogVisible: false, keyFilterParam: { server: '', db: 0, type: '', pattern: '*', }, keyFilterDialogVisible: false, addFieldParam: { server: '', db: 0, key: '', keyCode: null, type: null, }, addFieldsDialogVisible: false, renameKeyParam: { server: '', db: 0, key: '', }, renameDialogVisible: false, deleteKeyParam: { server: '', db: 0, key: '', }, deleteKeyDialogVisible: false, exportKeyParam: { server: '', db: 0, keys: [], }, exportKeyDialogVisible: false, importKeyParam: { server: '', db: 0, }, importKeyDialogVisible: false, flushDBParam: { server: '', db: 0, }, flushDBDialogVisible: false, ttlDialogVisible: false, ttlParam: { server: '', db: 0, key: '', keys: [], ttl: 0, }, decodeDialogVisible: false, decodeParam: { name: '', auto: true, decodePath: '', decodeArgs: [], encodePath: '', encodeArgs: [], }, preferencesDialogVisible: false, preferencesTag: '', aboutDialogVisible: false, }), actions: { openNewDialog() { this.connParam = null this.connType = ConnDialogType.NEW this.connDialogVisible = true }, closeConnDialog() { this.connDialogVisible = false }, async openEditDialog(name) { const connStore = useConnectionStore() const profile = await connStore.getConnectionProfile(name) this.connParam = connStore.mergeConnectionProfile(connStore.newDefaultConnection(name), profile) this.connType = ConnDialogType.EDIT this.connDialogVisible = true }, async openDuplicateDialog(name) { const connStore = useConnectionStore() this.connParam = {} let profile let suffix = 1 do { let profileName = name if (suffix > 1) { profileName += suffix } profile = await connStore.getConnectionProfile(profileName) if (profile != null) { suffix += 1 if (profileName === name) { this.connParam = profile } } else { this.connParam = connStore.mergeConnectionProfile( connStore.newDefaultConnection(profileName), this.connParam, ) this.connParam.name = profileName break } } while (true) this.connType = ConnDialogType.NEW this.connDialogVisible = true }, openNewGroupDialog() { this.editGroup = '' this.groupDialogVisible = true }, closeNewGroupDialog() { this.groupDialogVisible = false }, /** * * @param {string} server * @param {number} db * @param {string} [pattern] * @param {string} [type] */ openKeyFilterDialog(server, db, pattern, type) { this.keyFilterParam.server = server this.keyFilterParam.db = db this.keyFilterParam.type = type || '' this.keyFilterParam.pattern = pattern || '*' this.keyFilterDialogVisible = true }, closeKeyFilterDialog() { this.keyFilterDialogVisible = false }, /** * * @param {string} name */ openRenameGroupDialog(name) { this.editGroup = name this.groupDialogVisible = true }, closeRenameGroupDialog() { this.groupDialogVisible = false }, /** * * @param {string} server * @param {number} db * @param {string} key */ openRenameKeyDialog(server, db, key) { this.renameKeyParam.server = server this.renameKeyParam.db = db this.renameKeyParam.key = key this.renameDialogVisible = true }, closeRenameKeyDialog() { this.renameDialogVisible = false }, /** * * @param {string} server * @param {number} db * @param {string|string[]} [key] */ openDeleteKeyDialog(server, db, key = '*') { this.deleteKeyParam.server = server this.deleteKeyParam.db = db this.deleteKeyParam.key = key this.deleteKeyDialogVisible = true }, closeDeleteKeyDialog() { this.deleteKeyDialogVisible = false }, /** * * @param {string} server * @param {number} db * @param {string|string[]} keys */ openExportKeyDialog(server, db, keys) { this.exportKeyParam.server = server this.exportKeyParam.db = db this.exportKeyParam.keys = keys this.exportKeyDialogVisible = true }, closeExportKeyDialog() { this.exportKeyDialogVisible = false }, /** * * @param {string} server * @param {number} db */ openImportKeyDialog(server, db) { this.importKeyParam.server = server this.importKeyParam.db = db this.importKeyDialogVisible = true }, closeImportKeyDialog() { this.importKeyDialogVisible = false }, openFlushDBDialog(server, db) { this.flushDBParam.server = server this.flushDBParam.db = db this.flushDBDialogVisible = true }, closeFlushDBDialog() { this.flushDBDialogVisible = false }, /** * * @param {string} prefix * @param {string} server * @param {number} db */ openNewKeyDialog(prefix, server, db) { this.newKeyParam.prefix = prefix this.newKeyParam.server = server this.newKeyParam.db = db this.newKeyDialogVisible = true }, closeNewKeyDialog() { this.newKeyDialogVisible = false }, /** * * @param {string} server * @param {number} db * @param {string} key * @param {number[]|null} keyCode * @param {string} type */ openAddFieldsDialog(server, db, key, keyCode, type) { this.addFieldParam.server = server this.addFieldParam.db = db this.addFieldParam.key = key this.addFieldParam.keyCode = keyCode this.addFieldParam.type = type this.addFieldsDialogVisible = true }, closeAddFieldsDialog() { this.addFieldsDialogVisible = false }, /** * * @param {string} server * @param {number} db * @param {string|number[]} [key] * @param {string[]|number[][]} [keys] * @param {number} [ttl] */ openTTLDialog({ server, db, key, keys, ttl = -1 }) { this.ttlDialogVisible = true this.ttlParam.server = server this.ttlParam.db = db this.ttlParam.key = key this.ttlParam.keys = keys this.ttlParam.ttl = ttl }, closeTTLDialog() { this.ttlDialogVisible = false }, /** * * @param {string} name * @param {boolean} auto * @param {string} decodePath * @param {string[]} decodeArgs * @param {string} encodePath * @param {string[]} encodeArgs */ openDecoderDialog({ name = '', auto = true, decodePath = '', decodeArgs = [], encodePath = '', encodeArgs = [], } = {}) { this.decodeDialogVisible = true this.decodeParam.name = name this.decodeParam.auto = auto !== false this.decodeParam.decodePath = decodePath this.decodeParam.decodeArgs = decodeArgs || [] this.decodeParam.encodePath = encodePath this.decodeParam.encodeArgs = encodeArgs || [] }, closeDecoderDialog() { this.decodeDialogVisible = false }, openPreferencesDialog(tag = '') { this.preferencesDialogVisible = true this.preferencesTag = tag }, closePreferencesDialog() { this.preferencesDialogVisible = false this.preferencesTag = '' }, openAboutDialog() { this.aboutDialogVisible = true }, closeAboutDialog() { this.aboutDialogVisible = false }, }, }) export default useDialogStore ================================================ FILE: frontend/src/stores/preferences.js ================================================ import { defineStore } from 'pinia' import { lang } from '@/langs/index.js' import { cloneDeep, findIndex, get, isEmpty, join, map, pick, set, some, split } from 'lodash' import { CheckForUpdate, GetAppVersion, GetBuildInDecoder, GetFontList, GetPreferences, RestorePreferences, SetPreferences, } from 'wailsjs/go/services/preferencesService.js' import { BrowserOpenURL } from 'wailsjs/runtime/runtime.js' import { i18nGlobal } from '@/utils/i18n.js' import { enUS, NButton, NSpace, useOsTheme, zhCN } from 'naive-ui' import { h, nextTick } from 'vue' import { compareVersion } from '@/utils/version.js' import { typesIconStyle } from '@/consts/support_redis_type.js' import { TextAlignType } from '@/consts/text_align_type.js' const osTheme = useOsTheme() const usePreferencesStore = defineStore('preferences', { /** * @typedef {Object} FontItem * @property {string} name * @property {string} path */ /** * @typedef {Object} Preferences * @property {Object} general * @property {Object} editor * @property {FontItem[]} fontList */ /** * * @returns {Preferences} */ state: () => ({ behavior: { welcomed: false, asideWidth: 300, windowWidth: 0, windowHeight: 0, windowMaximised: false, }, general: { theme: 'auto', language: 'auto', font: '', fontFamily: [], fontSize: 14, scanSize: 3000, keyIconStyle: 0, useSysProxy: false, useSysProxyHttp: false, checkUpdate: true, skipVersion: '', allowTrack: true, }, editor: { font: '', fontFamily: [], fontSize: 14, showLineNum: true, showFolding: true, dropText: true, links: true, entryTextAlign: TextAlignType.Center, }, cli: { fontFamily: [], fontSize: 14, cursorStyle: 'block', }, buildInDecoder: [], decoder: [], lastPref: {}, fontList: [], appVersion: '', }), getters: { getSeparator() { return ':' }, themeOption() { return [ { value: 'light', label: 'preferences.general.theme_light', }, { value: 'dark', label: 'preferences.general.theme_dark', }, { value: 'auto', label: 'preferences.general.theme_auto', }, ] }, /** * all themes' name * @returns {string[]} */ allThemes() { return this.themeOption.map((o) => o.value) }, /** * all available language * @returns {{label: string, value: string}[]} */ langOption() { const options = Object.entries(lang).map(([key, value]) => ({ value: key, label: value['name'], })) options.splice(0, 0, { value: 'auto', label: 'preferences.general.system_lang', }) return options }, /** * all languages' name * @returns {string[]} */ allLangs() { return this.langOption.map((o) => o.value) }, /** * all system font list * @returns {{path: string, label: string, value: string}[]} */ fontOption() { return map(this.fontList, (font) => ({ value: font.name, label: font.name, path: font.path, })) }, /** * current font selection * @returns {{fontSize: string, fontFamily?: string}} */ generalFont() { const fontStyle = { fontSize: this.general.fontSize + 'px', } if (!isEmpty(this.general.fontFamily)) { fontStyle['fontFamily'] = join( map(this.general.fontFamily, (f) => `"${f}"`), ',', ) } // compatible with old preferences // if (isEmpty(fontStyle['fontFamily'])) { // if (!isEmpty(this.general.font) && this.general.font !== 'none') { // const font = find(this.fontList, { name: this.general.font }) // if (font != null) { // fontStyle['fontFamily'] = `${font.name}` // } // } // } return fontStyle }, /** * current editor font * @return {{fontSize: string, fontFamily?: string}} */ editorFont() { const fontStyle = { fontSize: (this.editor.fontSize || 14) + 'px', } if (!isEmpty(this.editor.fontFamily)) { fontStyle['fontFamily'] = join( map(this.editor.fontFamily, (f) => `"${f}"`), ',', ) } // compatible with old preferences // if (isEmpty(fontStyle['fontFamily'])) { // if (!isEmpty(this.editor.font) && this.editor.font !== 'none') { // const font = find(this.fontList, { name: this.editor.font }) // if (font != null) { // fontStyle['fontFamily'] = `${font.name}` // } // } // } if (isEmpty(fontStyle['fontFamily'])) { fontStyle['fontFamily'] = ['monaco'] } return fontStyle }, /** * current cli font * @return {{fontSize: string, fontFamily?: string}} */ cliFont() { const fontStyle = { fontSize: this.cli.fontSize || 14, } if (!isEmpty(this.cli.fontFamily)) { fontStyle['fontFamily'] = join( map(this.cli.fontFamily, (f) => `"${f}"`), ',', ) } if (isEmpty(fontStyle['fontFamily'])) { fontStyle['fontFamily'] = ['Courier New'] } return fontStyle }, cliCursorStyleOption() { return [ { value: 'block', label: 'preferences.cli.cursor_style_block', }, { value: 'underline', label: 'preferences.cli.cursor_style_underline', }, { value: 'bar', label: 'preferences.cli.cursor_style_bar', }, ] }, /** * get current language setting * @return {string} */ currentLanguage() { let lang = get(this.general, 'language', 'auto') if (lang === 'auto') { const systemLang = navigator.language || navigator.userLanguage lang = split(systemLang, '-')[0] } return lang || 'en' }, isDark() { const th = get(this.general, 'theme', 'auto') if (th !== 'auto') { return th === 'dark' } else { return osTheme.value === 'dark' } }, themeLocale() { const lang = this.currentLanguage switch (lang) { case 'zh': return zhCN default: return enUS } }, autoCheckUpdate() { return get(this.general, 'checkUpdate', false) }, showLineNum() { return get(this.editor, 'showLineNum', true) }, showFolding() { return get(this.editor, 'showFolding', true) }, dropText() { return get(this.editor, 'dropText', true) }, editorLinks() { return get(this.editor, 'links', true) }, keyIconType() { return get(this.general, 'keyIconStyle', typesIconStyle.SHORT) }, entryTextAlign() { return get(this.editor, 'entryTextAlign', TextAlignType.Center) }, }, actions: { _applyPreferences(data) { for (const key in data) { set(this, key, data[key]) } }, /** * load preferences from local * @returns {Promise} */ async loadPreferences() { const { success, data } = await GetPreferences() if (success) { this.lastPref = cloneDeep(data) this._applyPreferences(data) // default value const showLineNum = get(data, 'editor.showLineNum') if (showLineNum === undefined) { set(data, 'editor.showLineNum', true) } const showFolding = get(data, 'editor.showFolding') if (showFolding === undefined) { set(data, 'editor.showFolding', true) } const dropText = get(data, 'editor.dropText') if (dropText === undefined) { set(data, 'editor.dropText', true) } const links = get(data, 'editor.links') if (links === undefined) { set(data, 'editor.links', true) } i18nGlobal.locale.value = this.currentLanguage } return success }, /** * load system font list * @returns {Promise} */ async loadFontList() { const { success, data } = await GetFontList() if (success) { const { fonts = [] } = data this.fontList = fonts } else { this.fontList = [] } return this.fontList }, /** * get all available build-in decoder * @return {Promise} */ async loadBuildInDecoder() { const { success, data } = await GetBuildInDecoder() if (success) { const { decoder = [] } = data this.buildInDecoder = decoder } else { this.buildInDecoder = [] } }, /** * load app version * @return {Promise} */ async loadAppVersion() { const { success, data } = await GetAppVersion() if (success && data?.version) { this.appVersion = data.version } }, /** * save preferences to local * @returns {Promise} */ async savePreferences() { const pf = pick(this, ['behavior', 'general', 'editor', 'cli', 'decoder']) const { success } = await SetPreferences(pf) return success === true }, /** * reset to last-loaded preferences * @returns {Promise} */ async resetToLastPreferences() { if (!isEmpty(this.lastPref)) { this._applyPreferences(this.lastPref) } }, /** * restore preferences to default * @returns {Promise} */ async restorePreferences() { const { success, data } = await RestorePreferences() if (success === true) { const { pref } = data this._applyPreferences(pref) return true } return false }, /** * add a new custom decoder * @param {string} name * @param {boolean} enable * @param {boolean} auto * @param {string} encodePath * @param {string[]} encodeArgs * @param {string} decodePath * @param {string[]} decodeArgs */ addCustomDecoder({ name, enable = true, auto = true, encodePath, encodeArgs, decodePath, decodeArgs }) { if (some(this.decoder, { name })) { return false } this.decoder = this.decoder || [] this.decoder.push({ name, enable, auto, encodePath, encodeArgs, decodePath, decodeArgs }) return true }, /** * update an existing custom decoder * @param {string} newName * @param {boolean} enable * @param {boolean} auto * @param {string} name * @param {string} encodePath * @param {string[]} encodeArgs * @param {string} decodePath * @param {string[]} decodeArgs */ updateCustomDecoder({ newName, enable = true, auto = true, name, encodePath, encodeArgs, decodePath, decodeArgs, }) { const idx = findIndex(this.decoder, { name }) if (idx === -1) { return false } // conflicted if (newName !== name && some(this.decoder, { name: newName })) { return false } let selDecoder = this.decoder[idx] selDecoder.name = newName || name selDecoder.enable = enable selDecoder.auto = auto selDecoder.encodePath = encodePath selDecoder.encodeArgs = encodeArgs selDecoder.decodePath = decodePath selDecoder.decodeArgs = decodeArgs this.decoder[idx] = selDecoder return true }, /** * remove an existing custom decoder * @param {string} name * @return {boolean} */ removeCustomDecoder(name) { const idx = findIndex(this.decoder, { name }) if (idx === -1) { return false } this.decoder.splice(idx, 1) return true }, setAsWelcomed(acceptTrack) { this.behavior.welcomed = true this.general.allowTrack = acceptTrack this.savePreferences() }, async checkForUpdate(manual = false) { let msgRef = null if (manual) { msgRef = $message.loading(i18nGlobal.t('interface.retrieving_version'), { duration: 0 }) } try { const { success, data = {} } = await CheckForUpdate() if (success) { const { version = 'v1.0.0', latest, download_page: pageUrl = {}, description = {}, sponsor = [], banner = [], } = data const downUrl = pageUrl[this.currentLanguage] || pageUrl['en'] const descStr = description[this.currentLanguage] || description['en'] // save sponsor ad if (!isEmpty(sponsor)) { localStorage.setItem('sponsor_ad', JSON.stringify(sponsor)) } if (!isEmpty(banner)) { localStorage.setItem('banner', JSON.stringify(banner)) } if ( (manual || compareVersion(latest, this.general.skipVersion) !== 0) && compareVersion(latest, version) > 0 && !isEmpty(downUrl) ) { const notiRef = $notification.show({ title: `${i18nGlobal.t('dialogue.upgrade.title')} - ${latest}`, content: descStr || i18nGlobal.t('dialogue.upgrade.new_version_tip', { ver: latest }), action: () => h('div', { class: 'flex-box-h flex-item-expand' }, [ h(NSpace, { wrapItem: false }, () => [ h( NButton, { size: 'small', secondary: true, onClick: () => { // skip this update this.general.skipVersion = latest this.savePreferences() notiRef.destroy() }, }, () => i18nGlobal.t('dialogue.upgrade.skip'), ), h( NButton, { size: 'small', secondary: true, onClick: notiRef.destroy, }, () => i18nGlobal.t('dialogue.upgrade.later'), ), h( NButton, { type: 'primary', size: 'small', secondary: true, onClick: () => BrowserOpenURL(downUrl), }, () => i18nGlobal.t('dialogue.upgrade.download_now'), ), ]), ]), onPositiveClick: () => BrowserOpenURL(downUrl), }) return } } if (manual) { $message.info(i18nGlobal.t('dialogue.upgrade.no_update')) } } finally { nextTick().then(() => { if (msgRef != null) { msgRef.destroy() msgRef = null } }) } }, }, }) export default usePreferencesStore ================================================ FILE: frontend/src/stores/tab.js ================================================ import { assign, find, findIndex, get, includes, indexOf, isEmpty, pullAt, remove, set, size } from 'lodash' import { defineStore } from 'pinia' import { TabItem } from '@/objects/tabItem.js' import useBrowserStore from 'stores/browser.js' import { i18nGlobal } from '@/utils/i18n.js' import { BrowserTabType } from '@/consts/browser_tab_type.js' const useTabStore = defineStore('tab', { /** * @typedef {Object} ListEntryItem * @property {string|number[]} v value * @property {string} [dv] display value */ /** * @typedef {Object} ListReplaceItem * @property {number} index * @property {string|number[]} v value * @property {string} [dv] display value */ /** * @typedef {Object} HashEntryItem * @property {string} k field name * @property {string|number[]} v value * @property {string} [dv] display value */ /** * @typedef {Object} HashReplaceItem * @property {string|number[]} k field name * @property {string|number[]} nk new field name * @property {string|number[]} v value * @property {string} [dv] display value */ /** * @typedef {Object} SetEntryItem * @property {string|number[]} v value * @property {string} [dv] display value */ /** * @typedef {Object} ZSetEntryItem * @property {number} s score * @property {string|number[]} v value * @property {string} [dv] display value */ /** * @typedef {Object} ZSetReplaceItem * @property {number} s score * @property {string|number[]} v value * @property {string|number[]} nv new value * @property {string} [dv] display value */ /** * @typedef {Object} StreamEntryItem * @property {string} id * @property {Object.} v value * @property {string} [dv] display value */ /** * @typedef {Object} TabState * @property {string} nav * @property {number} asideWidth * @property {TabItem[]} tabList * @property {number} activatedIndex */ /** * * @returns {TabState} */ state: () => ({ nav: 'server', asideWidth: 300, tabList: [], activatedIndex: 0, // current activated tab index }), getters: { /** * get current tab list item * @returns {TabItem[]} */ tabs() { // if (isEmpty(this.tabList)) { // this.newBlankTab() // } return this.tabList }, /** * get current activated tab item * @returns {TabItem|null} */ currentTab() { return get(this.tabs, this.activatedIndex) }, currentTabName() { return get(this.tabs, [this.activatedIndex, 'name']) }, currentCheckedKeys() { const tab = this.currentTab return get(tab, 'checkedKeys', []) }, }, actions: { /** * * @param idx * @param {boolean} [switchNav] * @param {string} [subTab] * @private */ _setActivatedIndex(idx, switchNav, subTab) { this.activatedIndex = idx if (switchNav === true) { this.nav = idx >= 0 ? 'browser' : 'server' if (!isEmpty(subTab)) { set(this.tabList, [idx, 'subTab'], subTab) } } else { if (idx < 0) { this.nav = 'server' } } }, openBlank(server) { this.upsertTab({ server, clearValue: true }) }, /** * * @param {string} tabName */ closeTab(tabName) { $dialog.warning(i18nGlobal.t('dialogue.close_confirm', { name: tabName }), () => { const browserStore = useBrowserStore() browserStore.closeConnection(tabName) }) }, /** * update or insert a new tab if not exists with the same name * @param {string} subTab * @param {string} server * @param {number} [db] * @param {number} [type] * @param {number} [ttl] * @param {string} [key] * @param {string} [keyCode] * @param {number} [size] * @param {number} [length] * @param {string} [matchPattern] * @param {boolean} [clearValue] * @param {string} format * @param {string} decode * @param {boolean} forceSwitch * @param {*} [value] */ upsertTab({ subTab, server, db, type, ttl, key, keyCode, size, length, matchPattern = '', clearValue, format = '', decode = '', forceSwitch = false, }) { let tabIndex = findIndex(this.tabList, { name: server }) if (tabIndex === -1) { subTab = subTab || BrowserTabType.Status const tabItem = new TabItem({ name: server, title: server, subTab, server, db, type, ttl, key, keyCode, size, length, matchPattern, value: undefined, format, decode, }) this.tabList.push(tabItem) tabIndex = this.tabList.length - 1 this._setActivatedIndex(tabIndex, true, subTab) } else { const tab = this.tabList[tabIndex] tab.blank = false tab.subTab = subTab || tab.subTab // tab.title = db !== undefined ? `${server}/db${db}` : `${server}` tab.title = server tab.server = server tab.db = db == null ? tab.db : db tab.type = type tab.ttl = ttl tab.key = key tab.keyCode = keyCode tab.size = size tab.length = length tab.matchPattern = matchPattern tab.format = format tab.decode = decode if (clearValue === true) { tab.value = undefined } if (forceSwitch === true) { this._setActivatedIndex(tabIndex, true, subTab) } } }, /** * keep update value in tab * @param {string} server * @param {number} db * @param {string} key * @param {*} [value] * @param {string} [format] * @param {string] [decode] * @param {string} [matchPattern] * @param {boolean} [reset] * @param {boolean} [end] keep end status if not set * @param {number} [size] * @param {number} [length] */ updateValue({ server, db, key, value, format, decode, matchPattern, reset, end, size = -1, length = -1 }) { const tabData = find(this.tabList, { name: server, db, key }) if (tabData == null) { return } tabData.format = format || tabData.format tabData.decode = decode || tabData.decode tabData.matchPattern = matchPattern || '' if (size >= 0) { tabData.size = size } if (length >= 0) { tabData.length = length } if (typeof end === 'boolean') { tabData.end = end } if (!!!reset && typeof value === 'object') { if (value instanceof Array) { tabData.value = tabData.value || [] // direct deconstruction leads to 'Maximum call stack size exceeded'? // tabData.value.push(...value) for (let i = 0; i < value.length; i++) { tabData.value.push(value[i]) } } else { tabData.value = assign(value, tabData.value || {}) } } else { tabData.value = value } }, /** * insert entries * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} type * @param {ListEntryItem[]|HashEntryItem[]|SetEntryItem[]|ZSetEntryItem[]|StreamEntryItem[]} entries * @param {boolean} [prepend] for list only */ insertValueEntries({ server, db, key, type, entries, prepend }) { const tab = find(this.tabList, { name: server, db, key }) if (tab == null) { return } switch (type.toLowerCase()) { case 'list': // {v:string, dv:[string]}[] tab.value = tab.value || [] if (prepend === true) { const originList = tab.value const list = [] let starIndex = 0 for (const entry of entries) { entry.index = starIndex++ list.push(entry) } for (const entry of originList) { entry.index = starIndex++ list.push(entry) } tab.value = list } else { const list = tab.value let starIndex = list.length for (const entry of entries) { entry.index = starIndex++ list.push(entry) } } tab.length += size(entries) break case 'hash': // {k:string, v:string, dv:[string]}[] case 'set': // {v: string, s: number}[] case 'zset': // {v: string, s: number}[] tab.value = tab.value || [] tab.value.push(...entries) tab.length += size(entries) break case 'stream': // {id: string, v: {}}[] tab.value = tab.value || [] tab.value = [...entries, ...tab.value] tab.length += size(entries) break } }, /** * update entries' value * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} type * @param {ListEntryItem[]|HashEntryItem[]|SetEntryItem[]|ZSetEntryItem[]|StreamEntryItem[]} entries */ updateValueEntries({ server, db, key, type, entries }) { const tab = find(this.tabList, { name: server, db, key }) if (tab == null) { return } switch (type.toLowerCase()) { case 'hash': // {k:string, v:string, dv:string}[] tab.value = tab.value || [] for (const entry of entries) { let updated = false for (const val of tab.value) { if (val.k === entry.k) { val.v = entry.v val.dv = entry.dv updated = true break } } if (!updated) { // no match element, append tab.value.push(entry) tab.length += 1 } } break case 'zset': // {s:number, v:string, dv:string}[] tab.value = tab.value || [] for (const entry of entries) { let updated = false for (const val of tab.value) { if (val.v === entry.v) { val.s = entry.s val.dv = entry.dv updated = true break } } if (!updated) { // no match element, append tab.value.push(entry) tab.length += 1 } } break } }, /** * replace entry item key or field in value(modify the index key) * @param {string} server * @param {number} db * @param {string|number[]} key * @param {string} type * @param {ListReplaceItem[]|HashReplaceItem[]|ZSetReplaceItem[]} entries * @param {number[]} [index] indexes for replacement, can improve search efficiency if configured */ replaceValueEntries({ server, db, key, type, entries, index }) { const tab = find(this.tabList, { name: server, db, key }) if (tab == null) { return } switch (type.toLowerCase()) { case 'list': // ListReplaceItem[] tab.value = tab.value || [] for (const entry of entries) { if (size(tab.value) > entry.index) { tab.value[entry.index] = { index: entry.index, v: entry.v, dv: entry.dv, } } else { // out of range, append tab.value.push(entry) tab.length += 1 } } break case 'hash': // HashReplaceItem[] tab.value = tab.value || [] for (const idx of index) { const entry = get(tab.value, idx) if (entry != null) { /** @type HashReplaceItem[] **/ const replaceEntry = remove(entries, (e) => e.k === entry.k) if (!isEmpty(replaceEntry)) { entry.k = replaceEntry[0].nk entry.v = replaceEntry[0].v entry.dv = replaceEntry[0].dv } } } // the left entries do not included in index list, try to retrieve the whole list for (const entry of entries) { let updated = false for (const val of tab.value) { if (val.k === entry.k) { val.k = entry.nk val.v = entry.v val.dv = entry.dv updated = true break } } if (!updated) { // no match element, append tab.value.push({ k: entry.nk, v: entry.v, dv: entry.dv, }) tab.length += 1 } } break case 'zset': // ZSetReplaceItem[] tab.value = tab.value || [] for (const idx of index) { const entry = get(tab.value, idx) if (entry != null) { /** @type ZSetReplaceItem[] **/ const replaceEntry = remove(entries, ({ v }) => v === entry.k) if (!isEmpty(replaceEntry)) { entry.s = replaceEntry[0].s entry.v = replaceEntry[0].nv entry.dv = replaceEntry[0].dv } } } // the left entries do not included in index list, try to retrieve the whole list for (const entry of entries) { let updated = false for (const val of tab.value) { if (val.v === entry.v) { val.s = entry.s val.v = entry.nv val.dv = entry.dv updated = true break } } if (!updated) { // no match element, append tab.value.push({ s: entry.s, v: entry.nv, dv: entry.dv, }) tab.length += 1 } } break } }, /** * remove value entries * @param {string} server * @param {number} db * @param {string} key * @param {string} type * @param {string[] | number[]} entries */ removeValueEntries({ server, db, key, type, entries }) { const tab = find(this.tabList, { name: server, db, key }) if (tab == null) { return } switch (type.toLowerCase()) { case 'list': // string[] | number[] tab.value = tab.value || [] if (typeof entries[0] === 'number') { // remove by index, sort by desc first entries.sort((a, b) => b - a) const removed = pullAt(tab.value, ...entries) tab.length -= size(removed) } else { // append or prepend items for (const elem of entries) { if (!isEmpty(remove(tab.value, elem))) { tab.length -= 1 } } } break case 'hash': // string[] tab.value = tab.value || {} for (const k of entries) { for (let i = 0; i < tab.value.length; i++) { if (tab.value[i].k === k) { tab.value.splice(i, 1) tab.length -= 1 break } } } break case 'set': // string[] case 'zset': // string[] tab.value = tab.value || [] for (const v of entries) { for (let i = 0; i < tab.value.length; i++) { if (tab.value[i].v === v) { tab.value.splice(i, 1) tab.length -= 1 break } } } break case 'stream': // string[] tab.value = tab.value || [] for (const id of entries) { for (let i = 0; i < tab.value.length; i++) { if (tab.value[i].id === id) { tab.value.splice(i, 1) tab.length -= 1 break } } } break } }, /** * update loading status of content in tab * @param {string} server * @param {number} db * @param {boolean} loading */ updateLoading({ server, db, loading }) { const tab = find(this.tabList, { name: server, db }) if (tab == null) { return } tab.loading = loading }, /** * update ttl in tab * @param {string} server * @param {number} db * @param {string} key * @param {number} ttl */ updateTTL({ server, db, key, ttl }) { let tab = find(this.tabList, { name: server, db, key }) if (tab == null) { return } tab.ttl = ttl }, /** * set tab's content to empty * @param {string} name */ emptyTab(name) { const tab = find(this.tabList, { name }) if (tab != null) { tab.key = null tab.value = null } }, switchTab(tabIndex) { // const len = size(this.tabList) // if (tabIndex < 0 || tabIndex >= len) { // tabIndex = 0 // } // this.activatedIndex = tabIndex // const tabIndex = findIndex(this.tabList, {name}) // if (tabIndex === -1) { // return // } // this.activatedIndex = tabIndex }, switchSubTab(name) { const tab = this.currentTab if (tab == null) { return } tab.subTab = name }, /** * * @param {number} tabIndex * @returns {*|null} */ removeTab(tabIndex) { const len = size(this.tabs) // ignore remove last blank tab if (len === 1 && this.tabs[0].blank) { return null } if (tabIndex < 0 || tabIndex >= len) { return null } const removed = this.tabList.splice(tabIndex, 1) // update select index if removed index equal current selected this.activatedIndex -= 1 if (this.activatedIndex < 0) { if (this.tabList.length > 0) { this._setActivatedIndex(0, false) } else { this._setActivatedIndex(-1, false) } } else { this._setActivatedIndex(this.activatedIndex, false) } return size(removed) > 0 ? removed[0] : null }, /** * * @param {string} tabName */ removeTabByName(tabName) { const idx = findIndex(this.tabs, { name: tabName }) if (idx !== -1) { this.removeTab(idx) } }, /** * */ removeAllTab() { this.tabList = [] this._setActivatedIndex(-1, false) }, /** * set expanded keys for server * @param {string} server * @param {string|string[]} keys */ setExpandedKeys(server, keys = []) { /** @type TabItem**/ let tab = find(this.tabList, { name: server }) if (tab != null) { if (typeof keys === 'string') { keys = [keys] } if (isEmpty(keys)) { tab.expandedKeys = [] } else { tab.expandedKeys = keys } } }, /** * * @param {string} server * @param {string|string[]} keys */ addExpandedKey(server, keys) { /** @type TabItem**/ let tab = find(this.tabList, { name: server }) if (tab != null) { if (typeof keys === 'string') { keys = [keys] } for (const k of keys) { if (!includes(tab.expandedKeys, k)) { tab.expandedKeys.push(k) } } } }, /** * * @param {string} server * @param {string} key */ toggleExpandKey(server, key) { /** @type TabItem**/ let tab = find(this.tabList, { name: server }) if (tab != null) { const idx = indexOf(tab.expandedKeys, key) if (idx === -1) { tab.expandedKeys.push(key) } else { tab.expandedKeys.splice(idx, 1) } } }, /** * * @param {string} server * @param {string} key */ removeExpandedKey(server, key) { /** @type TabItem**/ let tab = find(this.tabList, { name: server }) if (tab != null) { remove(tab.expandedKeys, (v) => v === key) } }, /** * set selected keys for server * @param {string} server * @param {string|string[]} [keys] */ setSelectedKeys(server, keys = null) { /** @type TabItem**/ let tab = find(this.tabList, { name: server }) if (tab != null) { if (keys == null) { // select nothing tab.selectedKeys = [] tab.activatedKey = null } else if (typeof keys === 'string') { tab.selectedKeys = [keys] } else { tab.selectedKeys = keys } } }, /** * get checked keys * @param server * @returns {CheckedKey[]} */ getCheckedKeys(server) { /** @type TabItem**/ let tab = find(this.tabList, { name: server }) if (tab != null) { return tab.checkedKeys || [] } return [] }, /** * set checked keys for server * @param {string} server * @param {CheckedKey[]} [keys] */ setCheckedKeys(server, keys = null) { /** @type TabItem**/ let tab = find(this.tabList, { name: server }) if (tab != null) { if (isEmpty(keys)) { // select nothing tab.checkedKeys = [] } else { tab.checkedKeys = keys } } }, /** * get activated key * @param {string} server * @return {string|null} */ getActivatedKey(server) { let tab = find(this.tabList, { name: server }) return get(tab, 'activatedKey') }, /** * set activated key and return current activatedKey * @param {string} server * @param {string} key * @return {boolean} */ setActivatedKey(server, key) { /** @type TabItem**/ let tab = find(this.tabList, { name: server }) if (tab != null) { if (!isEmpty(key) && key !== tab.activatedKey) { tab.activatedKey = key return true } } return false }, }, }) export default useTabStore ================================================ FILE: frontend/src/styles/content.scss ================================================ .content-container { height: 100%; overflow: hidden; box-sizing: border-box; } .empty-content { height: 100%; justify-content: center; } .content-log { padding: 20px; } .content-value { user-select: text; cursor: text; } .tab-content { } :deep(.cmd-line) { word-wrap: break-word; white-space: pre-wrap; word-break: break-all; } ================================================ FILE: frontend/src/styles/style.scss ================================================ :root { //--bg-color: #f8f8f8; //--bg-color-accent: #fff; //--bg-color-page: #f2f3f5; //--text-color-regular: #606266; //--border-color: #dcdfe6; --transition-duration-fast: 0.2s; --transition-function-ease-in-out-bezier: cubic-bezier(0.645, 0.045, 0.355, 1); } html { //text-align: center; cursor: default; -webkit-user-select: none; /* Chrome, Safari */ -moz-user-select: none; /* Firefox */ user-select: none; overscroll-behavior: none; } body { margin: 0; padding: 0; background-color: #0000; line-height: 1.5; font-family: v-sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; overflow: hidden; overscroll-behavior: none; } @mixin bottom-shadow($transparent) { box-shadow: 0 5px 5px -5px rgba(0, 0, 0, $transparent); } @mixin top-shadow($transparent) { box-shadow: 0 -5px 5px -5px rgba(0, 0, 0, $transparent); } #app { height: 100vh; height: 100dvh; } .flex-box { display: flex; } .flex-box-v { @extend .flex-box; flex-direction: column; } .flex-box-h { @extend .flex-box; flex-direction: row; } .flex-item { flex: 0 0 auto; } .flex-item-expand { flex-grow: 1; } .clickable { cursor: pointer; } .wordline { word-break: break-all; } .icon-btn { @extend .clickable; line-height: 100%; } .ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .fill-height { height: 100%; } .text-block { white-space: pre-line; } .content-wrapper { height: 100%; flex-grow: 1; overflow: hidden; gap: 5px; padding-top: 5px; //padding: 5px; box-sizing: border-box; position: relative; .tb2 { gap: 5px; justify-content: flex-end; align-items: center; } .value-wrapper { //border-top: v-bind('themeVars.borderColor') 1px solid; user-select: text; //height: 100%; box-sizing: border-box; } .value-item-part { padding: 0 5px; } .value-footer { @include top-shadow(0.1); align-items: center; gap: 0; padding: 3px 10px 3px 10px; height: 30px; } } .n-dynamic-input-item { align-items: center; gap: 10px; } .n-tree-node-content__text { @extend .ellipsis; } .context-menu-item { min-width: 100px; padding-right: 10px; } .nav-pane-container { overflow: hidden; .nav-pane-func { align-items: center; justify-content: flex-end; gap: 3px; padding: 3px 8px; min-height: 30px; .nav-pane-func-btn { padding: 3px; border-radius: 3px; box-sizing: border-box; } } } .n-modal-mask { --wails-draggable: drag; } .n-tabs .n-tabs-nav { line-height: 1.3; } // animations .fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } .auto-rotate { animation: rotate 2s steps(60) infinite; } .pre-wrap { white-space: pre-wrap; } @keyframes rotate { 100% { transform: rotate(360deg); } } ================================================ FILE: frontend/src/utils/analytics.js ================================================ let inited = false /** * load umami analytics module * @param {boolean} allowTrack * @return {Promise} */ export const loadModule = async (allowTrack = true) => { try { await new Promise((resolve, reject) => { const script = document.createElement('script') script.setAttribute('src', 'https://analytics.tinycraft.cc/script.js') script.setAttribute('data-website-id', 'ad6de51d-1e27-44a5-958d-319679c56aec') script.setAttribute('data-cache', 'true') script.setAttribute('data-auto-track', allowTrack !== false ? 'true' : 'false') script.onload = () => { inited = true resolve() } script.onerror = () => { inited = false reject() } document.body.appendChild(script) }) } catch { // Script blocked by CSP or network error — silently ignore } } const enable = () => { return inited && typeof umami !== 'undefined' } export const trackEvent = async (event, data) => { if (!enable()) { return } try { umami.track(({ website, language }) => ({ language, website, name: event, data, })) } catch { // umami not available — silently ignore } } ================================================ FILE: frontend/src/utils/api.js ================================================ /** * HTTP API adapter layer - replaces Wails RPC bindings for web mode. * All functions match the original Wails-generated function signatures. */ const API_BASE = '/api' async function post(path, body = {}) { const resp = await fetch(`${API_BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body), }) if (resp.status === 401) { window.dispatchEvent(new Event('rdm:unauthorized')) return { success: false, msg: 'unauthorized' } } return resp.json() } async function get(path, params = {}) { const query = new URLSearchParams(params).toString() const url = query ? `${API_BASE}${path}?${query}` : `${API_BASE}${path}` const resp = await fetch(url, { credentials: 'same-origin' }) if (resp.status === 401) { window.dispatchEvent(new Event('rdm:unauthorized')) return { success: false, msg: 'unauthorized' } } return resp.json() } async function del(path, params = {}) { const query = new URLSearchParams(params).toString() const url = query ? `${API_BASE}${path}?${query}` : `${API_BASE}${path}` const resp = await fetch(url, { method: 'DELETE', credentials: 'same-origin' }) if (resp.status === 401) { window.dispatchEvent(new Event('rdm:unauthorized')) return { success: false, msg: 'unauthorized' } } return resp.json() } // ==================== Connection Service ==================== export function ListConnection() { return get('/connection/list') } export function GetConnection(name) { return get('/connection/get', { name }) } export function SaveConnection(name, param) { return post('/connection/save', { name, param }) } export function SaveSortedConnection(conns) { return post('/connection/save-sorted', { conns }) } export function TestConnection(param) { return post('/connection/test', param) } export function DeleteConnection(name) { return del('/connection/delete', { name }) } export function CreateGroup(name) { return post('/connection/group/create', { name }) } export function RenameGroup(name, newName) { return post('/connection/group/rename', { name, newName }) } export function DeleteGroup(name, includeConn) { return del('/connection/group/delete', { name, includeConn }) } export function SaveLastDB(name, db) { return post('/connection/save-last-db', { name, db }) } export function SaveRefreshInterval(name, interval) { return post('/connection/save-refresh-interval', { name, interval }) } export async function ExportConnections() { // Web mode: trigger browser download of connections zip try { const resp = await fetch(`${API_BASE}/connection/export-download`, { credentials: 'same-origin', }) if (resp.status === 401) { window.dispatchEvent(new Event('rdm:unauthorized')) return { success: false, msg: 'unauthorized' } } if (!resp.ok) { const err = await resp.json().catch(() => ({})) return { success: false, msg: err.msg || 'export failed' } } const blob = await resp.blob() const url = URL.createObjectURL(blob) const a = document.createElement('a') const disposition = resp.headers.get('Content-Disposition') || '' const match = disposition.match(/filename=(.+)/) a.download = match ? match[1] : 'connections.zip' a.href = url a.click() URL.revokeObjectURL(url) return { success: true, data: { path: '' } } } catch { return { success: false, msg: 'export failed' } } } export async function ImportConnections() { // Web mode: open file picker, upload zip to backend return new Promise((resolve) => { const input = document.createElement('input') input.type = 'file' input.accept = '.zip' input.onchange = async () => { if (input.files && input.files[0]) { const formData = new FormData() formData.append('file', input.files[0]) try { const resp = await fetch(`${API_BASE}/connection/import-upload`, { method: 'POST', credentials: 'same-origin', body: formData, }) if (resp.status === 401) { window.dispatchEvent(new Event('rdm:unauthorized')) resolve({ success: false, msg: 'unauthorized' }) return } resolve(await resp.json()) } catch { resolve({ success: false, msg: 'import failed' }) } } else { resolve({ success: false, msg: '' }) } } // User cancelled file picker input.addEventListener('cancel', () => resolve({ success: false, msg: '' })) input.click() }) } export function ParseConnectURL(url) { return post('/connection/parse-url', { url }) } export function ListSentinelMasters(param) { return post('/connection/list-sentinel-masters', param) } // ==================== Browser Service ==================== export function OpenConnection(name) { return post('/browser/open-connection', { name }) } export function CloseConnection(name) { return post('/browser/close-connection', { name }) } export function OpenDatabase(server, db) { return post('/browser/open-database', { server, db }) } export function ServerInfo(name) { return post('/browser/server-info', { name }) } export function LoadNextKeys(server, db, match, keyType, exactMatch) { return post('/browser/load-next-keys', { server, db, match, keyType, exactMatch }) } export function LoadNextAllKeys(server, db, match, keyType, exactMatch) { return post('/browser/load-next-all-keys', { server, db, match, keyType, exactMatch }) } export function LoadAllKeys(server, db, match, keyType, exactMatch) { return post('/browser/load-all-keys', { server, db, match, keyType, exactMatch }) } export function GetKeyType(param) { return post('/browser/get-key-type', param) } export function GetKeySummary(param) { return post('/browser/get-key-summary', param) } export function GetKeyDetail(param) { return post('/browser/get-key-detail', param) } export function ConvertValue(value, decode, format) { return post('/browser/convert-value', { value, decode, format }) } export function SetKeyValue(param) { return post('/browser/set-key-value', param) } export function GetHashValue(param) { return post('/browser/get-hash-value', param) } export function SetHashValue(param) { return post('/browser/set-hash-value', param) } export function AddHashField(server, db, key, action, fieldItems) { return post('/browser/add-hash-field', { server, db, key, action, fieldItems }) } export function AddListItem(server, db, key, action, items) { return post('/browser/add-list-item', { server, db, key, action, items }) } export function SetListItem(param) { return post('/browser/set-list-item', param) } export function SetSetItem(server, db, key, remove, members) { return post('/browser/set-set-item', { server, db, key, remove, members }) } export function UpdateSetItem(param) { return post('/browser/update-set-item', param) } export function UpdateZSetValue(param) { return post('/browser/update-zset-value', param) } export function AddZSetValue(server, db, key, action, valueScore) { return post('/browser/add-zset-value', { server, db, key, action, valueScore }) } export function AddStreamValue(server, db, key, id, fieldItems) { return post('/browser/add-stream-value', { server, db, key, id, fieldItems }) } export function RemoveStreamValues(server, db, key, ids) { return post('/browser/remove-stream-values', { server, db, key, ids }) } export function SetKeyTTL(server, db, key, ttl) { return post('/browser/set-key-ttl', { server, db, key, ttl }) } export function BatchSetTTL(server, db, keys, ttl, serialNo) { return post('/browser/batch-set-ttl', { server, db, keys, ttl, serialNo }) } export function DeleteKey(server, db, key, async) { return post('/browser/delete-key', { server, db, key, async }) } export function DeleteKeys(server, db, keys, serialNo) { return post('/browser/delete-keys', { server, db, keys, serialNo }) } export function DeleteKeysByPattern(server, db, pattern) { return post('/browser/delete-keys-by-pattern', { server, db, pattern }) } export function RenameKey(server, db, key, newKey) { return post('/browser/rename-key', { server, db, key, newKey }) } export function ExportKey(server, db, keys, path, includeExpire) { return post('/browser/export-key', { server, db, keys, path, includeExpire }) } export function ImportCSV(server, db, path, conflict, ttl) { return post('/browser/import-csv', { server, db, path, conflict, ttl }) } export function FlushDB(server, db, async) { return post('/browser/flush-db', { server, db, async }) } export function GetSlowLogs(server, db, num) { return post('/browser/get-slow-logs', { server, db, num }) } export function GetClientList(server, db) { return post('/browser/get-client-list', { server, db }) } export function GetCmdHistory() { return post('/browser/get-cmd-history') } export function CleanCmdHistory() { return post('/browser/clean-cmd-history') } // ==================== CLI Service ==================== export function StartCli(server, db) { return post('/cli/start', { server, db }) } export function CloseCli(server) { return post('/cli/close', { server }) } // ==================== Monitor Service ==================== export function StartMonitor(server) { return post('/monitor/start', { server }) } export function StopMonitor(server) { return post('/monitor/stop', { server }) } export function ExportLog(logs) { return post('/monitor/export-log', { logs }) } // ==================== Pubsub Service ==================== export function Publish(server, channel, payload) { return post('/pubsub/publish', { server, channel, payload }) } export function StartSubscribe(server) { return post('/pubsub/subscribe', { server }) } export function StopSubscribe(server) { return post('/pubsub/unsubscribe', { server }) } // ==================== Preferences Service ==================== export function GetPreferences() { return get('/preferences/get') } export function SetPreferences(pf) { return post('/preferences/set', pf) } export function UpdatePreferences(value) { return post('/preferences/update', value) } export function RestorePreferences() { return post('/preferences/restore') } // Common fonts to probe when Local Font Access API is unavailable const CANDIDATE_FONTS = [ // Sans-serif 'Arial', 'Helvetica', 'Helvetica Neue', 'Verdana', 'Geneva', 'Tahoma', 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Segoe UI', 'Roboto', 'Noto Sans', 'Open Sans', 'Lato', 'Source Sans Pro', // Serif 'Times New Roman', 'Georgia', 'Palatino', 'Book Antiqua', 'Cambria', 'Noto Serif', // Monospace 'Courier New', 'Consolas', 'Monaco', 'Menlo', 'DejaVu Sans Mono', 'Fira Code', 'JetBrains Mono', 'Source Code Pro', 'Cascadia Code', // CJK 'Microsoft YaHei', 'PingFang SC', 'PingFang TC', 'Hiragino Sans GB', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans JP', 'Noto Sans KR', 'Source Han Sans SC', 'Source Han Sans TC', 'WenQuanYi Micro Hei', 'Yu Gothic', 'Meiryo', 'Malgun Gothic', ] async function queryBrowserFonts() { await document.fonts.ready return CANDIDATE_FONTS.filter((f) => document.fonts.check(`16px "${f}"`)).map((name) => ({ name, path: '' })) } export async function GetFontList() { try { const fonts = await queryBrowserFonts() return { success: true, data: { fonts } } } catch (_) { return { success: true, data: { fonts: [] } } } } export function GetBuildInDecoder() { return get('/preferences/buildin-decoder') } export function GetAppVersion() { return get('/preferences/version') } export function CheckForUpdate() { return get('/preferences/check-update') } // ==================== System Service ==================== // Alias used in App.vue export function Info() { return get('/system/info') } // Web replacement for native file dialog export async function SelectFile(title, ext) { return new Promise((resolve) => { const input = document.createElement('input') input.type = 'file' if (ext && Array.isArray(ext) && ext.length > 0) { input.accept = ext.map((e) => '.' + e.replace(/^\./, '')).join(',') } input.onchange = async () => { if (input.files && input.files[0]) { const formData = new FormData() formData.append('file', input.files[0]) try { const resp = await fetch('/api/system/select-file', { method: 'POST', credentials: 'same-origin', body: formData, }) if (resp.status === 401) { window.dispatchEvent(new Event('rdm:unauthorized')) resolve({ success: false, msg: 'unauthorized' }) return } resolve(await resp.json()) } catch { resolve({ success: false, msg: 'upload failed' }) } } else { resolve({ success: false, msg: '' }) } } input.addEventListener('cancel', () => resolve({ success: false, msg: '' })) input.click() }) } export async function SaveFile(title, defaultName, ext) { // In web mode, file save dialogs are not applicable // The backend ExportLog etc. will handle download differently return { success: true, data: { path: '' } } } // ==================== Auth Service ==================== export async function Login(username, password) { return await post('/auth/login', { username, password }) } export async function Logout() { return await post('/auth/logout') } ================================================ FILE: frontend/src/utils/byte_convert.js ================================================ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] /** * convert byte value * @param {number} bytes * @param {number} decimals * @return {{unit: string, value: number}} */ export const convertBytes = (bytes, decimals = 2) => { if (bytes <= 0) { return { value: 0, unit: sizes[0], } } const k = 1024 const i = Math.floor(Math.log(bytes) / Math.log(k)) const j = Math.min(i, sizes.length - 1) return { value: parseFloat((bytes / Math.pow(k, j)).toFixed(decimals)), unit: sizes[j], } } /** * * @param {number} bytes * @param {number} decimals * @return {string} */ export const formatBytes = (bytes, decimals = 2) => { const res = convertBytes(bytes, decimals) return res.value + res.unit } ================================================ FILE: frontend/src/utils/chart.js ================================================ import { CategoryScale, Chart as ChartJS, Filler, Legend, LinearScale, LineElement, PointElement, Title, Tooltip, } from 'chart.js' export const setupChart = () => { ChartJS.register(Title, Tooltip, LineElement, CategoryScale, LinearScale, PointElement, Legend, Filler) } ================================================ FILE: frontend/src/utils/date.js ================================================ import { i18nGlobal } from '@/utils/i18n.js' import { padStart } from 'lodash' /** * convert seconds number to human-readable string * @param {number} duration duration in seconds * @return {string} */ export const toHumanReadable = (duration) => { const days = Math.floor(duration / 86400) const hours = Math.floor((duration % 86400) / 3600) const minutes = Math.floor((duration % 3600) / 60) const seconds = duration % 60 const time = `${padStart(hours, 2, '0')}:${padStart(minutes, 2, '0')}:${padStart(seconds, 2, '0')}` if (days > 0) { return days + i18nGlobal.t('common.unit_day') + ' ' + time } else { return time } } ================================================ FILE: frontend/src/utils/decoder_cmd.js ================================================ import { includes, isEmpty, toUpper, trim } from 'lodash' /** * join execute path and arguments into a command string * @param {string} path * @param {string[]} args * @param {string} [emptyContent] * @return {string} */ export const joinCommand = (path, args = [], emptyContent = '-') => { let cmd = '' if (!isEmpty(trim(path))) { let containValuePlaceholder = false cmd = includes(path, ' ') ? `"${path}"` : path for (let part of args || []) { part = trim(part) if (isEmpty(part)) { continue } if (includes(part, ' ')) { cmd += ' "' + part + '"' } else { if (toUpper(part) === '{VALUE}') { part = '{VALUE}' containValuePlaceholder = true } cmd += ' ' + part } } if (!containValuePlaceholder) { cmd += ' {VALUE}' } } return cmd || emptyContent } ================================================ FILE: frontend/src/utils/discrete.js ================================================ import usePreferencesStore from 'stores/preferences.js' import { createDiscreteApi, darkTheme } from 'naive-ui' import { darkThemeOverrides, themeOverrides } from '@/utils/theme.js' import { i18nGlobal } from '@/utils/i18n.js' import { computed } from 'vue' function setupMessage(message) { return { error: (content, option = null) => { return message.error(content, option) }, info: (content, option = null) => { return message.info(content, option) }, loading: (content, option = {}) => { option.duration = option.duration != null ? option.duration : 30000 option.keepAliveOnHover = option.keepAliveOnHover !== undefined ? option.keepAliveOnHover : true return message.loading(content, option) }, success: (content, option = null) => { return message.success(content, option) }, warning: (content, option = null) => { return message.warning(content, option) }, } } function setupNotification(notification) { return { /** * @param {NotificationOption} option * @return {NotificationReactive} */ show(option) { return notification.create(option) }, error: (content, option = {}) => { option.content = content option.title = option.title || i18nGlobal.t('common.error') return notification.error(option) }, info: (content, option = {}) => { option.content = content return notification.info(option) }, success: (content, option = {}) => { option.content = content option.title = option.title || i18nGlobal.t('common.success') return notification.success(option) }, warning: (content, option = {}) => { option.content = content option.title = option.title || i18nGlobal.t('common.warning') return notification.warning(option) }, } } /** * * @param {DialogApiInjection} dialog * @return {*} */ function setupDialog(dialog) { return { /** * @param {DialogOptions} option * @return {DialogReactive} */ show(option) { option.closable = option.closable === true option.autoFocus = option.autoFocus === true option.transformOrigin = 'center' return dialog.create(option) }, warning: (content, onConfirm) => { return dialog.warning({ title: i18nGlobal.t('common.warning'), content: content, closable: false, autoFocus: false, transformOrigin: 'center', positiveText: i18nGlobal.t('common.confirm'), negativeText: i18nGlobal.t('common.cancel'), onPositiveClick: () => { onConfirm && onConfirm() }, }) }, } } /** * setup discrete api and bind global component (like dialog, message, alert) to window * @return {Promise} */ export async function setupDiscreteApi() { const prefStore = usePreferencesStore() const configProviderProps = computed(() => ({ theme: prefStore.isDark ? darkTheme : undefined, themeOverrides, })) const { message, dialog, notification } = createDiscreteApi(['message', 'notification', 'dialog'], { configProviderProps, messageProviderProps: { placement: 'bottom', keepAliveOnHover: true, containerStyle: { marginBottom: '38px', }, themeOverrides: prefStore.isDark ? darkThemeOverrides.Message : themeOverrides.Message, }, notificationProviderProps: { max: 5, placement: 'bottom-right', keepAliveOnHover: true, containerStyle: { marginBottom: '38px', }, }, }) window.$message = setupMessage(message) window.$notification = setupNotification(notification) window.$dialog = setupDialog(dialog) } ================================================ FILE: frontend/src/utils/extra_theme.js ================================================ /** * @typedef ExtraTheme * @property {string} titleColor * @property {string} sidebarColor * @property {string} splitColor */ /** * * @type ExtraTheme */ export const extraLightTheme = { titleColor: '#F2F2F2', ribbonColor: '#F9F9F9', ribbonActiveColor: '#E3E3E3', sidebarColor: '#F2F2F2', splitColor: '#DADADA', } /** * * @type ExtraTheme */ export const extraDarkTheme = { titleColor: '#262626', ribbonColor: '#2C2C2C', ribbonActiveColor: '#363636', sidebarColor: '#262626', splitColor: '#474747', } /** * * @param {boolean} dark * @return ExtraTheme */ export const extraTheme = (dark) => { return dark ? extraDarkTheme : extraLightTheme } ================================================ FILE: frontend/src/utils/glob_pattern.js ================================================ import { includes, isEmpty } from 'lodash' const REDIS_GLOB_CHAR = ['?', '*', '[', ']', '{', '}'] export const isRedisGlob = (str) => { if (!isEmpty(str)) { for (const c of REDIS_GLOB_CHAR) { if (includes(str, c)) { return true } } } return false } ================================================ FILE: frontend/src/utils/i18n.js ================================================ import { createI18n } from 'vue-i18n' import { lang } from '@/langs/index.js' export const i18n = createI18n({ locale: 'en-us', fallbackLocale: 'en-us', globalInjection: true, legacy: false, messages: { ...lang, }, }) export const i18nGlobal = i18n.global ================================================ FILE: frontend/src/utils/key_convert.js ================================================ import { join, map, take } from 'lodash' /** * converted binary data in strings to hex format * @param {string|number[]} key * @return {string} */ export function decodeRedisKey(key) { if (key instanceof Array) { // char array, convert to hex string return join( map(key, (k) => { if (k >= 32 && k <= 126) { return String.fromCharCode(k) } return '\\x' + k.toString(16).toUpperCase().padStart(2, '0') }), '', ) } return key } /** * convert char code array to string * @param {string|number[]} key * @param {number|undefined} truncate * @return {string} */ export function nativeRedisKey(key, truncate) { if (key instanceof Array) { // truncate char code array if (typeof truncate === 'number' && truncate > 0) { key = take(key, truncate) } return map(key, (c) => String.fromCharCode(c)).join('') } return key } ================================================ FILE: frontend/src/utils/monaco.js ================================================ import * as monaco from 'monaco-editor' import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker' import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker' import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker' import { BrowserOpenURL } from 'wailsjs/runtime/runtime.js' export const setupMonaco = () => { window.MonacoEnvironment = { getWorker: (_, label) => { switch (label) { case 'json': return new jsonWorker() case 'css': case 'scss': case 'less': return new cssWorker() case 'html': return new htmlWorker() default: return new editorWorker() } }, } // setup light theme monaco.editor.defineTheme('rdm-light', { base: 'vs', inherit: true, rules: [], colors: { 'editorLineNumber.foreground': '#BABBBD', 'editorLineNumber.activeForeground': '#777D83', }, }) // setup dark theme monaco.editor.defineTheme('rdm-dark', { base: 'vs-dark', inherit: true, rules: [], colors: {}, }) // register default link opening behavior monaco.editor.registerLinkOpener({ open(resource) { BrowserOpenURL(resource.toString()) return true }, }) } ================================================ FILE: frontend/src/utils/platform.js ================================================ import { Environment } from 'wailsjs/runtime/runtime.js' let os = '' export async function loadEnvironment() { const env = await Environment() os = env.platform } export function isMacOS() { return os === 'darwin' } export function isWindows() { return os === 'windows' } export function isWeb() { return os === 'web' } ================================================ FILE: frontend/src/utils/promise.js ================================================ export const timeout = (ms) => { return new Promise((resolve) => setTimeout(resolve, ms)) } ================================================ FILE: frontend/src/utils/render.js ================================================ import { h } from 'vue' import { NIcon } from 'naive-ui' export function useRender() { return { /** * * @param {string|Object} icon * @param {{}} [props] * @return {VNode} */ renderIcon: (icon, props = {}) => { if (icon == null) { return undefined } return h(NIcon, null, { default: () => h(icon, props), }) }, /** * * @param {string} label * @param {{}} [props] * @return {VNode} */ renderLabel: (label, props = {}) => { return h('div', props, label) }, } } ================================================ FILE: frontend/src/utils/rgb.js ================================================ import { padStart, size, startsWith } from 'lodash' /** * @typedef {Object} RGB * @property {number} r * @property {number} g * @property {number} b * @property {number} [a] */ /** * parse hex color to rgb object * @param hex * @return {RGB} */ export function parseHexColor(hex) { if (size(hex) < 6) { return { r: 0, g: 0, b: 0 } } if (startsWith(hex, '#')) { hex = hex.slice(1) } const bigint = parseInt(hex, 16) const r = (bigint >> 16) & 255 const g = (bigint >> 8) & 255 const b = bigint & 255 return { r, g, b } } /** * do gamma correction with an RGB object * @param {RGB} rgb * @param {Number} gamma * @return {RGB} */ export function hexGammaCorrection(rgb, gamma) { if (typeof rgb !== 'object') { return { r: 0, g: 0, b: 0 } } return { r: Math.max(0, Math.min(255, Math.round(rgb.r * gamma))), g: Math.max(0, Math.min(255, Math.round(rgb.g * gamma))), b: Math.max(0, Math.min(255, Math.round(rgb.b * gamma))), } } /** * mix two colors * @param rgba1 * @param rgba2 * @param weight * @return {{a: number, r: number, b: number, g: number}} */ export function mixColors(rgba1, rgba2, weight = 0.5) { if (rgba1.a === undefined) { rgba1.a = 255 } if (rgba2.a === undefined) { rgba2.a = 255 } return { r: Math.floor(rgba1.r * (1 - weight) + rgba2.r * weight), g: Math.floor(rgba1.g * (1 - weight) + rgba2.g * weight), b: Math.floor(rgba1.b * (1 - weight) + rgba2.b * weight), a: Math.floor(rgba1.a * (1 - weight) + rgba2.a * weight), } } /** * RGB object to hex color string * @param {RGB} rgb * @return {string} */ export function toHexColor(rgb) { return ( '#' + padStart(rgb.r.toString(16), 2, '0') + padStart(rgb.g.toString(16), 2, '0') + padStart(rgb.b.toString(16), 2, '0') ) } ================================================ FILE: frontend/src/utils/theme.js ================================================ import { merge } from 'lodash' /** * * @type import('naive-ui').GlobalThemeOverrides */ export const themeOverrides = { common: { primaryColor: '#D33A31', primaryColorHover: '#FF6B6B', primaryColorPressed: '#D5271C', primaryColorSuppl: '#FF6B6B', borderRadius: '4px', borderRadiusSmall: '3px', heightMedium: '32px', lineHeight: 1.5, scrollbarWidth: '8px', tabColor: '#FFFFFF', }, Button: { heightMedium: '32px', paddingSmall: '0 8px', paddingMedium: '0 12px', }, Tag: { borderRadius: '4px', heightLarge: '32px', }, Input: { heightMedium: '32px', }, Tabs: { tabGapSmallCard: '2px', tabGapMediumCard: '2px', tabGapLargeCard: '2px', tabFontWeightActive: 450, }, Tree: { nodeWrapperPadding: '0 3px', }, Card: { colorEmbedded: '#FAFAFA', }, Form: { labelFontSizeTopSmall: '12px', labelFontSizeTopMedium: '13px', labelFontSizeTopLarge: '13px', labelHeightSmall: '18px', labelHeightMedium: '18px', labelHeightLarge: '18px', labelPaddingVertical: '0 0 5px 2px', feedbackHeightSmall: '18px', feedbackHeightMedium: '18px', feedbackHeightLarge: '20px', feedbackFontSizeSmall: '11px', feedbackFontSizeMedium: '12px', feedbackFontSizeLarge: '12px', labelTextColor: 'rgb(113,120,128)', labelFontWeight: '450', }, Radio: { buttonColorActive: '#D13B37', buttonTextColorActive: '#FFF', }, DataTable: { thPaddingSmall: '6px 8px', tdPaddingSmall: '6px 8px', }, Dropdown: { borderRadius: '5px', optionIconSizeMedium: '18px', padding: '6px 2px', optionColorHover: '#D33A31', optionTextColorHover: '#FFF', optionHeightMedium: '28px', }, Divider: { color: '#AAAAAB', }, } /** * * @type import('naive-ui').GlobalThemeOverrides */ const _darkThemeOverrides = { common: { bodyColor: '#1E1E1E', tabColor: '#1E1E1E', borderColor: '#515151', }, Tree: { nodeTextColor: '#CECED0', }, Card: { colorEmbedded: '#212121', }, Dropdown: { color: '#272727', }, Popover: { color: '#2C2C32', }, } export const darkThemeOverrides = merge({}, themeOverrides, _darkThemeOverrides) ================================================ FILE: frontend/src/utils/version.js ================================================ import { get, isEmpty, map, size, split, trimStart } from 'lodash' /** * convert version string to number array * @param ver * @return {number[]} */ export const toVersionArray = (ver) => { const v = trimStart(ver, 'v') let vParts = split(v, '.') if (isEmpty(vParts)) { vParts = ['0'] } return map(vParts, (v) => { let vNum = parseInt(v) return isNaN(vNum) ? 0 : vNum }) } /** * compare two version strings * @param {string} v1 * @param {string} v2 * @return {number} */ export const compareVersion = (v1, v2) => { if (v1 !== v2) { const v1Nums = toVersionArray(v1) const v2Nums = toVersionArray(v2) const length = Math.max(size(v1Nums), size(v2Nums)) for (let i = 0; i < length; i++) { const num1 = get(v1Nums, i, 0) const num2 = get(v2Nums, i, 0) if (num1 !== num2) { return num1 > num2 ? 1 : -1 } } } return 0 } ================================================ FILE: frontend/src/utils/wails_runtime.js ================================================ /** * Web-mode stubs for wailsjs/runtime/runtime.js * Replaces Wails desktop runtime functions with browser equivalents. */ import { offWsEvent, onWsEvent, reconnectWebSocket, sendWsMessage, waitForWebSocket } from '@/utils/websocket.js' // Don't auto-connect — wait for explicit call after login // connectWebSocket() // ==================== Events ==================== export function EventsOn(event, callback) { onWsEvent(event, callback) } export function EventsOnce(event, callback) { const wrapper = (...args) => { offWsEvent(event, wrapper) callback(...args) } onWsEvent(event, wrapper) } export function EventsEmit(event, ...data) { sendWsMessage({ event, data: data.length === 1 ? data[0] : data }) } export function EventsOff(event) { offWsEvent(event) } // ==================== Clipboard ==================== export async function ClipboardGetText() { try { return await navigator.clipboard.readText() } catch { // clipboard.readText() requires HTTPS + user permission grant // Throw so callers can show a meaningful error instead of silent empty string throw new Error('clipboard permission denied') } } export async function ClipboardSetText(text) { try { await navigator.clipboard.writeText(text) } catch { // fallback const ta = document.createElement('textarea') ta.value = text ta.style.position = 'fixed' ta.style.left = '-9999px' document.body.appendChild(ta) ta.select() document.execCommand('copy') document.body.removeChild(ta) } } // ==================== Browser ==================== export function BrowserOpenURL(url) { window.open(url, '_blank') } // ==================== Window Management (no-ops for web) ==================== export function WindowMinimise() {} export function WindowMaximise() {} export function WindowToggleMaximise() {} export function WindowIsMaximised() { return false } export function WindowIsFullscreen() { return false } export function WindowSetDarkTheme() {} export function WindowSetLightTheme() {} export function Quit() {} // ==================== Environment ==================== export async function Environment() { return { buildType: 'production', platform: 'web', arch: 'web', } } // ==================== WebSocket Management ==================== export { reconnectWebSocket as ReconnectWebSocket } export { waitForWebSocket as WaitForWebSocket } ================================================ FILE: frontend/src/utils/websocket.js ================================================ /** * WebSocket client for real-time communication with the Go backend. * Replaces Wails event system in web mode. */ let ws = null let reconnectTimer = null const listeners = new Map() // event -> Set let wsReadyResolve = null let wsReadyPromise = null function resetReadyPromise() { wsReadyPromise = new Promise((resolve) => { wsReadyResolve = resolve }) } resetReadyPromise() function getWsUrl() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' return `${proto}//${location.host}/ws` } export function connectWebSocket() { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { return } ws = new WebSocket(getWsUrl()) ws.onopen = () => { console.log('[ws] connected') if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } if (wsReadyResolve) { wsReadyResolve() wsReadyResolve = null } } ws.onmessage = (evt) => { try { const msg = JSON.parse(evt.data) if (msg.event) { dispatch(msg.event, msg.data) } } catch (e) { console.warn('[ws] parse error:', e) } } ws.onclose = () => { console.log('[ws] disconnected, reconnecting in 3s...') scheduleReconnect() } ws.onerror = () => { ws.close() } } // Wait until WebSocket is connected export function waitForWebSocket() { if (ws && ws.readyState === WebSocket.OPEN) { return Promise.resolve() } return wsReadyPromise } // Force reconnect (e.g. after login) export function reconnectWebSocket() { if (ws) { ws.onclose = null // prevent auto-reconnect ws.close() ws = null } if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } resetReadyPromise() connectWebSocket() return wsReadyPromise } function scheduleReconnect() { if (!reconnectTimer) { reconnectTimer = setTimeout(() => { reconnectTimer = null resetReadyPromise() connectWebSocket() }, 3000) } } function dispatch(event, data) { const cbs = listeners.get(event) if (cbs) { for (const cb of cbs) { try { cb(data) } catch (e) { console.error(`[ws] handler error for "${event}":`, e) } } } } export function onWsEvent(event, callback) { if (!listeners.has(event)) { listeners.set(event, new Set()) } listeners.get(event).add(callback) } export function offWsEvent(event, callback) { if (!callback) { listeners.delete(event) } else { const cbs = listeners.get(event) if (cbs) { cbs.delete(callback) } } } export function sendWsMessage(msg) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)) } else { console.warn('[ws] not connected, message dropped:', msg.event) } } ================================================ FILE: frontend/vite.config.js ================================================ import vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' import Icons from 'unplugin-icons/vite' import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' import Components from 'unplugin-vue-components/vite' import { defineConfig } from 'vite' const rootPath = new URL('.', import.meta.url).pathname const isWeb = process.env.VITE_WEB === 'true' // https://vitejs.dev/config/ export default defineConfig({ plugins: [ vue(), AutoImport({ imports: [ { 'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'], }, ], }), Components({ resolvers: [NaiveUiResolver()], }), Icons(), ], resolve: { alias: { '@': rootPath + 'src', stores: rootPath + 'src/stores', // Web mode: redirect wailsjs imports to HTTP/WebSocket adapters // Desktop mode (wails build): use real Wails RPC bindings ...(isWeb ? { 'wailsjs/runtime/runtime.js': rootPath + 'src/utils/wails_runtime.js', 'wailsjs/go/services/connectionService.js': rootPath + 'src/utils/api.js', 'wailsjs/go/services/browserService.js': rootPath + 'src/utils/api.js', 'wailsjs/go/services/cliService.js': rootPath + 'src/utils/api.js', 'wailsjs/go/services/monitorService.js': rootPath + 'src/utils/api.js', 'wailsjs/go/services/pubsubService.js': rootPath + 'src/utils/api.js', 'wailsjs/go/services/preferencesService.js': rootPath + 'src/utils/api.js', 'wailsjs/go/services/systemService.js': rootPath + 'src/utils/api.js', } : {}), wailsjs: rootPath + 'wailsjs', }, }, css: { preprocessorOptions: { scss: { api: 'modern-compiler', }, }, }, ...(isWeb ? { server: { proxy: { '/api': { target: 'http://localhost:8088', changeOrigin: true, }, '/ws': { target: 'ws://localhost:8088', ws: true, }, }, }, } : {}), }) ================================================ FILE: go.mod ================================================ module tinyrdm go 1.25.0 require ( github.com/adrg/sysfont v0.1.2 github.com/andybalholm/brotli v1.2.0 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/klauspost/compress v1.18.4 github.com/pierrec/lz4/v4 v4.1.25 github.com/redis/go-redis/v9 v9.18.0 github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68 github.com/wailsapp/wails/v2 v2.11.0 github.com/xanzy/ssh-agent v0.3.3 golang.org/x/crypto v0.48.0 golang.org/x/net v0.51.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/adrg/strutil v0.3.1 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/godbus/dbus/v5 v5.2.1 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/labstack/echo/v4 v4.14.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect github.com/leaanthony/gosod v1.0.4 // indirect github.com/leaanthony/slicer v1.6.0 // indirect github.com/leaanthony/u v1.1.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.52.0 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.41.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) // install latest wails: go install github.com/wailsapp/wails/v2/cmd/wails@latest // replace github.com/wailsapp/wails/v2 v2.11.0 => ~/go/pkg/mod ================================================ FILE: go.sum ================================================ github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/adrg/strutil v0.2.2/go.mod h1:EF2fjOFlGTepljfI+FzgTG13oXthR7ZAil9/aginnNQ= github.com/adrg/strutil v0.3.1 h1:OLvSS7CSJO8lBii4YmBt8jiK9QOtB9CzCzwl4Ic/Fz4= github.com/adrg/strutil v0.3.1/go.mod h1:8h90y18QLrs11IBffcGX3NW/GFBXCMcNg4M7H6MspPA= github.com/adrg/sysfont v0.1.2 h1:MSU3KREM4RhsQ+7QgH7wPEPTgAgBIz0Hw6Nd4u7QgjE= github.com/adrg/sysfont v0.1.2/go.mod h1:6d3l7/BSjX9VaeXWJt9fcrftFaD/t7l11xgSywCPZGk= github.com/adrg/xdg v0.3.0/go.mod h1:7I2hH/IT30IsupOpKZ5ue7/qNi3CoKzD6tL3HwpaRMQ= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk= github.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M= github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68 h1:Ah2/69Z24rwD6OByyOdpJDmttftz0FTF8Q4QZ/SF1E4= github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68/go.mod h1:EqKqAeKddSL9XSGnfXd/7iLncccKhR16HBKVva7ENw8= github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: main.go ================================================ //go:build !web package main import ( "context" "embed" "fmt" "runtime" "time" "tinyrdm/backend/consts" "tinyrdm/backend/services" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/menu" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/linux" "github.com/wailsapp/wails/v2/pkg/options/mac" "github.com/wailsapp/wails/v2/pkg/options/windows" runtime2 "github.com/wailsapp/wails/v2/pkg/runtime" ) //go:embed all:frontend/dist var assets embed.FS //go:embed build/appicon.png var icon []byte var version = "0.0.0" var gaMeasurementID, gaSecretKey string const appName = "Tiny RDM" func main() { // Create an instance of the app structure sysSvc := services.System() connSvc := services.Connection() browserSvc := services.Browser() cliSvc := services.Cli() monitorSvc := services.Monitor() pubsubSvc := services.Pubsub() prefSvc := services.Preferences() prefSvc.SetAppVersion(version) prefSvc.UpdateEnv() windowWidth, windowHeight, maximised := prefSvc.GetWindowSize() windowStartState := options.Normal if maximised { windowStartState = options.Maximised } // menu isMacOS := runtime.GOOS == "darwin" appMenu := menu.NewMenu() if isMacOS { appMenu.Append(menu.AppMenu()) appMenu.Append(menu.EditMenu()) appMenu.Append(menu.WindowMenu()) } // Create application with options err := wails.Run(&options.App{ Title: appName, Width: windowWidth, Height: windowHeight, MinWidth: consts.MIN_WINDOW_WIDTH, MinHeight: consts.MIN_WINDOW_HEIGHT, WindowStartState: windowStartState, Frameless: !isMacOS, Menu: appMenu, EnableDefaultContextMenu: true, AssetServer: &assetserver.Options{ Assets: assets, }, BackgroundColour: options.NewRGBA(255, 255, 255, 0), StartHidden: true, OnStartup: func(ctx context.Context) { sysSvc.Start(ctx, version) connSvc.Start(ctx) browserSvc.Start(ctx) cliSvc.Start(ctx) monitorSvc.Start(ctx) pubsubSvc.Start(ctx) services.GA().SetSecretKey(gaMeasurementID, gaSecretKey) services.GA().Startup(version) }, OnDomReady: func(ctx context.Context) { x, y := prefSvc.GetWindowPosition(ctx) runtime2.WindowSetPosition(ctx, x, y) runtime2.WindowShow(ctx) }, OnBeforeClose: func(ctx context.Context) (prevent bool) { x, y := runtime2.WindowGetPosition(ctx) prefSvc.SaveWindowPosition(x, y) return false }, OnShutdown: func(ctx context.Context) { browserSvc.Stop() cliSvc.CloseAll() monitorSvc.StopAll() pubsubSvc.StopAll() }, Bind: []interface{}{ sysSvc, connSvc, browserSvc, cliSvc, monitorSvc, pubsubSvc, prefSvc, }, Mac: &mac.Options{ TitleBar: mac.TitleBarHiddenInset(), About: &mac.AboutInfo{ Title: fmt.Sprintf("%s %s", appName, version), Message: "A modern lightweight cross-platform Redis desktop client.\n\nCopyright © " + time.Now().Format("2006"), Icon: icon, }, WebviewIsTransparent: false, WindowIsTranslucent: false, }, Windows: &windows.Options{ WebviewIsTransparent: false, WindowIsTranslucent: false, DisableFramelessWindowDecorations: false, }, Linux: &linux.Options{ ProgramName: appName, Icon: icon, WebviewGpuPolicy: linux.WebviewGpuPolicyOnDemand, WindowIsTranslucent: true, }, }) if err != nil { println("Error:", err.Error()) } } ================================================ FILE: main_web.go ================================================ //go:build web package main import ( "context" "fmt" "log" "net/http" "os" "os/signal" "syscall" "tinyrdm/backend/api" "tinyrdm/backend/services" ) var version = "0.0.0" func main() { // Wire up event bridge callbacks (breaks import cycle: services -> api) services.EmitEventFunc = func(event string, data any) { api.Hub().Emit(event, data) } services.RegisterHandlerFunc = api.RegisterHandler // Initialize all services with a background context ctx := context.Background() sysSvc := services.System() connSvc := services.Connection() browserSvc := services.Browser() cliSvc := services.Cli() monitorSvc := services.Monitor() pubsubSvc := services.Pubsub() prefSvc := services.Preferences() prefSvc.SetAppVersion(version) prefSvc.UpdateEnv() // Start services sysSvc.Start(ctx, version) connSvc.Start(ctx) browserSvc.Start(ctx) cliSvc.Start(ctx) monitorSvc.Start(ctx) pubsubSvc.Start(ctx) services.GA().SetSecretKey("", "") // Initialize auth api.InitAuth() // Get port from env or default port := os.Getenv("PORT") if port == "" { port = "8088" } // Setup HTTP server router := api.SetupRouter() srv := &http.Server{ Addr: fmt.Sprintf("0.0.0.0:%s", port), Handler: router, } // Graceful shutdown go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) <-sigCh log.Println("Shutting down...") browserSvc.Stop() cliSvc.CloseAll() monitorSvc.StopAll() pubsubSvc.StopAll() srv.Close() }() log.Printf("Tiny RDM Web starting on http://0.0.0.0:%s", port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Failed to start server: %v", err) } } ================================================ FILE: wails.json ================================================ { "$schema": "https://wails.io/schemas/config.v2.json", "name": "tinyrdm", "outputfilename": "Tiny RDM", "frontend:install": "npm install", "frontend:build": "npm run build", "frontend:dev:watcher": "npm run dev", "frontend:dev:serverUrl": "auto", "author": { "name": "tiny-craft", "email": "lykinhuang@outlook.com" }, "info": { "companyName": "Tiny Craft", "productName": "Tiny RDM", "productVersion": "1.0.0", "copyright": "Copyright © 2026", "comments": "Tiny Redis Desktop Manager" } }