[
  {
    "path": ".github/workflows/build.yaml",
    "content": "name: Wails build\n\non:\n  release:\n    types: [ created ]\n\nenv:\n  NODE_OPTIONS: \"--max-old-space-size=4096\"\n  APP_NAME: 'ES-King'\n  APP_WORKING_DIRECTORY: 'app'\n  GO_VERSION: '1.24'\n  NODE_VERSION: \"22.x\"\n\njobs:\n  build-windows:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [windows-latest]  # amd64/x64\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v2\n        with:\n          submodules: recursive\n\n      - name: Install 7-Zip\n        run: choco install 7zip\n\n      - name: GoLang\n        uses: actions/setup-go@v4\n        with:\n          check-latest: true\n          go-version: ${{ env.GO_VERSION }}\n\n      - name: NodeJS\n        uses: actions/setup-node@v3\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n\n      - name: Build & Compress\n        run: |\n          go install github.com/wailsapp/wails/v2/cmd/wails@latest\n          cd ${{ env.APP_WORKING_DIRECTORY }}\n          wails build -ldflags=\"-X 'app/backend/common.Version=${{ github.ref_name }}'\" -webview2 download -o ${{ env.APP_NAME }}.exe\n          cd ..\n          copy readme.md app\\build\\bin\\\n          copy LICENSE app\\build\\bin\\\n          & \"C:\\Program Files\\7-Zip\\7z.exe\" a -t7z \"${{ env.APP_NAME }}-${{ github.ref_name }}-windows-x64.7z\" \".\\app\\build\\bin\\*\" -r\n          \n      - name: Upload Release Asset\n        uses: softprops/action-gh-release@v1\n        with:\n          files: \"*.7z\"\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n  build-macos:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [macos-latest]  # macos-13是amd64，macos-latest是m1芯片\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v2\n        with:\n          submodules: recursive\n\n      - name: GoLang\n        uses: actions/setup-go@v4\n        with:\n          check-latest: true\n          go-version: ${{ env.GO_VERSION }}\n\n      - name: NodeJS\n        uses: actions/setup-node@v3\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n\n      - name: Build & Compress\n        shell: bash\n        run: |\n          go install github.com/wailsapp/wails/v2/cmd/wails@latest\n          cd ${{ env.APP_WORKING_DIRECTORY }}\n          wails build -ldflags \"-X 'app/backend/common.Version=${{ github.ref_name }}'\" -platform darwin/universal -o ${{ env.APP_NAME }} \n          chmod +x build/bin/*/Contents/MacOS/*\n          mkdir -p _build/ _dist/\n          cp -r ./build/bin/${{ env.APP_NAME }}.app _build/\n          brew install create-dmg\n          create-dmg \\\n            --no-internet-enable \\\n            --volname \"${{ env.APP_NAME }}\" \\\n            --volicon \"_build/${{ env.APP_NAME }}.app/Contents/Resources/iconfile.icns\" \\\n            --window-pos 400 400 \\\n            --window-size 660 450 \\\n            --icon \"${{ env.APP_NAME }}.app\" 180 180 \\\n            --icon-size 100 \\\n            --hide-extension \"${{ env.APP_NAME }}.app\" \\\n            --app-drop-link 480 180 \\\n            \"_dist/${{ env.APP_NAME }}-${{ github.ref_name }}-macos.dmg\" \\\n            \"_build/\"\n\n      - name: Upload Release Assets\n        uses: softprops/action-gh-release@v1\n        with:\n          files: ${{ env.APP_WORKING_DIRECTORY }}/_dist/*\n          fail_on_unmatched_files: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n  build-linux:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-22.04]\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v2\n        with:\n          submodules: recursive\n\n      - name: GoLang\n        uses: actions/setup-go@v4\n        with:\n          check-latest: true\n          go-version: ${{ env.GO_VERSION }}\n\n      - name: NodeJS\n        uses: actions/setup-node@v3\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n\n      - name: Build & Compress\n        run: |\n          ARCH=$(uname -m)\n          sudo apt-get update && sudo apt-get install libgtk-3-dev libwebkit2gtk-4.0-dev build-essential\n          go install github.com/wailsapp/wails/v2/cmd/wails@latest\n          cd ${{ env.APP_WORKING_DIRECTORY }}\n          wails build -ldflags=\"-X 'app/backend/common.Version=${{ github.ref_name }}'\" -webview2 download -o ${{ env.APP_NAME }}\n          cd ..\n          mkdir _temp_dist\n          cp readme.md _temp_dist/\n          cp LICENSE _temp_dist/\n          cp -r ${{ env.APP_WORKING_DIRECTORY }}/build/bin/* _temp_dist/\n          chmod +x _temp_dist/*\n          cd _temp_dist/\n          tar -zcvf ${{ env.APP_NAME }}-${{ github.ref_name }}-ubuntu-$ARCH.tar.gz *\n          mv ${{ env.APP_NAME }}-${{ github.ref_name }}-ubuntu-$ARCH.tar.gz ..\n\n      - name: Upload Release Assets\n        uses: softprops/action-gh-release@v1\n        with:\n          files: \"*.tar.gz\"\n          fail_on_unmatched_files: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "build/bin\n.vscode\n.idea\n/app/frontend/node_modules\n/app/frontend/wailsjs\n/app/frontend/dist\n/app/frontend/package.json.md5\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "app/app.go",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage main\n\nimport (\n\t\"app/backend/common\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"github.com/go-resty/resty/v2\"\n\t\"log\"\n\t\"runtime\"\n\t\"runtime/debug\"\n)\n\n// App struct\ntype App struct {\n\tctx context.Context\n}\n\n// NewApp creates a new App application struct\nfunc NewApp() *App {\n\treturn &App{}\n}\n\n// Start is called at application startup\nfunc (a *App) Start(ctx context.Context) {\n\ta.ctx = ctx\n\tlog.Println(\"===注意，接下来前端执行onMounted，后端初始化必须在此处完成===\")\n}\n\n// domReady is called after front-end resources have been loaded\nfunc (a *App) domReady(ctx context.Context) {\n\tlog.Println(\"===最后一步，页面即将显示……可以执行后端异步任务===\")\n\n\t// 统计版本使用情况\n\tgo func() {\n\t\tdefer func() {\n\t\t\tif err := recover(); err != nil {\n\t\t\t\tfmt.Println(string(debug.Stack()))\n\t\t\t}\n\t\t}()\n\t\tclient := resty.New().SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})\n\t\tbody := map[string]any{\n\t\t\t\"name\":     common.AppName,\n\t\t\t\"version\":  common.Version,\n\t\t\t\"platform\": runtime.GOOS,\n\t\t}\n\t\t_, _ = client.R().SetBody(body).Post(common.PingUrl)\n\t}()\n}\n\n// beforeClose is called when the application is about to quit,\n// either by clicking the window close button or calling runtime.Quit.\n// Returning true will cause the application to continue, false will continue shutdown as normal.\nfunc (a *App) beforeClose(ctx context.Context) (prevent bool) {\n\treturn false\n}\n\n// shutdown is called at application termination\nfunc (a *App) shutdown(ctx context.Context) {\n\t// Perform your teardown here\n}\n\n// Greet returns a greeting for the given name\nfunc (a *App) Greet(name string) string {\n\treturn fmt.Sprintf(\"Hello %s, It's show time!\", name)\n}\n"
  },
  {
    "path": "app/backend/common/vars.go",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage common\n\nimport \"fmt\"\n\nvar (\n\t// Version 会在编译时注入 -ldflags=\"-X 'app/backend/common.Version=${{ github.event.release.tag_name }}'\"\n\tVersion = \"\"\n)\n\nconst (\n\tAppName     = \"ES-King\"\n\tWidth       = 1600\n\tHeight      = 870\n\tTheme       = \"dark\"\n\tConfigDir   = \".es-king\"\n\tConfigPath  = \"config.yaml\"\n\tHistoryPath = \"history.yaml\"\n\tErrLogPath  = \"error.log\"\n\tLanguage    = \"zh-CN\"\n\tPingUrl     = \"https://ysboke.cn/api/kingTool/ping\"\n)\n\nvar (\n\tProject          = \"Bronya0/ES-King\"\n\tGITHUB_URL       = fmt.Sprintf(\"https://github.com/%s\", Project)\n\tGITHUB_REPOS_URL = fmt.Sprintf(\"https://api.github.com/repos/%s\", Project)\n\tUPDATE_URL       = fmt.Sprintf(\"https://api.github.com/repos/%s/releases/latest\", Project)\n\tISSUES_URL       = fmt.Sprintf(\"https://github.com/%s/issues\", Project)\n\tISSUES_API_URL   = fmt.Sprintf(\"https://api.github.com/repos/%s/issues?state=open\", Project)\n)\n"
  },
  {
    "path": "app/backend/config/app.go",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage config\n\nimport (\n\t\"app/backend/common\"\n\t\"app/backend/types\"\n\t\"context\"\n\t\"fmt\"\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n\t\"gopkg.in/yaml.v3\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n)\n\ntype AppConfig struct {\n\tctx context.Context\n\tmu  sync.Mutex\n}\n\nfunc (a *AppConfig) Start(ctx context.Context) {\n\ta.ctx = ctx\n}\n\nfunc (a *AppConfig) GetConfig() *types.Config {\n\tvar defaultConfig = &types.Config{\n\t\tWidth:    common.Width,\n\t\tHeight:   common.Height,\n\t\tLanguage: common.Language,\n\t\tTheme:    common.Theme,\n\t\tConnects: make([]types.Connect, 0),\n\t}\n\tconfigPath := a.getConfigPath()\n\tdata, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\treturn defaultConfig\n\t}\n\terr = yaml.Unmarshal(data, defaultConfig)\n\tif err != nil {\n\t\treturn defaultConfig\n\t}\n\treturn defaultConfig\n}\n\nfunc (a *AppConfig) SaveConfig(config *types.Config) string {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tconfigPath := a.getConfigPath()\n\tfmt.Println(configPath)\n\n\tdata, err := yaml.Marshal(config)\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\n\terr = os.WriteFile(configPath, data, 0644)\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn \"\"\n}\nfunc (a *AppConfig) SaveTheme(theme string) string {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\tconfig := a.GetConfig()\n\tconfig.Theme = theme\n\tdata, err := yaml.Marshal(config)\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\tconfigPath := a.getConfigPath()\n\terr = os.WriteFile(configPath, data, 0644)\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn \"\"\n}\n\nfunc (a *AppConfig) getConfigPath() string {\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil {\n\t\tlog.Printf(\"os.UserHomeDir() error: %s\", err.Error())\n\t\treturn common.ConfigPath\n\t}\n\tconfigDir := filepath.Join(homeDir, common.ConfigDir)\n\t_, err = os.Stat(configDir)\n\tif os.IsNotExist(err) {\n\t\terr = os.Mkdir(configDir, os.ModePerm)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"create configDir %s error: %s\", configDir, err.Error())\n\t\t\treturn common.ConfigPath\n\t\t}\n\t}\n\treturn filepath.Join(configDir, common.ConfigPath)\n}\n\nfunc (a *AppConfig) GetHistory() []*types.History {\n\thistoryPath := a.getHistoryPath()\n\tdata, err := os.ReadFile(historyPath)\n\thistory := make([]*types.History, 0, 200)\n\tif err != nil {\n\t\treturn history\n\t}\n\terr = yaml.Unmarshal(data, &history)\n\tif err != nil {\n\t\treturn history\n\t}\n\treturn history\n}\nfunc (a *AppConfig) SaveHistory(histories []types.History) string {\n\ta.mu.Lock()\n\tdefer a.mu.Unlock()\n\thistoryPath := a.getHistoryPath()\n\tdata, err := yaml.Marshal(histories)\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\terr = os.WriteFile(historyPath, data, 0644)\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn \"\"\n}\nfunc (a *AppConfig) getHistoryPath() string {\n\thomeDir, err := os.UserHomeDir()\n\tif err != nil {\n\t\tlog.Printf(\"os.UserHomeDir() error: %s\", err.Error())\n\t\treturn common.HistoryPath\n\t}\n\tconfigDir := filepath.Join(homeDir, common.ConfigDir)\n\t_, err = os.Stat(configDir)\n\tif os.IsNotExist(err) {\n\t\terr = os.Mkdir(configDir, os.ModePerm)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"create configDir %s error: %s\", configDir, err.Error())\n\t\t\treturn common.HistoryPath\n\t\t}\n\t}\n\treturn filepath.Join(configDir, common.HistoryPath)\n}\n\n// GetVersion returns the application version\nfunc (a *AppConfig) GetVersion() string {\n\treturn common.Version\n}\n\nfunc (a *AppConfig) GetAppName() string {\n\treturn common.AppName\n}\n\nfunc (a *AppConfig) OpenFileDialog(options runtime.OpenDialogOptions) (string, error) {\n\treturn runtime.OpenFileDialog(a.ctx, options)\n}\n\nfunc (a *AppConfig) LogErrToFile(message string) {\n\tfile, err := os.OpenFile(common.ErrLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)\n\tif err != nil {\n\t\tlog.Println(\"Failed to open log file:\", err)\n\t\treturn\n\t}\n\tdefer file.Close()\n\n\tif _, err := file.WriteString(message); err != nil {\n\t\tlog.Println(\"Failed to write to log file:\", err)\n\t}\n}\n"
  },
  {
    "path": "app/backend/service/es.go",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage service\n\nimport (\n\t\"app/backend/types\"\n\t\"bufio\"\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/go-resty/resty/v2\"\n)\n\nconst (\n\tFORMAT          = \"?format=json&pretty\"\n\tStatsApi        = \"/_cluster/stats\" + FORMAT\n\tAddDoc          = \"/_doc\"\n\tHealthApi       = \"/_cluster/health\"\n\tNodesApi        = \"/_nodes/stats/indices,os,fs,process,jvm\"\n\tAllIndexApi     = \"/_cat/indices?format=json&pretty&bytes=b\"\n\tClusterSettings = \"/_cluster/settings\"\n\tForceMerge      = \"/_forcemerge?wait_for_completion=false\"\n\tREFRESH         = \"/_refresh\"\n\tFLUSH           = \"/_flush\"\n\tCacheClear      = \"/_cache/clear\"\n\tTasksApi        = \"/_tasks\" + FORMAT\n\tCancelTasksApi  = \"/_tasks/%s/_cancel\"\n)\n\ntype ESService struct {\n\tConnectObj *types.Connect\n\tClient     *resty.Client\n\tmu         sync.RWMutex\n}\n\nfunc NewESService() *ESService {\n\tclient := resty.New()\n\tclient.SetTimeout(30 * time.Second)\n\tclient.SetRetryCount(0)\n\tclient.SetHeader(\"Content-Type\", \"application/json\")\n\treturn &ESService{\n\t\tClient:     client,\n\t\tConnectObj: &types.Connect{},\n\t}\n}\n\nfunc ConfigureSSL(UseSSL, SkipSSLVerify bool, client *resty.Client, CACert string) {\n\t// Configure SSL\n\t// CACert是证书内容\n\tif UseSSL {\n\t\tclient.SetScheme(\"https\")\n\t\tif SkipSSLVerify {\n\t\t\tclient.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})\n\t\t}\n\t\tif CACert != \"\" {\n\t\t\tclient.SetRootCertificateFromString(CACert)\n\t\t}\n\t} else {\n\t\tclient.SetScheme(\"http\")\n\t}\n}\n\nfunc (es *ESService) SetConnect(key, host, username, password, CACert string, UseSSL, SkipSSLVerify bool) {\n\tes.mu.Lock()         // 加写锁\n\tdefer es.mu.Unlock() // 方法结束时解锁\n\n\tes.ConnectObj = &types.Connect{\n\t\tName:          key,\n\t\tHost:          host,\n\t\tUsername:      username,\n\t\tPassword:      password,\n\t\tUseSSL:        UseSSL,\n\t\tSkipSSLVerify: SkipSSLVerify,\n\t\tCACert:        CACert,\n\t}\n\tif username != \"\" && password != \"\" {\n\t\tes.Client.SetBasicAuth(username, password)\n\t}\n\tConfigureSSL(UseSSL, SkipSSLVerify, es.Client, CACert)\n\n\tfmt.Println(\"设置当前连接：\", es.ConnectObj.Host)\n}\n\nfunc (es *ESService) TestClient(host, username, password, CACert string, UseSSL, SkipSSLVerify bool) string {\n\tclient := resty.New()\n\tif username != \"\" && password != \"\" {\n\t\tclient.SetBasicAuth(username, password)\n\t}\n\t// Configure SSL\n\tConfigureSSL(UseSSL, SkipSSLVerify, client, CACert)\n\n\tresp, err := client.R().Get(host + HealthApi)\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn string(resp.Body())\n\t}\n\treturn \"\"\n}\n\n// AddDocument 添加文档\nfunc (es *ESService) AddDocument(index, doc string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetBody(doc).\n\t\tSetResult(&result).\n\t\tPost(es.ConnectObj.Host + \"/\" + index + AddDoc)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusCreated {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) GetNodes() *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result any\n\tresp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + NodesApi)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) GetHealth() *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\tresp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + HealthApi)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) GetStats() *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\tresp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + StatsApi)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) GetIndexes(name string) *types.ResultsResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultsResp{Err: \"请先选择一个集群\"}\n\t}\n\tnewUrl := es.ConnectObj.Host + AllIndexApi\n\tif name != \"\" {\n\t\tnewUrl += \"&index=\" + \"*\" + name + \"*\"\n\t}\n\tlog.Println(newUrl)\n\tvar result []any\n\n\tresp, err := es.Client.R().SetResult(&result).Get(newUrl)\n\tif err != nil {\n\t\treturn &types.ResultsResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultsResp{Err: string(resp.Body())}\n\t}\n\n\treturn &types.ResultsResp{Results: result}\n\n}\n\nfunc (es *ESService) CreateIndex(name string, numberOfShards, numberOfReplicas int, mapping string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tindexConfig := types.H{\n\t\t\"settings\": types.H{\n\t\t\t\"number_of_shards\":   numberOfShards,\n\t\t\t\"number_of_replicas\": numberOfReplicas,\n\t\t},\n\t}\n\tif mapping != \"\" {\n\t\tvar mappings types.H\n\t\terr := json.Unmarshal([]byte(mapping), &mappings)\n\t\tif err != nil {\n\t\t\treturn &types.ResultResp{Err: err.Error()}\n\t\t}\n\t\tindexConfig[\"mappings\"] = mappings\n\t}\n\n\tresp, err := es.Client.R().\n\t\tSetBody(indexConfig).\n\t\tPut(es.ConnectObj.Host + \"/\" + name)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{}\n\n}\n\nfunc (es *ESService) GetIndexInfo(indexName string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\tresp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + \"/\" + indexName)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n\n}\n\nfunc (es *ESService) DeleteIndex(indexName string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\tresp, err := es.Client.R().SetResult(&result).Delete(es.ConnectObj.Host + \"/\" + indexName)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n\n}\n\nfunc (es *ESService) OpenCloseIndex(indexName, now string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\taction, ok := map[string]string{\n\t\t\"open\":  \"_close\",\n\t\t\"close\": \"_open\",\n\t}[now]\n\tif !ok {\n\t\treturn &types.ResultResp{Err: \"无效的状态参数: \" + now}\n\t}\n\tresp, err := es.Client.R().SetResult(&result).Post(es.ConnectObj.Host + \"/\" + indexName + \"/\" + action)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n\n}\n\nfunc (es *ESService) GetIndexMappings(indexName string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\n\tresp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + \"/\" + indexName)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n\n}\n\nfunc (es *ESService) MergeSegments(indexName string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\n\tresp, err := es.Client.R().SetResult(&result).Post(es.ConnectObj.Host + \"/\" + indexName + ForceMerge)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n\n}\n\nfunc (es *ESService) Refresh(indexName string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\n\tresp, err := es.Client.R().SetResult(&result).Post(es.ConnectObj.Host + \"/\" + indexName + REFRESH)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) Flush(indexName string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\n\tresp, err := es.Client.R().SetResult(&result).Post(es.ConnectObj.Host + \"/\" + indexName + FLUSH)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) CacheClear(indexName string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\n\tresp, err := es.Client.R().SetResult(&result).Post(es.ConnectObj.Host + \"/\" + indexName + CacheClear)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) GetDoc10(indexName string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\n\tbody := map[string]any{\n\t\t\"query\": map[string]any{\n\t\t\t\"query_string\": map[string]any{\n\t\t\t\t\"query\": \"*\",\n\t\t\t},\n\t\t},\n\t\t\"size\": 10,\n\t\t\"from\": 0,\n\t\t\"sort\": []any{},\n\t}\n\tvar result map[string]any\n\n\tresp, err := es.Client.R().\n\t\tSetBody(body).\n\t\tSetResult(&result).\n\t\tPost(es.ConnectObj.Host + \"/\" + indexName + \"/_search\")\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) Search(method, path string, body any) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result any\n\n\treq := es.Client.R().SetResult(&result)\n\tif body != nil {\n\t\treq = req.SetBody(body)\n\t}\n\tresp, err := req.Execute(method, es.ConnectObj.Host+path)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) GetClusterSettings() *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\n\tresp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + ClusterSettings)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) GetIndexSettings(indexName string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\n\tresp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + \"/\" + indexName)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) GetIndexAliases(indexNameList []string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\n\tindexNames := strings.Join(indexNameList, \",\")\n\tresp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + \"/\" + indexNames + \"/_alias\")\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\talias := make(map[string]any)\n\tfor name, obj := range result {\n\t\tif aliases, ok := obj.(map[string]any)[\"aliases\"]; ok {\n\t\t\tnames := make([]string, 0)\n\t\t\taliases, ok := aliases.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor aliasName := range aliases {\n\t\t\t\tnames = append(names, aliasName)\n\t\t\t}\n\t\t\tif len(names) > 0 {\n\t\t\t\talias[name] = strings.Join(names, \",\")\n\t\t\t}\n\t\t}\n\t}\n\treturn &types.ResultResp{Result: alias}\n}\n\nfunc (es *ESService) GetIndexSegments(indexName string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\n\tresp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + \"/\" + indexName)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\nfunc (es *ESService) GetTasks() *types.ResultsResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultsResp{Err: \"请先选择一个集群\"}\n\t}\n\tvar result map[string]any\n\n\tresp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + TasksApi)\n\tif err != nil {\n\t\treturn &types.ResultsResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultsResp{Err: string(resp.Body())}\n\t}\n\tnodes, ok := result[\"nodes\"].(map[string]any)\n\tif !ok {\n\t\treturn &types.ResultsResp{Err: \"获取任务列表失败\"}\n\t}\n\n\tvar data []any\n\tfor _, nodeObj := range nodes {\n\t\tnodeTasks, ok := nodeObj.(map[string]any)[\"tasks\"].(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tfor taskID, taskInfo := range nodeTasks {\n\t\t\ttaskInfoMap, ok := taskInfo.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnodeName, ok := nodeObj.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnodeIp, ok := nodeObj.(map[string]any)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdata = append(data, map[string]any{\n\t\t\t\t\"task_id\":               taskID,\n\t\t\t\t\"node_name\":             nodeName[\"name\"],\n\t\t\t\t\"node_ip\":               nodeIp[\"ip\"],\n\t\t\t\t\"type\":                  taskInfoMap[\"type\"],\n\t\t\t\t\"action\":                taskInfoMap[\"action\"],\n\t\t\t\t\"start_time_in_millis\":  taskInfoMap[\"start_time_in_millis\"],\n\t\t\t\t\"running_time_in_nanos\": taskInfoMap[\"running_time_in_nanos\"],\n\t\t\t\t\"cancellable\":           taskInfoMap[\"cancellable\"],\n\t\t\t\t\"parent_task_id\":        taskInfoMap[\"parent_task_id\"],\n\t\t\t})\n\t\t}\n\t}\n\treturn &types.ResultsResp{Results: data}\n}\n\nfunc (es *ESService) CancelTasks(taskID string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\n\tnewUrl := fmt.Sprintf(es.ConnectObj.Host+CancelTasksApi, url.PathEscape(taskID))\n\tvar result map[string]any\n\n\tresp, err := es.Client.R().SetResult(&result).Post(newUrl)\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// GetSnapshots 获取ES快照列表\nfunc (es *ESService) GetSnapshots() *types.ResultsResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultsResp{Err: \"请先选择一个集群\"}\n\t}\n\n\t// 1. 首先获取所有仓库列表\n\tvar repositories map[string]interface{} // 修改目标类型\n\treposResp, err := es.Client.R().Get(es.ConnectObj.Host + \"/_snapshot\")\n\tif err != nil {\n\t\treturn &types.ResultsResp{Err: err.Error()}\n\t}\n\tif reposResp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultsResp{Err: string(reposResp.Body())}\n\t}\n\terr = json.Unmarshal(reposResp.Body(), &repositories) // 直接解析到 map\n\tif err != nil {\n\t\treturn &types.ResultsResp{Err: err.Error()}\n\t}\n\n\t// 2. 并发遍历每个仓库获取其快照\n\tvar (\n\t\tmu           sync.Mutex\n\t\tallSnapshots []any\n\t\twg           sync.WaitGroup\n\t)\n\tfor repoName := range repositories {\n\t\twg.Add(1)\n\t\tgo func(repo string) {\n\t\t\tdefer wg.Done()\n\t\t\tvar repoResult map[string]interface{}\n\t\t\tresp, err := es.Client.R().SetResult(&repoResult).Get(es.ConnectObj.Host + \"/_snapshot/\" + repo + \"/_all\")\n\t\t\tif err != nil || resp.StatusCode() != http.StatusOK {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 3. 处理每个快照的数据（带安全类型断言）\n\t\t\tsnapshots, ok := repoResult[\"snapshots\"].([]interface{})\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tvar items []any\n\t\t\tfor _, snap := range snapshots {\n\t\t\t\tsnapshot, ok := snap.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tstate, _ := snapshot[\"state\"].(string)\n\t\t\t\titems = append(items, map[string]interface{}{\n\t\t\t\t\t\"snapshot\":          snapshot[\"snapshot\"],\n\t\t\t\t\t\"repository\":        repo,\n\t\t\t\t\t\"state\":             strings.ToUpper(state),\n\t\t\t\t\t\"start_time\":        snapshot[\"start_time\"],\n\t\t\t\t\t\"end_time\":          snapshot[\"end_time\"],\n\t\t\t\t\t\"indices\":           snapshot[\"indices\"],\n\t\t\t\t\t\"total_shards\":      snapshot[\"shards_total\"],\n\t\t\t\t\t\"successful_shards\": snapshot[\"shards_successful\"],\n\t\t\t\t})\n\t\t\t}\n\t\t\tmu.Lock()\n\t\t\tallSnapshots = append(allSnapshots, items...)\n\t\t\tmu.Unlock()\n\t\t}(repoName)\n\t}\n\twg.Wait()\n\n\treturn &types.ResultsResp{Results: allSnapshots}\n}\n\n// GetSnapshotRepositories 获取所有快照仓库\nfunc (es *ESService) GetSnapshotRepositories() *types.ResultsResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultsResp{Err: \"请先选择一个集群\"}\n\t}\n\n\tvar repositories map[string]any\n\tresp, err := es.Client.R().Get(es.ConnectObj.Host + \"/_snapshot\")\n\tif err != nil {\n\t\treturn &types.ResultsResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultsResp{Err: string(resp.Body())}\n\t}\n\terr = json.Unmarshal(resp.Body(), &repositories)\n\tif err != nil {\n\t\treturn &types.ResultsResp{Err: err.Error()}\n\t}\n\n\tvar data []any\n\tfor name, info := range repositories {\n\t\trepoInfo, ok := info.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\titem := map[string]any{\n\t\t\t\"name\":     name,\n\t\t\t\"type\":     repoInfo[\"type\"],\n\t\t\t\"settings\": repoInfo[\"settings\"],\n\t\t}\n\t\tdata = append(data, item)\n\t}\n\treturn &types.ResultsResp{Results: data}\n}\n\n// CreateSnapshotRepository 创建快照仓库\nfunc (es *ESService) CreateSnapshotRepository(name, repoType, settings string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tif name == \"\" || repoType == \"\" {\n\t\treturn &types.ResultResp{Err: \"仓库名称和类型不能为空\"}\n\t}\n\n\tvar settingsMap map[string]any\n\tif settings != \"\" {\n\t\tif err := json.Unmarshal([]byte(settings), &settingsMap); err != nil {\n\t\t\treturn &types.ResultResp{Err: \"settings JSON 格式无效: \" + err.Error()}\n\t\t}\n\t}\n\n\tbody := map[string]any{\n\t\t\"type\":     repoType,\n\t\t\"settings\": settingsMap,\n\t}\n\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetBody(body).\n\t\tSetResult(&result).\n\t\tPut(es.ConnectObj.Host + \"/_snapshot/\" + url.PathEscape(name))\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// DeleteSnapshotRepository 删除快照仓库\nfunc (es *ESService) DeleteSnapshotRepository(name string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tif name == \"\" {\n\t\treturn &types.ResultResp{Err: \"仓库名称不能为空\"}\n\t}\n\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetResult(&result).\n\t\tDelete(es.ConnectObj.Host + \"/_snapshot/\" + url.PathEscape(name))\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// VerifySnapshotRepository 验证快照仓库\nfunc (es *ESService) VerifySnapshotRepository(name string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tif name == \"\" {\n\t\treturn &types.ResultResp{Err: \"仓库名称不能为空\"}\n\t}\n\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetResult(&result).\n\t\tPost(es.ConnectObj.Host + \"/_snapshot/\" + url.PathEscape(name) + \"/_verify\")\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// CreateSnapshot 创建快照（异步）\nfunc (es *ESService) CreateSnapshot(repository, snapshot, indices string, includeGlobalState bool) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tif repository == \"\" || snapshot == \"\" {\n\t\treturn &types.ResultResp{Err: \"仓库名称和快照名称不能为空\"}\n\t}\n\n\tbody := map[string]any{\n\t\t\"include_global_state\": includeGlobalState,\n\t}\n\tif indices != \"\" {\n\t\tbody[\"indices\"] = indices\n\t}\n\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetBody(body).\n\t\tSetResult(&result).\n\t\tPut(es.ConnectObj.Host + \"/_snapshot/\" + url.PathEscape(repository) + \"/\" + url.PathEscape(snapshot) + \"?wait_for_completion=false\")\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusAccepted {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// DeleteSnapshot 删除快照\nfunc (es *ESService) DeleteSnapshot(repository, snapshot string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tif repository == \"\" || snapshot == \"\" {\n\t\treturn &types.ResultResp{Err: \"仓库名称和快照名称不能为空\"}\n\t}\n\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetResult(&result).\n\t\tDelete(es.ConnectObj.Host + \"/_snapshot/\" + url.PathEscape(repository) + \"/\" + url.PathEscape(snapshot))\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// GetSnapshotDetail 获取快照详情\nfunc (es *ESService) GetSnapshotDetail(repository, snapshot string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tif repository == \"\" || snapshot == \"\" {\n\t\treturn &types.ResultResp{Err: \"仓库名称和快照名称不能为空\"}\n\t}\n\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetResult(&result).\n\t\tGet(es.ConnectObj.Host + \"/_snapshot/\" + url.PathEscape(repository) + \"/\" + url.PathEscape(snapshot))\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// RestoreSnapshot 恢复快照（异步）\nfunc (es *ESService) RestoreSnapshot(repository, snapshot, indices, renamePattern, renameReplacement string, includeGlobalState bool) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tif repository == \"\" || snapshot == \"\" {\n\t\treturn &types.ResultResp{Err: \"仓库名称和快照名称不能为空\"}\n\t}\n\n\tbody := map[string]any{\n\t\t\"include_global_state\": includeGlobalState,\n\t}\n\tif indices != \"\" {\n\t\tbody[\"indices\"] = indices\n\t}\n\tif renamePattern != \"\" {\n\t\tbody[\"rename_pattern\"] = renamePattern\n\t}\n\tif renameReplacement != \"\" {\n\t\tbody[\"rename_replacement\"] = renameReplacement\n\t}\n\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetBody(body).\n\t\tSetResult(&result).\n\t\tPost(es.ConnectObj.Host + \"/_snapshot/\" + url.PathEscape(repository) + \"/\" + url.PathEscape(snapshot) + \"/_restore?wait_for_completion=false\")\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusAccepted {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// GetSnapshotRestoreStatus 获取快照恢复状态\nfunc (es *ESService) GetSnapshotRestoreStatus() *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetResult(&result).\n\t\tGet(es.ConnectObj.Host + \"/_recovery?active_only=true&format=json\")\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// GetSLMPolicies 获取所有SLM策略\nfunc (es *ESService) GetSLMPolicies() *types.ResultsResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultsResp{Err: \"请先选择一个集群\"}\n\t}\n\n\tvar result []any\n\tresp, err := es.Client.R().Get(es.ConnectObj.Host + \"/_slm/policy\")\n\tif err != nil {\n\t\treturn &types.ResultsResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultsResp{Err: string(resp.Body())}\n\t}\n\t// SLM API 返回的是一个对象 {policyId: {...}, ...}\n\tvar policiesMap map[string]any\n\tif err := json.Unmarshal(resp.Body(), &policiesMap); err != nil {\n\t\treturn &types.ResultsResp{Err: err.Error()}\n\t}\n\n\tfor name, info := range policiesMap {\n\t\tpolicyInfo, ok := info.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tpolicyInfo[\"name\"] = name\n\t\tresult = append(result, policyInfo)\n\t}\n\treturn &types.ResultsResp{Results: result}\n}\n\n// CreateSLMPolicy 创建SLM策略\nfunc (es *ESService) CreateSLMPolicy(policyId, name, schedule, repository, indices, expireAfter string, minCount, maxCount int) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tif policyId == \"\" || schedule == \"\" || repository == \"\" {\n\t\treturn &types.ResultResp{Err: \"策略ID、调度计划和仓库名称不能为空\"}\n\t}\n\n\tbody := map[string]any{\n\t\t\"schedule\":   schedule,\n\t\t\"name\":       name,\n\t\t\"repository\": repository,\n\t\t\"config\": map[string]any{\n\t\t\t\"indices\":              []string{\"*\"},\n\t\t\t\"include_global_state\": false,\n\t\t},\n\t}\n\tif indices != \"\" {\n\t\tbody[\"config\"].(map[string]any)[\"indices\"] = strings.Split(indices, \",\")\n\t}\n\n\tretention := map[string]any{}\n\tif expireAfter != \"\" {\n\t\tretention[\"expire_after\"] = expireAfter\n\t}\n\tif minCount > 0 {\n\t\tretention[\"min_count\"] = minCount\n\t}\n\tif maxCount > 0 {\n\t\tretention[\"max_count\"] = maxCount\n\t}\n\tif len(retention) > 0 {\n\t\tbody[\"retention\"] = retention\n\t}\n\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetBody(body).\n\t\tSetResult(&result).\n\t\tPut(es.ConnectObj.Host + \"/_slm/policy/\" + url.PathEscape(policyId))\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// DeleteSLMPolicy 删除SLM策略\nfunc (es *ESService) DeleteSLMPolicy(policyId string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tif policyId == \"\" {\n\t\treturn &types.ResultResp{Err: \"策略ID不能为空\"}\n\t}\n\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetResult(&result).\n\t\tDelete(es.ConnectObj.Host + \"/_slm/policy/\" + url.PathEscape(policyId))\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// ExecuteSLMPolicy 手动执行SLM策略\nfunc (es *ESService) ExecuteSLMPolicy(policyId string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\tif policyId == \"\" {\n\t\treturn &types.ResultResp{Err: \"策略ID不能为空\"}\n\t}\n\n\tvar result map[string]any\n\tresp, err := es.Client.R().\n\t\tSetResult(&result).\n\t\tPost(es.ConnectObj.Host + \"/_slm/policy/\" + url.PathEscape(policyId) + \"/_execute\")\n\tif err != nil {\n\t\treturn &types.ResultResp{Err: err.Error()}\n\t}\n\tif resp.StatusCode() != http.StatusOK {\n\t\treturn &types.ResultResp{Err: string(resp.Body())}\n\t}\n\treturn &types.ResultResp{Result: result}\n}\n\n// SearchResponse 定义 ES 搜索响应的结构\ntype SearchResponse struct {\n\tScrollID string `json:\"_scroll_id\"`\n\tHits     struct {\n\t\tTotal struct {\n\t\t\tValue int `json:\"value\"`\n\t\t} `json:\"total\"`\n\t\tHits []struct {\n\t\t\tSource json.RawMessage `json:\"_source\"`\n\t\t} `json:\"hits\"`\n\t} `json:\"hits\"`\n}\n\n// DownloadESIndex 使用 Resty 客户端从 ES 下载指定索引的数据\nfunc (es *ESService) DownloadESIndex(index string, queryDSL string, filePath string) *types.ResultResp {\n\tif es.ConnectObj.Host == \"\" {\n\t\treturn &types.ResultResp{Err: \"请先选择一个集群\"}\n\t}\n\n\tres := &types.ResultResp{}\n\t// 如果 queryDSL 为空，默认使用 match_all 查询\n\tif queryDSL == \"\" {\n\t\tqueryDSL = `{\"match_all\": {}}`\n\t}\n\n\t// 创建本地文件\n\tfile, err := os.Create(filePath)\n\tif err != nil {\n\t\tres.Err = fmt.Sprintf(\"创建文件失败: %v\", err)\n\t\treturn res\n\t}\n\tsuccess := false\n\tdefer func() {\n\t\tfile.Close()\n\t\tif !success {\n\t\t\tos.Remove(filePath) // 下载失败时清理残缺文件\n\t\t}\n\t}()\n\n\t// 构造初始搜索请求的 body，设置每批次大小为 10000\n\tbodyStr := fmt.Sprintf(`{\"size\": 10000, \"query\": %s}`, queryDSL)\n\tresp, err := es.Client.R().SetBody(bodyStr).Post(es.ConnectObj.Host + \"/\" + index + \"/_search?scroll=3m\")\n\tif err != nil {\n\t\tres.Err = fmt.Sprintf(\"初始搜索请求失败: %v\", err)\n\t\treturn res\n\t}\n\tif resp.StatusCode() != 200 {\n\t\tres.Err = \"初始搜索请求返回非 200 状态码\"\n\t\treturn res\n\t}\n\n\t// 解析初始响应\n\tvar searchResponse SearchResponse\n\terr = json.Unmarshal(resp.Body(), &searchResponse)\n\tif err != nil {\n\t\tres.Err = fmt.Sprintf(\"解析初始响应失败: %v\", err)\n\t\treturn res\n\t}\n\n\t// 使用 bufio.Writer 进行缓冲写入\n\twriter := bufio.NewWriter(file)\n\tdefer writer.Flush() // 确保缓冲区数据在函数结束时写入文件\n\n\t// 写入 JSON 数组的开头\n\t_, _ = writer.WriteString(\"[\")\n\n\t// 标志变量，用于控制逗号分隔符\n\tisFirst := true\n\n\t// 循环处理滚动下载\n\tfor {\n\t\t// 如果当前批次没有文档，则退出循环\n\t\tif len(searchResponse.Hits.Hits) == 0 {\n\t\t\tbreak\n\t\t}\n\n\t\t// 遍历当前批次的每个文档\n\t\tfor _, hit := range searchResponse.Hits.Hits {\n\t\t\tif !isFirst {\n\t\t\t\t// 除了第一个文档前，其他文档前添加逗号\n\t\t\t\t_, _ = writer.WriteString(\",\")\n\t\t\t}\n\t\t\tisFirst = false\n\t\t\t// 直接写入文档的 _source 字段（json.RawMessage 是 []byte 类型）\n\t\t\t_, _ = writer.Write(hit.Source)\n\t\t}\n\n\t\t// 发送滚动请求获取下一批数据\n\t\tscrollBody := map[string]interface{}{\n\t\t\t\"scroll\":    \"3m\", // 滚动上下文有效期 1 分钟\n\t\t\t\"scroll_id\": searchResponse.ScrollID,\n\t\t}\n\t\tresp, err = es.Client.R().SetBody(scrollBody).Post(es.ConnectObj.Host + \"/_search/scroll\")\n\t\tif err != nil {\n\t\t\tres.Err = fmt.Sprintf(\"滚动请求失败: %v\", err)\n\t\t\treturn res\n\t\t}\n\t\tif resp.StatusCode() != 200 {\n\t\t\tres.Err = fmt.Sprintf(\"滚动请求返回非 200 状态码 %v\", resp.StatusCode())\n\t\t\treturn res\n\t\t}\n\n\t\t// 解析滚动响应\n\t\terr = json.Unmarshal(resp.Body(), &searchResponse)\n\t\tif err != nil {\n\t\t\tres.Err = fmt.Sprintf(\"解析滚动响应失败: %v\", err)\n\t\t\treturn res\n\t\t}\n\t}\n\n\t// 写入 JSON 数组的结尾\n\t_, _ = writer.WriteString(\"]\")\n\n\tsuccess = true\n\treturn res\n}\n"
  },
  {
    "path": "app/backend/system/update.go",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage system\n\nimport (\n\t\"app/backend/common\"\n\t\"app/backend/types\"\n\t\"context\"\n\t\"fmt\"\n\t\"github.com/go-resty/resty/v2\"\n\t\"runtime\"\n\t\"runtime/debug\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype Update struct {\n\tctx context.Context\n}\n\nfunc (obj *Update) Start(ctx context.Context) {\n\tobj.ctx = ctx\n}\nfunc (obj *Update) CheckUpdate() *types.Tag {\n\tclient := resty.New()\n\ttag := &types.Tag{}\n\tresp, err := client.R().SetResult(tag).Get(common.UPDATE_URL)\n\tif err != nil || resp.StatusCode() != 200 {\n\t\treturn nil\n\t}\n\ttag.TagName = strings.TrimSpace(tag.TagName)\n\treturn tag\n}\n\nfunc (obj *Update) GetProcessInfo() string {\n\t// 获取内存统计信息\n\tvar memStats runtime.MemStats\n\truntime.ReadMemStats(&memStats)\n\n\t// 获取构建信息\n\tvar goVersion string\n\tif buildInfo, ok := debug.ReadBuildInfo(); ok {\n\t\tgoVersion = buildInfo.GoVersion\n\t} else {\n\t\tgoVersion = runtime.Version()\n\t}\n\n\t// 格式化输出详细信息\n\tinfo := fmt.Sprintf(\n\t\t\"基本信息:\\n\"+\n\t\t\t\"- Go版本: %s\\n\"+\n\t\t\t\"- 操作系统: %s\\n\"+\n\t\t\t\"- 体系结构: %s\\n\"+\n\t\t\t\"- CPU数量: %d\\n\"+\n\t\t\t\"- 协程数量: %d\\n\"+\n\t\t\t\"- 当前时间戳: %s\\n\\n\"+\n\t\t\t\"内存统计:\\n\"+\n\t\t\t\"- 已分配内存: %.2f MB\\n\"+\n\t\t\t\"- 总分配内存: %.2f MB\\n\"+\n\t\t\t\"- 系统内存: %.2f MB\\n\"+\n\t\t\t\"- 堆分配: %.2f MB\\n\"+\n\t\t\t\"- 堆系统内存: %.2f MB\\n\"+\n\t\t\t\"- 堆空闲: %.2f MB\\n\"+\n\t\t\t\"- 堆使用中: %.2f MB\\n\"+\n\t\t\t\"- 栈使用中: %.2f MB\\n\"+\n\t\t\t\"- 堆对象数量: %d\\n\"+\n\t\t\t\"- 内存分配次数: %d\\n\"+\n\t\t\t\"- 内存释放次数: %d\\n\\n\"+\n\t\t\t\"垃圾回收统计:\\n\"+\n\t\t\t\"- 垃圾回收运行次数: %d\\n\"+\n\t\t\t\"- 上次垃圾回收时间: %s\\n\"+\n\t\t\t\"- 下次垃圾回收限制: %.2f MB\\n\"+\n\t\t\t\"- 垃圾回收CPU占比: %.4f%%\\n\"+\n\t\t\t\"- 垃圾回收总暂停时间: %v\\n\",\n\t\tgoVersion,                        // Go版本\n\t\truntime.GOOS,                     // 操作系统\n\t\truntime.GOARCH,                   // 体系结构\n\t\truntime.NumCPU(),                 // CPU数量\n\t\truntime.NumGoroutine(),           // 协程数量\n\t\ttime.Now().Format(time.DateTime), // 当前时间戳\n\n\t\tfloat64(memStats.Alloc)/1024/1024,      // 已分配内存\n\t\tfloat64(memStats.TotalAlloc)/1024/1024, // 总分配内存\n\t\tfloat64(memStats.Sys)/1024/1024,        // 系统内存\n\t\tfloat64(memStats.HeapAlloc)/1024/1024,  // 堆分配\n\t\tfloat64(memStats.HeapSys)/1024/1024,    // 堆系统内存\n\t\tfloat64(memStats.HeapIdle)/1024/1024,   // 堆空闲\n\t\tfloat64(memStats.HeapInuse)/1024/1024,  // 堆使用中\n\t\tfloat64(memStats.StackInuse)/1024/1024, // 栈使用中\n\t\tmemStats.HeapObjects,                   // 堆对象数量\n\t\tmemStats.Mallocs,                       // 内存分配次数\n\t\tmemStats.Frees,                         // 内存释放次数\n\n\t\tmemStats.NumGC, // 垃圾回收运行次数\n\t\ttime.Unix(0, int64(memStats.LastGC)).Format(time.DateTime), // 上次垃圾回收时间\n\t\tfloat64(memStats.NextGC)/1024/1024,                         // 下次垃圾回收限制\n\t\tmemStats.GCCPUFraction*100,                                 // 垃圾回收CPU占比\n\t\ttime.Duration(memStats.PauseTotalNs),                       // 垃圾回收总暂停时间\n\t)\n\n\treturn info\n}\n"
  },
  {
    "path": "app/backend/types/resp.go",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage types\n\ntype Tag struct {\n\tName    string `json:\"name\"`\n\tTagName string `json:\"tag_name\"`\n\tBody    string `json:\"body\"`\n}\ntype Config struct {\n\tWidth    int       `json:\"width\"`\n\tHeight   int       `json:\"height\"`\n\tLanguage string    `json:\"language\"`\n\tTheme    string    `json:\"theme\"`\n\tConnects []Connect `json:\"connects\"`\n}\ntype History struct {\n\tTime   int    `json:\"timestamp\"`\n\tMethod string `json:\"method\"`\n\tPath   string `json:\"path\"`\n\tDSL    string `json:\"dsl\"`\n}\ntype ResultsResp struct {\n\tResults []any  `json:\"results\"`\n\tErr     string `json:\"err\"`\n}\ntype ResultResp struct {\n\tResult any    `json:\"result\"`\n\tErr    string `json:\"err\"`\n}\ntype Connect struct {\n\tId            int    `json:\"id\"`\n\tName          string `json:\"name\"`\n\tHost          string `json:\"host\"`\n\tUsername      string `json:\"username\"`\n\tPassword      string `json:\"password\"`\n\tUseSSL        bool   `json:\"useSSL\"`\n\tSkipSSLVerify bool   `json:\"skipSSLVerify\"`\n\tCACert        string `json:\"caCert\"`\n}\ntype H map[string]any\n"
  },
  {
    "path": "app/build/README.md",
    "content": "# Build Directory\n\nThe build directory is used to house all the build files and assets for your application. \n\nThe structure is:\n\n* bin - Output directory\n* darwin - macOS specific files\n* windows - Windows specific files\n\n## Mac\n\nThe `darwin` directory holds files specific to Mac builds.\nThese may be customised and used as part of the build. To return these files to the default state, simply delete them\nand\nbuild with `wails build`.\n\nThe directory contains the following files:\n\n- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.\n- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.\n\n## Windows\n\nThe `windows` directory contains the manifest and rc files used when building with `wails build`.\nThese may be customised for your application. To return these files to the default state, simply delete them and\nbuild with `wails build`.\n\n- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to\n  use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file\n  will be created using the `appicon.png` file in the build directory.\n- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.\n- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,\n  as well as the application itself (right click the exe -> properties -> details)\n- `wails.exe.manifest` - The main application manifest file."
  },
  {
    "path": "app/build/darwin/Info.dev.plist",
    "content": "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n    <dict>\n        <key>CFBundlePackageType</key>\n        <string>APPL</string>\n        <key>CFBundleName</key>\n        <string>{{.Info.ProductName}}</string>\n        <key>CFBundleExecutable</key>\n        <string>{{.Name}}</string>\n        <key>CFBundleIdentifier</key>\n        <string>com.wails.{{.Name}}</string>\n        <key>CFBundleVersion</key>\n        <string>{{.Info.ProductVersion}}</string>\n        <key>CFBundleGetInfoString</key>\n        <string>{{.Info.Comments}}</string>\n        <key>CFBundleShortVersionString</key>\n        <string>{{.Info.ProductVersion}}</string>\n        <key>CFBundleIconFile</key>\n        <string>iconfile</string>\n        <key>LSMinimumSystemVersion</key>\n        <string>10.13.0</string>\n        <key>NSHighResolutionCapable</key>\n        <string>true</string>\n        <key>NSHumanReadableCopyright</key>\n        <string>{{.Info.Copyright}}</string>\n        {{if .Info.FileAssociations}}\n        <key>CFBundleDocumentTypes</key>\n        <array>\n          {{range .Info.FileAssociations}}\n          <dict>\n            <key>CFBundleTypeExtensions</key>\n            <array>\n              <string>{{.Ext}}</string>\n            </array>\n            <key>CFBundleTypeName</key>\n            <string>{{.Name}}</string>\n            <key>CFBundleTypeRole</key>\n            <string>{{.Role}}</string>\n            <key>CFBundleTypeIconFile</key>\n            <string>{{.IconName}}</string>\n          </dict>\n          {{end}}\n        </array>\n        {{end}}\n        {{if .Info.Protocols}}\n        <key>CFBundleURLTypes</key>\n        <array>\n          {{range .Info.Protocols}}\n            <dict>\n                <key>CFBundleURLName</key>\n                <string>com.wails.{{.Scheme}}</string>\n                <key>CFBundleURLSchemes</key>\n                <array>\n                    <string>{{.Scheme}}</string>\n                </array>\n                <key>CFBundleTypeRole</key>\n                <string>{{.Role}}</string>\n            </dict>\n          {{end}}\n        </array>\n        {{end}}\n        <key>NSAppTransportSecurity</key>\n        <dict>\n            <key>NSAllowsLocalNetworking</key>\n            <true/>\n        </dict>\n    </dict>\n</plist>\n"
  },
  {
    "path": "app/build/darwin/Info.plist",
    "content": "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n    <dict>\n        <key>CFBundlePackageType</key>\n        <string>APPL</string>\n        <key>CFBundleName</key>\n        <string>{{.Info.ProductName}}</string>\n        <key>CFBundleExecutable</key>\n        <string>{{.Name}}</string>\n        <key>CFBundleIdentifier</key>\n        <string>com.github.Bronya0.{{.Name}}</string>\n        <key>CFBundleVersion</key>\n        <string>{{.Info.ProductVersion}}</string>\n        <key>CFBundleGetInfoString</key>\n        <string>{{.Info.Comments}}</string>\n        <key>CFBundleShortVersionString</key>\n        <string>{{.Info.ProductVersion}}</string>\n        <key>CFBundleIconFile</key>\n        <string>iconfile</string>\n        <key>LSMinimumSystemVersion</key>\n        <string>10.13.0</string>\n        <key>NSHighResolutionCapable</key>\n        <string>true</string>\n        <key>NSHumanReadableCopyright</key>\n        <string>{{.Info.Copyright}}</string>\n        {{if .Info.FileAssociations}}\n        <key>CFBundleDocumentTypes</key>\n        <array>\n          {{range .Info.FileAssociations}}\n          <dict>\n            <key>CFBundleTypeExtensions</key>\n            <array>\n              <string>{{.Ext}}</string>\n            </array>\n            <key>CFBundleTypeName</key>\n            <string>{{.Name}}</string>\n            <key>CFBundleTypeRole</key>\n            <string>{{.Role}}</string>\n            <key>CFBundleTypeIconFile</key>\n            <string>{{.IconName}}</string>\n          </dict>\n          {{end}}\n        </array>\n        {{end}}\n        {{if .Info.Protocols}}\n        <key>CFBundleURLTypes</key>\n        <array>\n          {{range .Info.Protocols}}\n            <dict>\n                <key>CFBundleURLName</key>\n                <string>com.wails.{{.Scheme}}</string>\n                <key>CFBundleURLSchemes</key>\n                <array>\n                    <string>{{.Scheme}}</string>\n                </array>\n                <key>CFBundleTypeRole</key>\n                <string>{{.Role}}</string>\n            </dict>\n          {{end}}\n        </array>\n        {{end}}\n    </dict>\n</plist>\n"
  },
  {
    "path": "app/build/windows/info.json",
    "content": "{\n\t\"fixed\": {\n\t\t\"file_version\": \"{{.Info.ProductVersion}}\"\n\t},\n\t\"info\": {\n\t\t\"0000\": {\n\t\t\t\"ProductVersion\": \"{{.Info.ProductVersion}}\",\n\t\t\t\"CompanyName\": \"{{.Info.CompanyName}}\",\n\t\t\t\"FileDescription\": \"{{.Info.ProductName}}\",\n\t\t\t\"LegalCopyright\": \"{{.Info.Copyright}}\",\n\t\t\t\"ProductName\": \"{{.Info.ProductName}}\",\n\t\t\t\"Comments\": \"{{.Info.Comments}}\"\n\t\t}\n\t}\n}"
  },
  {
    "path": "app/build/windows/installer/project.nsi",
    "content": "Unicode true\n\n####\n## Please note: Template replacements don't work in this file. They are provided with default defines like\n## mentioned underneath.\n## If the keyword is not defined, \"wails_tools.nsh\" will populate them with the values from ProjectInfo.\n## If they are defined here, \"wails_tools.nsh\" will not touch them. This allows to use this project.nsi manually\n## from outside of Wails for debugging and development of the installer.\n##\n## For development first make a wails nsis build to populate the \"wails_tools.nsh\":\n## > wails build --target windows/amd64 --nsis\n## Then you can call makensis on this file with specifying the path to your binary:\n## For a AMD64 only installer:\n## > makensis -DARG_WAILS_AMD64_BINARY=..\\..\\bin\\app.exe\n## For a ARM64 only installer:\n## > makensis -DARG_WAILS_ARM64_BINARY=..\\..\\bin\\app.exe\n## For a installer with both architectures:\n## > makensis -DARG_WAILS_AMD64_BINARY=..\\..\\bin\\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\\..\\bin\\app-arm64.exe\n####\n## The following information is taken from the ProjectInfo file, but they can be overwritten here.\n####\n## !define INFO_PROJECTNAME    \"MyProject\" # Default \"{{.Name}}\"\n## !define INFO_COMPANYNAME    \"MyCompany\" # Default \"{{.Info.CompanyName}}\"\n## !define INFO_PRODUCTNAME    \"MyProduct\" # Default \"{{.Info.ProductName}}\"\n## !define INFO_PRODUCTVERSION \"1.0.0\"     # Default \"{{.Info.ProductVersion}}\"\n## !define INFO_COPYRIGHT      \"Copyright\" # Default \"{{.Info.Copyright}}\"\n###\n## !define PRODUCT_EXECUTABLE  \"Application.exe\"      # Default \"${INFO_PROJECTNAME}.exe\"\n## !define UNINST_KEY_NAME     \"UninstKeyInRegistry\"  # Default \"${INFO_COMPANYNAME}${INFO_PRODUCTNAME}\"\n####\n## !define REQUEST_EXECUTION_LEVEL \"admin\"            # Default \"admin\"  see also https://nsis.sourceforge.io/Docs/Chapter4.html\n####\n## Include the wails tools\n####\n!include \"wails_tools.nsh\"\n\n# The version information for this two must consist of 4 parts\nVIProductVersion \"${INFO_PRODUCTVERSION}.0\"\nVIFileVersion    \"${INFO_PRODUCTVERSION}.0\"\n\nVIAddVersionKey \"CompanyName\"     \"${INFO_COMPANYNAME}\"\nVIAddVersionKey \"FileDescription\" \"${INFO_PRODUCTNAME} Installer\"\nVIAddVersionKey \"ProductVersion\"  \"${INFO_PRODUCTVERSION}\"\nVIAddVersionKey \"FileVersion\"     \"${INFO_PRODUCTVERSION}\"\nVIAddVersionKey \"LegalCopyright\"  \"${INFO_COPYRIGHT}\"\nVIAddVersionKey \"ProductName\"     \"${INFO_PRODUCTNAME}\"\n\n# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware\nManifestDPIAware true\n\n!include \"MUI.nsh\"\n\n!define MUI_ICON \"..\\icon.ico\"\n!define MUI_UNICON \"..\\icon.ico\"\n# !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\n!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps\n!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.\n\n!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.\n# !insertmacro MUI_PAGE_LICENSE \"resources\\eula.txt\" # Adds a EULA page to the installer\n!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.\n!insertmacro MUI_PAGE_INSTFILES # Installing page.\n!insertmacro MUI_PAGE_FINISH # Finished installation page.\n\n!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page\n\n!insertmacro MUI_LANGUAGE \"English\" # Set the Language of the installer\n\n## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1\n#!uninstfinalize 'signtool --file \"%1\"'\n#!finalize 'signtool --file \"%1\"'\n\nName \"${INFO_PRODUCTNAME}\"\nOutFile \"..\\..\\bin\\${INFO_PROJECTNAME}-${ARCH}-installer.exe\" # Name of the installer's file.\nInstallDir \"$PROGRAMFILES64\\${INFO_COMPANYNAME}\\${INFO_PRODUCTNAME}\" # Default installing folder ($PROGRAMFILES is Program Files folder).\nShowInstDetails show # This will always show the installation details.\n\nFunction .onInit\n   !insertmacro wails.checkArchitecture\nFunctionEnd\n\nSection\n    !insertmacro wails.setShellContext\n\n    !insertmacro wails.webview2runtime\n\n    SetOutPath $INSTDIR\n\n    !insertmacro wails.files\n\n    CreateShortcut \"$SMPROGRAMS\\${INFO_PRODUCTNAME}.lnk\" \"$INSTDIR\\${PRODUCT_EXECUTABLE}\"\n    CreateShortCut \"$DESKTOP\\${INFO_PRODUCTNAME}.lnk\" \"$INSTDIR\\${PRODUCT_EXECUTABLE}\"\n\n    !insertmacro wails.associateFiles\n    !insertmacro wails.associateCustomProtocols\n\n    !insertmacro wails.writeUninstaller\nSectionEnd\n\nSection \"uninstall\"\n    !insertmacro wails.setShellContext\n\n    RMDir /r \"$AppData\\${PRODUCT_EXECUTABLE}\" # Remove the WebView2 DataPath\n\n    RMDir /r $INSTDIR\n\n    Delete \"$SMPROGRAMS\\${INFO_PRODUCTNAME}.lnk\"\n    Delete \"$DESKTOP\\${INFO_PRODUCTNAME}.lnk\"\n\n    !insertmacro wails.unassociateFiles\n    !insertmacro wails.unassociateCustomProtocols\n\n    !insertmacro wails.deleteUninstaller\nSectionEnd\n"
  },
  {
    "path": "app/build/windows/installer/wails_tools.nsh",
    "content": "# DO NOT EDIT - Generated automatically by `wails build`\n\n!include \"x64.nsh\"\n!include \"WinVer.nsh\"\n!include \"FileFunc.nsh\"\n\n!ifndef INFO_PROJECTNAME\n    !define INFO_PROJECTNAME \"demo-app\"\n!endif\n!ifndef INFO_COMPANYNAME\n    !define INFO_COMPANYNAME \"demo-app\"\n!endif\n!ifndef INFO_PRODUCTNAME\n    !define INFO_PRODUCTNAME \"demo-app\"\n!endif\n!ifndef INFO_PRODUCTVERSION\n    !define INFO_PRODUCTVERSION \"1.0.0\"\n!endif\n!ifndef INFO_COPYRIGHT\n    !define INFO_COPYRIGHT \"Copyright.........\"\n!endif\n!ifndef PRODUCT_EXECUTABLE\n    !define PRODUCT_EXECUTABLE \"${INFO_PROJECTNAME}.exe\"\n!endif\n!ifndef UNINST_KEY_NAME\n    !define UNINST_KEY_NAME \"${INFO_COMPANYNAME}${INFO_PRODUCTNAME}\"\n!endif\n!define UNINST_KEY \"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${UNINST_KEY_NAME}\"\n\n!ifndef REQUEST_EXECUTION_LEVEL\n    !define REQUEST_EXECUTION_LEVEL \"admin\"\n!endif\n\nRequestExecutionLevel \"${REQUEST_EXECUTION_LEVEL}\"\n\n!ifdef ARG_WAILS_AMD64_BINARY\n    !define SUPPORTS_AMD64\n!endif\n\n!ifdef ARG_WAILS_ARM64_BINARY\n    !define SUPPORTS_ARM64\n!endif\n\n!ifdef SUPPORTS_AMD64\n    !ifdef SUPPORTS_ARM64\n        !define ARCH \"amd64_arm64\"\n    !else\n        !define ARCH \"amd64\"\n    !endif\n!else\n    !ifdef SUPPORTS_ARM64\n        !define ARCH \"arm64\"\n    !else\n        !error \"Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY\"\n    !endif\n!endif\n\n!macro wails.checkArchitecture\n    !ifndef WAILS_WIN10_REQUIRED\n        !define WAILS_WIN10_REQUIRED \"This product is only supported on Windows 10 (Server 2016) and later.\"\n    !endif\n\n    !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED\n        !define WAILS_ARCHITECTURE_NOT_SUPPORTED \"This product can't be installed on the current Windows architecture. Supports: ${ARCH}\"\n    !endif\n\n    ${If} ${AtLeastWin10}\n        !ifdef SUPPORTS_AMD64\n            ${if} ${IsNativeAMD64}\n                Goto ok\n            ${EndIf}\n        !endif\n\n        !ifdef SUPPORTS_ARM64\n            ${if} ${IsNativeARM64}\n                Goto ok\n            ${EndIf}\n        !endif\n\n        IfSilent silentArch notSilentArch\n        silentArch:\n            SetErrorLevel 65\n            Abort\n        notSilentArch:\n            MessageBox MB_OK \"${WAILS_ARCHITECTURE_NOT_SUPPORTED}\"\n            Quit\n    ${else}\n        IfSilent silentWin notSilentWin\n        silentWin:\n            SetErrorLevel 64\n            Abort\n        notSilentWin:\n            MessageBox MB_OK \"${WAILS_WIN10_REQUIRED}\"\n            Quit\n    ${EndIf}\n\n    ok:\n!macroend\n\n!macro wails.files\n    !ifdef SUPPORTS_AMD64\n        ${if} ${IsNativeAMD64}\n            File \"/oname=${PRODUCT_EXECUTABLE}\" \"${ARG_WAILS_AMD64_BINARY}\"\n        ${EndIf}\n    !endif\n\n    !ifdef SUPPORTS_ARM64\n        ${if} ${IsNativeARM64}\n            File \"/oname=${PRODUCT_EXECUTABLE}\" \"${ARG_WAILS_ARM64_BINARY}\"\n        ${EndIf}\n    !endif\n!macroend\n\n!macro wails.writeUninstaller\n    WriteUninstaller \"$INSTDIR\\uninstall.exe\"\n\n    SetRegView 64\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"Publisher\" \"${INFO_COMPANYNAME}\"\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"DisplayName\" \"${INFO_PRODUCTNAME}\"\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"DisplayVersion\" \"${INFO_PRODUCTVERSION}\"\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"DisplayIcon\" \"$INSTDIR\\${PRODUCT_EXECUTABLE}\"\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"UninstallString\" \"$\\\"$INSTDIR\\uninstall.exe$\\\"\"\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"QuietUninstallString\" \"$\\\"$INSTDIR\\uninstall.exe$\\\" /S\"\n\n    ${GetSize} \"$INSTDIR\" \"/S=0K\" $0 $1 $2\n    IntFmt $0 \"0x%08X\" $0\n    WriteRegDWORD HKLM \"${UNINST_KEY}\" \"EstimatedSize\" \"$0\"\n!macroend\n\n!macro wails.deleteUninstaller\n    Delete \"$INSTDIR\\uninstall.exe\"\n\n    SetRegView 64\n    DeleteRegKey HKLM \"${UNINST_KEY}\"\n!macroend\n\n!macro wails.setShellContext\n    ${If} ${REQUEST_EXECUTION_LEVEL} == \"admin\"\n        SetShellVarContext all\n    ${else}\n        SetShellVarContext current\n    ${EndIf}\n!macroend\n\n# Install webview2 by launching the bootstrapper\n# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment\n!macro wails.webview2runtime\n    !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT\n        !define WAILS_INSTALL_WEBVIEW_DETAILPRINT \"Installing: WebView2 Runtime\"\n    !endif\n\n    SetRegView 64\n\t# If the admin key exists and is not empty then webview2 is already installed\n\tReadRegStr $0 HKLM \"SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\" \"pv\"\n    ${If} $0 != \"\"\n        Goto ok\n    ${EndIf}\n\n    ${If} ${REQUEST_EXECUTION_LEVEL} == \"user\"\n        # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed\n\t    ReadRegStr $0 HKCU \"Software\\Microsoft\\EdgeUpdate\\Clients{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\" \"pv\"\n        ${If} $0 != \"\"\n            Goto ok\n        ${EndIf}\n     ${EndIf}\n\n\tSetDetailsPrint both\n    DetailPrint \"${WAILS_INSTALL_WEBVIEW_DETAILPRINT}\"\n    SetDetailsPrint listonly\n\n    InitPluginsDir\n    CreateDirectory \"$pluginsdir\\webview2bootstrapper\"\n    SetOutPath \"$pluginsdir\\webview2bootstrapper\"\n    File \"tmp\\MicrosoftEdgeWebview2Setup.exe\"\n    ExecWait '\"$pluginsdir\\webview2bootstrapper\\MicrosoftEdgeWebview2Setup.exe\" /silent /install'\n\n    SetDetailsPrint both\n    ok:\n!macroend\n\n# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b\n!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND\n  ; Backup the previously associated file class\n  ReadRegStr $R0 SHELL_CONTEXT \"Software\\Classes\\.${EXT}\" \"\"\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\.${EXT}\" \"${FILECLASS}_backup\" \"$R0\"\n\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\.${EXT}\" \"\" \"${FILECLASS}\"\n\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\${FILECLASS}\" \"\" `${DESCRIPTION}`\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\${FILECLASS}\\DefaultIcon\" \"\" `${ICON}`\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\${FILECLASS}\\shell\" \"\" \"open\"\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\${FILECLASS}\\shell\\open\" \"\" `${COMMANDTEXT}`\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\${FILECLASS}\\shell\\open\\command\" \"\" `${COMMAND}`\n!macroend\n\n!macro APP_UNASSOCIATE EXT FILECLASS\n  ; Backup the previously associated file class\n  ReadRegStr $R0 SHELL_CONTEXT \"Software\\Classes\\.${EXT}\" `${FILECLASS}_backup`\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\.${EXT}\" \"\" \"$R0\"\n\n  DeleteRegKey SHELL_CONTEXT `Software\\Classes\\${FILECLASS}`\n!macroend\n\n!macro wails.associateFiles\n    ; Create file associations\n    \n!macroend\n\n!macro wails.unassociateFiles\n    ; Delete app associations\n    \n!macroend\n\n!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND\n  DeleteRegKey SHELL_CONTEXT \"Software\\Classes\\${PROTOCOL}\"\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\${PROTOCOL}\" \"\" \"${DESCRIPTION}\"\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\${PROTOCOL}\" \"URL Protocol\" \"\"\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\${PROTOCOL}\\DefaultIcon\" \"\" \"${ICON}\"\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\${PROTOCOL}\\shell\" \"\" \"\"\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\${PROTOCOL}\\shell\\open\" \"\" \"\"\n  WriteRegStr SHELL_CONTEXT \"Software\\Classes\\${PROTOCOL}\\shell\\open\\command\" \"\" \"${COMMAND}\"\n!macroend\n\n!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL\n  DeleteRegKey SHELL_CONTEXT \"Software\\Classes\\${PROTOCOL}\"\n!macroend\n\n!macro wails.associateCustomProtocols\n    ; Create custom protocols associations\n    \n!macroend\n\n!macro wails.unassociateCustomProtocols\n    ; Delete app custom protocol associations\n    \n!macroend\n"
  },
  {
    "path": "app/build/windows/wails.exe.manifest",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<assembly manifestVersion=\"1.0\" xmlns=\"urn:schemas-microsoft-com:asm.v1\" xmlns:asmv3=\"urn:schemas-microsoft-com:asm.v3\">\n    <assemblyIdentity type=\"win32\" name=\"com.wails.{{.Name}}\" version=\"{{.Info.ProductVersion}}.0\" processorArchitecture=\"*\"/>\n    <dependency>\n        <dependentAssembly>\n            <assemblyIdentity type=\"win32\" name=\"Microsoft.Windows.Common-Controls\" version=\"6.0.0.0\" processorArchitecture=\"*\" publicKeyToken=\"6595b64144ccf1df\" language=\"*\"/>\n        </dependentAssembly>\n    </dependency>\n    <asmv3:application>\n        <asmv3:windowsSettings>\n            <dpiAware xmlns=\"http://schemas.microsoft.com/SMI/2005/WindowsSettings\">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->\n            <dpiAwareness xmlns=\"http://schemas.microsoft.com/SMI/2016/WindowsSettings\">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->\n        </asmv3:windowsSettings>\n    </asmv3:application>\n</assembly>"
  },
  {
    "path": "app/dev.bat",
    "content": "chcp 65001\n\nwails dev"
  },
  {
    "path": "app/frontend/index.html",
    "content": "<!--\n  ~ Copyright 2025 Bronya0 <tangssst@163.com>.\n  ~ Author Github: https://github.com/Bronya0\n  ~\n  ~ Licensed under the Apache License, Version 2.0 (the \"License\");\n  ~ you may not use this file except in compliance with the License.\n  ~ You may obtain a copy of the License at\n  ~\n  ~     https://www.apache.org/licenses/LICENSE-2.0\n  ~\n  ~ Unless required by applicable law or agreed to in writing, software\n  ~ distributed under the License is distributed on an \"AS IS\" BASIS,\n  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  ~ See the License for the specific language governing permissions and\n  ~ limitations under the License.\n  -->\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\"/>\n    <meta content=\"width=device-width, initial-scale=1.0\" name=\"viewport\"/>\n    <title>wails-naive-demo</title>\n    <link href=\"./src/style.css\" rel=\"stylesheet\">\n</head>\n<body>\n<div id=\"app\"></div>\n<script src=\"./src/main.js\" type=\"module\"></script>\n</body>\n</html>\n\n"
  },
  {
    "path": "app/frontend/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"version\": \"1.0.0\",\n  \"description\": \"\",\n  \"main\": \"\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"ace-builds\": \"1.4.12\",\n    \"highlight.js\": \"^11.10.0\",\n    \"jsoneditor\": \"^10.1.0\",\n    \"mitt\": \"^3.0.1\",\n    \"vue\": \"^3.5.8\"\n  },\n  \"devDependencies\": {\n    \"@vicons/material\": \"^0.12.0\",\n    \"@vitejs/plugin-vue\": \"^6.0.0\",\n    \"naive-ui\": \"^2.39.0\",\n    \"vite\": \"7.1.5\"\n  },\n  \"keywords\": [],\n  \"author\": \"bronya0\"\n}\n"
  },
  {
    "path": "app/frontend/src/App.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <n-config-provider\n      :theme=\"Theme\"\n      :hljs=\"hljs\"\n      :locale=\"zhCN\" :date-locale=\"dateZhCN\"\n  >\n    <!--https://www.naiveui.com/zh-CN/os-theme/components/layout-->\n    <n-message-provider container-style=\" word-break: break-all;\">\n      <n-notification-provider placement=\"bottom-right\" container-style=\"text-align: left;\">\n        <n-dialog-provider>\n          <n-loading-bar-provider>\n\n            <n-layout has-sider position=\"absolute\" style=\"height: 100vh;\" :class=\"headerClass\">\n              <!--header-->\n              <n-layout-header bordered style=\"height: 42px; bottom: 0; padding: 0; \">\n                <Header />\n              </n-layout-header>\n              <!--side + content-->\n              <n-layout has-sider position=\"absolute\" style=\"top: 42px; bottom: 0;\">\n                <n-layout-sider\n                    bordered\n                    collapse-mode=\"width\"\n                    :collapsed-width=\"60\"\n                    :collapsed=\"true\"\n                    style=\"--wails-draggable:drag\"\n                >\n                  <Aside\n                      :collapsed-width=\"60\"\n                      :value=\"activeItem.key\"\n                      :options=\"sideMenuOptions\"\n                  />\n\n                </n-layout-sider>\n                <n-layout-content style=\"padding: 0 16px;\">\n                  <keep-alive>\n                    <component :is=\"activeItem.component\"></component>\n                  </keep-alive>\n\n                </n-layout-content>\n              </n-layout>\n            </n-layout>\n          </n-loading-bar-provider>\n        </n-dialog-provider>\n      </n-notification-provider>\n    </n-message-provider>\n  </n-config-provider>\n</template>\n\n<script setup>\nimport {onMounted, ref, shallowRef} from 'vue'\nimport {\n  darkTheme,\n  lightTheme,\n  NConfigProvider,\n  NLayout,\n  NLayoutContent,\n  NLayoutHeader,\n  NMessageProvider,\n} from 'naive-ui'\nimport Header from './components/Header.vue'\nimport Settings from './components/Settings.vue'\nimport Health from './components/Health.vue'\nimport Core from './components/Core.vue'\nimport Nodes from './components/Nodes.vue'\nimport Index from './components/Index.vue'\nimport Rest from './components/Rest.vue'\nimport Conn from './components/Conn.vue'\nimport Task from './components/Task.vue'\nimport Snapshot from './components/Snapshot.vue'\nimport About from './components/About.vue'\nimport {GetConfig, SaveTheme} from \"../wailsjs/go/config/AppConfig\";\nimport {renderIcon} from \"./utils/common\";\nimport Aside from \"./components/Aside.vue\";\nimport emitter from \"./utils/eventBus\";\nimport {\n  FavoriteTwotone,\n  HiveOutlined,\n  SettingsSuggestOutlined, TaskAltFilled,\n  ApiOutlined, LibraryBooksOutlined, AllOutOutlined, BarChartOutlined, AddAPhotoTwotone, InfoOutlined\n} from '@vicons/material'\nimport hljs from 'highlight.js/lib/core'\nimport json from 'highlight.js/lib/languages/json'\nimport { zhCN, dateZhCN } from 'naive-ui'\n\nlet headerClass = shallowRef('lightTheme')\nlet Theme = shallowRef(lightTheme)\n\n\nhljs.registerLanguage('json', json)\n\nonMounted(async () => {\n  // 从后端加载配置\n  const loadedConfig = await GetConfig()\n  // 设置主题\n  themeChange(loadedConfig.theme)\n  // 语言切换\n  // handleLanguageChange(loadedConfig.language)\n\n  // =====================注册事件监听=====================\n  // 主题切换\n  emitter.on('update_theme', themeChange)\n  // 菜单切换\n  emitter.on('menu_select', handleMenuSelect)\n})\n\n\n// 左侧菜单\nconst sideMenuOptions = [\n  {\n    label: '集群',\n    key: '集群',\n    icon: renderIcon(HiveOutlined),\n    component: Conn,\n  },\n  {\n    label: '节点',\n    key: '节点',\n    icon: renderIcon(AllOutOutlined),\n    component: Nodes,\n  },\n  {\n    label: '索引',\n    key: '索引',\n    icon: renderIcon(LibraryBooksOutlined),\n    component: Index,\n  },\n  {\n    label: 'REST',\n    key: 'REST',\n    icon: renderIcon(ApiOutlined),\n    component: Rest,\n  },\n  {\n    label: 'Task',\n    key: 'Task',\n    icon: renderIcon(TaskAltFilled),\n    component: Task,\n  },\n  {\n    label: '健康',\n    key: '健康',\n    icon: renderIcon(FavoriteTwotone),\n    component: Health,\n  },\n  {\n    label: '指标',\n    key: '指标',\n    icon: renderIcon(BarChartOutlined),\n    component: Core,\n  },\n  {\n    label: '快照',\n    key: '快照',\n    icon: renderIcon(AddAPhotoTwotone),\n    component: Snapshot,\n  },\n  {\n    label: '设置',\n    key: '设置',\n    icon: renderIcon(SettingsSuggestOutlined),\n    component: Settings\n  },\n  {\n    label: \"关于\",\n    key: \"about\",\n    icon: renderIcon(InfoOutlined),\n    component: About\n  },\n]\nconst activeItem = shallowRef(sideMenuOptions[0])\n\n// 切换菜单\nfunction handleMenuSelect(key) {\n  // 根据key寻找item\n  activeItem.value = sideMenuOptions.find(item => item.key === key)\n}\n\n// 主题切换\nfunction themeChange(newTheme) {\n  Theme.value = newTheme === lightTheme.name ? lightTheme : darkTheme\n  headerClass = newTheme === lightTheme.name ? \"lightTheme\" : \"darkTheme\"\n}\n\n\n</script>\n\n<style>\nbody {\n  margin: 0;\n  font-family: sans-serif;\n\n}\n\n.lightTheme .n-layout-header {\n  background-color: #f7f7fa;\n}\n\n.lightTheme .n-layout-sider {\n  background-color: #f7f7fa !important;\n}\n</style>"
  },
  {
    "path": "app/frontend/src/assets/fonts/OFL.txt",
    "content": "Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),\n\nThis Font Software is licensed under the SIL Open Font License, Version 1.1.\nThis license is copied below, and is also available with a FAQ at:\nhttp://scripts.sil.org/OFL\n\n\n-----------------------------------------------------------\nSIL OPEN FONT LICENSE Version 1.1 - 26 February 2007\n-----------------------------------------------------------\n\nPREAMBLE\nThe goals of the Open Font License (OFL) are to stimulate worldwide\ndevelopment of collaborative font projects, to support the font creation\nefforts of academic and linguistic communities, and to provide a free and\nopen framework in which fonts may be shared and improved in partnership\nwith others.\n\nThe OFL allows the licensed fonts to be used, studied, modified and\nredistributed freely as long as they are not sold by themselves. The\nfonts, including any derivative works, can be bundled, embedded, \nredistributed and/or sold with any software provided that any reserved\nnames are not used by derivative works. The fonts and derivatives,\nhowever, cannot be released under any other type of license. The\nrequirement for fonts to remain under this license does not apply\nto any document created using the fonts or their derivatives.\n\nDEFINITIONS\n\"Font Software\" refers to the set of files released by the Copyright\nHolder(s) under this license and clearly marked as such. This may\ninclude source files, build scripts and documentation.\n\n\"Reserved Font Name\" refers to any names specified as such after the\ncopyright statement(s).\n\n\"Original Version\" refers to the collection of Font Software components as\ndistributed by the Copyright Holder(s).\n\n\"Modified Version\" refers to any derivative made by adding to, deleting,\nor substituting -- in part or in whole -- any of the components of the\nOriginal Version, by changing formats or by porting the Font Software to a\nnew environment.\n\n\"Author\" refers to any designer, engineer, programmer, technical\nwriter or other person who contributed to the Font Software.\n\nPERMISSION & CONDITIONS\nPermission is hereby granted, free of charge, to any person obtaining\na copy of the Font Software, to use, study, copy, merge, embed, modify,\nredistribute, and sell modified and unmodified copies of the Font\nSoftware, subject to the following conditions:\n\n1) Neither the Font Software nor any of its individual components,\nin Original or Modified Versions, may be sold by itself.\n\n2) Original or Modified Versions of the Font Software may be bundled,\nredistributed and/or sold with any software, provided that each copy\ncontains the above copyright notice and this license. These can be\nincluded either as stand-alone text files, human-readable headers or\nin the appropriate machine-readable metadata fields within text or\nbinary files as long as those fields can be easily viewed by the user.\n\n3) No Modified Version of the Font Software may use the Reserved Font\nName(s) unless explicit written permission is granted by the corresponding\nCopyright Holder. This restriction only applies to the primary font name as\npresented to the users.\n\n4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font\nSoftware shall not be used to promote, endorse or advertise any\nModified Version, except to acknowledge the contribution(s) of the\nCopyright Holder(s) and the Author(s) or with their explicit written\npermission.\n\n5) The Font Software, modified or unmodified, in part or in whole,\nmust be distributed entirely under this license, and must not be\ndistributed under any other license. The requirement for fonts to\nremain under this license does not apply to any document created\nusing the Font Software.\n\nTERMINATION\nThis license becomes null and void if any of the above conditions are\nnot met.\n\nDISCLAIMER\nTHE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\nOF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE\nCOPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nINCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL\nDAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM\nOTHER DEALINGS IN THE FONT SOFTWARE.\n"
  },
  {
    "path": "app/frontend/src/components/About.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <n-flex vertical>\n    <n-flex align=\"center\">\n      <h2>关于</h2>\n    </n-flex>\n  </n-flex>\n  <n-flex align=\"center\">\n\n    <n-form label-placement=\"top\" style=\"text-align: left;\">\n      <n-form-item label=\"项目主页\">\n        <n-button @click=\"BrowserOpenURL(home_url)\" :render-icon=\"renderIcon(HouseTwotone)\">ES-King</n-button>\n      </n-form-item>\n      <n-form-item label=\"推荐同款 Kafka 客户端\">\n        <n-button @click=\"BrowserOpenURL(kafka_home_url)\" :render-icon=\"renderIcon(HouseTwotone)\">KafKa-King</n-button>\n      </n-form-item>\n      <n-form-item label=\"技术交流群\">\n        <n-button :focusable=\"false\" @click=\"openUrl(qq_url)\">点我加群✨</n-button>\n      </n-form-item>\n      <n-form-item label=\"打赏是开源项目生存的动力，谢谢！\">\n        <img src=\"../assets/images/wechat.png\" alt=\"pay\" style=\"width: 200px; height: 200px;\">\n      </n-form-item>\n\n    </n-form>\n  </n-flex>\n</template>\n\n<script setup>\nimport {onMounted} from 'vue'\nimport {NButton, NForm, NFormItem, useMessage,} from 'naive-ui'\nimport {BrowserOpenURL} from \"../../wailsjs/runtime\";\nimport {openUrl, renderIcon} from \"../utils/common\";\nimport {HouseTwotone} from '@vicons/material'\n\nconst kafka_home_url = \"https://github.com/Bronya0/kafka-King\"\nconst qq_url = \"https://qm.qq.com/cgi-bin/qm/qr?k=pDqlVFyLMYEEw8DPJlRSBN27lF8qHV2v&jump_from=webapi&authKey=Wle/K0ARM1YQWlpn6vvfiZuMedy2tT9BI73mUvXVvCuktvi0fNfmNR19Jhyrf2Nz\"\nconst home_url = \"https://github.com/Bronya0/ES-King\"\n\n\n\nonMounted(async () => {\n\n})\n\n\n</script>"
  },
  {
    "path": "app/frontend/src/components/Aside.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <!--  https://www.naiveui.com/zh-CN/os-theme/components/menu-->\n  <n-menu\n      :mode=\"'vertical'\"\n      :value=\"props.value\"\n      @update:value=\"handleMenuSelect\"\n      :options=\"props.options\"\n      style=\"--wails-draggable:no-drag\"\n  />\n\n</template>\n\n\n<script setup>\n\nimport emitter from \"../utils/eventBus\";\n\nconst props = defineProps(['options', 'value']);\n\nconst handleMenuSelect = (key, item) => {\n  emitter.emit('menu_select', key)\n}\n\n</script>\n\n<style>\n\n</style>"
  },
  {
    "path": "app/frontend/src/components/Conn.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <div>\n    <n-flex vertical>\n      <n-flex align=\"center\">\n        <h2>集群</h2>\n        <n-text>共有 {{ esNodes.length }} 个</n-text>\n        <n-button @click=\"addNewNode\" :render-icon=\"renderIcon(AddFilled)\">添加集群</n-button>\n      </n-flex>\n      <n-spin :show=\"spin_loading\" description=\"Connecting...\">\n\n        <n-grid :x-gap=\"12\" :y-gap=\"12\" :cols=\"4\">\n          <n-gi v-for=\"node in esNodes\" :key=\"node.id\">\n            <n-card :title=\"node.name\" @click=\"selectNode(node)\" hoverable class=\"conn_card\">\n\n              <template #header-extra>\n                <n-space>\n                  <n-button @click.stop=\"editNode(node)\" size=\"small\">\n                    编辑\n                  </n-button>\n                  <n-popconfirm @positive-click=\"deleteNode(node.id)\" negative-text=\"取消\" positive-text=\"确定\">\n                    <template #trigger>\n                      <n-button @click.stop size=\"small\">\n                        删除\n                      </n-button>\n                    </template>\n                    确定删除吗？\n                  </n-popconfirm>\n                </n-space>\n              </template>\n              <n-descriptions :column=\"1\" label-placement=\"left\">\n                <n-descriptions-item label=\"主机\">\n                  {{ node.host }}\n                </n-descriptions-item>\n              </n-descriptions>\n            </n-card>\n          </n-gi>\n        </n-grid>\n      </n-spin>\n    </n-flex>\n\n    <n-drawer v-model:show=\"showEditDrawer\" style=\"width: 38.2%\" placement=\"right\">\n      <n-drawer-content :title=\"drawerTitle\">\n        <n-form\n            ref=\"formRef\"\n            :model=\"currentNode\"\n            :rules=\"{\n              name: {required: true, message: '请输入昵称', trigger: 'blur'},\n              host: {required: true, message: '请输入主机地址', trigger: 'blur'},\n              port: {required: true, type: 'number', message: '请输入有效的端口号', trigger: 'blur'},\n            }\"\n            label-placement=\"top\"\n            style=\"text-align: left;\"\n        >\n          <n-form-item label=\"昵称\" path=\"name\">\n            <n-input v-model:value=\"currentNode.name\" placeholder=\"输入节点名称\"/>\n          </n-form-item>\n          <n-form-item label=\"协议://主机:端口\" path=\"host\">\n            <n-input v-model:value=\"currentNode.host\" placeholder=\"输入协议://主机:端口\"/>\n          </n-form-item>\n          <n-form-item label=\"用户名\" path=\"username\">\n            <n-input v-model:value=\"currentNode.username\" placeholder=\"输入用户名\"/>\n          </n-form-item>\n          <n-form-item label=\"密码\" path=\"password\">\n            <n-input\n                v-model:value=\"currentNode.password\"\n                type=\"password\"\n                placeholder=\"输入密码\"\n            />\n          </n-form-item>\n\n          <n-form-item label=\"使用 SSL\" path=\"useSSL\">\n            <n-switch :round=\"false\" v-model:value=\"currentNode.useSSL\"/>\n          </n-form-item>\n\n          <n-form-item label=\"跳过 SSL 验证\" path=\"skipSSLVerify\">\n            <n-switch :round=\"false\" v-model:value=\"currentNode.skipSSLVerify\"/>\n          </n-form-item>\n\n          <n-form-item label=\"CA 证书\" path=\"caCert\">\n            <n-input v-model:value=\"currentNode.caCert\" type=\"textarea\" placeholder=\"输入 CA 证书内容\"/>\n          </n-form-item>\n\n        </n-form>\n        <template #footer>\n          <n-space justify=\"end\">\n            <n-button @click=\"test_connect\" :loading=\"test_connect_loading\">连接测试</n-button>\n            <n-button @click=\"showEditDrawer = false\">取消</n-button>\n            <n-button type=\"primary\" @click=\"saveNode\">保存</n-button>\n          </n-space>\n        </template>\n      </n-drawer-content>\n    </n-drawer>\n  </div>\n</template>\n\n<script setup>\nimport {computed, onMounted, ref} from 'vue'\nimport {useMessage} from 'naive-ui'\nimport {renderIcon} from \"../utils/common\";\nimport {AddFilled} from \"@vicons/material\";\nimport emitter from \"../utils/eventBus\";\nimport {SetConnect, TestClient} from \"../../wailsjs/go/service/ESService\";\nimport {GetConfig, SaveConfig} from \"../../wailsjs/go/config/AppConfig\";\n\n\nconst message = useMessage()\n\nconst esNodes = ref([])\n\nconst showEditDrawer = ref(false)\nconst currentNode = ref({\n  name: '',\n  host: '',\n  port: 9200,\n  username: '',\n  password: '',\n  useSSL: false,\n  skipSSLVerify: false,\n  caCert: ''\n})\nconst isEditing = ref(false)\nconst spin_loading = ref(false)\nconst test_connect_loading = ref(false)\n\nconst drawerTitle = computed(() => isEditing.value ? '编辑连接' : '添加连接')\n\nconst formRef = ref(null)\n\nonMounted(() => {\n  refreshNodeList()\n})\n\nconst refreshNodeList = async () => {\n  spin_loading.value = true\n  const config = await GetConfig()\n  esNodes.value = config.connects.filter(node => node.name !== null && node.name !== \"\")\n  spin_loading.value = false\n}\n\nfunction editNode(node) {\n  currentNode.value = {...node}\n  isEditing.value = true\n  showEditDrawer.value = true\n}\n\nconst addNewNode = async () => {\n  currentNode.value = {}\n  isEditing.value = false\n  showEditDrawer.value = true\n}\n\nconst saveNode = async () => {\n  formRef.value?.validate(async (errors) => {\n    if (!errors) {\n\n      const config = await GetConfig()\n      // edit\n      if (isEditing.value) {\n        const index = esNodes.value.findIndex(node => node.id === currentNode.value.id)\n        if (index !== -1) {\n          esNodes.value[index] = {...currentNode.value}\n        }\n      } else {\n        // add\n        const newId = Math.max(...esNodes.value.map(node => node.id), 0) + 1\n        esNodes.value.push({...currentNode.value, id: newId})\n      }\n\n      // 保存\n      config.connects = esNodes.value\n      await SaveConfig(config)\n      showEditDrawer.value = false\n\n      await refreshNodeList()\n      message.success('保存成功')\n    } else {\n      message.error('请填写所有必填字段')\n    }\n  })\n}\n\nconst deleteNode = async (id) => {\n  console.log(esNodes.value)\n  console.log(id)\n  esNodes.value = esNodes.value.filter(node => node.id !== id)\n  console.log(esNodes.value)\n  const config = await GetConfig()\n  config.connects = esNodes.value\n  await SaveConfig(config)\n  await refreshNodeList()\n  message.success('删除成功')\n}\n\nconst test_connect = async () => {\n  formRef.value?.validate(async (errors) => {\n    if (!errors) {\n\n      test_connect_loading.value = true\n      try {\n        const node = currentNode.value\n        const res = await TestClient(node.host, node.username, node.password, node.caCert, node.useSSL, node.skipSSLVerify)\n        if (res !== \"\") {\n          message.error(\"连接失败：\" + res)\n        } else {\n          message.success('连接成功')\n        }\n      } catch (e) {\n        message.error(e.message)\n      }\n      test_connect_loading.value = false\n\n    } else {\n      message.error('请填写所有必填字段')\n    }\n  })\n}\nconst selectNode = async (node) => {\n  // 这里实现切换菜单的逻辑\n  console.log('选中节点:', node)\n  spin_loading.value = true\n\n  try {\n    const res = await TestClient(node.host, node.username, node.password, node.caCert, node.useSSL, node.skipSSLVerify)\n    if (res !== \"\") {\n      message.error(\"连接失败：\" + res)\n    } else {\n      await SetConnect(node.name, node.host, node.username, node.password, node.caCert, node.useSSL, node.skipSSLVerify)\n      message.success('连接成功')\n      emitter.emit('menu_select', \"节点\")\n      emitter.emit('selectNode', node)\n    }\n  } catch (e) {\n    message.error(e.message)\n  }\n  spin_loading.value = false\n\n}\n</script>\n\n<style>\n\n.lightTheme .conn_card {\n  background-color: #fafafc\n}\n</style>"
  },
  {
    "path": "app/frontend/src/components/Core.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <n-flex vertical>\n    <n-flex align=\"center\">\n      <h2>指标</h2>\n      <n-button @click=\"getData\" text :render-icon=\"renderIcon(RefreshOutlined)\">refresh</n-button>\n\n    </n-flex>\n    <n-spin :show=\"loading\" description=\"Connecting...\">\n      <n-collapse>\n        <n-collapse-item v-for=\"(item_v, item_k) in collapse_item\" :name=\"item_v\" :title=\"item_v\">\n          <n-table :single-line=\"false\" size=\"small\">\n            <thead>\n            <tr>\n              <th>说明</th>\n              <th>值</th>\n              <th>key</th>\n            </tr>\n            </thead>\n            <tbody>\n            <tr v-for=\"(value, key) in filterByKey(data, item_k)\" :key=\"key\">\n              <td>\n                <n-tooltip placement=\"top\" trigger=\"hover\">\n                  <template #trigger>{{ getLabel(key) }}</template>\n                  {{ key }}\n                </n-tooltip>\n              </td>\n              <td>{{ value }}</td>\n              <td>{{ key }}</td>\n            </tr>\n            </tbody>\n          </n-table>\n        </n-collapse-item>\n      </n-collapse>\n\n    </n-spin>\n  </n-flex>\n</template>\n\n\n<script setup>\nimport {onMounted, ref} from \"vue\";\nimport emitter from \"../utils/eventBus\";\nimport {useMessage} from \"naive-ui\";\nimport {GetStats} from \"../../wailsjs/go/service/ESService\";\nimport {flattenObject, renderIcon} from \"../utils/common\";\nimport {RefreshOutlined} from \"@vicons/material\";\n\nconst loading = ref(false)\nconst data = ref({})\nconst message = useMessage()\n\nconst selectNode = async (node) => {\n  await getData()\n}\n\nonMounted(() => {\n  emitter.on('selectNode', selectNode)\n  getData()\n})\n\nconst collapse_item = {\n  'node': '节点',\n  'memory': '内存',\n  'indices': '索引',\n  'doc': '文档',\n  'shard': '分片',\n  'store': '存储'\n}\nconst getData = async () => {\n  loading.value = true\n  const res = await GetStats()\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    data.value = flattenObject(res.result)\n  }\n  console.log(data.value)\n  loading.value = false\n\n}\n// 方法，返回过滤后的数据\nconst filterByKey = (data, searchString) => {\n  const result = {};\n  for (const [key, value] of Object.entries(data)) {\n    if (key.includes(searchString)) {\n      result[key] = value;\n    }\n  }\n  return result;\n};\n\nconst getLabel = (key) => {\n  const descriptions = {\n    \"_nodes.failed\": '报告中失败的节点数量',\n    \"_nodes.successful\": '报告中成功的节点数量',\n    \"_nodes.total\": '集群中报告的总节点数',\n    \"cluster_name\": '集群名称',\n    \"cluster_uuid\": '集群的唯一标识符',\n    \"indices.analysis.analyzer_types\": '分析器类型列表',\n    \"indices.analysis.built_in_analyzers\": '内置分析器列表',\n    \"indices.analysis.built_in_char_filters\": '内置字符过滤器列表',\n    \"indices.analysis.built_in_filters\": '内置过滤器列表',\n    \"indices.analysis.built_in_tokenizers\": '内置分词器列表',\n    \"indices.analysis.char_filter_types\": '字符过滤器类型列表',\n    \"indices.analysis.filter_types\": '过滤器类型列表',\n    \"indices.analysis.tokenizer_types\": '分词器类型列表',\n    \"indices.completion.size_in_bytes\": '完成建议字段使用的内存大小（字节）',\n    \"indices.count\": '索引总数',\n    \"indices.docs.count\": '文档总数',\n    \"indices.docs.deleted\": '已删除文档的数量',\n    \"indices.fielddata.evictions\": '字段数据缓存驱逐次数',\n    \"indices.fielddata.memory_size_in_bytes\": '字段数据缓存使用的内存大小（字节）',\n    \"indices.mappings.field_types\": '映射字段类型列表',\n    \"indices.mappings.runtime_field_types\": '运行时字段类型列表',\n    \"indices.mappings.total_deduplicated_field_count\": '去重后的字段计数',\n    \"indices.mappings.total_deduplicated_mapping_size_in_bytes\": '去重后的映射大小（字节）',\n    \"indices.mappings.total_field_count\": '字段计数',\n    \"indices.query_cache.cache_count\": '查询缓存条目数',\n    \"indices.query_cache.cache_size\": '查询缓存大小',\n    \"indices.query_cache.evictions\": '查询缓存驱逐次数',\n    \"indices.query_cache.hit_count\": '查询缓存命中次数',\n    \"indices.query_cache.memory_size_in_bytes\": '查询缓存使用的内存大小（字节）',\n    \"indices.query_cache.miss_count\": '查询缓存未命中次数',\n    \"indices.query_cache.total_count\": '总的查询缓存条目数',\n    \"indices.segments.count\": '段数',\n    \"indices.segments.doc_values_memory_in_bytes\": '文档值使用的内存大小（字节）',\n    \"indices.segments.fixed_bit_set_memory_in_bytes\": '固定位集使用的内存大小（字节）',\n    \"indices.segments.index_writer_memory_in_bytes\": '索引写入器使用的内存大小（字节）',\n    \"indices.segments.max_unsafe_auto_id_timestamp\": '最大不安全的自动 ID 时间戳',\n    \"indices.segments.memory_in_bytes\": '段使用的内存大小（字节）',\n    \"indices.segments.norms_memory_in_bytes\": '规范化因子使用的内存大小（字节）',\n    \"indices.segments.points_memory_in_bytes\": '点使用的内存大小（字节）',\n    \"indices.segments.stored_fields_memory_in_bytes\": '存储字段使用的内存大小（字节）',\n    \"indices.segments.term_vectors_memory_in_bytes\": '词条向量使用的内存大小（字节）',\n    \"indices.segments.terms_memory_in_bytes\": '词条使用的内存大小（字节）',\n    \"indices.segments.version_map_memory_in_bytes\": '版本映射使用的内存大小（字节）',\n    \"indices.shards.index.primaries.avg\": '主分片平均数量',\n    \"indices.shards.index.primaries.max\": '主分片最大数量',\n    \"indices.shards.index.primaries.min\": '主分片最小数量',\n    \"indices.shards.index.replication.avg\": '副本平均数量',\n    \"indices.shards.index.replication.max\": '副本最大数量',\n    \"indices.shards.index.replication.min\": '副本最小数量',\n    \"indices.shards.index.shards.avg\": '分片平均数量',\n    \"indices.shards.index.shards.max\": '分片最大数量',\n    \"indices.shards.index.shards.min\": '分片最小数量',\n    \"indices.shards.primaries\": '主分片总数',\n    \"indices.shards.replication\": '副本总数',\n    \"indices.shards.total\": '分片总数',\n    \"indices.store.reserved_in_bytes\": '保留的空间大小（字节）',\n    \"indices.store.size_in_bytes\": '存储大小（字节）',\n    \"indices.store.total_data_set_size_in_bytes\": '总数据集大小（字节）',\n    \"indices.versions\": '索引版本信息',\n    \"nodes.count.coordinating_only\": '仅协调节点的数量',\n    \"nodes.count.data\": '数据节点的数量',\n    \"nodes.count.data_cold\": '冷数据节点的数量',\n    \"nodes.count.data_content\": '数据内容节点的数量',\n    \"nodes.count.data_frozen\": '冻结数据节点的数量',\n    \"nodes.count.data_hot\": '热数据节点的数量',\n    \"nodes.count.data_warm\": '温数据节点的数量',\n    \"nodes.count.ingest\": '摄取节点的数量',\n    \"nodes.count.master\": '主节点的数量',\n    \"nodes.count.ml\": '机器学习节点的数量',\n    \"nodes.count.remote_cluster_client\": '远程集群客户端节点的数量',\n    \"nodes.count.transform\": '转换节点的数量',\n    \"nodes.count.voting_only\": '仅投票节点的数量',\n    \"nodes.count.total\": '节点总数',\n    \"nodes.discovery_types.multi_node\": '多节点发现类型数量',\n    \"nodes.fs.available_in_bytes\": '可用磁盘空间大小（字节）',\n    \"nodes.fs.free_in_bytes\": '空闲磁盘空间大小（字节）',\n    \"nodes.fs.total_in_bytes\": '总磁盘空间大小（字节）',\n    \"nodes.indexing_pressure.memory.current.all_in_bytes\": '当前所有索引压力内存大小（字节）',\n    \"nodes.indexing_pressure.memory.current.combined_coordinating_and_primary_in_bytes\": '当前组合协调和主要索引压力内存大小（字节）',\n    \"nodes.indexing_pressure.memory.current.coordinating_in_bytes\": '当前协调索引压力内存大小（字节）',\n    \"nodes.indexing_pressure.memory.current.primary_in_bytes\": '当前主要索引压力内存大小（字节）',\n    \"nodes.indexing_pressure.memory.current.replica_in_bytes\": '当前副本索引压力内存大小（字节）',\n    \"nodes.indexing_pressure.memory.limit_in_bytes\": '索引压力内存限制大小（字节）',\n    \"nodes.indexing_pressure.memory.total.all_in_bytes\": '总计所有索引压力内存大小（字节）',\n    \"nodes.indexing_pressure.memory.total.combined_coordinating_and_primary_in_bytes\": '总计组合协调和主要索引压力内存大小（字节）',\n    \"nodes.indexing_pressure.memory.total.coordinating_in_bytes\": '总计协调索引压力内存大小（字节）',\n    \"nodes.indexing_pressure.memory.total.coordinating_rejections\": '总计协调索引拒绝次数',\n    \"nodes.indexing_pressure.memory.total.primary_in_bytes\": '总计主要索引压力内存大小（字节）',\n    \"nodes.indexing_pressure.memory.total.primary_rejections\": '总计主要索引拒绝次数',\n    \"nodes.indexing_pressure.memory.total.replica_in_bytes\": '总计副本索引压力内存大小（字节）',\n    \"nodes.indexing_pressure.memory.total.replica_rejections\": '总计副本索引拒绝次数',\n    \"nodes.ingest.number_of_pipelines\": '摄取管道数量',\n    \"nodes.jvm.max_uptime_in_millis\": 'JVM 上线时间（毫秒）',\n    \"nodes.jvm.mem.heap_max_in_bytes\": '堆内存最大大小（字节）',\n    \"nodes.jvm.mem.heap_used_in_bytes\": '堆内存使用大小（字节）',\n    \"nodes.jvm.threads\": 'JVM 线程数',\n    \"nodes.jvm.versions\": 'JVM 版本信息',\n    \"nodes.network_types.http_types.netty4\": 'HTTP 类型为 Netty 4 的节点数量',\n    \"nodes.network_types.transport_types.netty4\": '传输类型为 Netty 4 的节点数量',\n    \"nodes.os.allocated_processors\": '分配的处理器数量',\n    \"nodes.os.architectures\": '操作系统架构信息',\n    \"nodes.os.available_processors\": '可用处理器数量',\n    \"nodes.os.mem.adjusted_total_in_bytes\": '调整后的总内存大小（字节）',\n    \"nodes.os.mem.free_in_bytes\": '空闲内存大小（字节）',\n    \"nodes.os.mem.free_percent\": '空闲内存百分比',\n    \"nodes.os.mem.total_in_bytes\": '总内存大小（字节）',\n    \"nodes.os.mem.used_in_bytes\": '使用内存大小（字节）',\n    \"nodes.os.mem.used_percent\": '使用内存百分比',\n    \"nodes.os.names\": '操作系统名称',\n    \"nodes.os.pretty_names\": '操作系统友好名称',\n    \"nodes.packaging_types\": '打包类型信息',\n    \"nodes.plugins\": '插件信息',\n    \"nodes.process.cpu.percent\": 'CPU 使用率',\n    \"nodes.process.open_file_descriptors.avg\": '平均打开的文件描述符数量',\n    \"nodes.process.open_file_descriptors.max\": '最大打开的文件描述符数量',\n    \"nodes.process.open_file_descriptors.min\": '最小打开的文件描述符数量',\n    \"nodes.versions\": '节点版本信息',\n    \"status\": '集群健康状态（绿色、黄色、红色）',\n    \"timestamp\": '报告的时间戳'\n  };\n  return descriptions[key] || '暂无说明'\n}\n\n\n</script>\n\n<style scoped>\n\n</style>"
  },
  {
    "path": "app/frontend/src/components/Header.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <n-page-header style=\"padding: 4px;--wails-draggable:drag\">\n    <template #avatar>\n      <n-avatar :src=\"icon\"/>\n    </template>\n    <template #subtitle>\n      <n-tooltip>\n        <template #trigger>\n          <n-tag v-if=\"subtitle\" :type=title_tag>{{ subtitle }}</n-tag>\n          <n-p v-else>{{ desc }}</n-p>\n        </template>\n        健康：{{ title_tag }}\n      </n-tooltip>\n    </template>\n    <template #title>\n      <div style=\"font-weight: 800\">{{ app_name }}</div>\n    </template>\n    <template #extra>\n      <n-flex justify=\"flex-end\" style=\"--wails-draggable:no-drag\" class=\"right-section\">\n        <n-button quaternary :focusable=\"false\" @click=\"openUrl(qq_url)\">技术交流群</n-button>\n        <!--        <n-button quaternary :focusable=\"false\" @click=\"changeTheme\" :render-icon=\"renderIcon(MoonOrSunnyOutline)\"/>-->\n\n        <n-tooltip placement=\"bottom\" trigger=\"hover\">\n          <template #trigger>\n            <n-button :render-icon=\"renderIcon(HouseTwotone)\" quaternary\n                      @click=\"openUrl(update_url)\"/>\n          </template>\n          <span>主页</span>\n        </n-tooltip>\n\n        <n-tooltip placement=\"bottom\" trigger=\"hover\">\n          <template #trigger>\n            <n-button quaternary :focusable=\"false\" :loading=\"update_loading\" @click=\"checkForUpdates\"\n                      :render-icon=\"renderIcon(SystemUpdateAltSharp)\"/>\n          </template>\n          <span>检查版本：{{ version.tag_name }} {{ check_msg }}</span>\n        </n-tooltip>\n\n        <n-button quaternary :focusable=\"false\" @click=\"minimizeWindow\" :render-icon=\"renderIcon(RemoveOutlined)\"/>\n        <n-button quaternary :focusable=\"false\" @click=\"resizeWindow\" :render-icon=\"renderIcon(MaxMinIcon)\"/>\n        <n-button quaternary class=\"close-btn\" style=\"font-size: 22px\" :focusable=\"false\" @click=\"closeWindow\">\n          <n-icon>\n            <CloseFilled/>\n          </n-icon>\n        </n-button>\n      </n-flex>\n    </template>\n  </n-page-header>\n</template>\n\n<script setup>\nimport {NAvatar, NButton, NFlex, useMessage} from 'naive-ui'\nimport {\n  SystemUpdateAltSharp,\n  RemoveOutlined,\n  CloseFilled,\n  CropSquareFilled,\n  ContentCopyFilled, HouseTwotone\n} from '@vicons/material'\nimport icon from '../assets/images/appicon.png'\nimport {h, onMounted, ref, shallowRef} from \"vue\";\nimport {BrowserOpenURL, Quit, WindowMaximise, WindowMinimise, WindowUnmaximise} from \"../../wailsjs/runtime\";\nimport {CheckUpdate} from '../../wailsjs/go/system/Update'\nimport {useNotification} from 'naive-ui'\nimport {openUrl, renderIcon} from \"../utils/common\";\nimport {GetVersion, GetAppName} from \"../../wailsjs/go/config/AppConfig\";\nimport emitter from \"../utils/eventBus\";\n\n// defineProps(['options', 'value']);\n\n// const MoonOrSunnyOutline = shallowRef(WbSunnyOutlined)\nconst isMaximized = ref(false);\nconst title_tag = ref(\"success\");\nconst check_msg = ref(\"\");\nconst app_name = ref(\"\");\nconst MaxMinIcon = shallowRef(CropSquareFilled)\nconst update_url = \"https://github.com/Bronya0/ES-King/releases\"\nconst qq_url = \"https://qm.qq.com/cgi-bin/qm/qr?k=pDqlVFyLMYEEw8DPJlRSBN27lF8qHV2v&jump_from=webapi&authKey=Wle/K0ARM1YQWlpn6vvfiZuMedy2tT9BI73mUvXVvCuktvi0fNfmNR19Jhyrf2Nz\"\n\nconst update_loading = ref(false)\n// let theme = lightTheme\n\nlet version = ref({\n  tag_name: \"\",\n  body: \"\",\n})\n\nconst desc = \"更人性化的ES GUI \"\nconst subtitle = ref(\"\")\n\nconst notification = useNotification()\nconst message = useMessage()\n\nconst checkForUpdates = async () => {\n  update_loading.value = true\n  try {\n    const v = await GetVersion()\n    const resp = await CheckUpdate()\n    if (!resp) {\n      message.error(\"无法连接github，检查更新失败\")\n    } else if (resp.tag_name !== v) {\n      check_msg.value = '发现新版本 ' + resp.tag_name\n      version.value.body = resp.body\n      const n = notification.success({\n        title: '发现新版本: ' + resp.name,\n        description: resp.body,\n        action: () =>\n            h(NFlex, {justify: \"flex-end\"}, () => [\n              h(\n                  NButton,\n                  {\n                    type: 'primary',\n                    secondary: true,\n                    onClick: () => BrowserOpenURL(update_url),\n                  },\n                  () => \"立即下载\",\n              ),\n              h(\n                  NButton,\n                  {\n                    secondary: true,\n                    onClick: () => {\n                      n.destroy()\n                    },\n                  },\n                  () => \"取消\",\n              ),\n            ]),\n        onPositiveClick: () => BrowserOpenURL(update_url),\n      })\n    }\n  } finally {\n    update_loading.value = false\n  }\n}\n\nonMounted(async () => {\n  emitter.on('selectNode', selectNode)\n  emitter.on('changeTitleType', changeTitleType)\n\n  app_name.value = await GetAppName()\n\n  // const config = await GetConfig()\n  // MoonOrSunnyOutline.value = config.theme === lightTheme.name ? WbSunnyOutlined : NightlightRoundFilled\n  const v = await GetVersion()\n  version.value.tag_name = v\n  subtitle.value = desc + \" \" + v\n  await checkForUpdates()\n\n})\n\nconst selectNode = (node) => {\n  subtitle.value = \"当前集群：【\" + node.name + \"】\"\n}\n\n// 动态修改title的类型\nconst changeTitleType = (type) => {\n  console.log(type)\n  title_tag.value = type\n}\n\nconst minimizeWindow = () => {\n  WindowMinimise()\n}\n\nconst resizeWindow = () => {\n  isMaximized.value = !isMaximized.value;\n  if (isMaximized.value) {\n    WindowMaximise();\n    MaxMinIcon.value = ContentCopyFilled;\n  } else {\n    WindowUnmaximise();\n    MaxMinIcon.value = CropSquareFilled;\n  }\n  console.log(isMaximized.value)\n\n}\n\nconst closeWindow = () => {\n  Quit()\n}\n// const changeTheme = () => {\n//   MoonOrSunnyOutline.value = MoonOrSunnyOutline.value === NightlightRoundFilled ? WbSunnyOutlined : NightlightRoundFilled;\n//   theme = MoonOrSunnyOutline.value === NightlightRoundFilled ? darkTheme : lightTheme\n//   emitter.emit('update_theme', theme)\n// }\n</script>\n\n<style scoped>\n.close-btn:hover {\n  background-color: red;\n}\n\n.right-section .n-button {\n  padding: 0 8px;\n}\n</style>"
  },
  {
    "path": "app/frontend/src/components/Health.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <n-flex vertical>\n    <n-flex align=\"center\">\n      <h2>健康</h2>\n      <n-button :render-icon=\"renderIcon(RefreshOutlined)\" text @click=\"getData\">refresh</n-button>\n\n    </n-flex>\n    <n-spin :show=\"loading\" description=\"Connecting...\">\n\n      <n-table :bordered=\"false\" :single-line=\"false\">\n        <thead>\n        <tr>\n          <th>健康指标</th>\n          <th>值</th>\n          <th>完整键</th>\n        </tr>\n        </thead>\n        <tbody>\n        <tr v-for=\"(value, key) in data\" :key=\"key\">\n          <td>{{ getLabel(key) }}</td>\n          <td>\n            <n-tooltip placement=\"left\" trigger=\"hover\">\n              <template #trigger>\n                <n-tag :type=\"getTagType(key, value)\">\n                  {{ value }}\n                </n-tag>\n              </template>\n              {{ value }}\n            </n-tooltip>\n          </td>\n          <td>{{ key }}</td>\n\n        </tr>\n        </tbody>\n      </n-table>\n    </n-spin>\n  </n-flex>\n\n</template>\n<script setup>\nimport {onActivated, onMounted, ref} from \"vue\";\nimport emitter from \"../utils/eventBus\";\nimport {useMessage} from \"naive-ui\";\nimport {GetHealth} from \"../../wailsjs/go/service/ESService\";\nimport {renderIcon} from \"../utils/common\";\nimport {RefreshOutlined} from \"@vicons/material\";\n\nconst data = ref({})\nconst loading = ref(false)\n\nconst message = useMessage()\n\nconst selectNode = async (node) => {\n  await getData()\n}\n\nonMounted(() => {\n  emitter.on('selectNode', selectNode)\n  getData()\n})\n\n\nconst getData = async () => {\n  loading.value = true\n  const res = await GetHealth()\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    data.value = res.result\n    emitter.emit('changeTitleType', getTagType(\"status\", res.result['status']))\n\n  }\n  console.log(data.value)\n  loading.value = false\n\n}\n\nconst getTagType = (key, value) => {\n  if (['cluster_name'].includes(key)) {\n    return 'success'\n  }\n  if (['unassigned_shards', 'delayed_unassigned_shards', 'initializing_shards'].includes(key)) {\n    return 'warning'\n  }\n\n  if (key === 'timed_out') {\n    return value === true ? 'error' : 'success'\n  }\n  if (key === 'status') {\n    if (value === 'green') {\n      return 'success'\n    } else {\n      return value === 'yellow' ? 'warning' : 'error'\n    }\n  }\n  return 'default'\n}\n\nconst getLabel = (key) => {\n  const descriptions = {\n    cluster_name: '集群名称',\n    status: '集群健康状态（绿色、黄色、红色）',\n    timed_out: '请求是否超时',\n    number_of_nodes: '集群中的节点数',\n    number_of_data_nodes: '集群中的数据节点数',\n    active_primary_shards: '活跃的主分片数',\n    active_shards: '活跃的总分片数',\n    relocating_shards: '正在重新定位的分片数',\n    initializing_shards: '正在初始化的分片数',\n    unassigned_shards: '未分配的分片数',\n    delayed_unassigned_shards: '延迟未分配的分片数',\n    number_of_pending_tasks: '等待中的集群级任务数',\n    number_of_in_flight_fetch: '正在进行的分片数据获取数',\n    task_max_waiting_in_queue_millis: '任务在队列中的最长等待时间（毫秒）',\n    active_shards_percent_as_number: '活跃分片百分比'\n  }\n  return descriptions[key] || '暂无说明'\n}\n\n</script>\n<style scoped>\n\n</style>"
  },
  {
    "path": "app/frontend/src/components/Index.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <n-flex vertical>\n\n    <n-flex align=\"center\">\n      <h2>索引</h2>\n      <n-text>共计{{ data.length }}个</n-text>\n    </n-flex>\n    <n-flex align=\"center\">\n      <n-input v-model:value=\"search_text\" autosize placeholder=\"模糊搜索索引\" style=\"min-width: 20%\"\n               @keydown.enter=\"search\"/>\n\n      <n-button :render-icon=\"renderIcon(SearchFilled)\" @click=\"search\"></n-button>\n      <n-button :render-icon=\"renderIcon(AddFilled)\" @click=\"CreateIndexDrawerVisible = true\">添加索引</n-button>\n      <n-button :render-icon=\"renderIcon(DriveFileMoveTwotone)\" @click=\"downloadAllDataCsv\">导出为csv</n-button>\n      <n-button :render-icon=\"renderIcon(AnnouncementOutlined)\" @click=\"queryAlias\">读取别名</n-button>\n      <n-button :loading=\"downloadIndexConfig.loading\" :render-icon=\"renderIcon(DriveFileMoveTwotone)\"\n                @click=\"downloadIndexConfig.show = true\">\n        索引备份（预览）\n      </n-button>\n\n    </n-flex>\n\n    <n-spin :show=\"loading\" description=\"Connecting...\">\n      <n-data-table\n          ref=\"tableRef\"\n          v-model:checked-row-keys=\"selectedRowKeys\"\n          :bordered=\"false\"\n          :columns=\"refColumns(columns)\"\n          :data=\"data\"\n          :pagination=\"pagination\"\n          :row-key=\"rowKey\"\n          size=\"small\"\n          striped\n      />\n    </n-spin>\n    <n-flex align=\"center\">\n      <n-dropdown :options=\"bulk_options\">\n        <n-button>批量操作</n-button>\n      </n-dropdown>\n      <n-text> 你选中了 {{ selectedRowKeys.length }} 行。</n-text>\n    </n-flex>\n\n    <n-drawer v-model:show=\"downloadIndexConfig.show\" style=\"width: 38.2%\">\n      <n-drawer-content style=\"text-align: left;\" title=\"索引备份（预览）\">\n        <n-flex vertical>\n          <n-p>【当前该功能还在测试中，使用请输入QQ群号，否则无法下载。如果遇到了bug，请反馈给群主。】</n-p>\n          输入要备份的索引名称\n          <n-input v-model:value=\"downloadIndexConfig.indexName\"/>\n          输入查询dsl，不写默认查询所有\n          <n-input v-model:value=\"downloadIndexConfig.dsl\" style=\"min-height: 300px; max-height: 800px;\"\n                   type=\"textarea\"/>\n          测试码，也就是QQ群号，请在github上查看\n          <n-input v-model:value=\"downloadIndexConfig.code\"/>\n          <n-flex align=\"center\">\n            <n-button @click=\"downloadIndexConfig.show = false\">取消</n-button>\n            <n-button :loading=\"downloadIndexConfig.loading\" type=\"primary\" @click=\"downloadIndex\">开始下载</n-button>\n          </n-flex>\n          {{ downloadIndexConfig.msg }}\n        </n-flex>\n      </n-drawer-content>\n    </n-drawer>\n\n\n    <n-drawer v-model:show=\"drawerVisible\" style=\"width: 38.2%\">\n      <n-drawer-content :title=\"drawer_title\" style=\"text-align: left;\">\n        <n-code :code=\"json_data\" language=\"json\" show-line-numbers/>\n      </n-drawer-content>\n    </n-drawer>\n\n    <!--    添加index-->\n    <n-drawer v-model:show=\"CreateIndexDrawerVisible\" style=\"width: 38.2%\">\n      <n-drawer-content style=\"text-align: left;\" title=\"创建索引\">\n        <n-form\n            ref=\"formRef\"\n            :model=\"indexConfig\"\n            :rules=\"{\n              name: {required: true, message: '请输入索引名称', trigger: 'blur'},\n              numberOfShards: {required: true, type: 'number', message: '请输入主分片', trigger: 'blur'},\n              numberOfReplicas: {required: true, type: 'number', message: '请输入副本数量', trigger: 'blur'},\n            }\"\n            label-placement=\"top\"\n            style=\"text-align: left;\"\n        >\n          <n-form-item label=\"索引名称\" path=\"name\">\n            <n-input v-model:value=\"indexConfig.name\"/>\n          </n-form-item>\n          <n-form-item label=\"主分片\" path=\"numberOfShards\">\n            <n-input-number v-model:value=\"indexConfig.numberOfShards\"/>\n          </n-form-item>\n          <n-form-item label=\"副本数量\" path=\"numberOfReplicas\">\n            <n-input-number v-model:value=\"indexConfig.numberOfReplicas\"/>\n          </n-form-item>\n          <n-p>mapping</n-p>\n          <n-form-item path=\"mapping\">\n            <n-input v-model:value=\"indexConfig.mapping\" placeholder='输入mapping的json，例如\n{\n  \"properties\": {\n    \"created_at\": {\n      \"type\": \"date\",\n      \"format\": \"yyyy-MM-dd HH:mm:ss\"\n    }\n  }\n}' style=\"min-height: 300px; max-height: 800px;\"\n                     type=\"textarea\"/>\n          </n-form-item>\n        </n-form>\n        <template #footer>\n          <n-space justify=\"end\">\n            <n-button @click=\"CreateIndexDrawerVisible = false\">取消</n-button>\n            <n-button :loading=\"addIndexLoading\" type=\"primary\" @click=\"addIndex\">保存</n-button>\n          </n-space>\n        </template>\n      </n-drawer-content>\n    </n-drawer>\n\n    <!--    添加doc-->\n    <n-drawer v-model:show=\"addDocDrawerVisible\" style=\"width: 38.2%\">\n      <n-drawer-content style=\"text-align: left;\" title=\"添加文档\">\n        <n-form\n            :model=\"docConfig\"\n            :rules=\"{\n              doc: {required: true, message: '请输入文档内容', trigger: 'blur'},\n            }\"\n            label-placement=\"top\"\n            style=\"text-align: left;\"\n        >\n          <n-form-item label=\"文档内容\" path=\"doc\">\n            <n-input v-model:value=\"docConfig.doc\" placeholder='输入文档的json，例如\n{\n  \"field1\": \"value1\",\n  \"field2\": \"value2\"\n}' style=\"min-height: 300px; max-height: 800px;\"\n                     type=\"textarea\"/>\n          </n-form-item>\n        </n-form>\n        <template #footer>\n          <n-space justify=\"end\">\n            <n-button @click=\"addDocDrawerVisible = false\">取消</n-button>\n            <n-button :loading=\"addDocLoading\" type=\"primary\" @click=\"addDocumentFunc\">保存</n-button>\n          </n-space>\n        </template>\n      </n-drawer-content>\n    </n-drawer>\n  </n-flex>\n</template>\n\n<script setup>\nimport {h, onMounted, ref} from \"vue\";\nimport emitter from \"../utils/eventBus\";\nimport {NButton, NDataTable, NDropdown, NIcon, NTag, NText, useDialog, useMessage} from 'naive-ui'\nimport {\n  createCsvContent,\n  download_file,\n  formatBytes,\n  formattedJson,\n  isValidJson,\n  refColumns,\n  renderIcon\n} from \"../utils/common\";\nimport {AddFilled, AnnouncementOutlined, DriveFileMoveTwotone, MoreVertFilled, SearchFilled} from \"@vicons/material\";\nimport {\n  AddDocument,\n  CacheClear,\n  CreateIndex,\n  DeleteIndex,\n  DownloadESIndex,\n  Flush,\n  GetDoc10,\n  GetIndexAliases,\n  GetIndexes,\n  GetIndexInfo,\n  MergeSegments,\n  OpenCloseIndex,\n  Refresh,\n} from \"../../wailsjs/go/service/ESService\";\n\n// 抽屉的可见性\nconst drawerVisible = ref(false)\nconst CreateIndexDrawerVisible = ref(false)\nconst json_data = ref({})\nconst drawer_title = ref(\"\")\nconst loading = ref(false)\nconst tableRef = ref();\nconst formRef = ref();\nconst indexConfig = ref({\n  name: \"\",\n  numberOfShards: 1,\n  numberOfReplicas: 0,\n  mapping: \"\",\n});\nconst data = ref([])\nconst message = useMessage()\nconst dialog = useDialog()\n\nconst search_text = ref(\"\")\nconst selectedRowKeys = ref([]);\nconst rowKey = (row) => row.index\nlet aliases = {};\n\n\nconst downloadIndexConfig = ref({\n  indexName: \"\",\n  dsl: \"\",\n  loading: false,\n  show: false,\n  code: null,\n  msg: null,\n});\n\nconst downloadIndex = async () => {\n\n  const indexName = downloadIndexConfig.value.indexName; // 或者从其他地方获取\n  const dsl = downloadIndexConfig.value.dsl; // 或者从其他地方获取\n\n  if (!indexName) {\n    message.error(\"请填写索引名\");\n    return;\n  }\n  if (downloadIndexConfig.value.code !== \"964440643\") {\n    message.error(\"群号错误，请在github上查看\");\n    return;\n  }\n  const file_path = `/${indexName}-${Math.floor(Date.now() / 1000)}.json`\n  message.info(\"开始下载，请不要退出...数据json位置：\" + file_path);\n  downloadIndexConfig.value.msg = \"开始下载，请不要退出...数据json位置：\" + file_path;\n\n  downloadIndexConfig.value.loading = true;\n  try {\n    const res = await DownloadESIndex(indexName, dsl, file_path);\n    if (res.err !== \"\") {\n      message.error(res.err);\n      downloadIndexConfig.value.msg = res.err;\n    } else {\n      message.success(\"备份成功\");\n      downloadIndexConfig.value.msg = \"备份成功\";\n      CreateIndexDrawerVisible.value = false;\n    }\n  } catch (e) {\n    message.error(e.message);\n    downloadIndexConfig.value.msg = e;\n  } finally {\n    downloadIndexConfig.value.loading = false;\n  }\n};\n\nconst selectNode = (node) => {\n  data.value = []\n  selectedRowKeys.value = []\n  aliases = []\n}\n\n\nonMounted(() => {\n  emitter.on('selectNode', selectNode)\n})\n\nconst search = async () => {\n  loading.value = true\n  try {\n    await getData(search_text.value)\n  } catch (e) {\n    message.error(e.message)\n  }\n  loading.value = false\n\n}\n\n\nconst cacheData = (indexes) => {\n  // 缓存一下\n  const key = 'es_king_indexes';\n  let values = []\n  const stored = localStorage.getItem(key);\n  if (stored) {\n    values = JSON.parse(stored)\n    for (let v of indexes) {\n      if (!values.includes(v)) {\n        values.push(v);\n      }\n    }\n  }\n  if (values) {\n    localStorage.setItem(key, JSON.stringify(values.slice(-1000)))\n  }\n}\n\nconst getData = async (value) => {\n  const res = await GetIndexes(value)\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    data.value = res.results\n    cacheData(res.results.map(item => item.index))\n  }\n}\n\nconst pageKey = 'esKing:index:pageKey'\nconst pagination = ref({\n  page: 1,\n  pageSize: parseInt(localStorage.getItem(pageKey)) || 10,\n  showSizePicker: true,\n  pageSizes: [5, 10, 15, 20, 25, 30, 40],\n  onChange: (page) => {\n    pagination.value.page = page\n  },\n  onUpdatePageSize: (pageSize) => {\n    pagination.value.pageSize = pageSize\n    pagination.value.page = 1\n    localStorage.setItem(pageKey, pageSize.toString())\n  },\n  itemCount: data.value.length\n})\n\n\nconst getType = (value) => {\n  const type = {\n    \"green\": \"success\",\n    \"yellow\": \"warning\",\n    \"open\": \"success\",\n    \"close\": \"error\",\n  }\n  return type[value] || 'error'\n}\n\nconst columns = [\n  {\n    type: \"selection\",\n  },\n  {\n    title: '索引名',\n    key: 'index',\n    width: 200,\n    render: (row) => h(NText, {\n          type: 'info',\n          style: {cursor: 'pointer'},\n          onClick: () => viewIndexDocs(row)\n        },\n        {default: () => row['index']}\n    )\n  },\n  {title: '别名', key: 'alias',   },\n  {\n    title: '健康',\n    key: 'health',\n    render: (row) => h(NTag, {type: getType(row['health'])}, {default: () => row['health']}),\n  },\n  {\n    title: '状态',\n    key: 'status',\n    render: (row) => h(NTag, {type: getType(row['status'])}, {default: () => row['status']}),\n  },\n  {\n    title: '主分片',\n    key: 'pri',\n    sorter: (a, b) => Number(a['pri']) - Number(b['pri'])\n  },\n  {\n    title: '副本',\n    key: 'rep',\n    sorter: (a, b) => Number(a['rep']) - Number(b['rep'])\n  },\n  {\n    title: '文档总数',\n    key: 'docs.count',\n    sorter: (a, b) => Number(a['docs.count']) - Number(b['docs.count'])\n  },\n  {\n    title: '软删除文档',\n    key: 'docs.deleted',\n    sorter: (a, b) => Number(a['docs.deleted']) - Number(b['docs.deleted'])\n  },\n  {\n    title: '占用存储',\n    key: 'store.size',\n    sorter: (a, b) => Number(a['store.size']) - Number(b['store.size']),\n    render(row) {  // 这里要显示的是label，所以得转换一下\n      return h('span', formatBytes(row['store.size']))\n    }\n  },\n  {\n    title: '操作',\n    key: 'actions',\n    render: (row) => {\n      const options = [\n        {label: '添加文档', key: 'addDocument'},\n        {label: '查看索引构成', key: 'viewDetails'},\n        {label: '别名', key: 'viewAlias'},\n        {label: '查看10条文档', key: 'viewDocs'},\n        {label: '段合并', key: 'mergeSegments'},\n        {label: '删除索引', key: 'deleteIndex'},\n        {label: row.status === 'close' ? '打开索引' : '关闭索引', key: 'openCloseIndex'},\n        {label: 'Refresh', key: 'refresh'},\n        {label: 'Flush', key: 'flush'},\n        {label: '清理缓存', key: 'clearCache'},\n      ]\n      return h(\n          NDropdown,\n          {\n            trigger: 'click',\n            options,\n            onSelect: (key) => handleMenuSelect(key, row)\n          },\n          {\n            default: () => h(\n                NButton,\n                {\n                  strong: true,\n                  secondary: true,\n                },\n                {default: () => '操作', icon: () => h(NIcon, null, {default: () => h(MoreVertFilled)})}\n            )\n          }\n      )\n    }\n  }\n]\n\nconst handleMenuSelect = async (key, row) => {\n  const func = {\n    \"addDocument\": addDocument,\n    \"viewDetails\": viewIndexDetails,\n    \"viewAlias\": viewIndexAlias,\n    \"viewDocs\": viewIndexDocs,\n    \"mergeSegments\": mergeSegments,\n    \"deleteIndex\": deleteIndex,\n    \"refresh\": refreshIndex,\n    \"openCloseIndex\": openCloseIndex,\n    \"flush\": flushIndex,\n    \"clearCache\": clearCache,\n  }\n  loading.value = true\n  try {\n    await func[key](row)\n  } catch (e) {\n    message.error(e.message)\n  }\n  loading.value = false\n}\n\n// 定义各种操作函数\nconst addDocDrawerVisible = ref(false);\nconst addDocLoading = ref(false);\nconst docConfig = ref({\n  index: \"\",\n  doc: \"\",\n});\n\nconst addDocument = async (row) => {\n  addDocDrawerVisible.value = true;\n  docConfig.value.index = row.index;\n}\nconst addDocumentFunc = async () => {\n  if (!docConfig.value.doc) {\n    message.error(\"请输入文档内容\")\n    return\n  }\n  if (!isValidJson(docConfig.value.doc)) {\n    message.error(\"文档内容不是合法的json\")\n    return\n  }\n  addDocLoading.value = true;\n  try {\n    const res = await AddDocument(docConfig.value.index, docConfig.value.doc);\n    console.log(res);\n    if (res.err !== \"\") {\n      message.error(res.err);\n    } else {\n      message.success(`文档添加成功，id：` + res.result['_id']);\n      await search();\n    }\n  } catch (e) {\n    message.error(e.message);\n  } finally {\n    addDocLoading.value = false;\n    addDocDrawerVisible.value = false;\n    docConfig.value.index = \"\";\n    docConfig.value.doc = \"\";\n  }\n}\n\n\nconst viewIndexDetails = async (row) => {\n  const res = await GetIndexInfo(row.index)\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    json_data.value = formattedJson(res.result)\n    drawer_title.value = row.index\n    drawerVisible.value = true\n  }\n}\nconst viewIndexAlias = async (row) => {\n  const res = await GetIndexAliases([row.index])\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    json_data.value = formattedJson(res.result)\n    drawer_title.value = row.index\n    drawerVisible.value = true\n  }\n}\nconst viewIndexDocs = async (row) => {\n  loading.value = true\n  try {\n    const res = await GetDoc10(row.index)\n    if (res.err !== \"\") {\n      message.error(res.err)\n    } else {\n      json_data.value = formattedJson(res.result)\n      drawer_title.value = row.index\n      drawerVisible.value = true\n    }\n  } catch (e) {\n    message.error(e.message)\n  }\n  loading.value = false\n}\nconst mergeSegments = async (row) => {\n  dialog.info({\n    title: '警告',\n    content: `确定要对索引 ${row.index} 执行 段合并 吗？段合并非常耗资源，将提交给ES后台执行`,\n    positiveText: '确定',\n    negativeText: '取消',\n    onPositiveClick: async () => {\n      await mergeSegmentsFunc(row)\n    }\n  })\n}\nconst mergeSegmentsFunc = async (row) => {\n  const res = await MergeSegments(row.index)\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    json_data.value = formattedJson(res.result)\n    drawer_title.value = row.index\n    drawerVisible.value = true\n    message.success(\"已提交段合并请求，段合并是重IO任务，请注意集群负载\")\n    await search()\n  }\n}\nconst deleteIndex = async (row) => {\n  dialog.info({\n    title: '警告',\n    content: `确定要删除索引 ${row.index} 吗？`,\n    positiveText: '确定',\n    negativeText: '取消',\n    onPositiveClick: async () => {\n      await deleteIndexFunc(row)\n    }\n  })\n}\nconst deleteIndexFunc = async (row) => {\n  const res = await DeleteIndex(row.index)\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    json_data.value = formattedJson(res.result)\n    drawer_title.value = row.index\n    drawerVisible.value = true\n    await search()\n\n  }\n}\nconst openCloseIndex = async (row) => {\n  const res = await OpenCloseIndex(row.index, row.status)\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    json_data.value = formattedJson(res.result)\n    drawer_title.value = row.index\n    drawerVisible.value = true\n    await search()\n\n  }\n}\nconst refreshIndex = async (row) => {\n  const res = await Refresh(row.index)\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    json_data.value = formattedJson(res.result)\n    drawer_title.value = row.index\n    drawerVisible.value = true\n  }\n}\nconst flushIndex = async (row) => {\n  const res = await Flush(row.index)\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    json_data.value = formattedJson(res.result)\n    drawer_title.value = row.index\n    drawerVisible.value = true\n  }\n}\nconst clearCache = async (row) => {\n  const res = await CacheClear(row.index)\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    json_data.value = formattedJson(res.result)\n    drawer_title.value = row.index\n    drawerVisible.value = true\n    await search()\n\n  }\n}\nconst addIndexLoading = ref(false)\nconst addIndex = async () => {\n  formRef.value?.validate(async (errors) => {\n    if (!errors) {\n      // 测试mapping有的话，能不能json格式化\n      if (indexConfig.value.mapping) {\n        const err = isValidJson(indexConfig.value.mapping)\n        if (!err) {\n          message.error(\"mapping不是合法的json格式\")\n          return\n        }\n      }\n\n      addIndexLoading.value = true\n      try {\n        const res = await CreateIndex(indexConfig.value.name, indexConfig.value.numberOfShards, indexConfig.value.numberOfReplicas, indexConfig.value.mapping)\n        if (res.err !== \"\") {\n          message.error(res.err)\n        } else {\n          message.success(`索引【${indexConfig.value.name}】创建成功`)\n          await search()\n        }\n      } catch (e) {\n        message.error(e.message)\n      } finally {\n        addIndexLoading.value = false\n        CreateIndexDrawerVisible.value = false\n      }\n    } else {\n      message.error('请填写所有必填字段')\n    }\n  })\n}\n\n// 下载所有数据的 CSV 文件\nconst downloadAllDataCsv = async () => {\n  const csvContent = createCsvContent(data.value, columns)\n  download_file(csvContent, '索引列表.csv', 'text/csv;charset=utf-8;')\n}\nconst queryAlias = async () => {\n  loading.value = true\n  let name_lst = []\n  const start = (pagination.value.page - 1) * pagination.value.pageSize;\n  const end = start + pagination.value.pageSize;\n  let pagedData = data.value.slice(start, end);\n  for (const k in pagedData.value) {\n    name_lst.push(pagedData.value[k].index)\n  }\n  try {\n    const res = await GetIndexAliases(name_lst)\n    if (res.err !== \"\") {\n      loading.value = false\n      message.error(res.err)\n      return\n    }\n    // 合并别名缓存\n    // {\n    //   \"23\": \"xcx\",\n    // }\n    aliases = {...aliases, ...res.result}\n    console.log(aliases)\n    for (const k in data.value) {\n      const alias = aliases[data.value[k].index]\n      if (alias) {\n        data.value[k].alias = alias\n      }\n    }\n  } catch (e) {\n    message.error(e.message)\n  }\n  loading.value = false\n}\n\nconst bulk_delete = async () => {\n  loading.value = true\n  let success_count = 0\n  for (const Key in selectedRowKeys.value) {\n    const res = await DeleteIndex(selectedRowKeys.value[Key])\n    if (res.err !== \"\") {\n      message.error(res.err)\n      break\n    } else {\n      success_count += 1\n    }\n  }\n  // 重置\n  selectedRowKeys.value = []\n  // 提示删除了几个，失败了几个\n  message.success(`成功删除 ${success_count} 个索引`)\n  loading.value = false\n  await search()\n}\nconst bulk_options = [\n  {\n    label: '批量删除',\n    key: 'bulk_delete',\n    props: {\n      onClick: async () => {\n        dialog.info({\n          title: '警告',\n          content: `确定要删除索引 ${selectedRowKeys.value} 吗？`,\n          positiveText: '确定',\n          negativeText: '取消',\n          onPositiveClick: async () => {\n            await bulk_delete()\n          }\n        })\n      }\n    }\n  },\n]\n</script>\n\n\n<style scoped>\n\n</style>"
  },
  {
    "path": "app/frontend/src/components/Nodes.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <n-flex vertical>\n    <n-flex align=\"center\">\n      <h2>节点</h2>\n      <n-button :render-icon=\"renderIcon(RefreshOutlined)\" text @click=\"getData\">refresh</n-button>\n      <n-text>共计{{ data.length }}个</n-text>\n      <n-button :render-icon=\"renderIcon(RefreshOutlined)\" @click=\"getData\">刷新</n-button>\n      <n-button :render-icon=\"renderIcon(DriveFileMoveTwotone)\" @click=\"downloadAllDataCsv\">导出为csv</n-button>\n      <n-text>集群名：{{cluster_name}}</n-text>\n\n    </n-flex>\n    <n-spin :show=\"loading\" description=\"Connecting...\">\n      <n-data-table\n          ref=\"tableRef\"\n          :bordered=\"false\"\n          :columns=\"refColumns(columns)\"\n          :data=\"data\"\n          :pagination=\"pagination\"\n          size=\"small\"\n          striped\n      />\n    </n-spin>\n  </n-flex>\n</template>\n<script setup>\nimport {h, onMounted, ref} from \"vue\";\nimport emitter from \"../utils/eventBus\";\nimport {NButton, NDataTable, NFlex, NProgress, NTag, NText, NTooltip, useMessage} from 'naive-ui'\nimport {\n  createCsvContent,\n  download_file,\n  formatBytes,\n  formatMillis, formatMillisToDays,\n  formatNumber,\n  refColumns,\n  renderIcon\n} from \"../utils/common\";\nimport {DriveFileMoveTwotone, RefreshOutlined} from \"@vicons/material\";\nimport {GetNodes} from \"../../wailsjs/go/service/ESService\";\n\nconst selectNode = async (node) => {\n  await getData()\n}\n\nonMounted(() => {\n  emitter.on('selectNode', selectNode)\n  getData()\n})\n\n\nconst loading = ref(false)\nconst data = ref([])\nconst message = useMessage()\nconst tableRef = ref();\nconst cluster_name = ref(\"\");\n\n\nconst getData = async () => {\n  loading.value = true;\n  const res = await GetNodes();\n  if (res.err !== \"\") {\n    message.error(res.err);\n    loading.value = false;\n    return;\n  }\n  cluster_name.value = res.result.cluster_name;\n  const nodesData = res.result.nodes || {};\n\n  const masterEligibleNodes = Object.keys(nodesData).filter(id => nodesData[id].roles?.includes('master'));\n  const masterNodeId = masterEligibleNodes.length > 0 ? masterEligibleNodes[0] : null;\n\n  const flattenedData = Object.values(nodesData).map(node => {\n    // --- 安全地访问嵌套属性 ---\n    const diskTotal = node.fs?.total?.total_in_bytes ?? 0;\n    const diskFree = node.fs?.total?.available_in_bytes ?? 0;\n    const diskUsedPercent = diskTotal > 0 ? Math.round(((diskTotal - diskFree) / diskTotal) * 100) : 0;\n\n    // 为每个可能缺失的嵌套对象提供一个空对象作为默认值，简化后续访问\n    const indices = node.indices ?? {};\n    const search = indices.search ?? {};\n    const merges = indices.merges ?? {};\n    const query_cache = indices.query_cache ?? {};\n    const fielddata = indices.fielddata ?? {};\n    const request_cache = indices.request_cache ?? {};\n    const segments = indices.segments ?? {};\n    const jvm = node.jvm ?? {};\n    const jvm_mem = jvm.mem ?? {};\n    const os_cpu = node.os?.cpu ?? {};\n    const process = node.process ?? {};\n    const fs_total = node.fs?.total ?? {};\n    const fs_io = node.fs?.io_stats?.total ?? {};\n\n    return {\n      // 基础信息\n      name: node.name,\n      ip: node.transport_address?.split(':')[0] ?? 'N/A',\n      roles: node.roles || [], // 确保 roles 是一个数组\n      is_master: node.name === nodesData[masterNodeId]?.name,\n      uptime: jvm.uptime_in_millis ?? 0,\n\n      // 指标\n      docs_count: indices.docs?.count ?? 0,\n      store_size: indices.store?.size_in_bytes ?? 0,\n      disk_total: diskTotal,\n      disk_percent: diskUsedPercent,\n      load_5m: os_cpu.load_average?.['5m'] ?? 0,\n      heap_used_in_bytes: jvm_mem.heap_used_in_bytes ?? 0,\n      heap_max_in_bytes: jvm_mem.heap_max_in_bytes ?? 0,\n      heap_percent: jvm_mem.heap_used_percent ?? 0,\n      segments_count: segments.count ?? 0,\n\n      // 用于 Tooltip 的原始对象\n      stats_search: {\n        \"查询总数\": formatNumber(search.query_total ?? 0),\n        \"查询耗时\": formatMillis(search.query_time_in_millis ?? 0),\n        \"拉取总数\": formatNumber(search.fetch_total ?? 0),\n        \"拉取耗时\": formatMillis(search.fetch_time_in_millis ?? 0),\n        \"滚动总数\": formatNumber(search.scroll_total ?? 0),\n        \"滚动耗时\": formatMillis(search.scroll_time_in_millis ?? 0),\n      },\n      stats_merges: {\n        \"合并总数\": formatNumber(merges.total ?? 0),\n        \"合并总耗时\": formatMillis(merges.total_time_in_millis ?? 0),\n        \"合并文档总数\": formatNumber(merges.total_docs ?? 0),\n        \"合并总大小\": formatBytes(merges.total_size_in_bytes ?? 0),\n        \"当前合并数\": merges.current ?? 0,\n      },\n      stats_cache: `查询缓存: ${formatBytes(query_cache.memory_size_in_bytes ?? 0)} |\n      Fielddata: ${formatBytes(fielddata.memory_size_in_bytes ?? 0)} |\n      请求缓存: ${formatBytes(request_cache.memory_size_in_bytes ?? 0)} |\n      段内存: ${formatBytes(segments.memory_in_bytes ?? 0)}`,\n\n      stats_cache_size: (query_cache.memory_size_in_bytes ?? 0) + (fielddata.memory_size_in_bytes ?? 0) + (request_cache.memory_size_in_bytes ?? 0)\n          + (segments.memory_in_bytes ?? 0),\n\n      stats_segments: {\n        \"段总数\": formatNumber(segments.count ?? 0),\n        \"总内存占用\": formatBytes(segments.memory_in_bytes ?? 0),\n        \"词项 (Terms)\": formatBytes(segments.terms_memory_in_bytes ?? 0),\n        \"存储字段 (Stored Fields)\": formatBytes(segments.stored_fields_memory_in_bytes ?? 0),\n        \"Doc Values\": formatBytes(segments.doc_values_memory_in_bytes ?? 0),\n        \"Norms\": formatBytes(segments.norms_memory_in_bytes ?? 0),\n      },\n      stats_process: {\n        \"打开文件描述符\": `${formatNumber(process.open_file_descriptors ?? 0)} / ${formatNumber(process.max_file_descriptors ?? 0)}`,\n        \"进程CPU使用率\": `${process.cpu?.percent ?? 0}%`,\n        \"虚拟内存占用\": formatBytes(process.mem?.total_virtual_in_bytes ?? 0),\n      },\n      stats_fs: {\n        \"总空间\": formatBytes(fs_total.total_in_bytes ?? 0),\n        \"可用空间\": formatBytes(fs_total.available_in_bytes ?? 0),\n        \"IO读操作\": formatNumber(fs_io.read_operations ?? 0),\n        \"IO写操作\": formatNumber(fs_io.write_operations ?? 0),\n        \"IO读流量\": formatBytes((fs_io.read_kilobytes ?? 0) * 1024),\n        \"IO写流量\": formatBytes((fs_io.write_kilobytes ?? 0) * 1024),\n      }\n    };\n  });\n\n  flattenedData.sort((a, b) => (b.heap_percent ?? 0) - (a.heap_percent ?? 0));\n  data.value = flattenedData;\n  loading.value = false;\n};\n\n/**\n * 创建一个通用的 Tooltip 内容 VNode\n * @param {object} stats - 键值对对象\n * @param {boolean} formatValueAsBytes - 是否将值格式化为字节\n */\nfunction createTooltipContent(stats, formatValueAsBytes = false) {\n  return h(NFlex, { vertical: true, style: { maxWidth: '400px', textAlign: 'left'} }, () =>\n      Object.entries(stats).map(([key, value]) =>\n          h(NText, null, () => `${key}: ${formatValueAsBytes ? formatBytes(value) : value}`)\n      )\n  );\n}\n\nconst getProgressType = (value) => {\n  const numValue = Number(value)\n  if (numValue < 60) return 'success'\n  if (numValue < 80) return 'warning'\n  return 'error'\n}\n\nconst downloadAllDataCsv = () => {\n  if (!data.value || data.value.length === 0) {\n    message.warning(\"没有数据可以导出\");\n    return;\n  }\n\n  // 1. 定义基础列和需要特殊处理的列\n  const exportColumns = [\n    { title: '节点名', key: 'name' },\n    { title: 'IP', key: 'ip' },\n    { title: '角色', getCsvValue: (row) => row.roles.map(role => roleMap[role.charAt(0)] || role).join('/') },\n    { title: 'CPU负载(5m)', key: 'load_5m' },\n    { title: '运行时间', getCsvValue: (row) => formatMillisToDays(row.uptime) },\n    { title: '总文档数', getCsvValue: (row) => formatNumber(row.docs_count) },\n    { title: '段总数', getCsvValue: (row) => formatNumber(row.segments_count) },\n    { title: '堆内存使用率(%)', key: 'heap_percent' },\n    { title: '堆内存已用', getCsvValue: (row) => formatBytes(row.heap_used_in_bytes) },\n    { title: '堆内存上限', getCsvValue: (row) => formatBytes(row.heap_max_in_bytes) },\n    { title: '磁盘使用率(%)', key: 'disk_percent' },\n    { title: '已用空间', getCsvValue: (row) => formatBytes(row.store_size) },\n    { title: '总空间', getCsvValue: (row) => formatBytes(row.disk_total) },\n    { title: '缓存总大小', getCsvValue: (row) => formatBytes(row.stats_cache_size) },\n    {\n      title: '缓存详情',\n      key: 'stats_cache',\n      // 清理字符串中的换行和多余空格，使其在CSV中保持单行\n      getCsvValue: (row) => (row.stats_cache || '').replace(/\\s+/g, ' ').trim()\n    },\n  ];\n\n  // 2. 动态地将所有嵌套的性能/系统指标对象平铺展开，作为新的列\n  const firstRow = data.value[0];\n  // 定义需要平铺展开的数据对象的键名和在CSV表头中使用的前缀\n  const statsToFlatten = {\n    '搜索': 'stats_search',\n    '合并': 'stats_merges',\n    '段指标': 'stats_segments',\n    '进程': 'stats_process',\n    '文件系统': 'stats_fs',\n  };\n\n  for (const [prefix, dataKey] of Object.entries(statsToFlatten)) {\n    // 确保数据源中存在这个对象\n    if (firstRow && typeof firstRow[dataKey] === 'object' && firstRow[dataKey] !== null) {\n      // 遍历对象中的每一个键（如 \"查询总数\", \"查询耗时\" 等）\n      for (const statKey in firstRow[dataKey]) {\n        exportColumns.push({\n          // CSV 表头，例如：\"搜索 - 查询总数\"\n          title: `${prefix} - ${statKey}`,\n          // 使用闭包来捕获正确的 dataKey 和 statKey，从每一行数据中提取对应的值\n          getCsvValue: (row) => row[dataKey]?.[statKey] ?? '',\n        });\n      }\n    }\n  }\n\n  // 3. 生成并下载 CSV 文件\n  try {\n    const csvContent = createCsvContent(data.value, exportColumns);\n    const fileName = `es-nodes-${cluster_name.value || 'export'}.csv`;\n    download_file(csvContent, fileName, 'text/csv;charset=utf-8;');\n    message.success(\"CSV 文件导出成功\");\n  } catch (e) {\n    message.error(\"导出失败: \" + e.message);\n    console.error(\"CSV export error:\", e);\n  }\n};\n\nconst pagination = ref({\n  page: 1,\n  pageSize: 10,\n  showSizePicker: true,\n  pageSizes: [5, 10, 20, 30, 40],\n  onChange: (page) => {\n    pagination.value.page = page\n  },\n  onUpdatePageSize: (pageSize) => {\n    pagination.value.pageSize = pageSize\n    pagination.value.page = 1\n  },\n})\n\nconst roleMap = { m: '主', d: '数据', i: 'Ingest', l: 'ML', c: '协调', r: '远程', t: '转换', h: '热', w: '温', f: '冻', v: '投票' };\n\nconst columns = [\n  { title: '节点名', key: 'name', width: 120,\n    fixed: 'left' // 可以选择固定列\n  },\n  { title: 'IP', key: 'ip', width: 120 },\n  {\n    title: '角色', key: 'roles', width: 120,\n    render: (row) => row.roles.map(role => roleMap[role.charAt(0)] || role).join('/')\n  },\n  { title: 'CPU负载', key: 'load_5m', width: 100 },\n  {\n    title: '堆内存使用率', key: 'heap_percent', width: 160,\n    render: (row) => h(NFlex, { vertical: true }, () => [\n      h(NText, null, () => `${formatBytes(row.heap_used_in_bytes)} / ${formatBytes(row.heap_max_in_bytes)}`),\n      h(NProgress, { type: \"line\", percentage: row.heap_percent, status: getProgressType(row.heap_percent), indicatorPlacement: 'inside', borderRadius: 4, })\n    ]),\n    sorter: (a, b) => a.heap_percent - b.heap_percent\n  },\n  {\n    title: '磁盘使用率', key: 'disk_percent', width: 160,\n    render: (row) => h(NFlex, { vertical: true }, () => [\n      h(NText, null, () => `${formatBytes(row.store_size)} / ${formatBytes(row.disk_total)}`),\n      h(NProgress, { type: \"line\", percentage: row.disk_percent, status: getProgressType(row.disk_percent), indicatorPlacement: 'inside', borderRadius: 4, })\n    ]),\n    sorter: (a, b) => a.disk_percent - b.disk_percent\n  },\n\n  {\n    title: '性能指标', key: 'perf', width: 160,\n    render: (row) => h(NFlex, null, () => [\n      h(NTooltip, null, { trigger: () => h(NTag, { type: 'primary' }, { default: () => '搜索' }), default: () => createTooltipContent(row.stats_search) }),\n      h(NTooltip, null, { trigger: () => h(NTag, { type: 'primary' }, { default: () => '合并' }), default: () => createTooltipContent(row.stats_merges) }),\n      h(NTooltip, null, { trigger: () => h(NTag, { type: 'primary' }, { default: () => '段' }), default: () => createTooltipContent(row.stats_segments) }),\n    ])\n  },\n  {\n    title: '系统指标', key: 'sys', width: 120,\n    render: (row) => h(NFlex, null, () => [\n      h(NTooltip, null, { trigger: () => h(NTag, { type: 'info' }, { default: () => '进程' }), default: () => createTooltipContent(row.stats_process) }),\n      h(NTooltip, null, { trigger: () => h(NTag, { type: 'info' }, { default: () => 'FS' }), default: () => createTooltipContent(row.stats_fs) }),\n    ])\n  },\n  {title: '缓存', key: 'cache', width: 100, render: (row) => row.stats_cache,\n    sorter: (a, b) => a.stats_cache_size - b.stats_cache_size\n  },\n  {title: '总文档数', key: 'docs_count', width: 100, render: (row) => formatNumber(row.docs_count),},\n  { title: '段总数', key: 'segments_count', width: 100, render: (row) => formatNumber(row.segments_count)},\n  { title: '运行时间', key: 'uptime', width: 90, render: (row) => formatMillisToDays(row.uptime) },\n\n];\n\n\n</script>\n\n\n<style scoped>\n\n</style>"
  },
  {
    "path": "app/frontend/src/components/Rest.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <n-flex vertical>\n    <n-flex align=\"center\">\n      <h2>REST</h2>\n      <n-text>一个 Restful 调试工具，支持格式化/DSL提示补全/示例/下载/历史缓存</n-text>\n    </n-flex>\n    <n-flex align=\"center\">\n      <n-select v-model:value=\"method\" :options=\"methodOptions\" style=\"width: 120px;\"/>\n\n      <div :id=\"ace_editorId\" class=\"ace-editor\" style=\"height:34px;min-width: 46.8%;text-align: left;\n        line-height: 34px;box-sizing: border-box;\"/>\n\n      <n-button :loading=\"send_loading\" :render-icon=\"renderIcon(SendSharp)\" @click=\"sendRequest\">Send</n-button>\n      <n-button :render-icon=\"renderIcon(HistoryOutlined)\" @click=\"showHistoryDrawer = true\">历史记录</n-button>\n      <n-button :render-icon=\"renderIcon(MenuBookTwotone)\" @click=\"showDrawer = true\">ES查询示例</n-button>\n      <n-button :render-icon=\"renderIcon(ArrowDownwardOutlined)\" @click=\"exportJson\">导出结果</n-button>\n    </n-flex>\n    <n-grid :cols=\"2\" x-gap=\"20\">\n      <n-grid-item>\n        <div id=\"json_editor\" class=\"editarea\"\n             style=\"white-space: pre-wrap; white-space-collapse: preserve; border: 0 !important;\"\n             @paste=\"toTree\"></div>\n      </n-grid-item>\n      <n-grid-item>\n        <div id=\"json_view\" class=\"editarea\"></div>\n      </n-grid-item>\n    </n-grid>\n  </n-flex>\n  <!--  示例-->\n  <n-drawer v-model:show=\"showDrawer\" placement=\"right\" style=\"width: 38.2%\">\n    <n-drawer-content style=\"text-align: left;\" title=\"ES DSL查询示例\">\n      <n-flex vertical>\n        <n-collapse>\n          <n-collapse-item name=\"1\" title=\"1. Term查询\">\n            <n-code :code=\"dslExamples.term\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"2\" title=\"2. Terms查询\">\n            <n-code :code=\"dslExamples.terms\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"3\" title=\"3. Match查询\">\n            <n-code :code=\"dslExamples.match\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"4\" title=\"4. Match Phrase查询\">\n            <n-code :code=\"dslExamples.matchPhrase\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"5\" title=\"5. Range查询\">\n            <n-code :code=\"dslExamples.range\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"6\" title=\"6. Bool复合查询\">\n            <n-code :code=\"dslExamples.bool\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"7\" title=\"7. Terms Aggregation\">\n            <n-code :code=\"dslExamples.termsAggs\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"8\" title=\"8. Date Histogram聚合\">\n            <n-code :code=\"dslExamples.dateHistogram\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"9\" title=\"9. Nested查询\">\n            <n-code :code=\"dslExamples.nested\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"10\" title=\"10. Exists查询\">\n            <n-code :code=\"dslExamples.exists\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"11\" title=\"11. Multi-match查询\">\n            <n-code :code=\"dslExamples.multiMatch\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"12\" title=\"12. Wildcard查询\">\n            <n-code :code=\"dslExamples.wildcard\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"13\" title=\"13. Metrics聚合\">\n            <n-code :code=\"dslExamples.metrics\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"14\" title=\"14. Cardinality聚合\">\n            <n-code :code=\"dslExamples.cardinality\" language=\"json\"/>\n          </n-collapse-item>\n\n          <n-collapse-item name=\"15\" title=\"15. Script查询\">\n            <n-code :code=\"dslExamples.script\" language=\"json\"/>\n          </n-collapse-item>\n        </n-collapse>\n      </n-flex>\n    </n-drawer-content>\n  </n-drawer>\n\n  <!-- 历史记录抽屉 -->\n  <n-drawer v-model:show=\"showHistoryDrawer\" style=\"width: 38.2%\">\n    <n-drawer-content title=\"查询历史记录\">\n      <!-- 搜索框 -->\n      <n-input\n          v-model:value=\"searchText\"\n          clearable\n          placeholder=\"搜索历史记录，同时支持method、path、dsl\"\n          style=\"margin-bottom: 12px\"\n      >\n        <template #prefix>\n          <n-icon>\n            <SearchFilled/>\n          </n-icon>\n        </template>\n      </n-input>\n\n      <!-- 历史记录列表 -->\n      <n-list>\n        <n-pagination\n            v-model:page=\"currentPage\"\n            :item-count=\"filteredHistory?.length\"\n            :page-size=\"pageSize\"\n        />\n\n        <n-list-item v-for=\"item in currentPageData\" :key=\"item.timestamp\"\n                     style=\"cursor: pointer;\" @click=\"handleHistoryClick(item.method, item.path, item.dsl)\">\n          <n-tooltip placement=\"left\" style=\"max-height: 618px;overflow-y: auto\" trigger=\"hover\">\n            <template #trigger>\n              <div style=\"display: flex;font-size: 14px; justify-content: space-between;\">\n                <n-tag :type=\"getMethodTagType(item.method)\">\n                  {{ item.method }}\n                </n-tag>\n                <n-text>{{ item.path }}</n-text>\n                <n-text depth=\"3\">\n                  {{ formatTimestamp(item.timestamp) }}\n                </n-text>\n              </div>\n            </template>\n            <n-code v-if=\"item.dsl !== ''\" :code=\"formatDSL(item.dsl)\" language=\"json\" style=\"text-align: left;\"/>\n\n          </n-tooltip>\n        </n-list-item>\n\n      </n-list>\n    </n-drawer-content>\n  </n-drawer>\n</template>\n\n<script setup>\n\nimport {NButton, NGrid, NGridItem, NInput, NSelect, useMessage} from 'naive-ui'\nimport {computed, nextTick, onMounted, ref} from \"vue\";\nimport {Search} from \"../../wailsjs/go/service/ESService\";\nimport {ArrowDownwardOutlined, HistoryOutlined, MenuBookTwotone, SearchFilled, SendSharp} from \"@vicons/material\";\nimport {formatTimestamp, renderIcon} from \"../utils/common\";\nimport {GetConfig, GetHistory, SaveHistory} from \"../../wailsjs/go/config/AppConfig\";\nimport emitter from \"../utils/eventBus\";\n\nimport JSONEditor from 'jsoneditor';\nimport '../assets/css/jsoneditor.min.css'\nimport 'jsoneditor/src/js/ace/theme-jsoneditor';\nimport 'ace-builds/src-noconflict/mode-text'\nimport 'ace-builds/src-noconflict/ext-language_tools'\nimport 'ace-builds/src-noconflict/theme-textmate'\nimport 'ace-builds/src-noconflict/theme-monokai'\nimport ace from 'ace-builds';\n\nconst message = useMessage()\nconst method = ref('POST')\nconst searchText = ref('')\nconst history = ref([])\nconst editor = ref();\nconst response = ref()\nconst send_loading = ref(false)\nconst showDrawer = ref(false)\nconst showHistoryDrawer = ref(false)\n// 状态管理\nconst currentPage = ref(1)\nconst pageSize = ref(10)\n\nconst methodOptions = [\n  {label: 'GET', value: 'GET'},\n  {label: 'POST', value: 'POST'},\n  {label: 'PUT', value: 'PUT'},\n  {label: 'HEAD', value: 'HEAD'},\n  {label: 'PATCH', value: 'PATCH'},\n  {label: 'OPTIONS', value: 'OPTIONS'},\n  {label: 'DELETE', value: 'DELETE'}\n]\n// 自定义自动补全关键词\nconst keywords = [\n  {word: 'query', meta: 'keyword'},           // 查询入口\n  {word: 'bool', meta: 'keyword'},            // 布尔查询\n  {word: 'filter', meta: 'keyword'},          // 过滤条件\n  {word: 'must', meta: 'keyword'},            // 必须匹配\n  {word: 'should', meta: 'keyword'},          // 应该匹配\n  {word: 'must_not', meta: 'keyword'},        // 必须不匹配\n  {word: 'term', meta: 'keyword'},            // 精确匹配查询\n  {word: 'terms', meta: 'keyword'},           // 多值精确匹配查询\n  {word: 'match', meta: 'keyword'},           // 全文匹配查询\n  {word: 'match_phrase', meta: 'keyword'},    // 短语匹配查询\n  {word: 'multi_match', meta: 'keyword'},     // 多字段匹配查询\n  {word: 'range', meta: 'keyword'},           // 范围查询\n  {word: 'exists', meta: 'keyword'},          // 检查字段是否存在\n  {word: 'prefix', meta: 'keyword'},          // 前缀查询\n  {word: 'wildcard', meta: 'keyword'},        // 通配符查询\n  {word: 'regexp', meta: 'keyword'},          // 正则表达式查询\n  {word: 'aggs', meta: 'keyword'},            // 聚合入口\n  {word: 'aggregations', meta: 'keyword'},    // 聚合入口（aggs 的完整形式）\n  {word: 'terms', meta: 'aggregation'},       // 聚合中的 terms（按字段分组）\n  {word: 'sum', meta: 'aggregation'},         // 求和聚合\n  {word: 'avg', meta: 'aggregation'},         // 平均值聚合\n  {word: 'min', meta: 'aggregation'},         // 最小值聚合\n  {word: 'max', meta: 'aggregation'},         // 最大值聚合\n  {word: 'stats', meta: 'aggregation'},       // 统计聚合\n  {word: 'cardinality', meta: 'aggregation'}, // 去重计数聚合\n  {word: 'histogram', meta: 'aggregation'},   // 直方图聚合\n  {word: 'date_histogram', meta: 'aggregation'}, // 日期直方图聚合\n  {word: 'top_hits', meta: 'aggregation'},    // 返回顶部命中文档\n  {word: 'size', meta: 'keyword'},            // 返回结果数量\n  {word: 'from', meta: 'keyword'},            // 分页起始位置\n  {word: 'sort', meta: 'keyword'},            // 排序\n  {word: 'track_total_hits', meta: 'keyword'}, // 跟踪总命中数\n  {word: '_source', meta: 'keyword'},         // 控制返回的字段\n  {word: 'fields', meta: 'keyword'},          // 指定返回字段\n  {word: 'script', meta: 'keyword'},          // 脚本字段\n  {word: 'gte', meta: 'range'},               // 大于等于（范围查询）\n  {word: 'lte', meta: 'range'},               // 小于等于（范围查询）\n  {word: 'gt', meta: 'range'},                // 大于（范围查询）\n  {word: 'lt', meta: 'range'},                // 小于（范围查询）\n  {word: 'boost', meta: 'keyword'},           // 提升权重\n  {word: 'minimum_should_match', meta: 'keyword'}, // should 最小匹配数\n  {word: 'nested', meta: 'keyword'},          // 嵌套对象查询\n  {word: 'path', meta: 'keyword'},            // 嵌套路径\n  {word: 'score_mode', meta: 'keyword'},      // 嵌套查询评分模式\n  {word: 'bucket', meta: 'aggregation'},      // 聚合桶\n  {word: 'order', meta: 'keyword'},           // 排序顺序\n  {word: 'asc', meta: 'sort'},                // 升序\n  {word: 'desc', meta: 'sort'}                // 降序\n];\n\nconst selectNode = (node) => {\n  response.value.setText('{\"tip\": \"响应结果，支持搜索\"}')\n  send_loading.value = false\n}\n\nonMounted(async () => {\n\n  emitter.on('selectNode', selectNode)\n  emitter.on('update_theme', themeChange)\n\n  const loadedConfig = await GetConfig()\n  let theme = 'ace/theme/jsoneditor'\n  if (loadedConfig) {\n    if (loadedConfig.theme !== 'light') {\n      theme = 'ace/theme/monokai'\n    }\n    editor.value = new JSONEditor(document.getElementById('json_editor'), {\n      mode: 'code',\n      ace: ace,\n      theme: theme,\n      mainMenuBar: false,\n      statusBar: false,\n      showPrintMargin: false,\n      placeholder: '请求body'\n    });\n    response.value = new JSONEditor(document.getElementById('json_view'), {\n      mode: 'code',\n      ace: ace,\n      theme: theme,\n      mainMenuBar: false,\n      statusBar: false,\n      showPrintMargin: false,\n    });\n    editor.value.setText(null)\n    editor.value.aceEditor.setOptions({\n      enableBasicAutocompletion: true,\n      enableLiveAutocompletion: true\n    })\n\n    // 自定义补全器\n    const customCompleter = {\n      getCompletions: (editor, session, pos, prefix, callback) => {\n        // 根据前缀过滤关键词\n        const suggestions = keywords\n            .filter(k => k.word.startsWith(prefix))\n            .map(k => ({\n              caption: k.word,\n              value: k.word,\n              meta: k.meta\n            }));\n        callback(null, suggestions);\n      }\n    };\n\n    // 添加自定义补全器\n    editor.value.aceEditor.completers = [customCompleter];\n\n    response.value.setText('{\"tip\": \"响应结果，支持搜索\"}')\n  }\n  await read_history()\n\n  await nextTick()\n  initAce(\"输入rest api，以/开头；查询请用POST请求；GET不会携带body\", loadedConfig.theme)\n  await setAceIndex()\n\n});\n\n\n// =============== ace编辑器 =================\nconst ace_editor = ref(null)\nconst ace_editorId = \"ace-editor\"\n\n// 初始化 Ace 编辑器\nconst initAce = (defaultValue, theme) => {\n  ace.config.set('basePath', '/node_modules/ace-builds/src-noconflict')\n\n  ace_editor.value = ace.edit(document.getElementById(ace_editorId), {\n    mode: `ace/mode/text`,\n    theme: theme === 'light'? 'ace/theme/textmate': 'ace/theme/monokai',\n    placeholder: defaultValue,\n    fontSize: 14,\n    enableBasicAutocompletion: true,\n    enableLiveAutocompletion: true,\n    enableSnippets: true,\n    showLineNumbers: false,\n    maxLines: 1,\n    minLines: 1,\n    showGutter: false,\n    showPrintMargin: false,\n  })\n}\n\nconst getAceValue = () => {\n  return ace_editor.value?.getValue()\n}\n\nconst setAceValue = (newValue) => {\n  ace_editor.value?.setValue(newValue, -1)\n}\n\n// 定义提示词数据\n// const completions = [\n// {\n//   caption: \"console.log\", // 用户看到的名称（可选，如果和 value 相同可以省略）\n//   value: \"console.log(${1:value})\", // 实际插入的值\n//   score: 100, // 权重（越高越靠前）\n//   meta: \"JavaScript\" // 分类（如 \"JavaScript\"、\"CSS\"、\"Custom\"）\n// },\n// ];\nconst setAceCompleter = (completions) => {\n  const customCompleter = {\n    getCompletions: function (editor, session, pos, prefix, callback) {\n      callback(null, completions); // 返回提示词\n    }\n  };\n  // 添加到编辑器的补全器列表\n  ace_editor.value.completers = [customCompleter] // 覆盖默认补全器（不推荐）\n  // ace_editor.value.completers.push(customCompleter); // 追加自定义补全器（推荐）\n}\n// ================ ace编辑器 完结 =================\n\nconst setAceIndex = async () => {\n  const keywords = [\n    '_search',         // 搜索API\n    '_cluster',        // 集群API\n    '_cat',            // Cat API\n    '_nodes',          // 节点信息\n    '_doc',            // 文档操作\n    '_tasks',          // 任务管理\n    '_flush',          // 刷新索引\n    '_refresh',        // 刷新索引数据\n    '_mapping',        // 获取/设置映射\n    '_settings',       // 索引设置\n    '_stats',          // 统计信息\n    '_bulk',           // 批量操作\n    '_update',         // 更新文档\n    '_msearch',        // 多搜索\n    '_alias',          // 别名操作\n    '_rollover',       // 滚动索引\n    '_reindex',        // 重新索引\n    '_snapshot',       // 快照操作\n    '_forcemerge',     // 强制合并段\n    '_indices',        // 索引操作（补充）\n    '_count',          // 计数API（补充）\n    '_validate',       // 查询验证（补充）\n    '_explain',        // 解释查询（补充）\n    '_field_caps',     // 字段能力（补充）\n    '_search_shards',  // 搜索分片信息（补充）\n    '_analyze',        // 分析文本（补充）\n    'pretty',                   // 美化输出\n    'human',                    // 人类可读格式\n    'master_timeout',           // 主节点超时\n    'ignore_unavailable',       // 忽略不可用索引\n    'allow_no_indices',         // 允许无索引\n    'expand_wildcards',         // 通配符扩展\n    'wait_for_active_shards',   // 等待活跃分片\n    'wait_for_completion',      // 等待操作完成\n    'format=json',              // 指定返回格式（通常是单独使用）\n    'size',                    // 返回文档数（补充）\n    'from',                    // 分页起始（补充）\n    'q',                       // 查询字符串（补充）\n    'scroll',                  // 滚动查询（补充）\n    'routing',                 // 路由值（补充）\n    'preference',              // 查询偏好（补充）\n    'timeout',                 // 超时时间（补充）\n    'filter_path',             // 过滤返回字段（补充）\n  ];\n  let completions = [];\n  for (let k of keywords) {\n    completions.push({value: k});\n  }\n  const key = 'es_king_indexes';\n  const stored = localStorage.getItem(key);\n  if (stored) {\n    const values = JSON.parse(stored)\n    for (let v of values) {\n      completions.push({\n        value: v,\n      })\n    }\n  }\n  if (completions.length > 0) {\n    setAceCompleter(completions)\n  }\n}\nconst read_history = async () => {\n  console.log(\"read_history\")\n  try {\n    history.value = await GetHistory()\n  } catch (e) {\n    message.error(e.message)\n  }\n}\nconst write_history = async () => {\n  console.log(\"write_history\")\n  try {\n    // 从左侧插入history\n    history.value.unshift({\n      timestamp: Date.now(),\n      method: method.value,\n      path: getAceValue(),\n      dsl: editor.value.getText()\n    })\n    // 只保留100条\n    if (history.value.length > 100) {\n      history.value = history.value.slice(0, 100)\n    }\n    const res = await SaveHistory(history.value)\n    if (res !== \"\") {\n      message.error(\"保存查询失败：\" + res)\n    }\n  } catch (e) {\n    message.error(e.message)\n  }\n}\n\n// 填充历史记录\nfunction handleHistoryClick(m, p, d) {\n  method.value = m\n  setAceValue(p)\n  editor.value.setText(d)\n  showHistoryDrawer.value = false\n}\n\nfunction themeChange(newTheme) {\n  const new_editor_theme = newTheme.name === 'dark' ? 'ace/theme/monokai' : 'ace/theme/textmate'\n  editor.value.aceEditor.setTheme(new_editor_theme)\n  response.value.aceEditor.setTheme(new_editor_theme)\n  ace_editor.value?.setTheme(new_editor_theme)\n\n}\n\nconst formatDSL = (dsl) => {\n  try {\n    return JSON.stringify(JSON.parse(dsl), null, 2)\n  } catch {\n    return dsl\n  }\n}\n\nconst sendRequest = async () => {\n  send_loading.value = true\n  // 清空response\n  response.value.set({})\n  let path = getAceValue()\n  if (!path.startsWith('/')) {\n    setAceValue('/' + path);\n  }\n  try {\n    const res = await Search(method.value, path, editor.value.getText())\n    // 返回不是200也写入结果框\n    console.log(res)\n    if (res.err !== \"\") {\n      try {\n        response.value.set(JSON.parse(res.err))\n      } catch {\n        response.value.set(res.err)\n      }\n    } else {\n      response.value.set(res.result)\n      // 写入历史记录\n      await write_history()\n    }\n  } catch (e) {\n    message.error(e.message)\n  }\n  send_loading.value = false\n\n}\n\nconst toTree = () => {\n  editor.value.format();\n}\n\n\nfunction exportJson() {\n  const blob = new Blob([response.value.getText()], {type: 'application/json'})\n  const url = URL.createObjectURL(blob)\n  const link = document.createElement('a')\n  link.href = url\n  link.download = 'response.json'\n  document.body.appendChild(link)\n  link.click()\n  document.body.removeChild(link)\n  URL.revokeObjectURL(url)\n}\n\n// 过滤和分页逻辑\nconst filteredHistory = computed(() => {\n  if (!searchText.value) {\n    return history.value\n  } else {\n    return history.value.filter(item => {\n      return item.method.includes(searchText.value) ||\n          item.path.includes(searchText.value) ||\n          item.dsl.includes(searchText.value)\n    })\n  }\n\n})\n\nconst currentPageData = computed(() => {\n  const start = (currentPage.value - 1) * pageSize.value\n  const end = start + pageSize.value\n  return filteredHistory.value.slice(start, end)\n})\n\nconst getMethodTagType = (method) => {\n  const types = {\n    'GET': 'success',\n    'POST': 'info',\n    'PUT': 'warning',\n    'DELETE': 'error'\n  }\n  return types[method] || 'default'\n}\n\nconst dslExamples = {\n  term: JSON.stringify({\n    \"query\": {\n      \"term\": {\n        \"status\": \"active\"\n      }\n    },\n    \"size\": 10,\n    \"track_total_hits\": true\n  }, null, 2),\n\n  terms: JSON.stringify({\n    \"query\": {\n      \"terms\": {\n        \"user_id\": [1, 2, 3, 4]\n      }\n    },\n    \"size\": 10\n  }, null, 2),\n\n  match: JSON.stringify({\n    \"query\": {\n      \"match\": {\n        \"description\": \"quick brown fox\"\n      }\n    },\n    \"size\": 20\n  }, null, 2),\n\n  matchPhrase: JSON.stringify({\n    \"query\": {\n      \"match_phrase\": {\n        \"description\": {\n          \"query\": \"quick brown fox\",\n          \"slop\": 1\n        }\n      }\n    }\n  }, null, 2),\n\n  range: JSON.stringify({\n    \"query\": {\n      \"range\": {\n        \"age\": {\n          \"gte\": 20,\n          \"lte\": 30\n        }\n      }\n    }\n  }, null, 2),\n\n  bool: JSON.stringify({\n    \"query\": {\n      \"bool\": {\n        \"must\": [\n          {\"term\": {\"status\": \"active\"}}\n        ],\n        \"must_not\": [\n          {\"term\": {\"type\": \"deleted\"}}\n        ],\n        \"should\": [\n          {\"term\": {\"category\": \"electronics\"}},\n          {\"term\": {\"category\": \"computers\"}}\n        ],\n        \"minimum_should_match\": 1\n      }\n    }\n  }, null, 2),\n\n  termsAggs: JSON.stringify({\n    \"aggs\": {\n      \"status_counts\": {\n        \"terms\": {\n          \"field\": \"status\",\n          \"missing\": \"N/A\",\n          \"size\": 10\n        }\n      }\n    },\n    \"size\": 0\n  }, null, 2),\n\n  dateHistogram: JSON.stringify({\n    \"aggs\": {\n      \"sales_over_time\": {\n        \"date_histogram\": {\n          \"field\": \"created_at\",\n          \"calendar_interval\": \"1d\",\n          \"format\": \"yyyy-MM-dd\"\n        }\n      }\n    },\n    \"size\": 0\n  }, null, 2),\n\n  nested: JSON.stringify({\n    \"query\": {\n      \"nested\": {\n        \"path\": \"comments\",\n        \"query\": {\n          \"bool\": {\n            \"must\": [\n              {\"match\": {\"comments.text\": \"great\"}},\n              {\"term\": {\"comments.rating\": 5}}\n            ]\n          }\n        }\n      }\n    }\n  }, null, 2),\n\n  exists: JSON.stringify({\n    \"query\": {\n      \"exists\": {\n        \"field\": \"email\"\n      }\n    }\n  }, null, 2),\n\n  multiMatch: JSON.stringify({\n    \"query\": {\n      \"multi_match\": {\n        \"query\": \"quick brown fox\",\n        \"fields\": [\"title\", \"description^2\"],\n        \"type\": \"best_fields\"\n      }\n    }\n  }, null, 2),\n\n  wildcard: JSON.stringify({\n    \"query\": {\n      \"wildcard\": {\n        \"email\": \"*@gmail.com\"\n      }\n    }\n  }, null, 2),\n\n  metrics: JSON.stringify({\n    \"aggs\": {\n      \"avg_price\": {\"avg\": {\"field\": \"price\"}},\n      \"max_price\": {\"max\": {\"field\": \"price\"}},\n      \"min_price\": {\"min\": {\"field\": \"price\"}},\n      \"sum_quantity\": {\"sum\": {\"field\": \"quantity\"}}\n    },\n    \"size\": 0\n  }, null, 2),\n\n  cardinality: JSON.stringify({\n    \"aggs\": {\n      \"unique_users\": {\n        \"cardinality\": {\n          \"field\": \"user_id\",\n          \"precision_threshold\": 100\n        }\n      }\n    },\n    \"size\": 0\n  }, null, 2),\n\n  script: JSON.stringify({\n    \"query\": {\n      \"script_score\": {\n        \"query\": {\"match_all\": {}},\n        \"script\": {\n          \"source\": \"doc['price'].value * doc['rating'].value\",\n          \"lang\": \"painless\"\n        }\n      }\n    }\n  }, null, 2)\n}\n\n</script>\n\n<style>\n.editarea, .json_view {\n  height: 72dvh;\n}\n\n/* 隐藏ace编辑器的脱离聚焦时携带的光标 */\n.ace_editor:not(.ace_focus) .ace_cursor {\n  opacity: 0 !important;\n}\n\n/* 使主题支持placeholder */\n.ace_editor .ace_placeholder {\n  position: absolute;\n  z-index: 10;\n}\n</style>"
  },
  {
    "path": "app/frontend/src/components/Settings.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <!--  https://www.naiveui.com/zh-CN/os-theme/components/form  -->\n  <n-flex justify=\"start\" vertical>\n    <h2>设置</h2>\n    <n-form :model=\"config\" label-placement=\"top\" style=\"text-align: left;\">\n\n      <n-form-item label=\"窗口宽度\">\n        <n-input-number v-model:value=\"config.width\" :max=\"1920\" :min=\"800\" :style=\"{ maxWidth: '120px' }\"/>\n      </n-form-item>\n      <n-form-item label=\"窗口高度\">\n        <n-input-number v-model:value=\"config.height\" :max=\"1080\" :min=\"600\" :style=\"{ maxWidth: '120px' }\"/>\n      </n-form-item>\n      <n-form-item label=\"语言\">\n        <n-select v-model:value=\"config.language\" :options=\"languageOptions\" :style=\"{ maxWidth: '120px' }\"/>\n      </n-form-item>\n\n      <n-form-item label=\"主题\">\n        <n-switch\n            v-model:value=\"theme\"\n            :checked-value=\"darkTheme.name\"\n            :unchecked-value=\"lightTheme.name\"\n            @update:value=\"changeTheme\"\n        >\n          <template #checked-icon>\n            <n-icon :component=\"NightlightRoundFilled\"/>\n          </template>\n          <template #unchecked-icon>\n            <n-icon :component=\"WbSunnyOutlined\"/>\n          </template>\n        </n-switch>\n      </n-form-item>\n      <n-form-item>\n        <n-flex>\n          <n-button strong type=\"primary\" @click=\"saveConfig\">保存设置</n-button>\n          <n-tooltip>\n            <template #trigger>\n              <n-button style=\"width: 100px\" @click=\"getSysInfo()\">ProcessInfo</n-button>\n            </template>\n            <n-p style=\"white-space: pre-wrap; max-height: 400px; overflow: auto; text-align: left\">{{ sys_info }}</n-p>\n          </n-tooltip>\n        </n-flex>\n      </n-form-item>\n\n    </n-form>\n  </n-flex>\n</template>\n\n<script setup>\nimport {onMounted, ref, shallowRef} from 'vue'\nimport {\n  darkTheme,\n  lightTheme,\n  NButton,\n  NForm,\n  NFormItem,\n  NInputNumber,\n  NSelect,\n  useMessage,\n} from 'naive-ui'\nimport {WbSunnyOutlined, NightlightRoundFilled} from '@vicons/material'\n\nimport {GetConfig, SaveConfig} from '../../wailsjs/go/config/AppConfig'\nimport {GetProcessInfo} from '../../wailsjs/go/system/Update'\n\nimport {WindowSetSize} from \"../../wailsjs/runtime\";\nimport emitter from \"../utils/eventBus\";\n\nconst message = useMessage()\nlet theme = lightTheme.name\nconst sys_info = ref(\"\")\n\n\nconst config = ref({\n  width: 1248,\n  height: 768,\n  language: 'zh-CN',\n  theme: theme,\n})\nconst languageOptions = [\n  {label: '中文', value: 'zh-CN'},\n  {label: 'English', value: 'en-US'}\n]\n\nconst getSysInfo = async () => {\n  sys_info.value = await GetProcessInfo()\n}\n\nonMounted(async () => {\n  console.info(\"初始化settings……\")\n\n  // 从后端加载配置\n  const loadedConfig = await GetConfig()\n  console.log(loadedConfig)\n  if (loadedConfig) {\n    config.value = loadedConfig\n    theme = loadedConfig.theme\n  }\n  await getSysInfo()\n\n})\n\n\nconst saveConfig = async () => {\n  config.value.theme = theme\n  const err = await SaveConfig(config.value)\n  if (err !== \"\") {\n    message.error(\"保存失败：\" + err)\n    return\n  }\n\n  WindowSetSize(config.value.width, config.value.height)\n\n  emitter.emit('update_theme', theme)\n  // 可以添加保存成功的提示\n  message.success(\"保存成功\")\n  config.value = await GetConfig()\n\n}\n\nconst changeTheme = () => {\n  emitter.emit('update_theme', theme)\n}\n\n\n</script>"
  },
  {
    "path": "app/frontend/src/components/Snapshot.vue",
    "content": "<template>\n  <n-flex vertical>\n    <n-flex align=\"center\">\n      <h2>快照管理</h2>\n    </n-flex>\n    <n-tabs type=\"line\" animated>\n      <!-- ==================== Tab 1: 仓库管理 ==================== -->\n      <n-tab-pane name=\"repo\" tab=\"仓库管理\">\n        <n-flex vertical>\n          <n-flex align=\"center\">\n            <n-button :render-icon=\"renderIcon(RefreshOutlined)\" text @click=\"getRepos\">刷新</n-button>\n            <n-button :render-icon=\"renderIcon(AddFilled)\" @click=\"repoForm.show = true\">创建仓库</n-button>\n          </n-flex>\n          <n-spin :show=\"repoLoading\" description=\"加载中...\">\n            <n-data-table\n                :bordered=\"false\"\n                :columns=\"repoColumns\"\n                :data=\"repoData\"\n                :max-height=\"550\"\n                size=\"small\"\n                striped\n            />\n          </n-spin>\n        </n-flex>\n\n        <!-- 创建仓库弹窗 -->\n        <n-modal v-model:show=\"repoForm.show\" preset=\"dialog\" title=\"创建快照仓库\" positive-text=\"确认\" negative-text=\"取消\"\n                 @positive-click=\"handleCreateRepo\">\n          <n-form label-placement=\"left\" label-width=\"auto\">\n            <n-form-item label=\"仓库名称\">\n              <n-input v-model:value=\"repoForm.name\" placeholder=\"my_backup_repo\"/>\n            </n-form-item>\n            <n-form-item label=\"仓库类型\">\n              <n-select v-model:value=\"repoForm.type\" :options=\"repoTypeOptions\"/>\n            </n-form-item>\n            <n-form-item label=\"Settings (JSON)\">\n              <n-input v-model:value=\"repoForm.settings\" type=\"textarea\" :autosize=\"{minRows: 3, maxRows: 8}\"\n                       placeholder='例如: {\"location\": \"/mount/backups/my_backup\"}' />\n            </n-form-item>\n          </n-form>\n        </n-modal>\n      </n-tab-pane>\n\n      <!-- ==================== Tab 2: 快照管理 ==================== -->\n      <n-tab-pane name=\"snapshot\" tab=\"快照管理\">\n        <n-flex vertical>\n          <n-flex align=\"center\">\n            <n-button :render-icon=\"renderIcon(RefreshOutlined)\" text @click=\"getSnapshots\">刷新</n-button>\n            <n-button :render-icon=\"renderIcon(AddFilled)\" @click=\"snapForm.show = true\">创建快照</n-button>\n          </n-flex>\n          <n-spin :show=\"snapLoading\" description=\"加载中...\">\n            <n-data-table\n                :bordered=\"false\"\n                :columns=\"snapColumns\"\n                :data=\"snapData\"\n                :max-height=\"550\"\n                size=\"small\"\n                striped\n            />\n          </n-spin>\n        </n-flex>\n\n        <!-- 创建快照弹窗 -->\n        <n-modal v-model:show=\"snapForm.show\" preset=\"dialog\" title=\"创建快照\" positive-text=\"确认\" negative-text=\"取消\"\n                 @positive-click=\"handleCreateSnapshot\">\n          <n-form label-placement=\"left\" label-width=\"auto\">\n            <n-form-item label=\"仓库\">\n              <n-select v-model:value=\"snapForm.repository\" :options=\"repoSelectOptions\"\n                        placeholder=\"选择目标仓库\"/>\n            </n-form-item>\n            <n-form-item label=\"快照名称\">\n              <n-input v-model:value=\"snapForm.snapshot\" placeholder=\"snapshot_2026\"/>\n            </n-form-item>\n            <n-form-item label=\"索引（逗号分隔）\">\n              <n-input v-model:value=\"snapForm.indices\" placeholder=\"留空表示所有索引\"/>\n            </n-form-item>\n            <n-form-item label=\"包含全局状态\">\n              <n-switch v-model:value=\"snapForm.includeGlobalState\"/>\n            </n-form-item>\n          </n-form>\n          <n-text depth=\"3\">快照将在后台异步创建，可在快照列表中查看进度。</n-text>\n        </n-modal>\n\n        <!-- 快照详情弹窗 -->\n        <n-modal v-model:show=\"snapDetail.show\" preset=\"card\" title=\"快照详情\" style=\"width: 600px;\">\n          <n-code :code=\"snapDetail.content\" language=\"json\" show-line-numbers/>\n        </n-modal>\n      </n-tab-pane>\n\n      <!-- ==================== Tab 3: 快照恢复 ==================== -->\n      <n-tab-pane name=\"restore\" tab=\"快照恢复\">\n        <n-flex vertical>\n          <n-flex align=\"center\">\n            <n-button :render-icon=\"renderIcon(RefreshOutlined)\" text @click=\"getRestoreStatus\">刷新恢复状态\n            </n-button>\n          </n-flex>\n\n          <n-card title=\"恢复快照\" size=\"small\">\n            <n-form label-placement=\"left\" label-width=\"auto\">\n              <n-form-item label=\"仓库\">\n                <n-select v-model:value=\"restoreForm.repository\" :options=\"repoSelectOptions\"\n                          placeholder=\"选择仓库\" @update:value=\"onRestoreRepoChange\"/>\n              </n-form-item>\n              <n-form-item label=\"快照\">\n                <n-select v-model:value=\"restoreForm.snapshot\" :options=\"restoreSnapOptions\"\n                          placeholder=\"选择要恢复的快照\"/>\n              </n-form-item>\n              <n-form-item label=\"索引（逗号分隔）\">\n                <n-input v-model:value=\"restoreForm.indices\" placeholder=\"留空表示恢复所有索引\"/>\n              </n-form-item>\n              <n-form-item label=\"重命名模式\">\n                <n-input v-model:value=\"restoreForm.renamePattern\" placeholder=\"例如: index_(.+)\"/>\n              </n-form-item>\n              <n-form-item label=\"重命名替换\">\n                <n-input v-model:value=\"restoreForm.renameReplacement\" placeholder=\"例如: restored_index_$1\"/>\n              </n-form-item>\n              <n-form-item label=\"包含全局状态\">\n                <n-switch v-model:value=\"restoreForm.includeGlobalState\"/>\n              </n-form-item>\n            </n-form>\n            <n-flex>\n              <n-button type=\"warning\" @click=\"handleRestore\">执行恢复</n-button>\n            </n-flex>\n            <n-text depth=\"3\">\n              警告：恢复操作会在集群中创建索引，如果同名索引已存在且未关闭，恢复将失败。建议使用重命名功能避免冲突。\n            </n-text>\n          </n-card>\n\n          <n-card title=\"恢复进度\" size=\"small\" style=\"margin-top: 12px;\">\n            <n-spin :show=\"restoreStatusLoading\">\n              <n-data-table\n                  :bordered=\"false\"\n                  :columns=\"restoreColumns\"\n                  :data=\"restoreStatusData\"\n                  :max-height=\"300\"\n                  size=\"small\"\n                  striped\n              />\n            </n-spin>\n          </n-card>\n        </n-flex>\n      </n-tab-pane>\n\n      <!-- ==================== Tab 4: 自动策略 (SLM) ==================== -->\n      <n-tab-pane name=\"slm\" tab=\"自动策略 (SLM)\">\n        <n-flex vertical>\n          <n-flex align=\"center\">\n            <n-button :render-icon=\"renderIcon(RefreshOutlined)\" text @click=\"getSLMPolicies\">刷新</n-button>\n            <n-button :render-icon=\"renderIcon(AddFilled)\" @click=\"slmForm.show = true\">创建策略</n-button>\n          </n-flex>\n          <n-spin :show=\"slmLoading\" description=\"加载中...\">\n            <n-data-table\n                :bordered=\"false\"\n                :columns=\"slmColumns\"\n                :data=\"slmData\"\n                :max-height=\"550\"\n                size=\"small\"\n                striped\n            />\n          </n-spin>\n        </n-flex>\n\n        <!-- 创建SLM策略弹窗 -->\n        <n-modal v-model:show=\"slmForm.show\" preset=\"dialog\" title=\"创建自动快照策略\" positive-text=\"确认\"\n                 negative-text=\"取消\" @positive-click=\"handleCreateSLM\" style=\"width: 520px;\">\n          <n-form label-placement=\"left\" label-width=\"auto\">\n            <n-form-item label=\"策略ID\">\n              <n-input v-model:value=\"slmForm.policyId\" placeholder=\"daily-snapshots\"/>\n            </n-form-item>\n            <n-form-item label=\"快照名称模板\">\n              <n-input v-model:value=\"slmForm.name\" placeholder=\"<daily-snap-{now/d}>\"/>\n            </n-form-item>\n            <n-form-item label=\"Cron 调度\">\n              <n-input v-model:value=\"slmForm.schedule\" placeholder=\"0 30 1 * * ?（每天凌晨1:30）\"/>\n            </n-form-item>\n            <n-form-item label=\"仓库\">\n              <n-select v-model:value=\"slmForm.repository\" :options=\"repoSelectOptions\"\n                        placeholder=\"目标仓库\"/>\n            </n-form-item>\n            <n-form-item label=\"索引（逗号分隔）\">\n              <n-input v-model:value=\"slmForm.indices\" placeholder=\"留空表示所有索引\"/>\n            </n-form-item>\n            <n-form-item label=\"过期时间\">\n              <n-input v-model:value=\"slmForm.expireAfter\" placeholder=\"例如: 30d\"/>\n            </n-form-item>\n            <n-form-item label=\"最少保留数\">\n              <n-input-number v-model:value=\"slmForm.minCount\" :min=\"0\" placeholder=\"5\"/>\n            </n-form-item>\n            <n-form-item label=\"最多保留数\">\n              <n-input-number v-model:value=\"slmForm.maxCount\" :min=\"0\" placeholder=\"50\"/>\n            </n-form-item>\n          </n-form>\n        </n-modal>\n      </n-tab-pane>\n    </n-tabs>\n  </n-flex>\n</template>\n\n<script setup>\nimport {computed, h, onMounted, ref} from \"vue\";\nimport emitter from \"../utils/eventBus\";\nimport {NButton, NTag, useDialog, useMessage} from 'naive-ui'\nimport {renderIcon} from \"../utils/common\";\nimport {RefreshOutlined, AddFilled, DeleteOutlined, PlayArrowFilled, VisibilityOutlined, VerifiedOutlined} from \"@vicons/material\";\nimport {\n  GetSnapshots, GetSnapshotRepositories, CreateSnapshotRepository, DeleteSnapshotRepository,\n  VerifySnapshotRepository, CreateSnapshot, DeleteSnapshot, GetSnapshotDetail,\n  RestoreSnapshot, GetSnapshotRestoreStatus,\n  GetSLMPolicies, CreateSLMPolicy, DeleteSLMPolicy, ExecuteSLMPolicy,\n} from \"../../wailsjs/go/service/ESService\";\n\nconst message = useMessage()\nconst dialog = useDialog()\n\n// ==================== 仓库管理 ====================\nconst repoLoading = ref(false)\nconst repoData = ref([])\n\nconst repoForm = ref({\n  show: false,\n  name: '',\n  type: 'fs',\n  settings: '',\n})\n\nconst repoTypeOptions = [\n  {label: 'fs（共享文件系统）', value: 'fs'},\n  {label: 's3（AWS S3）', value: 's3'},\n  {label: 'hdfs（Hadoop HDFS）', value: 'hdfs'},\n  {label: 'azure（Azure Blob）', value: 'azure'},\n  {label: 'gcs（Google Cloud Storage）', value: 'gcs'},\n  {label: 'url（只读URL）', value: 'url'},\n]\n\nconst repoSelectOptions = computed(() => {\n  return repoData.value.map(r => ({label: r.name, value: r.name}))\n})\n\nconst getRepos = async () => {\n  repoLoading.value = true\n  const res = await GetSnapshotRepositories()\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    repoData.value = res.results || []\n  }\n  repoLoading.value = false\n}\n\nconst handleCreateRepo = async () => {\n  const res = await CreateSnapshotRepository(repoForm.value.name, repoForm.value.type, repoForm.value.settings)\n  if (res.err !== \"\") {\n    message.error(res.err)\n    return false\n  }\n  message.success(\"仓库创建成功\")\n  repoForm.value = {show: false, name: '', type: 'fs', settings: ''}\n  await getRepos()\n}\n\nconst handleDeleteRepo = (name) => {\n  dialog.warning({\n    title: '确认删除仓库',\n    content: `确定要删除快照仓库「${name}」吗？这不会删除仓库中已有的快照数据文件，但会从ES集群中移除此仓库的注册信息。`,\n    positiveText: '确认删除',\n    negativeText: '取消',\n    onPositiveClick: async () => {\n      const res = await DeleteSnapshotRepository(name)\n      if (res.err !== \"\") {\n        message.error(res.err)\n      } else {\n        message.success(\"仓库已删除\")\n        await getRepos()\n      }\n    }\n  })\n}\n\nconst handleVerifyRepo = async (name) => {\n  const res = await VerifySnapshotRepository(name)\n  if (res.err !== \"\") {\n    message.error(\"验证失败: \" + res.err)\n  } else {\n    message.success(\"仓库验证通过，所有节点可访问\")\n  }\n}\n\nconst repoColumns = [\n  {title: '仓库名称', key: 'name', width: 180},\n  {title: '类型', key: 'type', width: 100},\n  {\n    title: 'Settings',\n    key: 'settings',\n    render: (row) => row.settings ? JSON.stringify(row.settings) : '-',\n  },\n  {\n    title: '操作',\n    key: 'actions',\n    width: 180,\n    render: (row) => h('div', {style: 'display: flex; gap: 8px;'}, [\n      h(NButton, {size: 'small', quaternary: true, type: 'info', onClick: () => handleVerifyRepo(row.name)},\n          {default: () => '验证'}),\n      h(NButton, {size: 'small', quaternary: true, type: 'error', onClick: () => handleDeleteRepo(row.name)},\n          {default: () => '删除'}),\n    ])\n  }\n]\n\n// ==================== 快照管理 ====================\nconst snapLoading = ref(false)\nconst snapData = ref([])\n\nconst snapForm = ref({\n  show: false,\n  repository: null,\n  snapshot: '',\n  indices: '',\n  includeGlobalState: false,\n})\n\nconst snapDetail = ref({\n  show: false,\n  content: '',\n})\n\nconst getSnapshots = async () => {\n  snapLoading.value = true\n  const res = await GetSnapshots()\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    snapData.value = (res.results || []).sort((a, b) => {\n      return (b.start_time || '').localeCompare(a.start_time || '')\n    })\n  }\n  snapLoading.value = false\n}\n\nconst handleCreateSnapshot = async () => {\n  const res = await CreateSnapshot(\n      snapForm.value.repository,\n      snapForm.value.snapshot,\n      snapForm.value.indices,\n      snapForm.value.includeGlobalState\n  )\n  if (res.err !== \"\") {\n    message.error(res.err)\n    return false\n  }\n  message.success(\"快照开始创建（异步），请稍后刷新查看状态\")\n  snapForm.value = {show: false, repository: null, snapshot: '', indices: '', includeGlobalState: false}\n  await getSnapshots()\n}\n\nconst handleDeleteSnapshot = (repository, snapshot) => {\n  dialog.warning({\n    title: '确认删除快照',\n    content: `确定要删除仓库「${repository}」中的快照「${snapshot}」吗？此操作不可逆。`,\n    positiveText: '确认删除',\n    negativeText: '取消',\n    onPositiveClick: async () => {\n      const res = await DeleteSnapshot(repository, snapshot)\n      if (res.err !== \"\") {\n        message.error(res.err)\n      } else {\n        message.success(\"快照已删除\")\n        await getSnapshots()\n      }\n    }\n  })\n}\n\nconst handleViewDetail = async (repository, snapshot) => {\n  const res = await GetSnapshotDetail(repository, snapshot)\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    snapDetail.value.content = JSON.stringify(res.result, null, 2)\n    snapDetail.value.show = true\n  }\n}\n\nconst snapColumns = [\n  {title: '快照名称', key: 'snapshot', width: 160},\n  {title: '仓库', key: 'repository', width: 130},\n  {\n    title: '状态', key: 'state', width: 110,\n    render: (row) => h(NTag, {\n      size: 'small',\n      type: row.state === 'SUCCESS' ? \"success\" : row.state === 'FAILED' ? \"error\" :\n          row.state === 'IN_PROGRESS' ? \"info\" : \"warning\"\n    }, {default: () => row.state}),\n  },\n  {title: '开始时间', key: 'start_time', width: 170},\n  {title: '结束时间', key: 'end_time', width: 170},\n  {\n    title: '索引数', key: 'indices', width: 80,\n    render: (row) => Array.isArray(row.indices) ? row.indices.length : 0,\n  },\n  {title: '总分片', key: 'total_shards', width: 80},\n  {title: '成功分片', key: 'successful_shards', width: 90},\n  {\n    title: '操作', key: 'actions', width: 200,\n    render: (row) => h('div', {style: 'display: flex; gap: 8px;'}, [\n      h(NButton, {\n        size: 'small', quaternary: true, type: 'info',\n        onClick: () => handleViewDetail(row.repository, row.snapshot)\n      }, {default: () => '详情'}),\n      h(NButton, {\n        size: 'small', quaternary: true, type: 'warning',\n        onClick: () => {\n          restoreForm.value.repository = row.repository\n          restoreForm.value.snapshot = row.snapshot\n          // 切换到恢复tab (让用户确认参数后手动执行)\n          message.info('已填入恢复参数，请切换到「快照恢复」标签页确认并执行')\n        }\n      }, {default: () => '恢复'}),\n      h(NButton, {\n        size: 'small', quaternary: true, type: 'error',\n        onClick: () => handleDeleteSnapshot(row.repository, row.snapshot)\n      }, {default: () => '删除'}),\n    ])\n  }\n]\n\n// ==================== 快照恢复 ====================\nconst restoreForm = ref({\n  repository: null,\n  snapshot: null,\n  indices: '',\n  renamePattern: '',\n  renameReplacement: '',\n  includeGlobalState: false,\n})\n\nconst restoreSnapOptions = ref([])\nconst restoreStatusLoading = ref(false)\nconst restoreStatusData = ref([])\n\nconst onRestoreRepoChange = async (repo) => {\n  restoreSnapOptions.value = []\n  restoreForm.value.snapshot = null\n  // 如果数据为空则先获取，再统一过滤\n  if (snapData.value.length === 0) {\n    await getSnapshots()\n  }\n  const filtered = snapData.value.filter(s => s.repository === repo && s.state === 'SUCCESS')\n  restoreSnapOptions.value = filtered.map(s => ({label: s.snapshot, value: s.snapshot}))\n}\n\nconst handleRestore = () => {\n  if (!restoreForm.value.repository || !restoreForm.value.snapshot) {\n    message.warning(\"请选择仓库和快照\")\n    return\n  }\n  dialog.warning({\n    title: '确认恢复快照',\n    content: `即将从仓库「${restoreForm.value.repository}」恢复快照「${restoreForm.value.snapshot}」。恢复操作会在集群中创建索引数据，请确认已了解影响。`,\n    positiveText: '确认恢复',\n    negativeText: '取消',\n    onPositiveClick: async () => {\n      const res = await RestoreSnapshot(\n          restoreForm.value.repository,\n          restoreForm.value.snapshot,\n          restoreForm.value.indices,\n          restoreForm.value.renamePattern,\n          restoreForm.value.renameReplacement,\n          restoreForm.value.includeGlobalState\n      )\n      if (res.err !== \"\") {\n        message.error(res.err)\n      } else {\n        message.success(\"恢复任务已提交（异步执行），请查看恢复进度\")\n        await getRestoreStatus()\n      }\n    }\n  })\n}\n\nconst getRestoreStatus = async () => {\n  restoreStatusLoading.value = true\n  const res = await GetSnapshotRestoreStatus()\n  if (res.err !== \"\") {\n    message.error(res.err)\n    restoreStatusData.value = []\n  } else {\n    // _recovery 返回 {indexName: {shards: [...]}, ...}\n    const data = res.result || {}\n    const list = []\n    for (const [indexName, info] of Object.entries(data)) {\n      const shards = info.shards || []\n      for (const shard of shards) {\n        list.push({\n          index: indexName,\n          shard: shard.id,\n          type: shard.type,\n          stage: shard.stage,\n          source_node: shard.source?.name || '-',\n          target_node: shard.target?.name || '-',\n          bytes_recovered: shard.index?.size?.recovered_in_bytes || 0,\n          bytes_total: shard.index?.size?.total_in_bytes || 0,\n        })\n      }\n    }\n    restoreStatusData.value = list\n  }\n  restoreStatusLoading.value = false\n}\n\nconst restoreColumns = [\n  {title: '索引', key: 'index', width: 160},\n  {title: '分片', key: 'shard', width: 60},\n  {title: '类型', key: 'type', width: 100},\n  {\n    title: '阶段', key: 'stage', width: 100,\n    render: (row) => h(NTag, {\n      size: 'small',\n      type: row.stage === 'DONE' ? 'success' : row.stage === 'INDEX' ? 'info' : 'warning'\n    }, {default: () => row.stage})\n  },\n  {title: '源节点', key: 'source_node', width: 120},\n  {title: '目标节点', key: 'target_node', width: 120},\n  {\n    title: '进度', key: 'progress', width: 120,\n    render: (row) => {\n      if (row.bytes_total === 0) return '-'\n      const pct = Math.round(row.bytes_recovered / row.bytes_total * 100)\n      return pct + '%'\n    }\n  },\n]\n\n// ==================== SLM 策略管理 ====================\nconst slmLoading = ref(false)\nconst slmData = ref([])\n\nconst slmForm = ref({\n  show: false,\n  policyId: '',\n  name: '<daily-snap-{now/d}>',\n  schedule: '0 30 1 * * ?',\n  repository: null,\n  indices: '',\n  expireAfter: '30d',\n  minCount: 5,\n  maxCount: 50,\n})\n\nconst getSLMPolicies = async () => {\n  slmLoading.value = true\n  const res = await GetSLMPolicies()\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    slmData.value = res.results || []\n  }\n  slmLoading.value = false\n}\n\nconst handleCreateSLM = async () => {\n  const f = slmForm.value\n  const res = await CreateSLMPolicy(\n      f.policyId, f.name, f.schedule, f.repository, f.indices,\n      f.expireAfter, f.minCount || 0, f.maxCount || 0\n  )\n  if (res.err !== \"\") {\n    message.error(res.err)\n    return false\n  }\n  message.success(\"SLM 策略创建成功\")\n  slmForm.value = {\n    show: false, policyId: '', name: '<daily-snap-{now/d}>', schedule: '0 30 1 * * ?',\n    repository: null, indices: '', expireAfter: '30d', minCount: 5, maxCount: 50\n  }\n  await getSLMPolicies()\n}\n\nconst handleDeleteSLM = (policyId) => {\n  dialog.warning({\n    title: '确认删除策略',\n    content: `确定要删除自动快照策略「${policyId}」吗？已创建的快照不会被删除。`,\n    positiveText: '确认删除',\n    negativeText: '取消',\n    onPositiveClick: async () => {\n      const res = await DeleteSLMPolicy(policyId)\n      if (res.err !== \"\") {\n        message.error(res.err)\n      } else {\n        message.success(\"策略已删除\")\n        await getSLMPolicies()\n      }\n    }\n  })\n}\n\nconst handleExecuteSLM = (policyId) => {\n  dialog.info({\n    title: '手动执行策略',\n    content: `确定要立即执行策略「${policyId}」创建一个快照吗？`,\n    positiveText: '执行',\n    negativeText: '取消',\n    onPositiveClick: async () => {\n      const res = await ExecuteSLMPolicy(policyId)\n      if (res.err !== \"\") {\n        message.error(res.err)\n      } else {\n        message.success(\"策略已触发执行，快照正在创建\")\n      }\n    }\n  })\n}\n\nconst slmColumns = [\n  {title: '策略ID', key: 'name', width: 160},\n  {\n    title: '仓库', key: 'repository', width: 130,\n    render: (row) => (row.policy && row.policy.repository) || '-'\n  },\n  {\n    title: '调度计划', key: 'schedule', width: 160,\n    render: (row) => (row.policy && row.policy.schedule) || '-'\n  },\n  {\n    title: '快照名称模板', key: 'snapshot_name', width: 200,\n    render: (row) => (row.policy && row.policy.name) || '-'\n  },\n  {\n    title: '下次执行', key: 'next_execution_millis', width: 170,\n    render: (row) => row.next_execution ? row.next_execution : '-'\n  },\n  {\n    title: '最近成功', key: 'last_success', width: 170,\n    render: (row) => {\n      if (row.last_success && row.last_success.time) return row.last_success.time\n      return '-'\n    }\n  },\n  {\n    title: '最近失败', key: 'last_failure', width: 170,\n    render: (row) => {\n      if (row.last_failure && row.last_failure.time) {\n        return h(NTag, {size: 'small', type: 'error'}, {default: () => row.last_failure.time})\n      }\n      return '-'\n    }\n  },\n  {\n    title: '操作', key: 'actions', width: 180,\n    render: (row) => h('div', {style: 'display: flex; gap: 8px;'}, [\n      h(NButton, {\n        size: 'small', quaternary: true, type: 'info',\n        onClick: () => handleExecuteSLM(row.name)\n      }, {default: () => '执行'}),\n      h(NButton, {\n        size: 'small', quaternary: true, type: 'error',\n        onClick: () => handleDeleteSLM(row.name)\n      }, {default: () => '删除'}),\n    ])\n  }\n]\n\n// ==================== 生命周期 ====================\nconst selectNode = async () => {\n  repoData.value = []\n  snapData.value = []\n  slmData.value = []\n  await loadAllData()\n}\n\nconst loadAllData = async () => {\n  await getRepos()\n  await getSnapshots()\n  await getSLMPolicies()\n}\n\nonMounted(() => {\n  emitter.on('selectNode', selectNode)\n  loadAllData()\n})\n</script>\n\n<style scoped>\n</style>"
  },
  {
    "path": "app/frontend/src/components/Task.vue",
    "content": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under the Apache License, Version 2.0 (the \"License\");\n  - you may not use this file except in compliance with the License.\n  - You may obtain a copy of the License at\n  -\n  -     https://www.apache.org/licenses/LICENSE-2.0\n  -\n  - Unless required by applicable law or agreed to in writing, software\n  - distributed under the License is distributed on an \"AS IS\" BASIS,\n  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  - See the License for the specific language governing permissions and\n  - limitations under the License.\n  -->\n\n<template>\n  <n-flex vertical>\n\n    <n-flex align=\"center\">\n      <h2>Task线程</h2>\n      <n-button :render-icon=\"renderIcon(RefreshOutlined)\" text @click=\"getData\">refresh</n-button>\n    </n-flex>\n    <n-flex align=\"center\">\n      <!-- 新增的查询功能 -->\n      <n-input v-model:value=\"taskIdFilter\" placeholder=\"任务ID\" style=\"width: 150px; margin-right: 10px\" />\n      <n-input v-model:value=\"nodeFilter\" placeholder=\"任务节点\" style=\"width: 150px; margin-right: 10px\" />\n      <n-input v-model:value=\"actionFilter\" placeholder=\"任务行为\" style=\"width: 150px; margin-right: 10px\" />\n      <n-button  @click=\"applyFilters\" style=\"margin-right: 10px\">查询</n-button>\n      <!-- 原有的导出按钮 -->\n      <n-button :render-icon=\"renderIcon(DriveFileMoveTwotone)\" @click=\"downloadAllDataCsv\">导出为csv</n-button>\n    </n-flex>\n\n    <n-spin :show=\"loading\" description=\"Connecting...\">\n      <n-data-table\n          v-model:checked-row-keys=\"selectedRowKeys\"\n          :bordered=\"false\"\n          :columns=\"refColumns(columns)\"\n          :data=\"filteredData\"\n          :max-height=\"600\"\n          :pagination=\"pagination\"\n          :row-key=\"rowKey\"\n          size=\"small\"\n          striped\n      />\n    </n-spin>\n  </n-flex>\n\n  <n-drawer v-model:show=\"drawerVisible\" style=\"width: 38.2%\">\n    <n-drawer-content style=\"text-align: left;\" title=\"结果\">\n      <n-code :code=\"json_data\" language=\"json\" show-line-numbers/>\n    </n-drawer-content>\n  </n-drawer>\n</template>\n\n<script setup>\nimport {h, onMounted, ref} from \"vue\";\nimport emitter from \"../utils/eventBus\";\nimport {NButton, NDataTable, NDropdown, NIcon, NTag, NText, useMessage} from 'naive-ui'\nimport {createCsvContent, download_file, formatDate, formattedJson, refColumns, renderIcon} from \"../utils/common\";\nimport {DriveFileMoveTwotone, DeleteOutlined, RefreshOutlined} from \"@vicons/material\";\nimport {\n  GetTasks, CancelTasks\n} from \"../../wailsjs/go/service/ESService\";\n\nconst drawerVisible = ref(false)\nconst json_data = ref()\n\nconst loading = ref(false)\nconst data = ref([])\nconst message = useMessage()\nconst selectedRowKeys = ref([]);\nconst rowKey = (row) => row['task_id']\n\nconst selectNode = async (node) => {\n  drawerVisible.value = false\n  json_data.value = null\n  loading.value = false\n  data.value = []\n  selectedRowKeys.value = []\n\n  await getData()\n}\n\nonMounted(() => {\n  emitter.on('selectNode', selectNode)\n  getData()\n})\n\n// 新增的过滤相关响应式数据\nconst taskIdFilter = ref('')\nconst nodeFilter = ref('')\nconst actionFilter = ref('')\nconst filteredData = ref([])\n\n// 新增的过滤方法\nconst applyFilters = () => {\n  filteredData.value = data.value.filter(item => {\n    const matchesTaskId = !taskIdFilter.value || item.task_id.toLowerCase().includes(taskIdFilter.value.toLowerCase())\n    const matchesNode = !nodeFilter.value || item.node_name.toLowerCase().includes(nodeFilter.value.toLowerCase())\n    const matchesAction = !actionFilter.value || item.action.toLowerCase().includes(actionFilter.value.toLowerCase())\n    return matchesTaskId && matchesNode && matchesAction\n  })\n}\n\n// 修改初始化获取数据的方法，使用过滤后的数据\nconst getData = async () => {\n  const res = await GetTasks()\n  console.log(res)\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    console.log(res)\n    data.value = res.results\n    // 初始化时也应用过滤\n    applyFilters()\n  }\n\n}\n\nconst CancelTask = async (row) => {\n  loading.value = true\n  const res = await CancelTasks(row['task_id'])\n  console.log(res)\n  if (res.err !== \"\") {\n    message.error(res.err)\n  } else {\n    json_data.value = formattedJson(res.result)\n    drawerVisible.value = true\n  }\n\n  loading.value = false\n  await getData()\n}\n\nconst pagination = ref({\n  page: 1,\n  pageSize: 10,\n  showSizePicker: true,\n  pageSizes: [5, 10, 20, 30, 40],\n  onChange: (page) => {\n    pagination.value.page = page\n  },\n  onUpdatePageSize: (pageSize) => {\n    pagination.value.pageSize = pageSize\n    pagination.value.page = 1\n  },\n  itemCount: data.value.length\n})\n\n\nconst columns = [\n  {\n    title: '任务id',\n    key: 'task_id',\n    width: 80,\n  },\n  {\n    title: '父任务id',\n    key: 'parent_task_id',\n    width: 80,\n  },\n  {\n    title: '任务节点',\n    key: 'node_name',\n    width: 60,\n  },\n  {title: 'IP', key: 'node_ip', width: 60,},\n  {title: '类型', key: 'type', width: 60,},\n  {\n    title: '任务行为',\n    key: 'action',\n    width: 120,\n    render: (row) => h(NTag, {type: \"info\"}, {default: () => row['action']}),\n  },\n  {\n    title: '开始时间',\n    key: 'start_time_in_millis',\n    width: 60,\n    render(row) {\n      // 将毫秒时间戳转换为 Date 对象\n      const date = new Date(row['start_time_in_millis']);\n      // 格式化日期时间\n      return h('span', null, formatDate(date));\n    },\n  },\n  {\n    title: '运行时间',\n    key: 'running_time_in_nanos',\n    width: 60,\n  },\n  // 在 Vue 的模板中，布尔值会被转换为空字符串！！！\n  {\n    title: '是否可取消',\n    key: 'cancellable',\n    width: 60,\n    render: (row) => h(NTag,\n        {type: row['cancellable'] ? \"error\" : \"info\"},\n        {default: () => row['cancellable'] ? '是' : '否'}\n    ),\n  },\n  {\n    title: '操作',\n    key: 'actions',\n    width: 60,\n    render: (row) => {\n      return h(\n          NButton,\n          {\n            strong: true,\n            secondary: true,\n            onClick: () => CancelTask(row),\n            disabled: !row['cancellable'],\n          },\n          {\n            default: () => '终止', icon: () => h(NIcon, null, {default: () => h(DeleteOutlined)})\n          }\n      )\n    }\n  }\n]\n\n\n// 下载所有数据的 CSV 文件\nconst downloadAllDataCsv = async () => {\n  const csvContent = createCsvContent(data.value, columns)\n  download_file(csvContent, '任务列表.csv', 'text/csv;charset=utf-8;')\n}\n\n</script>\n\n\n<style scoped>\n\n</style>"
  },
  {
    "path": "app/frontend/src/main.js",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {createApp} from 'vue'\nimport naive from 'naive-ui'\nimport App from './App.vue'\n\n\nconst app = createApp(App)\n\napp.use(naive)\n\napp.mount('#app')"
  },
  {
    "path": "app/frontend/src/style.css",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nhtml {\n    background-color: rgba(27, 38, 54, 1);\n    text-align: center;\n    color: white;\n}\n\nbody {\n    margin: 0;\n    color: white;\n    font-family: \"Nunito\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Roboto\",\n    \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\",\n    sans-serif;\n}\n\n@font-face {\n    font-family: \"Nunito\";\n    font-style: normal;\n    font-weight: 400;\n    src: local(\"\"),\n    url(\"assets/fonts/nunito-v16-latin-regular.woff2\") format(\"woff2\");\n}\n\n#app {\n    height: 100vh;\n    text-align: center;\n}\n\nh2  {\n    text-align: left\n}"
  },
  {
    "path": "app/frontend/src/utils/common.js",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// 渲染图标给菜单\nimport {h} from \"vue\";\nimport {NIcon} from \"naive-ui\";\nimport {BrowserOpenURL} from \"../../wailsjs/runtime\";\n\n// 渲染图标\nexport function renderIcon(icon) {\n    return () => h(NIcon, null, {default: () => h(icon)});\n}\n\n// 打开链接\nexport function openUrl(url) {\n    BrowserOpenURL(url)\n}\n\n// 压扁json\nexport function flattenObject(obj, parentKey = '') {\n    let flatResult = {};\n\n    for (let key in obj) {\n        if (obj.hasOwnProperty(key)) {\n            let newKey = parentKey ? `${parentKey}.${key}` : key;\n\n            if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {\n                // 如果当前值也是一个对象，则递归调用\n                Object.assign(flatResult, flattenObject(obj[key], newKey));\n            } else {\n                // 否则直接赋值\n                flatResult[newKey] = obj[key];\n            }\n        }\n    }\n\n    return flatResult;\n}\n\n// 格式化的 JSON 字符串\nexport function formattedJson(value) {\n    if (!value) return ''\n    return JSON.stringify(value, null, 1)\n}\n\n// 验证json\nexport function isValidJson(jsonString) {\n    try {\n        // 尝试解析 JSON 字符串\n        JSON.parse(jsonString);\n        return true; // 解析成功，是有效的 JSON\n    } catch (error) {\n        // 解析失败，不是有效的 JSON\n        return false;\n    }\n}\n\n// 单位处理\nexport function formatBytes(bytes, decimals = 2) {\n    if (bytes === 0) return '0 Bytes';\n    if (bytes === null) return '';\n\n    const k = 1024;\n    const dm = decimals < 0 ? 0 : decimals;\n    const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];\n\n    const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n    return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];\n}\n\n\n// 创建 CSV 内容的函数\nexport function createCsvContent(allData, columns) {\n    // 过滤掉没有 title 的列\n    columns = columns.filter(col => col.title !== undefined);\n\n    const headers = columns.map(col => col.title).join(',');\n    const rows = allData.map(row =>\n        columns.map(col => {\n            // 如果定义了自定义的 getCsvValue 函数，则使用它\n            if (col.getCsvValue) {\n                return `\"${col.getCsvValue(row)}\"`; // 加上引号防止内容中的逗号导致换列\n            }\n            // 否则，使用默认的 key 来取值\n            const value = row[col.key];\n            // 对于可能包含逗号的值，最好也用引号包起来\n            return typeof value === 'string' ? `\"${value}\"` : value;\n        }).join(',')\n    ).join('\\n');\n    return `${headers}\\n${rows}`;\n}\n\n// 下载件的函数，csv type：'text/csv;charset=utf-8;'\nexport function download_file(content, fileName, type) {\n    const blob = new Blob([content], {type: type})\n    const link = document.createElement('a')\n    if (link.download !== undefined) {\n        const url = URL.createObjectURL(blob)\n        link.setAttribute('href', url)\n        link.setAttribute('download', fileName)\n        link.style.visibility = 'hidden'\n        document.body.appendChild(link)\n        link.click()\n        document.body.removeChild(link)\n    }\n}\n\n// 日期格式化函数\nexport function formatDate(date) {\n    const year = date.getFullYear();\n    const month = String(date.getMonth() + 1).padStart(2, '0');\n    const day = String(date.getDate()).padStart(2, '0');\n    const hours = String(date.getHours()).padStart(2, '0');\n    const minutes = String(date.getMinutes()).padStart(2, '0');\n    const seconds = String(date.getSeconds()).padStart(2, '0');\n    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\n}\n\nexport function formatTimestamp(timestamp) {\n    return new Date(timestamp).toLocaleString()\n}\n\n/**\n * 智能处理表格列配置\n * minWidth 不会主动影响列的初始宽度，它只是作为拖拽调整时的最小宽度限制。\n *  初始宽度由以下优先级决定：\n *      width > 内容自适应宽度 > 表格容器的等分分配。\n * 1. 自动计算未配置 minWidth 的列（基于 title 长度）\n * 2. 默认允许拖动（除非显式禁用）\n * 3. 自动添加 ellipsis 省略效果\n * 4. 当允许拖动时，移除默认 width 配置（避免冲突）\n */\nexport function refColumns(columns) {\n    return columns.map((column) => {\n        // 深拷贝原始列配置（避免修改原对象）\n        const processed = { ...column };\n        if (processed.type === 'selection'){\n            return processed;\n        }\n        if (\"_ignore\" in processed){\n            delete processed._ignore;\n            return processed;\n        }\n\n        if (!('sorter' in processed)) {\n            processed.sorter = 'default';\n        }\n        // ===== 处理 resizable =====\n        if (!('resizable' in processed)) {\n            processed.resizable = true; // 默认允许拖动\n        }\n\n        if (processed.resizable){\n            // ===== 处理 minWidth =====\n            if (!('width' in processed)) {\n                processed.width = calculateWidthByTitle(processed.title);\n            }\n        }\n\n        // ===== 处理 ellipsis =====\n        if (!('ellipsis' in processed)) {\n            processed.ellipsis = {\n                tooltip: {\n                    scrollable: true,\n                    style: { maxWidth: '800px' } // 自定义提示框最大宽度\n                }\n            };\n        }\n        return processed;\n    });\n}\n\n/**\n * 根据标题文本计算建议宽度（中英文混合）\n */\nexport function calculateWidthByTitle(title) {\n    let width = 0;\n    for (const char of title) {\n        width += /[\\u4e00-\\u9fa5]/.test(char) ? 16 : 8; // 中文16px，英文8px\n    }\n    return Math.max(width + 24, 80); // 加 padding 且不低于 80px\n}\n\n/**\n * 格式化毫秒为可读时长\n */\nexport function formatMillis(ms) {\n    if (!+ms) return '0s';\n    const seconds = Math.floor(ms / 1000);\n    const d = Math.floor(seconds / (3600 * 24));\n    const h = Math.floor((seconds % (3600 * 24)) / 3600);\n    const m = Math.floor((seconds % 3600) / 60);\n    const s = Math.floor(seconds % 60);\n    let result = '';\n    if (d > 0) result += `${d}d `;\n    if (h > 0) result += `${h}h `;\n    if (m > 0) result += `${m}m `;\n    if (s > 0 || result === '') result += `${s}s`;\n    return result.trim();\n}\n\nexport function formatMillisToDays(ms){\n    if (!+ms) return '0天';\n    const days = Math.floor(ms / (1000 * 60 * 60 * 24));\n    return `${days}天`;\n}\n\n/**\n * 格式化大数字（加逗号）\n */\nexport function formatNumber(num) {\n    return num?.toLocaleString() ?? '0';\n}"
  },
  {
    "path": "app/frontend/src/utils/eventBus.js",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// eventBus.js\nimport mitt from 'mitt'\nconst emitter = mitt()\nexport default emitter"
  },
  {
    "path": "app/frontend/vite.config.js",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {defineConfig} from 'vite'\nimport vue from '@vitejs/plugin-vue'\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  server: {\n    port: 5174,\n  },\n  plugins: [vue()]\n})\n"
  },
  {
    "path": "app/go.mod",
    "content": "module app\n\ngo 1.22.0\n\ntoolchain go1.24.0\n\nrequire (\n\tgithub.com/go-resty/resty/v2 v2.16.2\n\tgithub.com/wailsapp/wails/v2 v2.11.0\n\tgopkg.in/yaml.v3 v3.0.1\n)\n\nrequire (\n\tgithub.com/bep/debounce v1.2.1 // indirect\n\tgithub.com/go-ole/go-ole v1.3.0 // indirect\n\tgithub.com/godbus/dbus/v5 v5.1.0 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/gorilla/websocket v1.5.3 // indirect\n\tgithub.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect\n\tgithub.com/labstack/echo/v4 v4.13.3 // indirect\n\tgithub.com/labstack/gommon v0.4.2 // indirect\n\tgithub.com/leaanthony/go-ansi-parser v1.6.1 // indirect\n\tgithub.com/leaanthony/gosod v1.0.4 // indirect\n\tgithub.com/leaanthony/slicer v1.6.0 // indirect\n\tgithub.com/leaanthony/u v1.1.1 // indirect\n\tgithub.com/mattn/go-colorable v0.1.13 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/samber/lo v1.49.1 // indirect\n\tgithub.com/tkrajina/go-reflector v0.5.8 // indirect\n\tgithub.com/valyala/bytebufferpool v1.0.0 // indirect\n\tgithub.com/valyala/fasttemplate v1.2.2 // indirect\n\tgithub.com/wailsapp/go-webview2 v1.0.22 // indirect\n\tgithub.com/wailsapp/mimetype v1.4.1 // indirect\n\tgolang.org/x/crypto v0.33.0 // indirect\n\tgolang.org/x/net v0.35.0 // indirect\n\tgolang.org/x/sys v0.30.0 // indirect\n\tgolang.org/x/text v0.22.0 // indirect\n)\n\n// replace github.com/wailsapp/wails/v2 v2.9.2 => F:\\coding\\go\\pkg\\mod\n"
  },
  {
    "path": "app/go.sum",
    "content": "github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=\ngithub.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=\ngithub.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=\ngithub.com/go-resty/resty/v2 v2.16.2 h1:CpRqTjIzq/rweXUt9+GxzzQdlkqMdt8Lm/fuK/CAbAg=\ngithub.com/go-resty/resty/v2 v2.16.2/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU=\ngithub.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=\ngithub.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=\ngithub.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=\ngithub.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=\ngithub.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=\ngithub.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=\ngithub.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=\ngithub.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=\ngithub.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=\ngithub.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=\ngithub.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=\ngithub.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=\ngithub.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=\ngithub.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=\ngithub.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=\ngithub.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=\ngithub.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=\ngithub.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=\ngithub.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=\ngithub.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=\ngithub.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=\ngithub.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=\ngithub.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=\ngithub.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=\ngithub.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=\ngithub.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=\ngithub.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=\ngithub.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=\ngithub.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=\ngithub.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=\ngithub.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=\ngithub.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=\ngithub.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=\ngithub.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=\ngolang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=\ngolang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=\ngolang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=\ngolang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=\ngolang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=\ngolang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=\ngolang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=\ngolang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=\ngolang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=\ngopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "app/main.go",
    "content": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage main\n\nimport (\n\t\"app/backend/common\"\n\t\"app/backend/config\"\n\t\"app/backend/service\"\n\t\"app/backend/system\"\n\t\"context\"\n\t\"embed\"\n\t\"fmt\"\n\t\"github.com/wailsapp/wails/v2\"\n\t\"github.com/wailsapp/wails/v2/pkg/options\"\n\t\"github.com/wailsapp/wails/v2/pkg/options/assetserver\"\n\t\"github.com/wailsapp/wails/v2/pkg/options/linux\"\n\t\"github.com/wailsapp/wails/v2/pkg/options/mac\"\n\t\"github.com/wailsapp/wails/v2/pkg/options/windows\"\n)\n\n// 在开发模式下使用 wails dev 命令，资产从磁盘加载，任何更改都会导致“实时重新加载”。 资产的位置将从 embed.FS 推断。\n//\n//go:embed frontend/dist\nvar assets embed.FS\n\n//go:embed build/appicon.png\nvar icon []byte\n\nfunc main() {\n\tapp := NewApp()\n\tappConfig := &config.AppConfig{}\n\tconfigInfo := appConfig.GetConfig()\n\tupdate := &system.Update{}\n\tesService := service.NewESService()\n\n\t// 主应用程序由对 wails.Run() 的调用组成。 它接受描述应用程序窗口大小、窗口标题、要使用的资源等应用程序配置\n\t// 完整说明：https://wails.io/zh-Hans/docs/reference/options/\n\terr := wails.Run(&options.App{\n\t\tTitle:  common.AppName,\n\t\tWidth:  configInfo.Width,\n\t\tHeight: configInfo.Height,\n\t\t//MinWidth:          1024,\n\t\t//MinHeight:         768,\n\t\t//MaxWidth:  1440,\n\t\t//MaxHeight: 920,\n\t\t//DisableResize:     false,\n\t\tFrameless: true, //无边框\n\t\t//HideWindowOnClose: false,  //关闭时隐藏窗口\n\t\tBackgroundColour: &options.RGBA{R: 0, G: 0, B: 0},\n\t\tAssetServer: &assetserver.Options{\n\t\t\tAssets: assets,\n\t\t},\n\t\tMenu: nil,\n\t\t//EnableDefaultContextMenu: true,\n\t\t//Logger:                   nil,\n\t\t//LogLevel:                 logger.DEBUG,\n\t\t//OnStartup 此回调在前端创建之后调用，但在 index.html 加载之前调用。 它提供了应用程序上下文。\n\t\t// 传递 ctx\n\t\tOnStartup: func(ctx context.Context) {\n\t\t\tapp.Start(ctx)\n\t\t\tappConfig.Start(ctx)\n\t\t\tupdate.Start(ctx)\n\t\t},\n\t\t//在前端加载完毕 index.html 及其资源后调用此回调\n\t\tOnDomReady: app.domReady,\n\t\t//在前端被销毁之后，应用程序终止之前，调用此回调。 它提供了应用程序上下文。\n\t\tOnBeforeClose: app.beforeClose,\n\t\t//应用关闭前回调\n\t\tOnShutdown: app.shutdown,\n\t\t//WindowStartState: options.Normal,\n\t\t//指定向前端暴露哪些结构体方法\n\t\tBind: []any{\n\t\t\tapp,\n\t\t\tappConfig,\n\t\t\tupdate,\n\t\t\tesService,\n\t\t},\n\t\tWindows: &windows.Options{\n\t\t\tWebviewIsTransparent:              false,\n\t\t\tWindowIsTranslucent:               false,\n\t\t\tDisableFramelessWindowDecorations: false,\n\t\t\tResizeDebounceMS:                  2,\n\t\t},\n\t\tLinux: &linux.Options{\n\t\t\tProgramName:         common.AppName,\n\t\t\tIcon:                icon,\n\t\t\tWebviewGpuPolicy:    linux.WebviewGpuPolicyOnDemand,\n\t\t\tWindowIsTranslucent: true,\n\t\t},\n\t\t// Mac platform specific options\n\t\tMac: &mac.Options{\n\t\t\tTitleBar: mac.TitleBarHiddenInset(),\n\t\t\tAbout: &mac.AboutInfo{\n\t\t\t\tTitle:   fmt.Sprintf(\"%s %s\", common.AppName, common.Version),\n\t\t\t\tMessage: \"\",\n\t\t\t\tIcon:    icon,\n\t\t\t},\n\t\t\tWebviewIsTransparent: false,\n\t\t\tWindowIsTranslucent:  false,\n\t\t},\n\t})\n\n\tif err != nil {\n\t\tappConfig.LogErrToFile(err.Error())\n\t}\n}\n"
  },
  {
    "path": "app/wails.json",
    "content": "{\n  \"name\": \"ES-King\",\n  \"outputfilename\": \"ES-King\",\n  \"frontend:install\": \"npm install\",\n  \"frontend:build\": \"npm run build\",\n  \"frontend:dev:watcher\": \"npm run dev\",\n  \"frontend:dev:serverUrl\": \"http://localhost:5174\",\n  \"author\": {\n    \"name\": \"bronya0\",\n    \"email\": \"tangssst@qq.com\"\n  },\n  \"info\": {\n    \"companyName\": \"bronya0\",\n    \"productName\": \"ES-King\",\n    \"productVersion\": \"1.0.0\",\n    \"copyright\": \"Copyright © 2024\",\n    \"comments\": \"comments\"\n  }\n}"
  },
  {
    "path": "readme-en.md",
    "content": "![](docs/snap/2.png)\n\n<h1 align=\"center\">ES-King </h1>\n<h4 align=\"center\"><strong>简体中文</strong> | <a href=\"https://github.com/Bronya0/ES-King/blob/wails/readme-en.md\">English</a></h4>\n\n<div align=\"center\">\n\n![License](https://img.shields.io/github/license/Bronya0/ES-King)\n![GitHub release](https://img.shields.io/github/release/Bronya0/ES-King)\n![GitHub All Releases](https://img.shields.io/github/downloads/Bronya0/ES-King/total)\n![GitHub stars](https://img.shields.io/github/stars/Bronya0/ES-King)\n![GitHub forks](https://img.shields.io/github/forks/Bronya0/ES-King)\n\n<strong>A modern, practical, lightweight ES GUI client that supports multiple platforms and the installation package is less than 10mb. </strong>\n\n</div>\n\nThe same Kafka client has been developed and has been downloaded by more than a thousand people: [Kafka-King](https:// github.com/Bronya0/Kafka-King)\n\nIf you need to raise requirements, bugs and improvement suggestions, please raise an issue.\n\nClick a star to support the author's hard work in open source. Thank you❤❤\n\nJoin the group and communicate with the author: <a target=\"_blank\" href=\"https://qm.qq.com/cgi-bin/qm/qr?k=pDqlVFyLMYEEw8DPJlRSBN27lF8qHV2v&jump_from= webapi&authKey=Wle/K0ARM1YQWlpn6vvfiZuMedy2tT9BI73mUvXVvCuktvi0fNfmNR19Jhyrf2Nz\">R&D technical exchange group: 964440643</a>\n\n# Function list\n- Detailed cluster information: node information, heap memory usage, total memory usage, cpu usage, disk usage, network traffic, node role, cluster Health, 5-minute load, field cache per node, segment cache, query cache, request cache, total number of segments metrics\n- Metrics: total number of active shards, number of shards being initialized, number of delayed unallocated shards (possibly because allocation strategy waiting conditions are not met), percentage of active shards (possibly frozen, closed, faulty, etc.)\n- Index index, document index, memory index, node index, storage index, segment index...\n- Support cluster viewing- Support index search, management, and export csv\n- Support index operations: index management, sample viewing of 10 document contents, index alias, View index settings, refresh index, merge index segments, delete index, close or open index, flush index, clean index cache...\n- Comes with rest window (of course you can use postman if you like)\n\n# Download\n[Download address](https ://github.com/Bronya0/ES-King/releases), click [Assets], and choose the platform of your office computer to download. It supports Windows, MacOS, and Linux.\n\n# Screenshot\n![](docs/snap/1.png)\n![](docs/snap/3.png)\n![](docs/snap/4.png)\n![](docs/snap/5.png) \n\n# Build \nis only needed to study the source code.\nInstall wails, refer to: https://wails.io/docs/gettingstarted/installation\n\n```\ncd app\nwails dev\n```\n\n# Star\n[![Stargazers over time](https:/ /starchart.cc/Bronya0/ES-King.svg)](https://starchart.cc/Bronya0/ES-King)\n\n# Thanks\n- wails: https://wails.io/docs/gettingstarted/installation\n- naive ui : https://www.naiveui.com/"
  },
  {
    "path": "readme.md",
    "content": "<p align=\"center\">\n  <img src=\"app/build/appicon.png\" alt=\"图片标题\" width=\"200\">\n</p>\n<h1 align=\"center\">ES-King </h1>\n<h4 align=\"center\"><strong>简体中文</strong> | <a href=\"https://github.com/Bronya0/ES-King/blob/wails/readme-en.md\">English</a></h4>\n\n<div align=\"center\">\n\n![License](https://img.shields.io/github/license/Bronya0/ES-King)\n![GitHub release](https://img.shields.io/github/release/Bronya0/ES-King)\n![GitHub All Releases](https://img.shields.io/github/downloads/Bronya0/ES-King/total)\n![GitHub stars](https://img.shields.io/github/stars/Bronya0/ES-King)\n![GitHub forks](https://img.shields.io/github/forks/Bronya0/ES-King)\n\n<strong>一个现代、实用、轻量的ES GUI客户端，支持多平台，安装包不到10mb。</strong>\n\n\n</div>\n\n让ES更好用，make es great again! \n\n该桌面软件用于操作、查询ES，适配各大桌面系统（除了win7），通信方式为REST API，一般无ES版本兼容问题。通过集成大量监控指标、索引操作、优化过的便捷查询界面，提升ES的使用体验和效率。\n\n如需提出需求、bug和改进建议，请提issue。\n\n点个star支持作者辛苦开源 谢谢❤❤\n\n加群和作者一起交流： <a target=\"_blank\" href=\"https://qm.qq.com/cgi-bin/qm/qr?k=pDqlVFyLMYEEw8DPJlRSBN27lF8qHV2v&jump_from=webapi&authKey=Wle/K0ARM1YQWlpn6vvfiZuMedy2tT9BI73mUvXVvCuktvi0fNfmNR19Jhyrf2Nz\">研发技术交流群：964440643</a>\n\n**同款Kafka客户端，已有上万人下载**：[Kafka-King](https://github.com/Bronya0/Kafka-King)\n\n**使用&开发文档（AI生成）**：[https://zread.ai/Bronya0/ES-King](https://zread.ai/Bronya0/ES-King)\n\n\n# 功能清单\n- 详尽的集群信息：节点信息、堆内存占用、总内存占用、cpu占用、磁盘占用、网络流量、节点角色、集群健康、5分钟负载、每个节点的字段缓存、段缓存、查询缓存、请求缓存、段总数指标\n- 指标查看：活跃的分片总数、初始化中的分片数量、延迟未分配的分片数量量（可能因为分配策略等待条件未满足）、活跃分片占比 (可能冻结、关闭、故障等)\n- 索引指标、文档指标、内存指标、节点指标、存储指标、段指标……\n- 支持集群查看\n- 支持索引搜索、管理，导出csv\n- 支持索引操作：索引管理、抽样查看10条文档内容、索引别名、索引设置查看、索引刷新、索引段合并、删除索引、关闭or打开索引、flush索引、清理索引缓存……\n- 自带rest窗口（当然你喜欢也可以自己用postman），自动存储历史查询，一键恢复，查询结果支持搜索导出；支持es dsl的关键字悬浮提示\n- 支持索引备份下载到本地\n\n# 下载\n[下载地址](https://github.com/Bronya0/ES-King/releases)，点击【Assets】，选择自己办公电脑的平台下载，支持windows、macos、linux。\n\n\n# 截图\n![2025-04-25_16-26-00](https://github.com/user-attachments/assets/45284be3-bc18-49f2-bc77-95729735a31a)\n\n![](docs/snap/1.png)\n![](docs/snap/3.png)\n![](docs/snap/4.png)\n![](docs/snap/5.png)\n\n# 捐赠\n有条件可以请作者喝杯咖啡，支持项目发展，感谢💕\n\n![image](https://github.com/user-attachments/assets/da6d46da-4e24-41e3-843d-495c6cd32065)\n\n\n\n# 参与开发\n安装golang、node.js、npm，运行 go install github.com/wailsapp/wails/v2/cmd/wails@latest 安装 Wails CLI。\n```\ncd app\nwails dev\n```\n\n# 星\n[![Stargazers over time](https://starchart.cc/Bronya0/ES-King.svg)](https://starchart.cc/Bronya0/ES-King)\n\n\n# 感谢\n- wails：https://wails.io/docs/gettingstarted/installation\n- naive ui：https://www.naiveui.com/\n"
  }
]