Full Code of Bronya0/ES-King for AI

wails d53ffad3f0dc cached
44 files
233.7 KB
70.0k tokens
113 symbols
1 requests
Download .txt
Showing preview only (262K chars total). Download the full file or copy to clipboard to get everything.
Repository: Bronya0/ES-King
Branch: wails
Commit: d53ffad3f0dc
Files: 44
Total size: 233.7 KB

Directory structure:
gitextract_q841ds1i/

├── .github/
│   └── workflows/
│       └── build.yaml
├── .gitignore
├── LICENSE
├── app/
│   ├── app.go
│   ├── backend/
│   │   ├── common/
│   │   │   └── vars.go
│   │   ├── config/
│   │   │   └── app.go
│   │   ├── service/
│   │   │   └── es.go
│   │   ├── system/
│   │   │   └── update.go
│   │   └── types/
│   │       └── resp.go
│   ├── build/
│   │   ├── README.md
│   │   ├── darwin/
│   │   │   ├── Info.dev.plist
│   │   │   └── Info.plist
│   │   └── windows/
│   │       ├── info.json
│   │       ├── installer/
│   │       │   ├── project.nsi
│   │       │   └── wails_tools.nsh
│   │       └── wails.exe.manifest
│   ├── dev.bat
│   ├── frontend/
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── src/
│   │   │   ├── App.vue
│   │   │   ├── assets/
│   │   │   │   └── fonts/
│   │   │   │       └── OFL.txt
│   │   │   ├── components/
│   │   │   │   ├── About.vue
│   │   │   │   ├── Aside.vue
│   │   │   │   ├── Conn.vue
│   │   │   │   ├── Core.vue
│   │   │   │   ├── Header.vue
│   │   │   │   ├── Health.vue
│   │   │   │   ├── Index.vue
│   │   │   │   ├── Nodes.vue
│   │   │   │   ├── Rest.vue
│   │   │   │   ├── Settings.vue
│   │   │   │   ├── Snapshot.vue
│   │   │   │   └── Task.vue
│   │   │   ├── main.js
│   │   │   ├── style.css
│   │   │   └── utils/
│   │   │       ├── common.js
│   │   │       └── eventBus.js
│   │   └── vite.config.js
│   ├── go.mod
│   ├── go.sum
│   ├── main.go
│   └── wails.json
├── readme-en.md
└── readme.md

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/workflows/build.yaml
================================================
name: Wails build

on:
  release:
    types: [ created ]

env:
  NODE_OPTIONS: "--max-old-space-size=4096"
  APP_NAME: 'ES-King'
  APP_WORKING_DIRECTORY: 'app'
  GO_VERSION: '1.24'
  NODE_VERSION: "22.x"

jobs:
  build-windows:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [windows-latest]  # amd64/x64

    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          submodules: recursive

      - name: Install 7-Zip
        run: choco install 7zip

      - name: GoLang
        uses: actions/setup-go@v4
        with:
          check-latest: true
          go-version: ${{ env.GO_VERSION }}

      - name: NodeJS
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Build & Compress
        run: |
          go install github.com/wailsapp/wails/v2/cmd/wails@latest
          cd ${{ env.APP_WORKING_DIRECTORY }}
          wails build -ldflags="-X 'app/backend/common.Version=${{ github.ref_name }}'" -webview2 download -o ${{ env.APP_NAME }}.exe
          cd ..
          copy readme.md app\build\bin\
          copy LICENSE app\build\bin\
          & "C:\Program Files\7-Zip\7z.exe" a -t7z "${{ env.APP_NAME }}-${{ github.ref_name }}-windows-x64.7z" ".\app\build\bin\*" -r
          
      - name: Upload Release Asset
        uses: softprops/action-gh-release@v1
        with:
          files: "*.7z"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  build-macos:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [macos-latest]  # macos-13是amd64,macos-latest是m1芯片

    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          submodules: recursive

      - name: GoLang
        uses: actions/setup-go@v4
        with:
          check-latest: true
          go-version: ${{ env.GO_VERSION }}

      - name: NodeJS
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Build & Compress
        shell: bash
        run: |
          go install github.com/wailsapp/wails/v2/cmd/wails@latest
          cd ${{ env.APP_WORKING_DIRECTORY }}
          wails build -ldflags "-X 'app/backend/common.Version=${{ github.ref_name }}'" -platform darwin/universal -o ${{ env.APP_NAME }} 
          chmod +x build/bin/*/Contents/MacOS/*
          mkdir -p _build/ _dist/
          cp -r ./build/bin/${{ env.APP_NAME }}.app _build/
          brew install create-dmg
          create-dmg \
            --no-internet-enable \
            --volname "${{ env.APP_NAME }}" \
            --volicon "_build/${{ env.APP_NAME }}.app/Contents/Resources/iconfile.icns" \
            --window-pos 400 400 \
            --window-size 660 450 \
            --icon "${{ env.APP_NAME }}.app" 180 180 \
            --icon-size 100 \
            --hide-extension "${{ env.APP_NAME }}.app" \
            --app-drop-link 480 180 \
            "_dist/${{ env.APP_NAME }}-${{ github.ref_name }}-macos.dmg" \
            "_build/"

      - name: Upload Release Assets
        uses: softprops/action-gh-release@v1
        with:
          files: ${{ env.APP_WORKING_DIRECTORY }}/_dist/*
          fail_on_unmatched_files: true
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  build-linux:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-22.04]

    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          submodules: recursive

      - name: GoLang
        uses: actions/setup-go@v4
        with:
          check-latest: true
          go-version: ${{ env.GO_VERSION }}

      - name: NodeJS
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Build & Compress
        run: |
          ARCH=$(uname -m)
          sudo apt-get update && sudo apt-get install libgtk-3-dev libwebkit2gtk-4.0-dev build-essential
          go install github.com/wailsapp/wails/v2/cmd/wails@latest
          cd ${{ env.APP_WORKING_DIRECTORY }}
          wails build -ldflags="-X 'app/backend/common.Version=${{ github.ref_name }}'" -webview2 download -o ${{ env.APP_NAME }}
          cd ..
          mkdir _temp_dist
          cp readme.md _temp_dist/
          cp LICENSE _temp_dist/
          cp -r ${{ env.APP_WORKING_DIRECTORY }}/build/bin/* _temp_dist/
          chmod +x _temp_dist/*
          cd _temp_dist/
          tar -zcvf ${{ env.APP_NAME }}-${{ github.ref_name }}-ubuntu-$ARCH.tar.gz *
          mv ${{ env.APP_NAME }}-${{ github.ref_name }}-ubuntu-$ARCH.tar.gz ..

      - name: Upload Release Assets
        uses: softprops/action-gh-release@v1
        with:
          files: "*.tar.gz"
          fail_on_unmatched_files: true
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


================================================
FILE: .gitignore
================================================
build/bin
.vscode
.idea
/app/frontend/node_modules
/app/frontend/wailsjs
/app/frontend/dist
/app/frontend/package.json.md5


================================================
FILE: LICENSE
================================================
                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.


================================================
FILE: app/app.go
================================================
/*
 * Copyright 2025 Bronya0 <tangssst@163.com>.
 * Author Github: https://github.com/Bronya0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package main

import (
	"app/backend/common"
	"context"
	"crypto/tls"
	"fmt"
	"github.com/go-resty/resty/v2"
	"log"
	"runtime"
	"runtime/debug"
)

// App struct
type App struct {
	ctx context.Context
}

// NewApp creates a new App application struct
func NewApp() *App {
	return &App{}
}

// Start is called at application startup
func (a *App) Start(ctx context.Context) {
	a.ctx = ctx
	log.Println("===注意,接下来前端执行onMounted,后端初始化必须在此处完成===")
}

// domReady is called after front-end resources have been loaded
func (a *App) domReady(ctx context.Context) {
	log.Println("===最后一步,页面即将显示……可以执行后端异步任务===")

	// 统计版本使用情况
	go func() {
		defer func() {
			if err := recover(); err != nil {
				fmt.Println(string(debug.Stack()))
			}
		}()
		client := resty.New().SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
		body := map[string]any{
			"name":     common.AppName,
			"version":  common.Version,
			"platform": runtime.GOOS,
		}
		_, _ = client.R().SetBody(body).Post(common.PingUrl)
	}()
}

// beforeClose is called when the application is about to quit,
// either by clicking the window close button or calling runtime.Quit.
// Returning true will cause the application to continue, false will continue shutdown as normal.
func (a *App) beforeClose(ctx context.Context) (prevent bool) {
	return false
}

// shutdown is called at application termination
func (a *App) shutdown(ctx context.Context) {
	// Perform your teardown here
}

// Greet returns a greeting for the given name
func (a *App) Greet(name string) string {
	return fmt.Sprintf("Hello %s, It's show time!", name)
}


================================================
FILE: app/backend/common/vars.go
================================================
/*
 * Copyright 2025 Bronya0 <tangssst@163.com>.
 * Author Github: https://github.com/Bronya0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package common

import "fmt"

var (
	// Version 会在编译时注入 -ldflags="-X 'app/backend/common.Version=${{ github.event.release.tag_name }}'"
	Version = ""
)

const (
	AppName     = "ES-King"
	Width       = 1600
	Height      = 870
	Theme       = "dark"
	ConfigDir   = ".es-king"
	ConfigPath  = "config.yaml"
	HistoryPath = "history.yaml"
	ErrLogPath  = "error.log"
	Language    = "zh-CN"
	PingUrl     = "https://ysboke.cn/api/kingTool/ping"
)

var (
	Project          = "Bronya0/ES-King"
	GITHUB_URL       = fmt.Sprintf("https://github.com/%s", Project)
	GITHUB_REPOS_URL = fmt.Sprintf("https://api.github.com/repos/%s", Project)
	UPDATE_URL       = fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", Project)
	ISSUES_URL       = fmt.Sprintf("https://github.com/%s/issues", Project)
	ISSUES_API_URL   = fmt.Sprintf("https://api.github.com/repos/%s/issues?state=open", Project)
)


================================================
FILE: app/backend/config/app.go
================================================
/*
 * Copyright 2025 Bronya0 <tangssst@163.com>.
 * Author Github: https://github.com/Bronya0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package config

import (
	"app/backend/common"
	"app/backend/types"
	"context"
	"fmt"
	"github.com/wailsapp/wails/v2/pkg/runtime"
	"gopkg.in/yaml.v3"
	"log"
	"os"
	"path/filepath"
	"sync"
)

type AppConfig struct {
	ctx context.Context
	mu  sync.Mutex
}

func (a *AppConfig) Start(ctx context.Context) {
	a.ctx = ctx
}

func (a *AppConfig) GetConfig() *types.Config {
	var defaultConfig = &types.Config{
		Width:    common.Width,
		Height:   common.Height,
		Language: common.Language,
		Theme:    common.Theme,
		Connects: make([]types.Connect, 0),
	}
	configPath := a.getConfigPath()
	data, err := os.ReadFile(configPath)
	if err != nil {
		return defaultConfig
	}
	err = yaml.Unmarshal(data, defaultConfig)
	if err != nil {
		return defaultConfig
	}
	return defaultConfig
}

func (a *AppConfig) SaveConfig(config *types.Config) string {
	a.mu.Lock()
	defer a.mu.Unlock()
	configPath := a.getConfigPath()
	fmt.Println(configPath)

	data, err := yaml.Marshal(config)
	if err != nil {
		return err.Error()
	}

	err = os.WriteFile(configPath, data, 0644)
	if err != nil {
		return err.Error()
	}
	return ""
}
func (a *AppConfig) SaveTheme(theme string) string {
	a.mu.Lock()
	defer a.mu.Unlock()
	config := a.GetConfig()
	config.Theme = theme
	data, err := yaml.Marshal(config)
	if err != nil {
		return err.Error()
	}
	configPath := a.getConfigPath()
	err = os.WriteFile(configPath, data, 0644)
	if err != nil {
		return err.Error()
	}
	return ""
}

func (a *AppConfig) getConfigPath() string {
	homeDir, err := os.UserHomeDir()
	if err != nil {
		log.Printf("os.UserHomeDir() error: %s", err.Error())
		return common.ConfigPath
	}
	configDir := filepath.Join(homeDir, common.ConfigDir)
	_, err = os.Stat(configDir)
	if os.IsNotExist(err) {
		err = os.Mkdir(configDir, os.ModePerm)
		if err != nil {
			log.Printf("create configDir %s error: %s", configDir, err.Error())
			return common.ConfigPath
		}
	}
	return filepath.Join(configDir, common.ConfigPath)
}

func (a *AppConfig) GetHistory() []*types.History {
	historyPath := a.getHistoryPath()
	data, err := os.ReadFile(historyPath)
	history := make([]*types.History, 0, 200)
	if err != nil {
		return history
	}
	err = yaml.Unmarshal(data, &history)
	if err != nil {
		return history
	}
	return history
}
func (a *AppConfig) SaveHistory(histories []types.History) string {
	a.mu.Lock()
	defer a.mu.Unlock()
	historyPath := a.getHistoryPath()
	data, err := yaml.Marshal(histories)
	if err != nil {
		return err.Error()
	}
	err = os.WriteFile(historyPath, data, 0644)
	if err != nil {
		return err.Error()
	}
	return ""
}
func (a *AppConfig) getHistoryPath() string {
	homeDir, err := os.UserHomeDir()
	if err != nil {
		log.Printf("os.UserHomeDir() error: %s", err.Error())
		return common.HistoryPath
	}
	configDir := filepath.Join(homeDir, common.ConfigDir)
	_, err = os.Stat(configDir)
	if os.IsNotExist(err) {
		err = os.Mkdir(configDir, os.ModePerm)
		if err != nil {
			log.Printf("create configDir %s error: %s", configDir, err.Error())
			return common.HistoryPath
		}
	}
	return filepath.Join(configDir, common.HistoryPath)
}

// GetVersion returns the application version
func (a *AppConfig) GetVersion() string {
	return common.Version
}

func (a *AppConfig) GetAppName() string {
	return common.AppName
}

func (a *AppConfig) OpenFileDialog(options runtime.OpenDialogOptions) (string, error) {
	return runtime.OpenFileDialog(a.ctx, options)
}

func (a *AppConfig) LogErrToFile(message string) {
	file, err := os.OpenFile(common.ErrLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		log.Println("Failed to open log file:", err)
		return
	}
	defer file.Close()

	if _, err := file.WriteString(message); err != nil {
		log.Println("Failed to write to log file:", err)
	}
}


================================================
FILE: app/backend/service/es.go
================================================
/*
 * Copyright 2025 Bronya0 <tangssst@163.com>.
 * Author Github: https://github.com/Bronya0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package service

import (
	"app/backend/types"
	"bufio"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"
	"sync"
	"time"

	"github.com/go-resty/resty/v2"
)

const (
	FORMAT          = "?format=json&pretty"
	StatsApi        = "/_cluster/stats" + FORMAT
	AddDoc          = "/_doc"
	HealthApi       = "/_cluster/health"
	NodesApi        = "/_nodes/stats/indices,os,fs,process,jvm"
	AllIndexApi     = "/_cat/indices?format=json&pretty&bytes=b"
	ClusterSettings = "/_cluster/settings"
	ForceMerge      = "/_forcemerge?wait_for_completion=false"
	REFRESH         = "/_refresh"
	FLUSH           = "/_flush"
	CacheClear      = "/_cache/clear"
	TasksApi        = "/_tasks" + FORMAT
	CancelTasksApi  = "/_tasks/%s/_cancel"
)

type ESService struct {
	ConnectObj *types.Connect
	Client     *resty.Client
	mu         sync.RWMutex
}

func NewESService() *ESService {
	client := resty.New()
	client.SetTimeout(30 * time.Second)
	client.SetRetryCount(0)
	client.SetHeader("Content-Type", "application/json")
	return &ESService{
		Client:     client,
		ConnectObj: &types.Connect{},
	}
}

func ConfigureSSL(UseSSL, SkipSSLVerify bool, client *resty.Client, CACert string) {
	// Configure SSL
	// CACert是证书内容
	if UseSSL {
		client.SetScheme("https")
		if SkipSSLVerify {
			client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
		}
		if CACert != "" {
			client.SetRootCertificateFromString(CACert)
		}
	} else {
		client.SetScheme("http")
	}
}

func (es *ESService) SetConnect(key, host, username, password, CACert string, UseSSL, SkipSSLVerify bool) {
	es.mu.Lock()         // 加写锁
	defer es.mu.Unlock() // 方法结束时解锁

	es.ConnectObj = &types.Connect{
		Name:          key,
		Host:          host,
		Username:      username,
		Password:      password,
		UseSSL:        UseSSL,
		SkipSSLVerify: SkipSSLVerify,
		CACert:        CACert,
	}
	if username != "" && password != "" {
		es.Client.SetBasicAuth(username, password)
	}
	ConfigureSSL(UseSSL, SkipSSLVerify, es.Client, CACert)

	fmt.Println("设置当前连接:", es.ConnectObj.Host)
}

func (es *ESService) TestClient(host, username, password, CACert string, UseSSL, SkipSSLVerify bool) string {
	client := resty.New()
	if username != "" && password != "" {
		client.SetBasicAuth(username, password)
	}
	// Configure SSL
	ConfigureSSL(UseSSL, SkipSSLVerify, client, CACert)

	resp, err := client.R().Get(host + HealthApi)
	if err != nil {
		return err.Error()
	}
	if resp.StatusCode() != http.StatusOK {
		return string(resp.Body())
	}
	return ""
}

// AddDocument 添加文档
func (es *ESService) AddDocument(index, doc string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any
	resp, err := es.Client.R().
		SetBody(doc).
		SetResult(&result).
		Post(es.ConnectObj.Host + "/" + index + AddDoc)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusCreated {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) GetNodes() *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result any
	resp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + NodesApi)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) GetHealth() *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any
	resp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + HealthApi)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) GetStats() *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any
	resp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + StatsApi)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}

	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) GetIndexes(name string) *types.ResultsResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultsResp{Err: "请先选择一个集群"}
	}
	newUrl := es.ConnectObj.Host + AllIndexApi
	if name != "" {
		newUrl += "&index=" + "*" + name + "*"
	}
	log.Println(newUrl)
	var result []any

	resp, err := es.Client.R().SetResult(&result).Get(newUrl)
	if err != nil {
		return &types.ResultsResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultsResp{Err: string(resp.Body())}
	}

	return &types.ResultsResp{Results: result}

}

func (es *ESService) CreateIndex(name string, numberOfShards, numberOfReplicas int, mapping string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	indexConfig := types.H{
		"settings": types.H{
			"number_of_shards":   numberOfShards,
			"number_of_replicas": numberOfReplicas,
		},
	}
	if mapping != "" {
		var mappings types.H
		err := json.Unmarshal([]byte(mapping), &mappings)
		if err != nil {
			return &types.ResultResp{Err: err.Error()}
		}
		indexConfig["mappings"] = mappings
	}

	resp, err := es.Client.R().
		SetBody(indexConfig).
		Put(es.ConnectObj.Host + "/" + name)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{}

}

func (es *ESService) GetIndexInfo(indexName string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any
	resp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + "/" + indexName)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}

}

func (es *ESService) DeleteIndex(indexName string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any
	resp, err := es.Client.R().SetResult(&result).Delete(es.ConnectObj.Host + "/" + indexName)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}

}

func (es *ESService) OpenCloseIndex(indexName, now string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any
	action, ok := map[string]string{
		"open":  "_close",
		"close": "_open",
	}[now]
	if !ok {
		return &types.ResultResp{Err: "无效的状态参数: " + now}
	}
	resp, err := es.Client.R().SetResult(&result).Post(es.ConnectObj.Host + "/" + indexName + "/" + action)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}

}

func (es *ESService) GetIndexMappings(indexName string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any

	resp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + "/" + indexName)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}

}

func (es *ESService) MergeSegments(indexName string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any

	resp, err := es.Client.R().SetResult(&result).Post(es.ConnectObj.Host + "/" + indexName + ForceMerge)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}

}

func (es *ESService) Refresh(indexName string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any

	resp, err := es.Client.R().SetResult(&result).Post(es.ConnectObj.Host + "/" + indexName + REFRESH)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) Flush(indexName string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any

	resp, err := es.Client.R().SetResult(&result).Post(es.ConnectObj.Host + "/" + indexName + FLUSH)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) CacheClear(indexName string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any

	resp, err := es.Client.R().SetResult(&result).Post(es.ConnectObj.Host + "/" + indexName + CacheClear)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}

	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) GetDoc10(indexName string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}

	body := map[string]any{
		"query": map[string]any{
			"query_string": map[string]any{
				"query": "*",
			},
		},
		"size": 10,
		"from": 0,
		"sort": []any{},
	}
	var result map[string]any

	resp, err := es.Client.R().
		SetBody(body).
		SetResult(&result).
		Post(es.ConnectObj.Host + "/" + indexName + "/_search")
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) Search(method, path string, body any) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result any

	req := es.Client.R().SetResult(&result)
	if body != nil {
		req = req.SetBody(body)
	}
	resp, err := req.Execute(method, es.ConnectObj.Host+path)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) GetClusterSettings() *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any

	resp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + ClusterSettings)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) GetIndexSettings(indexName string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any

	resp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + "/" + indexName)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) GetIndexAliases(indexNameList []string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any

	indexNames := strings.Join(indexNameList, ",")
	resp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + "/" + indexNames + "/_alias")
	if err != nil {
		return &types.ResultResp{Err: err.Error()}

	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	alias := make(map[string]any)
	for name, obj := range result {
		if aliases, ok := obj.(map[string]any)["aliases"]; ok {
			names := make([]string, 0)
			aliases, ok := aliases.(map[string]any)
			if !ok {
				continue
			}
			for aliasName := range aliases {
				names = append(names, aliasName)
			}
			if len(names) > 0 {
				alias[name] = strings.Join(names, ",")
			}
		}
	}
	return &types.ResultResp{Result: alias}
}

func (es *ESService) GetIndexSegments(indexName string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	var result map[string]any

	resp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + "/" + indexName)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

func (es *ESService) GetTasks() *types.ResultsResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultsResp{Err: "请先选择一个集群"}
	}
	var result map[string]any

	resp, err := es.Client.R().SetResult(&result).Get(es.ConnectObj.Host + TasksApi)
	if err != nil {
		return &types.ResultsResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultsResp{Err: string(resp.Body())}
	}
	nodes, ok := result["nodes"].(map[string]any)
	if !ok {
		return &types.ResultsResp{Err: "获取任务列表失败"}
	}

	var data []any
	for _, nodeObj := range nodes {
		nodeTasks, ok := nodeObj.(map[string]any)["tasks"].(map[string]any)
		if !ok {
			continue
		}
		for taskID, taskInfo := range nodeTasks {
			taskInfoMap, ok := taskInfo.(map[string]any)
			if !ok {
				continue
			}
			nodeName, ok := nodeObj.(map[string]any)
			if !ok {
				continue
			}
			nodeIp, ok := nodeObj.(map[string]any)
			if !ok {
				continue
			}
			data = append(data, map[string]any{
				"task_id":               taskID,
				"node_name":             nodeName["name"],
				"node_ip":               nodeIp["ip"],
				"type":                  taskInfoMap["type"],
				"action":                taskInfoMap["action"],
				"start_time_in_millis":  taskInfoMap["start_time_in_millis"],
				"running_time_in_nanos": taskInfoMap["running_time_in_nanos"],
				"cancellable":           taskInfoMap["cancellable"],
				"parent_task_id":        taskInfoMap["parent_task_id"],
			})
		}
	}
	return &types.ResultsResp{Results: data}
}

func (es *ESService) CancelTasks(taskID string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}

	newUrl := fmt.Sprintf(es.ConnectObj.Host+CancelTasksApi, url.PathEscape(taskID))
	var result map[string]any

	resp, err := es.Client.R().SetResult(&result).Post(newUrl)
	if err != nil {
		return &types.ResultResp{Err: err.Error()}

	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// GetSnapshots 获取ES快照列表
func (es *ESService) GetSnapshots() *types.ResultsResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultsResp{Err: "请先选择一个集群"}
	}

	// 1. 首先获取所有仓库列表
	var repositories map[string]interface{} // 修改目标类型
	reposResp, err := es.Client.R().Get(es.ConnectObj.Host + "/_snapshot")
	if err != nil {
		return &types.ResultsResp{Err: err.Error()}
	}
	if reposResp.StatusCode() != http.StatusOK {
		return &types.ResultsResp{Err: string(reposResp.Body())}
	}
	err = json.Unmarshal(reposResp.Body(), &repositories) // 直接解析到 map
	if err != nil {
		return &types.ResultsResp{Err: err.Error()}
	}

	// 2. 并发遍历每个仓库获取其快照
	var (
		mu           sync.Mutex
		allSnapshots []any
		wg           sync.WaitGroup
	)
	for repoName := range repositories {
		wg.Add(1)
		go func(repo string) {
			defer wg.Done()
			var repoResult map[string]interface{}
			resp, err := es.Client.R().SetResult(&repoResult).Get(es.ConnectObj.Host + "/_snapshot/" + repo + "/_all")
			if err != nil || resp.StatusCode() != http.StatusOK {
				return
			}

			// 3. 处理每个快照的数据(带安全类型断言)
			snapshots, ok := repoResult["snapshots"].([]interface{})
			if !ok {
				return
			}
			var items []any
			for _, snap := range snapshots {
				snapshot, ok := snap.(map[string]interface{})
				if !ok {
					continue
				}
				state, _ := snapshot["state"].(string)
				items = append(items, map[string]interface{}{
					"snapshot":          snapshot["snapshot"],
					"repository":        repo,
					"state":             strings.ToUpper(state),
					"start_time":        snapshot["start_time"],
					"end_time":          snapshot["end_time"],
					"indices":           snapshot["indices"],
					"total_shards":      snapshot["shards_total"],
					"successful_shards": snapshot["shards_successful"],
				})
			}
			mu.Lock()
			allSnapshots = append(allSnapshots, items...)
			mu.Unlock()
		}(repoName)
	}
	wg.Wait()

	return &types.ResultsResp{Results: allSnapshots}
}

// GetSnapshotRepositories 获取所有快照仓库
func (es *ESService) GetSnapshotRepositories() *types.ResultsResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultsResp{Err: "请先选择一个集群"}
	}

	var repositories map[string]any
	resp, err := es.Client.R().Get(es.ConnectObj.Host + "/_snapshot")
	if err != nil {
		return &types.ResultsResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultsResp{Err: string(resp.Body())}
	}
	err = json.Unmarshal(resp.Body(), &repositories)
	if err != nil {
		return &types.ResultsResp{Err: err.Error()}
	}

	var data []any
	for name, info := range repositories {
		repoInfo, ok := info.(map[string]any)
		if !ok {
			continue
		}
		item := map[string]any{
			"name":     name,
			"type":     repoInfo["type"],
			"settings": repoInfo["settings"],
		}
		data = append(data, item)
	}
	return &types.ResultsResp{Results: data}
}

// CreateSnapshotRepository 创建快照仓库
func (es *ESService) CreateSnapshotRepository(name, repoType, settings string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	if name == "" || repoType == "" {
		return &types.ResultResp{Err: "仓库名称和类型不能为空"}
	}

	var settingsMap map[string]any
	if settings != "" {
		if err := json.Unmarshal([]byte(settings), &settingsMap); err != nil {
			return &types.ResultResp{Err: "settings JSON 格式无效: " + err.Error()}
		}
	}

	body := map[string]any{
		"type":     repoType,
		"settings": settingsMap,
	}

	var result map[string]any
	resp, err := es.Client.R().
		SetBody(body).
		SetResult(&result).
		Put(es.ConnectObj.Host + "/_snapshot/" + url.PathEscape(name))
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// DeleteSnapshotRepository 删除快照仓库
func (es *ESService) DeleteSnapshotRepository(name string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	if name == "" {
		return &types.ResultResp{Err: "仓库名称不能为空"}
	}

	var result map[string]any
	resp, err := es.Client.R().
		SetResult(&result).
		Delete(es.ConnectObj.Host + "/_snapshot/" + url.PathEscape(name))
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// VerifySnapshotRepository 验证快照仓库
func (es *ESService) VerifySnapshotRepository(name string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	if name == "" {
		return &types.ResultResp{Err: "仓库名称不能为空"}
	}

	var result map[string]any
	resp, err := es.Client.R().
		SetResult(&result).
		Post(es.ConnectObj.Host + "/_snapshot/" + url.PathEscape(name) + "/_verify")
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// CreateSnapshot 创建快照(异步)
func (es *ESService) CreateSnapshot(repository, snapshot, indices string, includeGlobalState bool) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	if repository == "" || snapshot == "" {
		return &types.ResultResp{Err: "仓库名称和快照名称不能为空"}
	}

	body := map[string]any{
		"include_global_state": includeGlobalState,
	}
	if indices != "" {
		body["indices"] = indices
	}

	var result map[string]any
	resp, err := es.Client.R().
		SetBody(body).
		SetResult(&result).
		Put(es.ConnectObj.Host + "/_snapshot/" + url.PathEscape(repository) + "/" + url.PathEscape(snapshot) + "?wait_for_completion=false")
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusAccepted {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// DeleteSnapshot 删除快照
func (es *ESService) DeleteSnapshot(repository, snapshot string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	if repository == "" || snapshot == "" {
		return &types.ResultResp{Err: "仓库名称和快照名称不能为空"}
	}

	var result map[string]any
	resp, err := es.Client.R().
		SetResult(&result).
		Delete(es.ConnectObj.Host + "/_snapshot/" + url.PathEscape(repository) + "/" + url.PathEscape(snapshot))
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// GetSnapshotDetail 获取快照详情
func (es *ESService) GetSnapshotDetail(repository, snapshot string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	if repository == "" || snapshot == "" {
		return &types.ResultResp{Err: "仓库名称和快照名称不能为空"}
	}

	var result map[string]any
	resp, err := es.Client.R().
		SetResult(&result).
		Get(es.ConnectObj.Host + "/_snapshot/" + url.PathEscape(repository) + "/" + url.PathEscape(snapshot))
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// RestoreSnapshot 恢复快照(异步)
func (es *ESService) RestoreSnapshot(repository, snapshot, indices, renamePattern, renameReplacement string, includeGlobalState bool) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	if repository == "" || snapshot == "" {
		return &types.ResultResp{Err: "仓库名称和快照名称不能为空"}
	}

	body := map[string]any{
		"include_global_state": includeGlobalState,
	}
	if indices != "" {
		body["indices"] = indices
	}
	if renamePattern != "" {
		body["rename_pattern"] = renamePattern
	}
	if renameReplacement != "" {
		body["rename_replacement"] = renameReplacement
	}

	var result map[string]any
	resp, err := es.Client.R().
		SetBody(body).
		SetResult(&result).
		Post(es.ConnectObj.Host + "/_snapshot/" + url.PathEscape(repository) + "/" + url.PathEscape(snapshot) + "/_restore?wait_for_completion=false")
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusAccepted {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// GetSnapshotRestoreStatus 获取快照恢复状态
func (es *ESService) GetSnapshotRestoreStatus() *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}

	var result map[string]any
	resp, err := es.Client.R().
		SetResult(&result).
		Get(es.ConnectObj.Host + "/_recovery?active_only=true&format=json")
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// GetSLMPolicies 获取所有SLM策略
func (es *ESService) GetSLMPolicies() *types.ResultsResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultsResp{Err: "请先选择一个集群"}
	}

	var result []any
	resp, err := es.Client.R().Get(es.ConnectObj.Host + "/_slm/policy")
	if err != nil {
		return &types.ResultsResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultsResp{Err: string(resp.Body())}
	}
	// SLM API 返回的是一个对象 {policyId: {...}, ...}
	var policiesMap map[string]any
	if err := json.Unmarshal(resp.Body(), &policiesMap); err != nil {
		return &types.ResultsResp{Err: err.Error()}
	}

	for name, info := range policiesMap {
		policyInfo, ok := info.(map[string]any)
		if !ok {
			continue
		}
		policyInfo["name"] = name
		result = append(result, policyInfo)
	}
	return &types.ResultsResp{Results: result}
}

// CreateSLMPolicy 创建SLM策略
func (es *ESService) CreateSLMPolicy(policyId, name, schedule, repository, indices, expireAfter string, minCount, maxCount int) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	if policyId == "" || schedule == "" || repository == "" {
		return &types.ResultResp{Err: "策略ID、调度计划和仓库名称不能为空"}
	}

	body := map[string]any{
		"schedule":   schedule,
		"name":       name,
		"repository": repository,
		"config": map[string]any{
			"indices":              []string{"*"},
			"include_global_state": false,
		},
	}
	if indices != "" {
		body["config"].(map[string]any)["indices"] = strings.Split(indices, ",")
	}

	retention := map[string]any{}
	if expireAfter != "" {
		retention["expire_after"] = expireAfter
	}
	if minCount > 0 {
		retention["min_count"] = minCount
	}
	if maxCount > 0 {
		retention["max_count"] = maxCount
	}
	if len(retention) > 0 {
		body["retention"] = retention
	}

	var result map[string]any
	resp, err := es.Client.R().
		SetBody(body).
		SetResult(&result).
		Put(es.ConnectObj.Host + "/_slm/policy/" + url.PathEscape(policyId))
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// DeleteSLMPolicy 删除SLM策略
func (es *ESService) DeleteSLMPolicy(policyId string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	if policyId == "" {
		return &types.ResultResp{Err: "策略ID不能为空"}
	}

	var result map[string]any
	resp, err := es.Client.R().
		SetResult(&result).
		Delete(es.ConnectObj.Host + "/_slm/policy/" + url.PathEscape(policyId))
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// ExecuteSLMPolicy 手动执行SLM策略
func (es *ESService) ExecuteSLMPolicy(policyId string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}
	if policyId == "" {
		return &types.ResultResp{Err: "策略ID不能为空"}
	}

	var result map[string]any
	resp, err := es.Client.R().
		SetResult(&result).
		Post(es.ConnectObj.Host + "/_slm/policy/" + url.PathEscape(policyId) + "/_execute")
	if err != nil {
		return &types.ResultResp{Err: err.Error()}
	}
	if resp.StatusCode() != http.StatusOK {
		return &types.ResultResp{Err: string(resp.Body())}
	}
	return &types.ResultResp{Result: result}
}

// SearchResponse 定义 ES 搜索响应的结构
type SearchResponse struct {
	ScrollID string `json:"_scroll_id"`
	Hits     struct {
		Total struct {
			Value int `json:"value"`
		} `json:"total"`
		Hits []struct {
			Source json.RawMessage `json:"_source"`
		} `json:"hits"`
	} `json:"hits"`
}

// DownloadESIndex 使用 Resty 客户端从 ES 下载指定索引的数据
func (es *ESService) DownloadESIndex(index string, queryDSL string, filePath string) *types.ResultResp {
	if es.ConnectObj.Host == "" {
		return &types.ResultResp{Err: "请先选择一个集群"}
	}

	res := &types.ResultResp{}
	// 如果 queryDSL 为空,默认使用 match_all 查询
	if queryDSL == "" {
		queryDSL = `{"match_all": {}}`
	}

	// 创建本地文件
	file, err := os.Create(filePath)
	if err != nil {
		res.Err = fmt.Sprintf("创建文件失败: %v", err)
		return res
	}
	success := false
	defer func() {
		file.Close()
		if !success {
			os.Remove(filePath) // 下载失败时清理残缺文件
		}
	}()

	// 构造初始搜索请求的 body,设置每批次大小为 10000
	bodyStr := fmt.Sprintf(`{"size": 10000, "query": %s}`, queryDSL)
	resp, err := es.Client.R().SetBody(bodyStr).Post(es.ConnectObj.Host + "/" + index + "/_search?scroll=3m")
	if err != nil {
		res.Err = fmt.Sprintf("初始搜索请求失败: %v", err)
		return res
	}
	if resp.StatusCode() != 200 {
		res.Err = "初始搜索请求返回非 200 状态码"
		return res
	}

	// 解析初始响应
	var searchResponse SearchResponse
	err = json.Unmarshal(resp.Body(), &searchResponse)
	if err != nil {
		res.Err = fmt.Sprintf("解析初始响应失败: %v", err)
		return res
	}

	// 使用 bufio.Writer 进行缓冲写入
	writer := bufio.NewWriter(file)
	defer writer.Flush() // 确保缓冲区数据在函数结束时写入文件

	// 写入 JSON 数组的开头
	_, _ = writer.WriteString("[")

	// 标志变量,用于控制逗号分隔符
	isFirst := true

	// 循环处理滚动下载
	for {
		// 如果当前批次没有文档,则退出循环
		if len(searchResponse.Hits.Hits) == 0 {
			break
		}

		// 遍历当前批次的每个文档
		for _, hit := range searchResponse.Hits.Hits {
			if !isFirst {
				// 除了第一个文档前,其他文档前添加逗号
				_, _ = writer.WriteString(",")
			}
			isFirst = false
			// 直接写入文档的 _source 字段(json.RawMessage 是 []byte 类型)
			_, _ = writer.Write(hit.Source)
		}

		// 发送滚动请求获取下一批数据
		scrollBody := map[string]interface{}{
			"scroll":    "3m", // 滚动上下文有效期 1 分钟
			"scroll_id": searchResponse.ScrollID,
		}
		resp, err = es.Client.R().SetBody(scrollBody).Post(es.ConnectObj.Host + "/_search/scroll")
		if err != nil {
			res.Err = fmt.Sprintf("滚动请求失败: %v", err)
			return res
		}
		if resp.StatusCode() != 200 {
			res.Err = fmt.Sprintf("滚动请求返回非 200 状态码 %v", resp.StatusCode())
			return res
		}

		// 解析滚动响应
		err = json.Unmarshal(resp.Body(), &searchResponse)
		if err != nil {
			res.Err = fmt.Sprintf("解析滚动响应失败: %v", err)
			return res
		}
	}

	// 写入 JSON 数组的结尾
	_, _ = writer.WriteString("]")

	success = true
	return res
}


================================================
FILE: app/backend/system/update.go
================================================
/*
 * Copyright 2025 Bronya0 <tangssst@163.com>.
 * Author Github: https://github.com/Bronya0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package system

import (
	"app/backend/common"
	"app/backend/types"
	"context"
	"fmt"
	"github.com/go-resty/resty/v2"
	"runtime"
	"runtime/debug"
	"strings"
	"time"
)

type Update struct {
	ctx context.Context
}

func (obj *Update) Start(ctx context.Context) {
	obj.ctx = ctx
}
func (obj *Update) CheckUpdate() *types.Tag {
	client := resty.New()
	tag := &types.Tag{}
	resp, err := client.R().SetResult(tag).Get(common.UPDATE_URL)
	if err != nil || resp.StatusCode() != 200 {
		return nil
	}
	tag.TagName = strings.TrimSpace(tag.TagName)
	return tag
}

func (obj *Update) GetProcessInfo() string {
	// 获取内存统计信息
	var memStats runtime.MemStats
	runtime.ReadMemStats(&memStats)

	// 获取构建信息
	var goVersion string
	if buildInfo, ok := debug.ReadBuildInfo(); ok {
		goVersion = buildInfo.GoVersion
	} else {
		goVersion = runtime.Version()
	}

	// 格式化输出详细信息
	info := fmt.Sprintf(
		"基本信息:\n"+
			"- Go版本: %s\n"+
			"- 操作系统: %s\n"+
			"- 体系结构: %s\n"+
			"- CPU数量: %d\n"+
			"- 协程数量: %d\n"+
			"- 当前时间戳: %s\n\n"+
			"内存统计:\n"+
			"- 已分配内存: %.2f MB\n"+
			"- 总分配内存: %.2f MB\n"+
			"- 系统内存: %.2f MB\n"+
			"- 堆分配: %.2f MB\n"+
			"- 堆系统内存: %.2f MB\n"+
			"- 堆空闲: %.2f MB\n"+
			"- 堆使用中: %.2f MB\n"+
			"- 栈使用中: %.2f MB\n"+
			"- 堆对象数量: %d\n"+
			"- 内存分配次数: %d\n"+
			"- 内存释放次数: %d\n\n"+
			"垃圾回收统计:\n"+
			"- 垃圾回收运行次数: %d\n"+
			"- 上次垃圾回收时间: %s\n"+
			"- 下次垃圾回收限制: %.2f MB\n"+
			"- 垃圾回收CPU占比: %.4f%%\n"+
			"- 垃圾回收总暂停时间: %v\n",
		goVersion,                        // Go版本
		runtime.GOOS,                     // 操作系统
		runtime.GOARCH,                   // 体系结构
		runtime.NumCPU(),                 // CPU数量
		runtime.NumGoroutine(),           // 协程数量
		time.Now().Format(time.DateTime), // 当前时间戳

		float64(memStats.Alloc)/1024/1024,      // 已分配内存
		float64(memStats.TotalAlloc)/1024/1024, // 总分配内存
		float64(memStats.Sys)/1024/1024,        // 系统内存
		float64(memStats.HeapAlloc)/1024/1024,  // 堆分配
		float64(memStats.HeapSys)/1024/1024,    // 堆系统内存
		float64(memStats.HeapIdle)/1024/1024,   // 堆空闲
		float64(memStats.HeapInuse)/1024/1024,  // 堆使用中
		float64(memStats.StackInuse)/1024/1024, // 栈使用中
		memStats.HeapObjects,                   // 堆对象数量
		memStats.Mallocs,                       // 内存分配次数
		memStats.Frees,                         // 内存释放次数

		memStats.NumGC, // 垃圾回收运行次数
		time.Unix(0, int64(memStats.LastGC)).Format(time.DateTime), // 上次垃圾回收时间
		float64(memStats.NextGC)/1024/1024,                         // 下次垃圾回收限制
		memStats.GCCPUFraction*100,                                 // 垃圾回收CPU占比
		time.Duration(memStats.PauseTotalNs),                       // 垃圾回收总暂停时间
	)

	return info
}


================================================
FILE: app/backend/types/resp.go
================================================
/*
 * Copyright 2025 Bronya0 <tangssst@163.com>.
 * Author Github: https://github.com/Bronya0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package types

type Tag struct {
	Name    string `json:"name"`
	TagName string `json:"tag_name"`
	Body    string `json:"body"`
}
type Config struct {
	Width    int       `json:"width"`
	Height   int       `json:"height"`
	Language string    `json:"language"`
	Theme    string    `json:"theme"`
	Connects []Connect `json:"connects"`
}
type History struct {
	Time   int    `json:"timestamp"`
	Method string `json:"method"`
	Path   string `json:"path"`
	DSL    string `json:"dsl"`
}
type ResultsResp struct {
	Results []any  `json:"results"`
	Err     string `json:"err"`
}
type ResultResp struct {
	Result any    `json:"result"`
	Err    string `json:"err"`
}
type Connect struct {
	Id            int    `json:"id"`
	Name          string `json:"name"`
	Host          string `json:"host"`
	Username      string `json:"username"`
	Password      string `json:"password"`
	UseSSL        bool   `json:"useSSL"`
	SkipSSLVerify bool   `json:"skipSSLVerify"`
	CACert        string `json:"caCert"`
}
type H map[string]any


================================================
FILE: app/build/README.md
================================================
# Build Directory

The build directory is used to house all the build files and assets for your application. 

The structure is:

* bin - Output directory
* darwin - macOS specific files
* windows - Windows specific files

## Mac

The `darwin` directory holds files specific to Mac builds.
These may be customised and used as part of the build. To return these files to the default state, simply delete them
and
build with `wails build`.

The directory contains the following files:

- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.

## Windows

The `windows` directory contains the manifest and rc files used when building with `wails build`.
These may be customised for your application. To return these files to the default state, simply delete them and
build with `wails build`.

- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
  use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
  will be created using the `appicon.png` file in the build directory.
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
  as well as the application itself (right click the exe -> properties -> details)
- `wails.exe.manifest` - The main application manifest file.

================================================
FILE: app/build/darwin/Info.dev.plist
================================================
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>CFBundlePackageType</key>
        <string>APPL</string>
        <key>CFBundleName</key>
        <string>{{.Info.ProductName}}</string>
        <key>CFBundleExecutable</key>
        <string>{{.Name}}</string>
        <key>CFBundleIdentifier</key>
        <string>com.wails.{{.Name}}</string>
        <key>CFBundleVersion</key>
        <string>{{.Info.ProductVersion}}</string>
        <key>CFBundleGetInfoString</key>
        <string>{{.Info.Comments}}</string>
        <key>CFBundleShortVersionString</key>
        <string>{{.Info.ProductVersion}}</string>
        <key>CFBundleIconFile</key>
        <string>iconfile</string>
        <key>LSMinimumSystemVersion</key>
        <string>10.13.0</string>
        <key>NSHighResolutionCapable</key>
        <string>true</string>
        <key>NSHumanReadableCopyright</key>
        <string>{{.Info.Copyright}}</string>
        {{if .Info.FileAssociations}}
        <key>CFBundleDocumentTypes</key>
        <array>
          {{range .Info.FileAssociations}}
          <dict>
            <key>CFBundleTypeExtensions</key>
            <array>
              <string>{{.Ext}}</string>
            </array>
            <key>CFBundleTypeName</key>
            <string>{{.Name}}</string>
            <key>CFBundleTypeRole</key>
            <string>{{.Role}}</string>
            <key>CFBundleTypeIconFile</key>
            <string>{{.IconName}}</string>
          </dict>
          {{end}}
        </array>
        {{end}}
        {{if .Info.Protocols}}
        <key>CFBundleURLTypes</key>
        <array>
          {{range .Info.Protocols}}
            <dict>
                <key>CFBundleURLName</key>
                <string>com.wails.{{.Scheme}}</string>
                <key>CFBundleURLSchemes</key>
                <array>
                    <string>{{.Scheme}}</string>
                </array>
                <key>CFBundleTypeRole</key>
                <string>{{.Role}}</string>
            </dict>
          {{end}}
        </array>
        {{end}}
        <key>NSAppTransportSecurity</key>
        <dict>
            <key>NSAllowsLocalNetworking</key>
            <true/>
        </dict>
    </dict>
</plist>


================================================
FILE: app/build/darwin/Info.plist
================================================
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>CFBundlePackageType</key>
        <string>APPL</string>
        <key>CFBundleName</key>
        <string>{{.Info.ProductName}}</string>
        <key>CFBundleExecutable</key>
        <string>{{.Name}}</string>
        <key>CFBundleIdentifier</key>
        <string>com.github.Bronya0.{{.Name}}</string>
        <key>CFBundleVersion</key>
        <string>{{.Info.ProductVersion}}</string>
        <key>CFBundleGetInfoString</key>
        <string>{{.Info.Comments}}</string>
        <key>CFBundleShortVersionString</key>
        <string>{{.Info.ProductVersion}}</string>
        <key>CFBundleIconFile</key>
        <string>iconfile</string>
        <key>LSMinimumSystemVersion</key>
        <string>10.13.0</string>
        <key>NSHighResolutionCapable</key>
        <string>true</string>
        <key>NSHumanReadableCopyright</key>
        <string>{{.Info.Copyright}}</string>
        {{if .Info.FileAssociations}}
        <key>CFBundleDocumentTypes</key>
        <array>
          {{range .Info.FileAssociations}}
          <dict>
            <key>CFBundleTypeExtensions</key>
            <array>
              <string>{{.Ext}}</string>
            </array>
            <key>CFBundleTypeName</key>
            <string>{{.Name}}</string>
            <key>CFBundleTypeRole</key>
            <string>{{.Role}}</string>
            <key>CFBundleTypeIconFile</key>
            <string>{{.IconName}}</string>
          </dict>
          {{end}}
        </array>
        {{end}}
        {{if .Info.Protocols}}
        <key>CFBundleURLTypes</key>
        <array>
          {{range .Info.Protocols}}
            <dict>
                <key>CFBundleURLName</key>
                <string>com.wails.{{.Scheme}}</string>
                <key>CFBundleURLSchemes</key>
                <array>
                    <string>{{.Scheme}}</string>
                </array>
                <key>CFBundleTypeRole</key>
                <string>{{.Role}}</string>
            </dict>
          {{end}}
        </array>
        {{end}}
    </dict>
</plist>


================================================
FILE: app/build/windows/info.json
================================================
{
	"fixed": {
		"file_version": "{{.Info.ProductVersion}}"
	},
	"info": {
		"0000": {
			"ProductVersion": "{{.Info.ProductVersion}}",
			"CompanyName": "{{.Info.CompanyName}}",
			"FileDescription": "{{.Info.ProductName}}",
			"LegalCopyright": "{{.Info.Copyright}}",
			"ProductName": "{{.Info.ProductName}}",
			"Comments": "{{.Info.Comments}}"
		}
	}
}

================================================
FILE: app/build/windows/installer/project.nsi
================================================
Unicode true

####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME    "MyProject" # Default "{{.Name}}"
## !define INFO_COMPANYNAME    "MyCompany" # Default "{{.Info.CompanyName}}"
## !define INFO_PRODUCTNAME    "MyProduct" # Default "{{.Info.ProductName}}"
## !define INFO_PRODUCTVERSION "1.0.0"     # Default "{{.Info.ProductVersion}}"
## !define INFO_COPYRIGHT      "Copyright" # Default "{{.Info.Copyright}}"
###
## !define PRODUCT_EXECUTABLE  "Application.exe"      # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME     "UninstKeyInRegistry"  # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin"            # Default "admin"  see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"

# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion    "${INFO_PRODUCTVERSION}.0"

VIAddVersionKey "CompanyName"     "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion"  "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion"     "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright"  "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName"     "${INFO_PRODUCTNAME}"

# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true

!include "MUI.nsh"

!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.

!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.

!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page

!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer

## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'

Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.

Function .onInit
   !insertmacro wails.checkArchitecture
FunctionEnd

Section
    !insertmacro wails.setShellContext

    !insertmacro wails.webview2runtime

    SetOutPath $INSTDIR

    !insertmacro wails.files

    CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
    CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"

    !insertmacro wails.associateFiles
    !insertmacro wails.associateCustomProtocols

    !insertmacro wails.writeUninstaller
SectionEnd

Section "uninstall"
    !insertmacro wails.setShellContext

    RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath

    RMDir /r $INSTDIR

    Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
    Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"

    !insertmacro wails.unassociateFiles
    !insertmacro wails.unassociateCustomProtocols

    !insertmacro wails.deleteUninstaller
SectionEnd


================================================
FILE: app/build/windows/installer/wails_tools.nsh
================================================
# DO NOT EDIT - Generated automatically by `wails build`

!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"

!ifndef INFO_PROJECTNAME
    !define INFO_PROJECTNAME "demo-app"
!endif
!ifndef INFO_COMPANYNAME
    !define INFO_COMPANYNAME "demo-app"
!endif
!ifndef INFO_PRODUCTNAME
    !define INFO_PRODUCTNAME "demo-app"
!endif
!ifndef INFO_PRODUCTVERSION
    !define INFO_PRODUCTVERSION "1.0.0"
!endif
!ifndef INFO_COPYRIGHT
    !define INFO_COPYRIGHT "Copyright........."
!endif
!ifndef PRODUCT_EXECUTABLE
    !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
    !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"

!ifndef REQUEST_EXECUTION_LEVEL
    !define REQUEST_EXECUTION_LEVEL "admin"
!endif

RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"

!ifdef ARG_WAILS_AMD64_BINARY
    !define SUPPORTS_AMD64
!endif

!ifdef ARG_WAILS_ARM64_BINARY
    !define SUPPORTS_ARM64
!endif

!ifdef SUPPORTS_AMD64
    !ifdef SUPPORTS_ARM64
        !define ARCH "amd64_arm64"
    !else
        !define ARCH "amd64"
    !endif
!else
    !ifdef SUPPORTS_ARM64
        !define ARCH "arm64"
    !else
        !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
    !endif
!endif

!macro wails.checkArchitecture
    !ifndef WAILS_WIN10_REQUIRED
        !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
    !endif

    !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
        !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
    !endif

    ${If} ${AtLeastWin10}
        !ifdef SUPPORTS_AMD64
            ${if} ${IsNativeAMD64}
                Goto ok
            ${EndIf}
        !endif

        !ifdef SUPPORTS_ARM64
            ${if} ${IsNativeARM64}
                Goto ok
            ${EndIf}
        !endif

        IfSilent silentArch notSilentArch
        silentArch:
            SetErrorLevel 65
            Abort
        notSilentArch:
            MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
            Quit
    ${else}
        IfSilent silentWin notSilentWin
        silentWin:
            SetErrorLevel 64
            Abort
        notSilentWin:
            MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
            Quit
    ${EndIf}

    ok:
!macroend

!macro wails.files
    !ifdef SUPPORTS_AMD64
        ${if} ${IsNativeAMD64}
            File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
        ${EndIf}
    !endif

    !ifdef SUPPORTS_ARM64
        ${if} ${IsNativeARM64}
            File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
        ${EndIf}
    !endif
!macroend

!macro wails.writeUninstaller
    WriteUninstaller "$INSTDIR\uninstall.exe"

    SetRegView 64
    WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
    WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
    WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
    WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
    WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
    WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"

    ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
    IntFmt $0 "0x%08X" $0
    WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend

!macro wails.deleteUninstaller
    Delete "$INSTDIR\uninstall.exe"

    SetRegView 64
    DeleteRegKey HKLM "${UNINST_KEY}"
!macroend

!macro wails.setShellContext
    ${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
        SetShellVarContext all
    ${else}
        SetShellVarContext current
    ${EndIf}
!macroend

# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
    !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
        !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
    !endif

    SetRegView 64
	# If the admin key exists and is not empty then webview2 is already installed
	ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
    ${If} $0 != ""
        Goto ok
    ${EndIf}

    ${If} ${REQUEST_EXECUTION_LEVEL} == "user"
        # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
	    ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
        ${If} $0 != ""
            Goto ok
        ${EndIf}
     ${EndIf}

	SetDetailsPrint both
    DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
    SetDetailsPrint listonly

    InitPluginsDir
    CreateDirectory "$pluginsdir\webview2bootstrapper"
    SetOutPath "$pluginsdir\webview2bootstrapper"
    File "tmp\MicrosoftEdgeWebview2Setup.exe"
    ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'

    SetDetailsPrint both
    ok:
!macroend

# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
  ; Backup the previously associated file class
  ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
  WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"

  WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"

  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
  WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend

!macro APP_UNASSOCIATE EXT FILECLASS
  ; Backup the previously associated file class
  ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
  WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"

  DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend

!macro wails.associateFiles
    ; Create file associations
    
!macroend

!macro wails.unassociateFiles
    ; Delete app associations
    
!macroend

!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
  DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
  WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend

!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
  DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend

!macro wails.associateCustomProtocols
    ; Create custom protocols associations
    
!macroend

!macro wails.unassociateCustomProtocols
    ; Delete app custom protocol associations
    
!macroend


================================================
FILE: app/build/windows/wails.exe.manifest
================================================
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
    <assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
    <dependency>
        <dependentAssembly>
            <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
        </dependentAssembly>
    </dependency>
    <asmv3:application>
        <asmv3:windowsSettings>
            <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
            <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
        </asmv3:windowsSettings>
    </asmv3:application>
</assembly>

================================================
FILE: app/dev.bat
================================================
chcp 65001

wails dev

================================================
FILE: app/frontend/index.html
================================================
<!--
  ~ Copyright 2025 Bronya0 <tangssst@163.com>.
  ~ Author Github: https://github.com/Bronya0
  ~
  ~ Licensed under the Apache License, Version 2.0 (the "License");
  ~ you may not use this file except in compliance with the License.
  ~ You may obtain a copy of the License at
  ~
  ~     https://www.apache.org/licenses/LICENSE-2.0
  ~
  ~ Unless required by applicable law or agreed to in writing, software
  ~ distributed under the License is distributed on an "AS IS" BASIS,
  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
  -->

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
    <title>wails-naive-demo</title>
    <link href="./src/style.css" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script src="./src/main.js" type="module"></script>
</body>
</html>



================================================
FILE: app/frontend/package.json
================================================
{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "main": "",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "ace-builds": "1.4.12",
    "highlight.js": "^11.10.0",
    "jsoneditor": "^10.1.0",
    "mitt": "^3.0.1",
    "vue": "^3.5.8"
  },
  "devDependencies": {
    "@vicons/material": "^0.12.0",
    "@vitejs/plugin-vue": "^6.0.0",
    "naive-ui": "^2.39.0",
    "vite": "7.1.5"
  },
  "keywords": [],
  "author": "bronya0"
}


================================================
FILE: app/frontend/src/App.vue
================================================
<!--
  - Copyright 2025 Bronya0 <tangssst@163.com>.
  - Author Github: https://github.com/Bronya0
  -
  - Licensed under the Apache License, Version 2.0 (the "License");
  - you may not use this file except in compliance with the License.
  - You may obtain a copy of the License at
  -
  -     https://www.apache.org/licenses/LICENSE-2.0
  -
  - Unless required by applicable law or agreed to in writing, software
  - distributed under the License is distributed on an "AS IS" BASIS,
  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  - See the License for the specific language governing permissions and
  - limitations under the License.
  -->

<template>
  <n-config-provider
      :theme="Theme"
      :hljs="hljs"
      :locale="zhCN" :date-locale="dateZhCN"
  >
    <!--https://www.naiveui.com/zh-CN/os-theme/components/layout-->
    <n-message-provider container-style=" word-break: break-all;">
      <n-notification-provider placement="bottom-right" container-style="text-align: left;">
        <n-dialog-provider>
          <n-loading-bar-provider>

            <n-layout has-sider position="absolute" style="height: 100vh;" :class="headerClass">
              <!--header-->
              <n-layout-header bordered style="height: 42px; bottom: 0; padding: 0; ">
                <Header />
              </n-layout-header>
              <!--side + content-->
              <n-layout has-sider position="absolute" style="top: 42px; bottom: 0;">
                <n-layout-sider
                    bordered
                    collapse-mode="width"
                    :collapsed-width="60"
                    :collapsed="true"
                    style="--wails-draggable:drag"
                >
                  <Aside
                      :collapsed-width="60"
                      :value="activeItem.key"
                      :options="sideMenuOptions"
                  />

                </n-layout-sider>
                <n-layout-content style="padding: 0 16px;">
                  <keep-alive>
                    <component :is="activeItem.component"></component>
                  </keep-alive>

                </n-layout-content>
              </n-layout>
            </n-layout>
          </n-loading-bar-provider>
        </n-dialog-provider>
      </n-notification-provider>
    </n-message-provider>
  </n-config-provider>
</template>

<script setup>
import {onMounted, ref, shallowRef} from 'vue'
import {
  darkTheme,
  lightTheme,
  NConfigProvider,
  NLayout,
  NLayoutContent,
  NLayoutHeader,
  NMessageProvider,
} from 'naive-ui'
import Header from './components/Header.vue'
import Settings from './components/Settings.vue'
import Health from './components/Health.vue'
import Core from './components/Core.vue'
import Nodes from './components/Nodes.vue'
import Index from './components/Index.vue'
import Rest from './components/Rest.vue'
import Conn from './components/Conn.vue'
import Task from './components/Task.vue'
import Snapshot from './components/Snapshot.vue'
import About from './components/About.vue'
import {GetConfig, SaveTheme} from "../wailsjs/go/config/AppConfig";
import {renderIcon} from "./utils/common";
import Aside from "./components/Aside.vue";
import emitter from "./utils/eventBus";
import {
  FavoriteTwotone,
  HiveOutlined,
  SettingsSuggestOutlined, TaskAltFilled,
  ApiOutlined, LibraryBooksOutlined, AllOutOutlined, BarChartOutlined, AddAPhotoTwotone, InfoOutlined
} from '@vicons/material'
import hljs from 'highlight.js/lib/core'
import json from 'highlight.js/lib/languages/json'
import { zhCN, dateZhCN } from 'naive-ui'

let headerClass = shallowRef('lightTheme')
let Theme = shallowRef(lightTheme)


hljs.registerLanguage('json', json)

onMounted(async () => {
  // 从后端加载配置
  const loadedConfig = await GetConfig()
  // 设置主题
  themeChange(loadedConfig.theme)
  // 语言切换
  // handleLanguageChange(loadedConfig.language)

  // =====================注册事件监听=====================
  // 主题切换
  emitter.on('update_theme', themeChange)
  // 菜单切换
  emitter.on('menu_select', handleMenuSelect)
})


// 左侧菜单
const sideMenuOptions = [
  {
    label: '集群',
    key: '集群',
    icon: renderIcon(HiveOutlined),
    component: Conn,
  },
  {
    label: '节点',
    key: '节点',
    icon: renderIcon(AllOutOutlined),
    component: Nodes,
  },
  {
    label: '索引',
    key: '索引',
    icon: renderIcon(LibraryBooksOutlined),
    component: Index,
  },
  {
    label: 'REST',
    key: 'REST',
    icon: renderIcon(ApiOutlined),
    component: Rest,
  },
  {
    label: 'Task',
    key: 'Task',
    icon: renderIcon(TaskAltFilled),
    component: Task,
  },
  {
    label: '健康',
    key: '健康',
    icon: renderIcon(FavoriteTwotone),
    component: Health,
  },
  {
    label: '指标',
    key: '指标',
    icon: renderIcon(BarChartOutlined),
    component: Core,
  },
  {
    label: '快照',
    key: '快照',
    icon: renderIcon(AddAPhotoTwotone),
    component: Snapshot,
  },
  {
    label: '设置',
    key: '设置',
    icon: renderIcon(SettingsSuggestOutlined),
    component: Settings
  },
  {
    label: "关于",
    key: "about",
    icon: renderIcon(InfoOutlined),
    component: About
  },
]
const activeItem = shallowRef(sideMenuOptions[0])

// 切换菜单
function handleMenuSelect(key) {
  // 根据key寻找item
  activeItem.value = sideMenuOptions.find(item => item.key === key)
}

// 主题切换
function themeChange(newTheme) {
  Theme.value = newTheme === lightTheme.name ? lightTheme : darkTheme
  headerClass = newTheme === lightTheme.name ? "lightTheme" : "darkTheme"
}


</script>

<style>
body {
  margin: 0;
  font-family: sans-serif;

}

.lightTheme .n-layout-header {
  background-color: #f7f7fa;
}

.lightTheme .n-layout-sider {
  background-color: #f7f7fa !important;
}
</style>

================================================
FILE: app/frontend/src/assets/fonts/OFL.txt
================================================
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL


-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded, 
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.


================================================
FILE: app/frontend/src/components/About.vue
================================================
<!--
  - Copyright 2025 Bronya0 <tangssst@163.com>.
  - Author Github: https://github.com/Bronya0
  -
  - Licensed under the Apache License, Version 2.0 (the "License");
  - you may not use this file except in compliance with the License.
  - You may obtain a copy of the License at
  -
  -     https://www.apache.org/licenses/LICENSE-2.0
  -
  - Unless required by applicable law or agreed to in writing, software
  - distributed under the License is distributed on an "AS IS" BASIS,
  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  - See the License for the specific language governing permissions and
  - limitations under the License.
  -->

<template>
  <n-flex vertical>
    <n-flex align="center">
      <h2>关于</h2>
    </n-flex>
  </n-flex>
  <n-flex align="center">

    <n-form label-placement="top" style="text-align: left;">
      <n-form-item label="项目主页">
        <n-button @click="BrowserOpenURL(home_url)" :render-icon="renderIcon(HouseTwotone)">ES-King</n-button>
      </n-form-item>
      <n-form-item label="推荐同款 Kafka 客户端">
        <n-button @click="BrowserOpenURL(kafka_home_url)" :render-icon="renderIcon(HouseTwotone)">KafKa-King</n-button>
      </n-form-item>
      <n-form-item label="技术交流群">
        <n-button :focusable="false" @click="openUrl(qq_url)">点我加群✨</n-button>
      </n-form-item>
      <n-form-item label="打赏是开源项目生存的动力,谢谢!">
        <img src="../assets/images/wechat.png" alt="pay" style="width: 200px; height: 200px;">
      </n-form-item>

    </n-form>
  </n-flex>
</template>

<script setup>
import {onMounted} from 'vue'
import {NButton, NForm, NFormItem, useMessage,} from 'naive-ui'
import {BrowserOpenURL} from "../../wailsjs/runtime";
import {openUrl, renderIcon} from "../utils/common";
import {HouseTwotone} from '@vicons/material'

const kafka_home_url = "https://github.com/Bronya0/kafka-King"
const qq_url = "https://qm.qq.com/cgi-bin/qm/qr?k=pDqlVFyLMYEEw8DPJlRSBN27lF8qHV2v&jump_from=webapi&authKey=Wle/K0ARM1YQWlpn6vvfiZuMedy2tT9BI73mUvXVvCuktvi0fNfmNR19Jhyrf2Nz"
const home_url = "https://github.com/Bronya0/ES-King"



onMounted(async () => {

})


</script>

================================================
FILE: app/frontend/src/components/Aside.vue
================================================
<!--
  - Copyright 2025 Bronya0 <tangssst@163.com>.
  - Author Github: https://github.com/Bronya0
  -
  - Licensed under the Apache License, Version 2.0 (the "License");
  - you may not use this file except in compliance with the License.
  - You may obtain a copy of the License at
  -
  -     https://www.apache.org/licenses/LICENSE-2.0
  -
  - Unless required by applicable law or agreed to in writing, software
  - distributed under the License is distributed on an "AS IS" BASIS,
  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  - See the License for the specific language governing permissions and
  - limitations under the License.
  -->

<template>
  <!--  https://www.naiveui.com/zh-CN/os-theme/components/menu-->
  <n-menu
      :mode="'vertical'"
      :value="props.value"
      @update:value="handleMenuSelect"
      :options="props.options"
      style="--wails-draggable:no-drag"
  />

</template>


<script setup>

import emitter from "../utils/eventBus";

const props = defineProps(['options', 'value']);

const handleMenuSelect = (key, item) => {
  emitter.emit('menu_select', key)
}

</script>

<style>

</style>

================================================
FILE: app/frontend/src/components/Conn.vue
================================================
<!--
  - Copyright 2025 Bronya0 <tangssst@163.com>.
  - Author Github: https://github.com/Bronya0
  -
  - Licensed under the Apache License, Version 2.0 (the "License");
  - you may not use this file except in compliance with the License.
  - You may obtain a copy of the License at
  -
  -     https://www.apache.org/licenses/LICENSE-2.0
  -
  - Unless required by applicable law or agreed to in writing, software
  - distributed under the License is distributed on an "AS IS" BASIS,
  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  - See the License for the specific language governing permissions and
  - limitations under the License.
  -->

<template>
  <div>
    <n-flex vertical>
      <n-flex align="center">
        <h2>集群</h2>
        <n-text>共有 {{ esNodes.length }} 个</n-text>
        <n-button @click="addNewNode" :render-icon="renderIcon(AddFilled)">添加集群</n-button>
      </n-flex>
      <n-spin :show="spin_loading" description="Connecting...">

        <n-grid :x-gap="12" :y-gap="12" :cols="4">
          <n-gi v-for="node in esNodes" :key="node.id">
            <n-card :title="node.name" @click="selectNode(node)" hoverable class="conn_card">

              <template #header-extra>
                <n-space>
                  <n-button @click.stop="editNode(node)" size="small">
                    编辑
                  </n-button>
                  <n-popconfirm @positive-click="deleteNode(node.id)" negative-text="取消" positive-text="确定">
                    <template #trigger>
                      <n-button @click.stop size="small">
                        删除
                      </n-button>
                    </template>
                    确定删除吗?
                  </n-popconfirm>
                </n-space>
              </template>
              <n-descriptions :column="1" label-placement="left">
                <n-descriptions-item label="主机">
                  {{ node.host }}
                </n-descriptions-item>
              </n-descriptions>
            </n-card>
          </n-gi>
        </n-grid>
      </n-spin>
    </n-flex>

    <n-drawer v-model:show="showEditDrawer" style="width: 38.2%" placement="right">
      <n-drawer-content :title="drawerTitle">
        <n-form
            ref="formRef"
            :model="currentNode"
            :rules="{
              name: {required: true, message: '请输入昵称', trigger: 'blur'},
              host: {required: true, message: '请输入主机地址', trigger: 'blur'},
              port: {required: true, type: 'number', message: '请输入有效的端口号', trigger: 'blur'},
            }"
            label-placement="top"
            style="text-align: left;"
        >
          <n-form-item label="昵称" path="name">
            <n-input v-model:value="currentNode.name" placeholder="输入节点名称"/>
          </n-form-item>
          <n-form-item label="协议://主机:端口" path="host">
            <n-input v-model:value="currentNode.host" placeholder="输入协议://主机:端口"/>
          </n-form-item>
          <n-form-item label="用户名" path="username">
            <n-input v-model:value="currentNode.username" placeholder="输入用户名"/>
          </n-form-item>
          <n-form-item label="密码" path="password">
            <n-input
                v-model:value="currentNode.password"
                type="password"
                placeholder="输入密码"
            />
          </n-form-item>

          <n-form-item label="使用 SSL" path="useSSL">
            <n-switch :round="false" v-model:value="currentNode.useSSL"/>
          </n-form-item>

          <n-form-item label="跳过 SSL 验证" path="skipSSLVerify">
            <n-switch :round="false" v-model:value="currentNode.skipSSLVerify"/>
          </n-form-item>

          <n-form-item label="CA 证书" path="caCert">
            <n-input v-model:value="currentNode.caCert" type="textarea" placeholder="输入 CA 证书内容"/>
          </n-form-item>

        </n-form>
        <template #footer>
          <n-space justify="end">
            <n-button @click="test_connect" :loading="test_connect_loading">连接测试</n-button>
            <n-button @click="showEditDrawer = false">取消</n-button>
            <n-button type="primary" @click="saveNode">保存</n-button>
          </n-space>
        </template>
      </n-drawer-content>
    </n-drawer>
  </div>
</template>

<script setup>
import {computed, onMounted, ref} from 'vue'
import {useMessage} from 'naive-ui'
import {renderIcon} from "../utils/common";
import {AddFilled} from "@vicons/material";
import emitter from "../utils/eventBus";
import {SetConnect, TestClient} from "../../wailsjs/go/service/ESService";
import {GetConfig, SaveConfig} from "../../wailsjs/go/config/AppConfig";


const message = useMessage()

const esNodes = ref([])

const showEditDrawer = ref(false)
const currentNode = ref({
  name: '',
  host: '',
  port: 9200,
  username: '',
  password: '',
  useSSL: false,
  skipSSLVerify: false,
  caCert: ''
})
const isEditing = ref(false)
const spin_loading = ref(false)
const test_connect_loading = ref(false)

const drawerTitle = computed(() => isEditing.value ? '编辑连接' : '添加连接')

const formRef = ref(null)

onMounted(() => {
  refreshNodeList()
})

const refreshNodeList = async () => {
  spin_loading.value = true
  const config = await GetConfig()
  esNodes.value = config.connects.filter(node => node.name !== null && node.name !== "")
  spin_loading.value = false
}

function editNode(node) {
  currentNode.value = {...node}
  isEditing.value = true
  showEditDrawer.value = true
}

const addNewNode = async () => {
  currentNode.value = {}
  isEditing.value = false
  showEditDrawer.value = true
}

const saveNode = async () => {
  formRef.value?.validate(async (errors) => {
    if (!errors) {

      const config = await GetConfig()
      // edit
      if (isEditing.value) {
        const index = esNodes.value.findIndex(node => node.id === currentNode.value.id)
        if (index !== -1) {
          esNodes.value[index] = {...currentNode.value}
        }
      } else {
        // add
        const newId = Math.max(...esNodes.value.map(node => node.id), 0) + 1
        esNodes.value.push({...currentNode.value, id: newId})
      }

      // 保存
      config.connects = esNodes.value
      await SaveConfig(config)
      showEditDrawer.value = false

      await refreshNodeList()
      message.success('保存成功')
    } else {
      message.error('请填写所有必填字段')
    }
  })
}

const deleteNode = async (id) => {
  console.log(esNodes.value)
  console.log(id)
  esNodes.value = esNodes.value.filter(node => node.id !== id)
  console.log(esNodes.value)
  const config = await GetConfig()
  config.connects = esNodes.value
  await SaveConfig(config)
  await refreshNodeList()
  message.success('删除成功')
}

const test_connect = async () => {
  formRef.value?.validate(async (errors) => {
    if (!errors) {

      test_connect_loading.value = true
      try {
        const node = currentNode.value
        const res = await TestClient(node.host, node.username, node.password, node.caCert, node.useSSL, node.skipSSLVerify)
        if (res !== "") {
          message.error("连接失败:" + res)
        } else {
          message.success('连接成功')
        }
      } catch (e) {
        message.error(e.message)
      }
      test_connect_loading.value = false

    } else {
      message.error('请填写所有必填字段')
    }
  })
}
const selectNode = async (node) => {
  // 这里实现切换菜单的逻辑
  console.log('选中节点:', node)
  spin_loading.value = true

  try {
    const res = await TestClient(node.host, node.username, node.password, node.caCert, node.useSSL, node.skipSSLVerify)
    if (res !== "") {
      message.error("连接失败:" + res)
    } else {
      await SetConnect(node.name, node.host, node.username, node.password, node.caCert, node.useSSL, node.skipSSLVerify)
      message.success('连接成功')
      emitter.emit('menu_select', "节点")
      emitter.emit('selectNode', node)
    }
  } catch (e) {
    message.error(e.message)
  }
  spin_loading.value = false

}
</script>

<style>

.lightTheme .conn_card {
  background-color: #fafafc
}
</style>

================================================
FILE: app/frontend/src/components/Core.vue
================================================
<!--
  - Copyright 2025 Bronya0 <tangssst@163.com>.
  - Author Github: https://github.com/Bronya0
  -
  - Licensed under the Apache License, Version 2.0 (the "License");
  - you may not use this file except in compliance with the License.
  - You may obtain a copy of the License at
  -
  -     https://www.apache.org/licenses/LICENSE-2.0
  -
  - Unless required by applicable law or agreed to in writing, software
  - distributed under the License is distributed on an "AS IS" BASIS,
  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  - See the License for the specific language governing permissions and
  - limitations under the License.
  -->

<template>
  <n-flex vertical>
    <n-flex align="center">
      <h2>指标</h2>
      <n-button @click="getData" text :render-icon="renderIcon(RefreshOutlined)">refresh</n-button>

    </n-flex>
    <n-spin :show="loading" description="Connecting...">
      <n-collapse>
        <n-collapse-item v-for="(item_v, item_k) in collapse_item" :name="item_v" :title="item_v">
          <n-table :single-line="false" size="small">
            <thead>
            <tr>
              <th>说明</th>
              <th>值</th>
              <th>key</th>
            </tr>
            </thead>
            <tbody>
            <tr v-for="(value, key) in filterByKey(data, item_k)" :key="key">
              <td>
                <n-tooltip placement="top" trigger="hover">
                  <template #trigger>{{ getLabel(key) }}</template>
                  {{ key }}
                </n-tooltip>
              </td>
              <td>{{ value }}</td>
              <td>{{ key }}</td>
            </tr>
            </tbody>
          </n-table>
        </n-collapse-item>
      </n-collapse>

    </n-spin>
  </n-flex>
</template>


<script setup>
import {onMounted, ref} from "vue";
import emitter from "../utils/eventBus";
import {useMessage} from "naive-ui";
import {GetStats} from "../../wailsjs/go/service/ESService";
import {flattenObject, renderIcon} from "../utils/common";
import {RefreshOutlined} from "@vicons/material";

const loading = ref(false)
const data = ref({})
const message = useMessage()

const selectNode = async (node) => {
  await getData()
}

onMounted(() => {
  emitter.on('selectNode', selectNode)
  getData()
})

const collapse_item = {
  'node': '节点',
  'memory': '内存',
  'indices': '索引',
  'doc': '文档',
  'shard': '分片',
  'store': '存储'
}
const getData = async () => {
  loading.value = true
  const res = await GetStats()
  if (res.err !== "") {
    message.error(res.err)
  } else {
    data.value = flattenObject(res.result)
  }
  console.log(data.value)
  loading.value = false

}
// 方法,返回过滤后的数据
const filterByKey = (data, searchString) => {
  const result = {};
  for (const [key, value] of Object.entries(data)) {
    if (key.includes(searchString)) {
      result[key] = value;
    }
  }
  return result;
};

const getLabel = (key) => {
  const descriptions = {
    "_nodes.failed": '报告中失败的节点数量',
    "_nodes.successful": '报告中成功的节点数量',
    "_nodes.total": '集群中报告的总节点数',
    "cluster_name": '集群名称',
    "cluster_uuid": '集群的唯一标识符',
    "indices.analysis.analyzer_types": '分析器类型列表',
    "indices.analysis.built_in_analyzers": '内置分析器列表',
    "indices.analysis.built_in_char_filters": '内置字符过滤器列表',
    "indices.analysis.built_in_filters": '内置过滤器列表',
    "indices.analysis.built_in_tokenizers": '内置分词器列表',
    "indices.analysis.char_filter_types": '字符过滤器类型列表',
    "indices.analysis.filter_types": '过滤器类型列表',
    "indices.analysis.tokenizer_types": '分词器类型列表',
    "indices.completion.size_in_bytes": '完成建议字段使用的内存大小(字节)',
    "indices.count": '索引总数',
    "indices.docs.count": '文档总数',
    "indices.docs.deleted": '已删除文档的数量',
    "indices.fielddata.evictions": '字段数据缓存驱逐次数',
    "indices.fielddata.memory_size_in_bytes": '字段数据缓存使用的内存大小(字节)',
    "indices.mappings.field_types": '映射字段类型列表',
    "indices.mappings.runtime_field_types": '运行时字段类型列表',
    "indices.mappings.total_deduplicated_field_count": '去重后的字段计数',
    "indices.mappings.total_deduplicated_mapping_size_in_bytes": '去重后的映射大小(字节)',
    "indices.mappings.total_field_count": '字段计数',
    "indices.query_cache.cache_count": '查询缓存条目数',
    "indices.query_cache.cache_size": '查询缓存大小',
    "indices.query_cache.evictions": '查询缓存驱逐次数',
    "indices.query_cache.hit_count": '查询缓存命中次数',
    "indices.query_cache.memory_size_in_bytes": '查询缓存使用的内存大小(字节)',
    "indices.query_cache.miss_count": '查询缓存未命中次数',
    "indices.query_cache.total_count": '总的查询缓存条目数',
    "indices.segments.count": '段数',
    "indices.segments.doc_values_memory_in_bytes": '文档值使用的内存大小(字节)',
    "indices.segments.fixed_bit_set_memory_in_bytes": '固定位集使用的内存大小(字节)',
    "indices.segments.index_writer_memory_in_bytes": '索引写入器使用的内存大小(字节)',
    "indices.segments.max_unsafe_auto_id_timestamp": '最大不安全的自动 ID 时间戳',
    "indices.segments.memory_in_bytes": '段使用的内存大小(字节)',
    "indices.segments.norms_memory_in_bytes": '规范化因子使用的内存大小(字节)',
    "indices.segments.points_memory_in_bytes": '点使用的内存大小(字节)',
    "indices.segments.stored_fields_memory_in_bytes": '存储字段使用的内存大小(字节)',
    "indices.segments.term_vectors_memory_in_bytes": '词条向量使用的内存大小(字节)',
    "indices.segments.terms_memory_in_bytes": '词条使用的内存大小(字节)',
    "indices.segments.version_map_memory_in_bytes": '版本映射使用的内存大小(字节)',
    "indices.shards.index.primaries.avg": '主分片平均数量',
    "indices.shards.index.primaries.max": '主分片最大数量',
    "indices.shards.index.primaries.min": '主分片最小数量',
    "indices.shards.index.replication.avg": '副本平均数量',
    "indices.shards.index.replication.max": '副本最大数量',
    "indices.shards.index.replication.min": '副本最小数量',
    "indices.shards.index.shards.avg": '分片平均数量',
    "indices.shards.index.shards.max": '分片最大数量',
    "indices.shards.index.shards.min": '分片最小数量',
    "indices.shards.primaries": '主分片总数',
    "indices.shards.replication": '副本总数',
    "indices.shards.total": '分片总数',
    "indices.store.reserved_in_bytes": '保留的空间大小(字节)',
    "indices.store.size_in_bytes": '存储大小(字节)',
    "indices.store.total_data_set_size_in_bytes": '总数据集大小(字节)',
    "indices.versions": '索引版本信息',
    "nodes.count.coordinating_only": '仅协调节点的数量',
    "nodes.count.data": '数据节点的数量',
    "nodes.count.data_cold": '冷数据节点的数量',
    "nodes.count.data_content": '数据内容节点的数量',
    "nodes.count.data_frozen": '冻结数据节点的数量',
    "nodes.count.data_hot": '热数据节点的数量',
    "nodes.count.data_warm": '温数据节点的数量',
    "nodes.count.ingest": '摄取节点的数量',
    "nodes.count.master": '主节点的数量',
    "nodes.count.ml": '机器学习节点的数量',
    "nodes.count.remote_cluster_client": '远程集群客户端节点的数量',
    "nodes.count.transform": '转换节点的数量',
    "nodes.count.voting_only": '仅投票节点的数量',
    "nodes.count.total": '节点总数',
    "nodes.discovery_types.multi_node": '多节点发现类型数量',
    "nodes.fs.available_in_bytes": '可用磁盘空间大小(字节)',
    "nodes.fs.free_in_bytes": '空闲磁盘空间大小(字节)',
    "nodes.fs.total_in_bytes": '总磁盘空间大小(字节)',
    "nodes.indexing_pressure.memory.current.all_in_bytes": '当前所有索引压力内存大小(字节)',
    "nodes.indexing_pressure.memory.current.combined_coordinating_and_primary_in_bytes": '当前组合协调和主要索引压力内存大小(字节)',
    "nodes.indexing_pressure.memory.current.coordinating_in_bytes": '当前协调索引压力内存大小(字节)',
    "nodes.indexing_pressure.memory.current.primary_in_bytes": '当前主要索引压力内存大小(字节)',
    "nodes.indexing_pressure.memory.current.replica_in_bytes": '当前副本索引压力内存大小(字节)',
    "nodes.indexing_pressure.memory.limit_in_bytes": '索引压力内存限制大小(字节)',
    "nodes.indexing_pressure.memory.total.all_in_bytes": '总计所有索引压力内存大小(字节)',
    "nodes.indexing_pressure.memory.total.combined_coordinating_and_primary_in_bytes": '总计组合协调和主要索引压力内存大小(字节)',
    "nodes.indexing_pressure.memory.total.coordinating_in_bytes": '总计协调索引压力内存大小(字节)',
    "nodes.indexing_pressure.memory.total.coordinating_rejections": '总计协调索引拒绝次数',
    "nodes.indexing_pressure.memory.total.primary_in_bytes": '总计主要索引压力内存大小(字节)',
    "nodes.indexing_pressure.memory.total.primary_rejections": '总计主要索引拒绝次数',
    "nodes.indexing_pressure.memory.total.replica_in_bytes": '总计副本索引压力内存大小(字节)',
    "nodes.indexing_pressure.memory.total.replica_rejections": '总计副本索引拒绝次数',
    "nodes.ingest.number_of_pipelines": '摄取管道数量',
    "nodes.jvm.max_uptime_in_millis": 'JVM 上线时间(毫秒)',
    "nodes.jvm.mem.heap_max_in_bytes": '堆内存最大大小(字节)',
    "nodes.jvm.mem.heap_used_in_bytes": '堆内存使用大小(字节)',
    "nodes.jvm.threads": 'JVM 线程数',
    "nodes.jvm.versions": 'JVM 版本信息',
    "nodes.network_types.http_types.netty4": 'HTTP 类型为 Netty 4 的节点数量',
    "nodes.network_types.transport_types.netty4": '传输类型为 Netty 4 的节点数量',
    "nodes.os.allocated_processors": '分配的处理器数量',
    "nodes.os.architectures": '操作系统架构信息',
    "nodes.os.available_processors": '可用处理器数量',
    "nodes.os.mem.adjusted_total_in_bytes": '调整后的总内存大小(字节)',
    "nodes.os.mem.free_in_bytes": '空闲内存大小(字节)',
    "nodes.os.mem.free_percent": '空闲内存百分比',
    "nodes.os.mem.total_in_bytes": '总内存大小(字节)',
    "nodes.os.mem.used_in_bytes": '使用内存大小(字节)',
    "nodes.os.mem.used_percent": '使用内存百分比',
    "nodes.os.names": '操作系统名称',
    "nodes.os.pretty_names": '操作系统友好名称',
    "nodes.packaging_types": '打包类型信息',
    "nodes.plugins": '插件信息',
    "nodes.process.cpu.percent": 'CPU 使用率',
    "nodes.process.open_file_descriptors.avg": '平均打开的文件描述符数量',
    "nodes.process.open_file_descriptors.max": '最大打开的文件描述符数量',
    "nodes.process.open_file_descriptors.min": '最小打开的文件描述符数量',
    "nodes.versions": '节点版本信息',
    "status": '集群健康状态(绿色、黄色、红色)',
    "timestamp": '报告的时间戳'
  };
  return descriptions[key] || '暂无说明'
}


</script>

<style scoped>

</style>

================================================
FILE: app/frontend/src/components/Header.vue
================================================
<!--
  - Copyright 2025 Bronya0 <tangssst@163.com>.
  - Author Github: https://github.com/Bronya0
  -
  - Licensed under the Apache License, Version 2.0 (the "License");
  - you may not use this file except in compliance with the License.
  - You may obtain a copy of the License at
  -
  -     https://www.apache.org/licenses/LICENSE-2.0
  -
  - Unless required by applicable law or agreed to in writing, software
  - distributed under the License is distributed on an "AS IS" BASIS,
  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  - See the License for the specific language governing permissions and
  - limitations under the License.
  -->

<template>
  <n-page-header style="padding: 4px;--wails-draggable:drag">
    <template #avatar>
      <n-avatar :src="icon"/>
    </template>
    <template #subtitle>
      <n-tooltip>
        <template #trigger>
          <n-tag v-if="subtitle" :type=title_tag>{{ subtitle }}</n-tag>
          <n-p v-else>{{ desc }}</n-p>
        </template>
        健康:{{ title_tag }}
      </n-tooltip>
    </template>
    <template #title>
      <div style="font-weight: 800">{{ app_name }}</div>
    </template>
    <template #extra>
      <n-flex justify="flex-end" style="--wails-draggable:no-drag" class="right-section">
        <n-button quaternary :focusable="false" @click="openUrl(qq_url)">技术交流群</n-button>
        <!--        <n-button quaternary :focusable="false" @click="changeTheme" :render-icon="renderIcon(MoonOrSunnyOutline)"/>-->

        <n-tooltip placement="bottom" trigger="hover">
          <template #trigger>
            <n-button :render-icon="renderIcon(HouseTwotone)" quaternary
                      @click="openUrl(update_url)"/>
          </template>
          <span>主页</span>
        </n-tooltip>

        <n-tooltip placement="bottom" trigger="hover">
          <template #trigger>
            <n-button quaternary :focusable="false" :loading="update_loading" @click="checkForUpdates"
                      :render-icon="renderIcon(SystemUpdateAltSharp)"/>
          </template>
          <span>检查版本:{{ version.tag_name }} {{ check_msg }}</span>
        </n-tooltip>

        <n-button quaternary :focusable="false" @click="minimizeWindow" :render-icon="renderIcon(RemoveOutlined)"/>
        <n-button quaternary :focusable="false" @click="resizeWindow" :render-icon="renderIcon(MaxMinIcon)"/>
        <n-button quaternary class="close-btn" style="font-size: 22px" :focusable="false" @click="closeWindow">
          <n-icon>
            <CloseFilled/>
          </n-icon>
        </n-button>
      </n-flex>
    </template>
  </n-page-header>
</template>

<script setup>
import {NAvatar, NButton, NFlex, useMessage} from 'naive-ui'
import {
  SystemUpdateAltSharp,
  RemoveOutlined,
  CloseFilled,
  CropSquareFilled,
  ContentCopyFilled, HouseTwotone
} from '@vicons/material'
import icon from '../assets/images/appicon.png'
import {h, onMounted, ref, shallowRef} from "vue";
import {BrowserOpenURL, Quit, WindowMaximise, WindowMinimise, WindowUnmaximise} from "../../wailsjs/runtime";
import {CheckUpdate} from '../../wailsjs/go/system/Update'
import {useNotification} from 'naive-ui'
import {openUrl, renderIcon} from "../utils/common";
import {GetVersion, GetAppName} from "../../wailsjs/go/config/AppConfig";
import emitter from "../utils/eventBus";

// defineProps(['options', 'value']);

// const MoonOrSunnyOutline = shallowRef(WbSunnyOutlined)
const isMaximized = ref(false);
const title_tag = ref("success");
const check_msg = ref("");
const app_name = ref("");
const MaxMinIcon = shallowRef(CropSquareFilled)
const update_url = "https://github.com/Bronya0/ES-King/releases"
const qq_url = "https://qm.qq.com/cgi-bin/qm/qr?k=pDqlVFyLMYEEw8DPJlRSBN27lF8qHV2v&jump_from=webapi&authKey=Wle/K0ARM1YQWlpn6vvfiZuMedy2tT9BI73mUvXVvCuktvi0fNfmNR19Jhyrf2Nz"

const update_loading = ref(false)
// let theme = lightTheme

let version = ref({
  tag_name: "",
  body: "",
})

const desc = "更人性化的ES GUI "
const subtitle = ref("")

const notification = useNotification()
const message = useMessage()

const checkForUpdates = async () => {
  update_loading.value = true
  try {
    const v = await GetVersion()
    const resp = await CheckUpdate()
    if (!resp) {
      message.error("无法连接github,检查更新失败")
    } else if (resp.tag_name !== v) {
      check_msg.value = '发现新版本 ' + resp.tag_name
      version.value.body = resp.body
      const n = notification.success({
        title: '发现新版本: ' + resp.name,
        description: resp.body,
        action: () =>
            h(NFlex, {justify: "flex-end"}, () => [
              h(
                  NButton,
                  {
                    type: 'primary',
                    secondary: true,
                    onClick: () => BrowserOpenURL(update_url),
                  },
                  () => "立即下载",
              ),
              h(
                  NButton,
                  {
                    secondary: true,
                    onClick: () => {
                      n.destroy()
                    },
                  },
                  () => "取消",
              ),
            ]),
        onPositiveClick: () => BrowserOpenURL(update_url),
      })
    }
  } finally {
    update_loading.value = false
  }
}

onMounted(async () => {
  emitter.on('selectNode', selectNode)
  emitter.on('changeTitleType', changeTitleType)

  app_name.value = await GetAppName()

  // const config = await GetConfig()
  // MoonOrSunnyOutline.value = config.theme === lightTheme.name ? WbSunnyOutlined : NightlightRoundFilled
  const v = await GetVersion()
  version.value.tag_name = v
  subtitle.value = desc + " " + v
  await checkForUpdates()

})

const selectNode = (node) => {
  subtitle.value = "当前集群:【" + node.name + "】"
}

// 动态修改title的类型
const changeTitleType = (type) => {
  console.log(type)
  title_tag.value = type
}

const minimizeWindow = () => {
  WindowMinimise()
}

const resizeWindow = () => {
  isMaximized.value = !isMaximized.value;
  if (isMaximized.value) {
    WindowMaximise();
    MaxMinIcon.value = ContentCopyFilled;
  } else {
    WindowUnmaximise();
    MaxMinIcon.value = CropSquareFilled;
  }
  console.log(isMaximized.value)

}

const closeWindow = () => {
  Quit()
}
// const changeTheme = () => {
//   MoonOrSunnyOutline.value = MoonOrSunnyOutline.value === NightlightRoundFilled ? WbSunnyOutlined : NightlightRoundFilled;
//   theme = MoonOrSunnyOutline.value === NightlightRoundFilled ? darkTheme : lightTheme
//   emitter.emit('update_theme', theme)
// }
</script>

<style scoped>
.close-btn:hover {
  background-color: red;
}

.right-section .n-button {
  padding: 0 8px;
}
</style>

================================================
FILE: app/frontend/src/components/Health.vue
================================================
<!--
  - Copyright 2025 Bronya0 <tangssst@163.com>.
  - Author Github: https://github.com/Bronya0
  -
  - Licensed under the Apache License, Version 2.0 (the "License");
  - you may not use this file except in compliance with the License.
  - You may obtain a copy of the License at
  -
  -     https://www.apache.org/licenses/LICENSE-2.0
  -
  - Unless required by applicable law or agreed to in writing, software
  - distributed under the License is distributed on an "AS IS" BASIS,
  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  - See the License for the specific language governing permissions and
  - limitations under the License.
  -->

<template>
  <n-flex vertical>
    <n-flex align="center">
      <h2>健康</h2>
      <n-button :render-icon="renderIcon(RefreshOutlined)" text @click="getData">refresh</n-button>

    </n-flex>
    <n-spin :show="loading" description="Connecting...">

      <n-table :bordered="false" :single-line="false">
        <thead>
        <tr>
          <th>健康指标</th>
          <th>值</th>
          <th>完整键</th>
        </tr>
        </thead>
        <tbody>
        <tr v-for="(value, key) in data" :key="key">
          <td>{{ getLabel(key) }}</td>
          <td>
            <n-tooltip placement="left" trigger="hover">
              <template #trigger>
                <n-tag :type="getTagType(key, value)">
                  {{ value }}
                </n-tag>
              </template>
              {{ value }}
            </n-tooltip>
          </td>
          <td>{{ key }}</td>

        </tr>
        </tbody>
      </n-table>
    </n-spin>
  </n-flex>

</template>
<script setup>
import {onActivated, onMounted, ref} from "vue";
import emitter from "../utils/eventBus";
import {useMessage} from "naive-ui";
import {GetHealth} from "../../wailsjs/go/service/ESService";
import {renderIcon} from "../utils/common";
import {RefreshOutlined} from "@vicons/material";

const data = ref({})
const loading = ref(false)

const message = useMessage()

const selectNode = async (node) => {
  await getData()
}

onMounted(() => {
  emitter.on('selectNode', selectNode)
  getData()
})


const getData = async () => {
  loading.value = true
  const res = await GetHealth()
  if (res.err !== "") {
    message.error(res.err)
  } else {
    data.value = res.result
    emitter.emit('changeTitleType', getTagType("status", res.result['status']))

  }
  console.log(data.value)
  loading.value = false

}

const getTagType = (key, value) => {
  if (['cluster_name'].includes(key)) {
    return 'success'
  }
  if (['unassigned_shards', 'delayed_unassigned_shards', 'initializing_shards'].includes(key)) {
    return 'warning'
  }

  if (key === 'timed_out') {
    return value === true ? 'error' : 'success'
  }
  if (key === 'status') {
    if (value === 'green') {
      return 'success'
    } else {
      return value === 'yellow' ? 'warning' : 'error'
    }
  }
  return 'default'
}

const getLabel = (key) => {
  const descriptions = {
    cluster_name: '集群名称',
    status: '集群健康状态(绿色、黄色、红色)',
    timed_out: '请求是否超时',
    number_of_nodes: '集群中的节点数',
    number_of_data_nodes: '集群中的数据节点数',
    active_primary_shards: '活跃的主分片数',
    active_shards: '活跃的总分片数',
    relocating_shards: '正在重新定位的分片数',
    initializing_shards: '正在初始化的分片数',
    unassigned_shards: '未分配的分片数',
    delayed_unassigned_shards: '延迟未分配的分片数',
    number_of_pending_tasks: '等待中的集群级任务数',
    number_of_in_flight_fetch: '正在进行的分片数据获取数',
    task_max_waiting_in_queue_millis: '任务在队列中的最长等待时间(毫秒)',
    active_shards_percent_as_number: '活跃分片百分比'
  }
  return descriptions[key] || '暂无说明'
}

</script>
<style scoped>

</style>

================================================
FILE: app/frontend/src/components/Index.vue
================================================
<!--
  - Copyright 2025 Bronya0 <tangssst@163.com>.
  - Author Github: https://github.com/Bronya0
  -
  - Licensed under the Apache License, Version 2.0 (the "License");
  - you may not use this file except in compliance with the License.
  - You may obtain a copy of the License at
  -
  -     https://www.apache.org/licenses/LICENSE-2.0
  -
  - Unless required by applicable law or agreed to in writing, software
  - distributed under the License is distributed on an "AS IS" BASIS,
  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  - See the License for the specific language governing permissions and
  - limitations under the License.
  -->

<template>
  <n-flex vertical>

    <n-flex align="center">
      <h2>索引</h2>
      <n-text>共计{{ data.length }}个</n-text>
    </n-flex>
    <n-flex align="center">
      <n-input v-model:value="search_text" autosize placeholder="模糊搜索索引" style="min-width: 20%"
               @keydown.enter="search"/>

      <n-button :render-icon="renderIcon(SearchFilled)" @click="search"></n-button>
      <n-button :render-icon="renderIcon(AddFilled)" @click="CreateIndexDrawerVisible = true">添加索引</n-button>
      <n-button :render-icon="renderIcon(DriveFileMoveTwotone)" @click="downloadAllDataCsv">导出为csv</n-button>
      <n-button :render-icon="renderIcon(AnnouncementOutlined)" @click="queryAlias">读取别名</n-button>
      <n-button :loading="downloadIndexConfig.loading" :render-icon="renderIcon(DriveFileMoveTwotone)"
                @click="downloadIndexConfig.show = true">
        索引备份(预览)
      </n-button>

    </n-flex>

    <n-spin :show="loading" description="Connecting...">
      <n-data-table
          ref="tableRef"
          v-model:checked-row-keys="selectedRowKeys"
          :bordered="false"
          :columns="refColumns(columns)"
          :data="data"
          :pagination="pagination"
          :row-key="rowKey"
          size="small"
          striped
      />
    </n-spin>
    <n-flex align="center">
      <n-dropdown :options="bulk_options">
        <n-button>批量操作</n-button>
      </n-dropdown>
      <n-text> 你选中了 {{ selectedRowKeys.length }} 行。</n-text>
    </n-flex>

    <n-drawer v-model:show="downloadIndexConfig.show" style="width: 38.2%">
      <n-drawer-content style="text-align: left;" title="索引备份(预览)">
        <n-flex vertical>
          <n-p>【当前该功能还在测试中,使用请输入QQ群号,否则无法下载。如果遇到了bug,请反馈给群主。】</n-p>
          输入要备份的索引名称
          <n-input v-model:value="downloadIndexConfig.indexName"/>
          输入查询dsl,不写默认查询所有
          <n-input v-model:value="downloadIndexConfig.dsl" style="min-height: 300px; max-height: 800px;"
                   type="textarea"/>
          测试码,也就是QQ群号,请在github上查看
          <n-input v-model:value="downloadIndexConfig.code"/>
          <n-flex align="center">
            <n-button @click="downloadIndexConfig.show = false">取消</n-button>
            <n-button :loading="downloadIndexConfig.loading" type="primary" @click="downloadIndex">开始下载</n-button>
          </n-flex>
          {{ downloadIndexConfig.msg }}
        </n-flex>
      </n-drawer-content>
    </n-drawer>


    <n-drawer v-model:show="drawerVisible" style="width: 38.2%">
      <n-drawer-content :title="drawer_title" style="text-align: left;">
        <n-code :code="json_data" language="json" show-line-numbers/>
      </n-drawer-content>
    </n-drawer>

    <!--    添加index-->
    <n-drawer v-model:show="CreateIndexDrawerVisible" style="width: 38.2%">
      <n-drawer-content style="text-align: left;" title="创建索引">
        <n-form
            ref="formRef"
            :model="indexConfig"
            :rules="{
              name: {required: true, message: '请输入索引名称', trigger: 'blur'},
              numberOfShards: {required: true, type: 'number', message: '请输入主分片', trigger: 'blur'},
              numberOfReplicas: {required: true, type: 'number', message: '请输入副本数量', trigger: 'blur'},
            }"
            label-placement="top"
            style="text-align: left;"
        >
          <n-form-item label="索引名称" path="name">
            <n-input v-model:value="indexConfig.name"/>
          </n-form-item>
          <n-form-item label="主分片" path="numberOfShards">
            <n-input-number v-model:value="indexConfig.numberOfShards"/>
          </n-form-item>
          <n-form-item label="副本数量" path="numberOfReplicas">
            <n-input-number v-model:value="indexConfig.numberOfReplicas"/>
          </n-form-item>
          <n-p>mapping</n-p>
          <n-form-item path="mapping">
            <n-input v-model:value="indexConfig.mapping" placeholder='输入mapping的json,例如
{
  "properties": {
    "created_at": {
      "type": "date",
      "format": "yyyy-MM-dd HH:mm:ss"
    }
  }
}' style="min-height: 300px; max-height: 800px;"
                     type="textarea"/>
          </n-form-item>
        </n-form>
        <template #footer>
          <n-space justify="end">
            <n-button @click="CreateIndexDrawerVisible = false">取消</n-button>
            <n-button :loading="addIndexLoading" type="primary" @click="addIndex">保存</n-button>
          </n-space>
        </template>
      </n-drawer-content>
    </n-drawer>

    <!--    添加doc-->
    <n-drawer v-model:show="addDocDrawerVisible" style="width: 38.2%">
      <n-drawer-content style="text-align: left;" title="添加文档">
        <n-form
            :model="docConfig"
            :rules="{
              doc: {required: true, message: '请输入文档内容', trigger: 'blur'},
            }"
            label-placement="top"
            style="text-align: left;"
        >
          <n-form-item label="文档内容" path="doc">
            <n-input v-model:value="docConfig.doc" placeholder='输入文档的json,例如
{
  "field1": "value1",
  "field2": "value2"
}' style="min-height: 300px; max-height: 800px;"
                     type="textarea"/>
          </n-form-item>
        </n-form>
        <template #footer>
          <n-space justify="end">
            <n-button @click="addDocDrawerVisible = false">取消</n-button>
            <n-button :loading="addDocLoading" type="primary" @click="addDocumentFunc">保存</n-button>
          </n-space>
        </template>
      </n-drawer-content>
    </n-drawer>
  </n-flex>
</template>

<script setup>
import {h, onMounted, ref} from "vue";
import emitter from "../utils/eventBus";
import {NButton, NDataTable, NDropdown, NIcon, NTag, NText, useDialog, useMessage} from 'naive-ui'
import {
  createCsvContent,
  download_file,
  formatBytes,
  formattedJson,
  isValidJson,
  refColumns,
  renderIcon
} from "../utils/common";
import {AddFilled, AnnouncementOutlined, DriveFileMoveTwotone, MoreVertFilled, SearchFilled} from "@vicons/material";
import {
  AddDocument,
  CacheClear,
  CreateIndex,
  DeleteIndex,
  DownloadESIndex,
  Flush,
  GetDoc10,
  GetIndexAliases,
  GetIndexes,
  GetIndexInfo,
  MergeSegments,
  OpenCloseIndex,
  Refresh,
} from "../../wailsjs/go/service/ESService";

// 抽屉的可见性
const drawerVisible = ref(false)
const CreateIndexDrawerVisible = ref(false)
const json_data = ref({})
const drawer_title = ref("")
const loading = ref(false)
const tableRef = ref();
const formRef = ref();
const indexConfig = ref({
  name: "",
  numberOfShards: 1,
  numberOfReplicas: 0,
  mapping: "",
});
const data = ref([])
const message = useMessage()
const dialog = useDialog()

const search_text = ref("")
const selectedRowKeys = ref([]);
const rowKey = (row) => row.index
let aliases = {};


const downloadIndexConfig = ref({
  indexName: "",
  dsl: "",
  loading: false,
  show: false,
  code: null,
  msg: null,
});

const downloadIndex = async () => {

  const indexName = downloadIndexConfig.value.indexName; // 或者从其他地方获取
  const dsl = downloadIndexConfig.value.dsl; // 或者从其他地方获取

  if (!indexName) {
    message.error("请填写索引名");
    return;
  }
  if (downloadIndexConfig.value.code !== "964440643") {
    message.error("群号错误,请在github上查看");
    return;
  }
  const file_path = `/${indexName}-${Math.floor(Date.now() / 1000)}.json`
  message.info("开始下载,请不要退出...数据json位置:" + file_path);
  downloadIndexConfig.value.msg = "开始下载,请不要退出...数据json位置:" + file_path;

  downloadIndexConfig.value.loading = true;
  try {
    const res = await DownloadESIndex(indexName, dsl, file_path);
    if (res.err !== "") {
      message.error(res.err);
      downloadIndexConfig.value.msg = res.err;
    } else {
      message.success("备份成功");
      downloadIndexConfig.value.msg = "备份成功";
      CreateIndexDrawerVisible.value = false;
    }
  } catch (e) {
    message.error(e.message);
    downloadIndexConfig.value.msg = e;
  } finally {
    downloadIndexConfig.value.loading = false;
  }
};

const selectNode = (node) => {
  data.value = []
  selectedRowKeys.value = []
  aliases = []
}


onMounted(() => {
  emitter.on('selectNode', selectNode)
})

const search = async () => {
  loading.value = true
  try {
    await getData(search_text.value)
  } catch (e) {
    message.error(e.message)
  }
  loading.value = false

}


const cacheData = (indexes) => {
  // 缓存一下
  const key = 'es_king_indexes';
  let values = []
  const stored = localStorage.getItem(key);
  if (stored) {
    values = JSON.parse(stored)
    for (let v of indexes) {
      if (!values.includes(v)) {
        values.push(v);
      }
    }
  }
  if (values) {
    localStorage.setItem(key, JSON.stringify(values.slice(-1000)))
  }
}

const getData = async (value) => {
  const res = await GetIndexes(value)
  if (res.err !== "") {
    message.error(res.err)
  } else {
    data.value = res.results
    cacheData(res.results.map(item => item.index))
  }
}

const pageKey = 'esKing:index:pageKey'
const pagination = ref({
  page: 1,
  pageSize: parseInt(localStorage.getItem(pageKey)) || 10,
  showSizePicker: true,
  pageSizes: [5, 10, 15, 20, 25, 30, 40],
  onChange: (page) => {
    pagination.value.page = page
  },
  onUpdatePageSize: (pageSize) => {
    pagination.value.pageSize = pageSize
    pagination.value.page = 1
    localStorage.setItem(pageKey, pageSize.toString())
  },
  itemCount: data.value.length
})


const getType = (value) => {
  const type = {
    "green": "success",
    "yellow": "warning",
    "open": "success",
    "close": "error",
  }
  return type[value] || 'error'
}

const columns = [
  {
    type: "selection",
  },
  {
    title: '索引名',
    key: 'index',
    width: 200,
    render: (row) => h(NText, {
          type: 'info',
          style: {cursor: 'pointer'},
          onClick: () => viewIndexDocs(row)
        },
        {default: () => row['index']}
    )
  },
  {title: '别名', key: 'alias',   },
  {
    title: '健康',
    key: 'health',
    render: (row) => h(NTag, {type: getType(row['health'])}, {default: () => row['health']}),
  },
  {
    title: '状态',
    key: 'status',
    render: (row) => h(NTag, {type: getType(row['status'])}, {default: () => row['status']}),
  },
  {
    title: '主分片',
    key: 'pri',
    sorter: (a, b) => Number(a['pri']) - Number(b['pri'])
  },
  {
    title: '副本',
    key: 'rep',
    sorter: (a, b) => Number(a['rep']) - Number(b['rep'])
  },
  {
    title: '文档总数',
    key: 'docs.count',
    sorter: (a, b) => Number(a['docs.count']) - Number(b['docs.count'])
  },
  {
    title: '软删除文档',
    key: 'docs.deleted',
    sorter: (a, b) => Number(a['docs.deleted']) - Number(b['docs.deleted'])
  },
  {
    title: '占用存储',
    key: 'store.size',
    sorter: (a, b) => Number(a['store.size']) - Number(b['store.size']),
    render(row) {  // 这里要显示的是label,所以得转换一下
      return h('span', formatBytes(row['store.size']))
    }
  },
  {
    title: '操作',
    key: 'actions',
    render: (row) => {
      const options = [
        {label: '添加文档', key: 'addDocument'},
        {label: '查看索引构成', key: 'viewDetails'},
        {label: '别名', key: 'viewAlias'},
        {label: '查看10条文档', key: 'viewDocs'},
        {label: '段合并', key: 'mergeSegments'},
        {label: '删除索引', key: 'deleteIndex'},
        {label: row.status === 'close' ? '打开索引' : '关闭索引', key: 'openCloseIndex'},
        {label: 'Refresh', key: 'refresh'},
        {label: 'Flush', key: 'flush'},
        {label: '清理缓存', key: 'clearCache'},
      ]
      return h(
          NDropdown,
          {
            trigger: 'click',
            options,
            onSelect: (key) => handleMenuSelect(key, row)
          },
          {
            default: () => h(
                NButton,
                {
                  strong: true,
                  secondary: true,
                },
                {default: () => '操作', icon: () => h(NIcon, null, {default: () => h(MoreVertFilled)})}
            )
          }
      )
    }
  }
]

const handleMenuSelect = async (key, row) => {
  const func = {
    "addDocument": addDocument,
    "viewDetails": viewIndexDetails,
    "viewAlias": viewIndexAlias,
    "viewDocs": viewIndexDocs,
    "mergeSegments": mergeSegments,
    "deleteIndex": deleteIndex,
    "refresh": refreshIndex,
    "openCloseIndex": openCloseIndex,
    "flush": flushIndex,
    "clearCache": clearCache,
  }
  loading.value = true
  try {
    await func[key](row)
  } catch (e) {
    message.error(e.message)
  }
  loading.value = false
}

// 定义各种操作函数
const addDocDrawerVisible = ref(false);
const addDocLoading = ref(false);
const docConfig = ref({
  index: "",
  doc: "",
});

const addDocument = async (row) => {
  addDocDrawerVisible.value = true;
  docConfig.value.index = row.index;
}
const addDocumentFunc = async () => {
  if (!docConfig.value.doc) {
    message.error("请输入文档内容")
    return
  }
  if (!isValidJson(docConfig.value.doc)) {
    message.error("文档内容不是合法的json")
    return
  }
  addDocLoading.value = true;
  try {
    const res = await AddDocument(docConfig.value.index, docConfig.value.doc);
    console.log(res);
    if (res.err !== "") {
      message.error(res.err);
    } else {
      message.success(`文档添加成功,id:` + res.result['_id']);
      await search();
    }
  } catch (e) {
    message.error(e.message);
  } finally {
    addDocLoading.value = false;
    addDocDrawerVisible.value = false;
    docConfig.value.index = "";
    docConfig.value.doc = "";
  }
}


const viewIndexDetails = async (row) => {
  const res = await GetIndexInfo(row.index)
  if (res.err !== "") {
    message.error(res.err)
  } else {
    json_data.value = formattedJson(res.result)
    drawer_title.value = row.index
    drawerVisible.value = true
  }
}
const viewIndexAlias = async (row) => {
  const res = await GetIndexAliases([row.index])
  if (res.err !== "") {
    message.error(res.err)
  } else {
    json_data.value = formattedJson(res.result)
    drawer_title.value = row.index
    drawerVisible.value = true
  }
}
const viewIndexDocs = async (row) => {
  loading.value = true
  try {
    const res = await GetDoc10(row.index)
    if (res.err !== "") {
      message.error(res.err)
    } else {
      json_data.value = formattedJson(res.result)
      drawer_title.value = row.index
      drawerVisible.value = true
    }
  } catch (e) {
    message.error(e.message)
  }
  loading.value = false
}
const mergeSegments = async (row) => {
  dialog.info({
    title: '警告',
    content: `确定要对索引 ${row.index} 执行 段合并 吗?段合并非常耗资源,将提交给ES后台执行`,
    positiveText: '确定',
    negativeText: '取消',
    onPositiveClick: async () => {
      await mergeSegmentsFunc(row)
    }
  })
}
const mergeSegmentsFunc = async (row) => {
  const res = await MergeSegments(row.index)
  if (res.err !== "") {
    message.error(res.err)
  } else {
    json_data.value = formattedJson(res.result)
    drawer_title.value = row.index
    drawerVisible.value = true
    message.success("已提交段合并请求,段合并是重IO任务,请注意集群负载")
    await search()
  }
}
const deleteIndex = async (row) => {
  dialog.info({
    title: '警告',
    content: `确定要删除索引 ${row.index} 吗?`,
    positiveText: '确定',
    negativeText: '取消',
    onPositiveClick: async () => {
      await deleteIndexFunc(row)
    }
  })
}
const deleteIndexFunc = async (row) => {
  const res = await DeleteIndex(row.index)
  if (res.err !== "") {
    message.error(res.err)
  } else {
    json_data.value = formattedJson(res.result)
    drawer_title.value = row.index
    drawerVisible.value = true
    await search()

  }
}
const openCloseIndex = async (row) => {
  const res = await OpenCloseIndex(row.index, row.status)
  if (res.err !== "") {
    message.error(res.err)
  } else {
    json_data.value = formattedJson(res.result)
    drawer_title.value = row.index
    drawerVisible.value = true
    await search()

  }
}
const refreshIndex = async (row) => {
  const res = await Refresh(row.index)
  if (res.err !== "") {
    message.error(res.err)
  } else {
    json_data.value = formattedJson(res.result)
    drawer_title.value = row.index
    drawerVisible.value = true
  }
}
const flushIndex = async (row) => {
  const res = await Flush(row.index)
  if (res.err !== "") {
    message.error(res.err)
  } else {
    json_data.value = formattedJson(res.result)
    drawer_title.value = row.index
    drawerVisible.value = true
  }
}
const clearCache = async (row) => {
  const res = await CacheClear(row.index)
  if (res.err !== "") {
    message.error(res.err)
  } else {
    json_data.value = formattedJson(res.result)
    drawer_title.value = row.index
    drawerVisible.value = true
    await search()

  }
}
const addIndexLoading = ref(false)
const addIndex = async () => {
  formRef.value?.validate(async (errors) => {
    if (!errors) {
      // 测试mapping有的话,能不能json格式化
      if (indexConfig.value.mapping) {
        const err = isValidJson(indexConfig.value.mapping)
        if (!err) {
          message.error("mapping不是合法的json格式")
          return
        }
      }

      addIndexLoading.value = true
      try {
        const res = await CreateIndex(indexConfig.value.name, indexConfig.value.numberOfShards, indexConfig.value.numberOfReplicas, indexConfig.value.mapping)
        if (res.err !== "") {
          message.error(res.err)
        } else {
          message.success(`索引【${indexConfig.value.name}】创建成功`)
          await search()
        }
      } catch (e) {
        message.error(e.message)
      } finally {
        addIndexLoading.value = false
        CreateIndexDrawerVisible.value = false
      }
    } else {
      message.error('请填写所有必填字段')
    }
  })
}

// 下载所有数据的 CSV 文件
const downloadAllDataCsv = async () => {
  const csvContent = createCsvContent(data.value, columns)
  download_file(csvContent, '索引列表.csv', 'text/csv;charset=utf-8;')
}
const queryAlias = async () => {
  loading.value = true
  let name_lst = []
  const start = (pagination.value.page - 1) * pagination.value.pageSize;
  const end = start + pagination.value.pageSize;
  let pagedData = data.value.slice(start, end);
  for (const k in pagedData.value) {
    name_lst.push(pagedData.value[k].index)
  }
  try {
    const res = await GetIndexAliases(name_lst)
    if (res.err !== "") {
      loading.value = false
      message.error(res.err)
      return
    }
    // 合并别名缓存
    // {
    //   "23": "xcx",
    // }
    aliases = {...aliases, ...res.result}
    console.log(aliases)
    for (const k in data.value) {
      const alias = aliases[data.value[k].index]
      if (alias) {
        data.value[k].alias = alias
      }
    }
  } catch (e) {
    message.error(e.message)
  }
  loading.value = false
}

const bulk_delete = async () => {
  loading.value = true
  let success_count = 0
  for (const Key in selectedRowKeys.value) {
    const res = await DeleteIndex(selectedRowKeys.value[Key])
    if (res.err !== "") {
      message.error(res.err)
      break
    } else {
      success_count += 1
    }
  }
  // 重置
  selectedRowKeys.value = []
  // 提示删除了几个,失败了几个
  message.success(`成功删除 ${success_count} 个索引`)
  loading.value = false
  await search()
}
const bulk_options = [
  {
    label: '批量删除',
    key: 'bulk_delete',
    props: {
      onClick: async () => {
        dialog.info({
          title: '警告',
          content: `确定要删除索引 ${selectedRowKeys.value} 吗?`,
          positiveText: '确定',
          negativeText: '取消',
          onPositiveClick: async () => {
            await bulk_delete()
          }
        })
      }
    }
  },
]
</script>


<style scoped>

</style>

================================================
FILE: app/frontend/src/components/Nodes.vue
================================================
<!--
  - Copyright 2025 Bronya0 <tangssst@163.com>.
  - Author Github: https://github.com/Bronya0
  -
  - Licensed under the Apache License, Version 2.0 (the "License");
  - you may not use this file except in compliance with the License.
  - You may obtain a copy of the License at
  -
  -     https://www.apache.org/licenses/LICENSE-2.0
  -
  - Unless required by applicable law or agreed to in writing, software
  - distributed under the License is distributed on an "AS IS" BASIS,
  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  - See the License for the specific language governing permissions and
  - limitations under the License.
  -->

<template>
  <n-flex vertical>
    <n-flex align="center">
      <h2>节点</h2>
      <n-button :render-icon="renderIcon(RefreshOutlined)" text @click="getData">refresh</n-button>
      <n-text>共计{{ data.length }}个</n-text>
      <n-button :render-icon="renderIcon(RefreshOutlined)" @click="getData">刷新</n-button>
      <n-button :render-icon="renderIcon(DriveFileMoveTwotone)" @click="downloadAllDataCsv">导出为csv</n-button>
      <n-text>集群名:{{cluster_name}}</n-text>

    </n-flex>
    <n-spin :show="loading" description="Connecting...">
      <n-data-table
          ref="tableRef"
          :bordered="false"
          :columns="refColumns(columns)"
          :data="data"
          :pagination="pagination"
          size="small"
          striped
      />
    </n-spin>
  </n-flex>
</template>
<script setup>
import {h, onMounted, ref} from "vue";
import emitter from "../utils/eventBus";
import {NButton, NDataTable, NFlex, NProgress, NTag, NText, NTooltip, useMessage} from 'naive-ui'
import {
  createCsvContent,
  download_file,
  formatBytes,
  formatMillis, formatMillisToDays,
  formatNumber,
  refColumns,
  renderIcon
} from "../utils/common";
import {DriveFileMoveTwotone, RefreshOutlined} from "@vicons/material";
import {GetNodes} from "../../wailsjs/go/service/ESService";

const selectNode = async (node) => {
  await getData()
}

onMounted(() => {
  emitter.on('selectNode', selectNode)
  getData()
})


const loading = ref(false)
const data = ref([])
const message = useMessage()
const tableRef = ref();
const cluster_name = ref("");


const getData = async () => {
  loading.value = true;
  const res = await GetNodes();
  if (res.err !== "") {
    message.error(res.err);
    loading.value = false;
    return;
  }
  cluster_name.value = res.result.cluster_name;
  const nodesData = res.result.nodes || {};

  const masterEligibleNodes = Object.keys(nodesData).filter(id => nodesData[id].roles?.includes('master'));
  const masterNodeId = masterEligibleNodes.length > 0 ? masterEligibleNodes[0] : null;

  const flattenedData = Object.values(nodesData).map(node => {
    // --- 安全地访问嵌套属性 ---
    const diskTotal = node.fs?.total?.total_in_bytes ?? 0;
    const diskFree = node.fs?.total?.available_in_bytes ?? 0;
    const diskUsedPercent = diskTotal > 0 ? Math.round(((diskTotal - diskFree) / diskTotal) * 100) : 0;

    // 为每个可能缺失的嵌套对象提供一个空对象作为默认值,简化后续访问
    const indices = node.indices ?? {};
    const search = indices.search ?? {};
    const merges = indices.merges ?? {};
    const query_cache = indices.query_cache ?? {};
    const fielddata = indices.fielddata ?? {};
    const request_cache = indices.request_cache ?? {};
    const segments = indices.segments ?? {};
    const jvm = node.jvm ?? {};
    const jvm_mem = jvm.mem ?? {};
    const os_cpu = node.os?.cpu ?? {};
    const process = node.process ?? {};
    const fs_total = node.fs?.total ?? {};
    const fs_io = node.fs?.io_stats?.total ?? {};

    return {
      // 基础信息
      name: node.name,
      ip: node.transport_address?.split(':')[0] ?? 'N/A',
      roles: node.roles || [], // 确保 roles 是一个数组
      is_master: node.name === nodesData[masterNodeId]?.name,
      uptime: jvm.uptime_in_millis ?? 0,

      // 指标
      docs_count: indices.docs?.count ?? 0,
      store_size: indices.store?.size_in_bytes ?? 0,
      disk_total: diskTotal,
      disk_percent: diskUsedPercent,
      load_5m: os_cpu.load_average?.['5m'] ?? 0,
      heap_used_in_bytes: jvm_mem.heap_used_in_bytes ?? 0,
      heap_max_in_bytes: jvm_mem.heap_max_in_bytes ?? 0,
      heap_percent: jvm_mem.heap_used_percent ?? 0,
      segments_count: segments.count ?? 0,

      // 用于 Tooltip 的原始对象
      stats_search: {
        "查询总数": formatNumber(search.query_total ?? 0),
        "查询耗时": formatMillis(search.query_time_in_millis ?? 0),
        "拉取总数": formatNumber(search.fetch_total ?? 0),
        "拉取耗时": formatMillis(search.fetch_time_in_millis ?? 0),
        "滚动总数": formatNumber(search.scroll_total ?? 0),
        "滚动耗时": formatMillis(search.scroll_time_in_millis ?? 0),
      },
      stats_merges: {
        "合并总数": formatNumber(merges.total ?? 0),
        "合并总耗时": formatMillis(merges.total_time_in_millis ?? 0),
        "合并文档总数": formatNumber(merges.total_docs ?? 0),
        "合并总大小": formatBytes(merges.total_size_in_bytes ?? 0),
        "当前合并数": merges.current ?? 0,
      },
      stats_cache: `查询缓存: ${formatBytes(query_cache.memory_size_in_bytes ?? 0)} |
      Fielddata: ${formatBytes(fielddata.memory_size_in_bytes ?? 0)} |
      请求缓存: ${formatBytes(request_cache.memory_size_in_bytes ?? 0)} |
      段内存: ${formatBytes(segments.memory_in_bytes ?? 0)}`,

      stats_cache_size: (query_cache.memory_size_in_bytes ?? 0) + (fielddata.memory_size_in_bytes ?? 0) + (request_cache.memory_size_in_bytes ?? 0)
          + (segments.memory_in_bytes ?? 0),

      stats_segments: {
        "段总数": formatNumber(segments.count ?? 0),
        "总内存占用": formatBytes(segments.memory_in_bytes ?? 0),
        "词项 (Terms)": formatBytes(segments.terms_memory_in_bytes ?? 0),
        "存储字段 (Stored Fields)": formatBytes(segments.stored_fields_memory_in_bytes ?? 0),
        "Doc Values": formatBytes(segments.doc_values_memory_in_bytes ?? 0),
        "Norms": formatBytes(segments.norms_memory_in_bytes ?? 0),
      },
      stats_process: {
        "打开文件描述符": `${formatNumber(process.open_file_descriptors ?? 0)} / ${formatNumber(process.max_file_descriptors ?? 0)}`,
        "进程CPU使用率": `${process.cpu?.percent ?? 0}%`,
        "虚拟内存占用": formatBytes(process.mem?.total_virtual_in_bytes ?? 0),
      },
      stats_fs: {
        "总空间": formatBytes(fs_total.total_in_bytes ?? 0),
        "可用空间": formatBytes(fs_total.available_in_bytes ?? 0),
        "IO读操作": formatNumber(fs_io.read_operations ?? 0),
        "IO写操作": formatNumber(fs_io.write_operations ?? 0),
        "IO读流量": formatBytes((fs_io.read_kilobytes ?? 0) * 1024),
        "IO写流量": formatBytes((fs_io.write_kilobytes ?? 0) * 1024),
      }
    };
  });

  flattenedData.sort((a, b) => (b.heap_percent ?? 0) - (a.heap_percent ?? 0));
  data.value = flattenedData;
  loading.value = false;
};

/**
 * 创建一个通用的 Tooltip 内容 VNode
 * @param {object} stats - 键值对对象
 * @param {boolean} formatValueAsBytes - 是否将值格式化为字节
 */
function createTooltipContent(stats, formatValueAsBytes = false) {
  return h(NFlex, { vertical: true, style: { maxWidth: '400px', textAlign: 'left'} }, () =>
      Object.entries(stats).map(([key, value]) =>
          h(NText, null, () => `${key}: ${formatValueAsBytes ? formatBytes(value) : value}`)
      )
  );
}

const getProgressType = (value) => {
  const numValue = Number(value)
  if (numValue < 60) return 'success'
  if (numValue < 80) return 'warning'
  return 'error'
}

const downloadAllDataCsv = () => {
  if (!data.value || data.value.length === 0) {
    message.warning("没有数据可以导出");
    return;
  }

  // 1. 定义基础列和需要特殊处理的列
  const exportColumns = [
    { title: '节点名', key: 'name' },
    { title: 'IP', key: 'ip' },
    { title: '角色', getCsvValue: (row) => row.roles.map(role => roleMap[role.charAt(0)] || role).join('/') },
    { title: 'CPU负载(5m)', key: 'load_5m' },
    { title: '运行时间', getCsvValue: (row) => formatMillisToDays(row.uptime) },
    { title: '总文档数', getCsvValue: (row) => formatNumber(row.docs_count) },
    { title: '段总数', getCsvValue: (row) => formatNumber(row.segments_count) },
    { title: '堆内存使用率(%)', key: 'heap_percent' },
    { title: '堆内存已用', getCsvValue: (row) => formatBytes(row.heap_used_in_bytes) },
    { title: '堆内存上限', getCsvValue: (row) => formatBytes(row.heap_max_in_bytes) },
    { title: '磁盘使用率(%)', key: 'disk_percent' },
    { title: '已用空间', getCsvValue: (row) => formatBytes(row.store_size) },
    { title: '总空间', getCsvValue: (row) => formatBytes(row.disk_total) },
    { title: '缓存总大小', getCsvValue: (row) => formatBytes(row.stats_cache_size) },
    {
      title: '缓存详情',
      key: 'stats_cache',
      // 清理字符串中的换行和多余空格,使其在CSV中保持单行
      getCsvValue: (row) => (row.stats_cache || '').replace(/\s+/g, ' ').trim()
    },
  ];

  // 2. 动态地将所有嵌套的性能/系统指标对象平铺展开,作为新的列
  const firstRow = data.value[0];
  // 定义需要平铺展开的数据对象的键名和在CSV表头中使用的前缀
  const statsToFlatten = {
    '搜索': 'stats_search',
    '合并': 'stats_merges',
    '段指标': 'stats_segments',
    '进程': 'stats_process',
    '文件系统': 'stats_fs',
  };

  for (const [prefix, dataKey] of Object.entries(statsToFlatten)) {
    // 确保数据源中存在这个对象
    if (firstRow && typeof firstRow[dataKey] === 'object' && firstRow[dataKey] !== null) {
      // 遍历对象中的每一个键(如 "查询总数", "查询耗时" 等)
      for (const statKey in firstRow[dataKey]) {
        exportColumns.push({
          // CSV 表头,例如:"搜索 - 查询总数"
          title: `${prefix} - ${statKey}`,
          // 使用闭包来捕获正确的 dataKey 和 statKey,从每一行数据中提取对应的值
          getCsvValue: (row) => row[dataKey]?.[statKey] ?? '',
        });
      }
    }
  }

  // 3. 生成并下载 CSV 文件
  try {
    const csvContent = createCsvContent(data.value, exportColumns);
    const fileName = `es-nodes-${cluster_name.value || 'export'}.csv`;
    download_file(csvContent, fileName, 'text/csv;charset=utf-8;');
    message.success("CSV 文件导出成功");
  } catch (e) {
    message.error("导出失败: " + e.message);
    console.error("CSV export error:", e);
  }
};

const pagination = ref({
  page: 1,
  pageSize: 10,
  showSizePicker: true,
  pageSizes: [5, 10, 20, 30, 40],
  onChange: (page) => {
    pagination.value.page = page
  },
  onUpdatePageSize: (pageSize) => {
    pagination.value.pageSize = pageSize
    pagination.value.page = 1
  },
})

const roleMap = { m: '主', d: '数据', i: 'Ingest', l: 'ML', c: '协调', r: '远程', t: '转换', h: '热', w: '温', f: '冻', v: '投票' };

const columns = [
  { title: '节点名', key: 'name', width: 120,
    fixed: 'left' // 可以选择固定列
  },
  { title: 'IP', key: 'ip', width: 120 },
  {
    title: '角色', key: 'roles', width: 120,
    render: (row) => row.roles.map(role => roleMap[role.charAt(0)] || role).join('/')
  },
  { title: 'CPU负载', key: 'load_5m', width: 100 },
  {
    title: '堆内存使用率', key: 'heap_percent', width: 160,
    render: (row) => h(NFlex, { vertical: true }, () => [
      h(NText, null, () => `${formatBytes(row.heap_used_in_bytes)} / ${formatBytes(row.heap_max_in_bytes)}`),
      h(NProgress, { type: "line", percentage: row.heap_percent, status: getProgressType(row.heap_percent), indicatorPlacement: 'inside', borderRadius: 4, })
    ]),
    sorter: (a, b) => a.heap_percent - b.heap_percent
  },
  {
    title: '磁盘使用率', key: 'disk_percent', width: 160,
    render: (row) => h(NFlex, { vertical: true }, () => [
      h(NText, null, () => `${formatBytes(row.store_size)} / ${formatBytes(row.disk_total)}`),
      h(NProgress, { type: "line", percentage: row.disk_percent, status: getProgressType(row.disk_percent), indicatorPlacement: 'inside', borderRadius: 4, })
    ]),
    sorter: (a, b) => a.disk_percent - b.disk_percent
  },

  {
    title: '性能指标', key: 'perf', width: 160,
    render: (row) => h(NFlex, null, () => [
      h(NTooltip, null, { trigger: () => h(NTag, { type: 'primary' }, { default: () => '搜索' }), default: () => createTooltipContent(row.stats_search) }),
      h(NTooltip, null, { trigger: () => h(NTag, { type: 'primary' }, { default: () => '合并' }), default: () => createTooltipContent(row.stats_merges) }),
      h(NTooltip, null, { trigger: () => h(NTag, { type: 'primary' }, { default: () => '段' }), default: () => createTooltipContent(row.stats_segments) }),
    ])
  },
  {
    title: '系统指标', key: 'sys', width: 120,
    render: (row) => h(NFlex, null, () => [
      h(NTooltip, null, { trigger: () => h(NTag, { type: 'info' }, { default: () => '进程' }), default: () => createTooltipContent(row.stats_process) }),
      h(NTooltip, null, { trigger: () => h(NTag, { type: 'info' }, { default: () => 'FS' }), default: () => createTooltipContent(row.stats_fs) }),
    ])
  },
  {title: '缓存', key: 'cache', width: 100, render: (row) => row.stats_cache,
    sorter: (a, b) => a.stats_cache_size - b.stats_cache_size
  },
  {title: '总文档数', key: 'docs_count', width: 100, render: (row) => formatNumber(row.docs_count),},
  { title: '段总数', key: 'segments_count', width: 100, render: (row) => formatNumber(row.segments_count)},
  { title: '运行时间', key: 'uptime', width: 90, render: (row) => formatMillisToDays(row.uptime) },

];


</script>


<style scoped>

</style>

================================================
FILE: app/frontend/src/components/Rest.vue
================================================
<!--
  - Copyright 2025 Bronya0 <tangssst@163.com>.
  - Author Github: https://github.com/Bronya0
  -
  - Licensed under the Apache License, Version 2.0 (the "License");
  - you may not use this file except in compliance with the License.
  - You may obtain a copy of the License at
  -
  -     https://www.apache.org/licenses/LICENSE-2.0
  -
  - Unless required by applicable law or agreed to in writing, software
  - distributed under the License is distributed on an "AS IS" BASIS,
  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  - See the License for the specific language governing permissions and
  - limitations under the License.
  -->

<template>
  <n-flex vertical>
    <n-flex align="center">
      <h2>REST</h2>
      <n-text>一个 Restful 调试工具,支持格式化/DSL提示补全/示例/下载/历史缓存</n-text>
    </n-flex>
    <n-flex align="center">
      <n-select v-model:value="method" :options="methodOptions" style="width: 120px;"/>

      <div :id="ace_editorId" class="ace-editor" style="height:34px;min-width: 46.8%;text-align: left;
        line-height: 34px;box-sizing: border-box;"/>

      <n-button :loading="send_loading" :render-icon="renderIcon(SendSharp)" @click="sendRequest">Send</n-button>
      <n-button :render-icon="renderIcon(HistoryOutlined)" @click="showHistoryDrawer = true">历史记录</n-button>
      <n-button :render-icon="renderIcon(MenuBookTwotone)" @click="showDrawer = true">ES查询示例</n-button>
      <n-button :render-icon="renderIcon(ArrowDownwardOutlined)" @click="exportJson">导出结果</n-button>
    </n-flex>
    <n-grid :cols="2" x-gap="20">
      <n-grid-item>
        <div id="json_editor" class="editarea"
             style="white-space: pre-wrap; white-space-collapse: preserve; border: 0 !important;"
             @paste="toTree"></div>
      </n-grid-item>
      <n-grid-item>
        <div id="json_view" class="editarea"></div>
      </n-grid-item>
    </n-grid>
  </n-flex>
  <!--  示例-->
  <n-drawer v-model:show="showDrawer" placement="right" style="width: 38.2%">
    <n-drawer-content style="text-align: left;" title="ES DSL查询示例">
      <n-flex vertical>
        <n-collapse>
          <n-collapse-item name="1" title="1. Term查询">
            <n-code :code="dslExamples.term" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="2" title="2. Terms查询">
            <n-code :code="dslExamples.terms" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="3" title="3. Match查询">
            <n-code :code="dslExamples.match" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="4" title="4. Match Phrase查询">
            <n-code :code="dslExamples.matchPhrase" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="5" title="5. Range查询">
            <n-code :code="dslExamples.range" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="6" title="6. Bool复合查询">
            <n-code :code="dslExamples.bool" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="7" title="7. Terms Aggregation">
            <n-code :code="dslExamples.termsAggs" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="8" title="8. Date Histogram聚合">
            <n-code :code="dslExamples.dateHistogram" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="9" title="9. Nested查询">
            <n-code :code="dslExamples.nested" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="10" title="10. Exists查询">
            <n-code :code="dslExamples.exists" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="11" title="11. Multi-match查询">
            <n-code :code="dslExamples.multiMatch" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="12" title="12. Wildcard查询">
            <n-code :code="dslExamples.wildcard" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="13" title="13. Metrics聚合">
            <n-code :code="dslExamples.metrics" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="14" title="14. Cardinality聚合">
            <n-code :code="dslExamples.cardinality" language="json"/>
          </n-collapse-item>

          <n-collapse-item name="15" title="15. Script查询">
            <n-code :code="dslExamples.script" language="json"/>
          </n-collapse-item>
        </n-collapse>
      </n-flex>
    </n-drawer-content>
  </n-drawer>

  <!-- 历史记录抽屉 -->
  <n-drawer v-model:show="showHistoryDrawer" style="width: 38.2%">
    <n-drawer-content title="查询历史记录">
      <!-- 搜索框 -->
      <n-input
          v-model:value="searchText"
          clearable
          placeholder="搜索历史记录,同时支持method、path、dsl"
          style="margin-bottom: 12px"
      >
        <template #prefix>
          <n-icon>
            <SearchFilled/>
          </n-icon>
        </template>
      </n-input>

      <!-- 历史记录列表 -->
      <n-list>
        <n-pagination
            v-model:page="currentPage"
            :item-count="filteredHistory?.length"
            :page-size="pageSize"
        />

        <n-list-item v-for="item in currentPageData" :key="item.timestamp"
                     style="cursor: pointer;" @click="handleHistoryClick(item.method, item.path, item.dsl)">
          <n-tooltip placement="left" style="max-height: 618px;overflow-y: auto" trigger="hover">
            <template #trigger>
              <div style="display: flex;font-size: 14px; justify-content: space-between;">
                <n-tag :type="getMethodTagType(item.method)">
                  {{ item.method }}
                </n-tag>
                <n-text>{{ item.path }}</n-text>
                <n-text depth="3">
                  {{ formatTimestamp(item.timestamp) }}
                </n-text>
              </div>
            </template>
            <n-code v-if="item.dsl !== ''" :code="formatDSL(item.dsl)" language="json" style="text-align: left;"/>

          </n-tooltip>
        </n-list-item>

      </n-list>
    </n-drawer-content>
  </n-drawer>
</template>

<script setup>

import {NButton, NGrid, NGridItem, NInput, NSelect, useMessage} from 'naive-ui'
import {computed, nextTick, onMounted, ref} from "vue";
import {Search} from "../../wailsjs/go/service/ESService";
import {ArrowDownwardOutlined, HistoryOutlined, MenuBookTwotone, SearchFilled, SendSharp} from "@vicons/material";
import {formatTimestamp, renderIcon} from "../utils/common";
import {GetConfig, GetHistory, SaveHistory} from "../../wailsjs/go/config/AppConfig";
import emitter from "../utils/eventBus";

import JSONEditor from 'jsoneditor';
import '../assets/css/jsoneditor.min.css'
import 'jsoneditor/src/js/ace/theme-jsoneditor';
import 'ace-builds/src-noconflict/mode-text'
import 'ace-builds/src-noconflict/ext-language_tools'
import 'ace-builds/src-noconflict/theme-textmate'
import 'ace-builds/src-noconflict/theme-monokai'
import ace from 'ace-builds';

const message = useMessage()
const method = ref('POST')
const searchText = ref('')
const history = ref([])
const editor = ref();
const response = ref()
const send_loading = ref(false)
const showDrawer = ref(false)
const showHistoryDrawer = ref(false)
// 状态管理
const currentPage = ref(1)
const pageSize = ref(10)

const methodOptions = [
  {label: 'GET', value: 'GET'},
  {label: 'POST', value: 'POST'},
  {label: 'PUT', value: 'PUT'},
  {label: 'HEAD', value: 'HEAD'},
  {label: 'PATCH', value: 'PATCH'},
  {label: 'OPTIONS', value: 'OPTIONS'},
  {label: 'DELETE', value: 'DELETE'}
]
// 自定义自动补全关键词
const keywords = [
  {word: 'query', meta: 'keyword'},           // 查询入口
  {word: 'bool', meta: 'keyword'},            // 布尔查询
  {word: 'filter', meta: 'keyword'},          // 过滤条件
  {word: 'must', meta: 'keyword'},            // 必须匹配
  {word: 'should', meta: 'keyword'},          // 应该匹配
  {word: 'must_not', meta: 'keyword'},        // 必须不匹配
  {word: 'term', meta: 'keyword'},            // 精确匹配查询
  {word: 'terms', meta: 'keyword'},           // 多值精确匹配查询
  {word: 'match', meta: 'keyword'},           // 全文匹配查询
  {word: 'match_phrase', meta: 'keyword'},    // 短语匹配查询
  {word: 'multi_match', meta: 'keyword'},     // 多字段匹配查询
  {word: 'range', meta: 'keyword'},           // 范围查询
  {word: 'exists', meta: 'keyword'},          // 检查字段是否存在
  {word: 'prefix', meta: 'keyword'},          // 前缀查询
  {word: 'wildcard', meta: 'keyword'},        // 通配符查询
  {word: 'regexp', meta: 'keyword'},          // 正则表达式查询
  {word: 'aggs', meta: 'keyword'},            // 聚合入口
  {word: 'aggregations', meta: 'keyword'},    // 聚合入口(aggs 的完整形式)
  {word: 'terms', meta: 'aggregation'},       // 聚合中的 terms(按字段分组)
  {word: 'sum', meta: 'aggregation'},         // 求和聚合
  {word: 'avg', meta: 'aggregation'},         // 平均值聚合
  {word: 'min', meta: 'aggregation'},         // 最小值聚合
  {word: 'max', meta: 'aggregation'},         // 最大值聚合
  {word: 'stats', meta: 'aggregation'},       // 统计聚合
  {word: 'cardinality', meta: 'aggregation'}, // 去重计数聚合
  {word: 'histogram', meta: 'aggregation'},   // 直方图聚合
  {word: 'date_histogram', meta: 'aggregation'}, // 日期直方图聚合
  {word: 'top_hits', meta: 'aggregation'},    // 返回顶部命中文档
  {word: 'size', meta: 'keyword'},            // 返回结果数量
  {word: 'from', meta: 'keyword'},            // 分页起始位置
  {word: 'sort', meta: 'keyword'},            // 排序
  {word: 'track_total_hits', meta: 'keyword'}, // 跟踪总命中数
  {word: '_source', meta: 'keyword'},         // 控制返回的字段
  {word: 'fields', meta: 'keyword'},          // 指定返回字段
  {word: 'script', meta: 'keyword'},          // 脚本字段
  {word: 'gte', meta: 'range'},               // 大于等于(范围查询)
  {word: 'lte', meta: 'range'},               // 小于等于(范围查询)
  {word: 'gt', meta: 'range'},                // 大于(范围查询)
  {word: 'lt', meta: 'range'},                // 小于(范围查询)
  {word: 'boost', meta: 'keyword'},           // 提升权重
  {word: 'minimum_should_match', meta: 'keyword'}, // should 最小匹配数
  {word: 'nested', meta: 'keyword'},          // 嵌套对象查询
  {word: 'path', meta: 'keyword'},            // 嵌套路径
  {word: 'score_mode', meta: 'keyword'},      // 嵌套查询评分模式
  {word: 'bucket', meta: 'aggregation'},      // 聚合桶
  {word: 'order', meta: 'keyword'},           // 排序顺序
  {word: 'asc', meta: 'sort'},                // 升序
  {word: 'desc', meta: 'sort'}                // 降序
];

const selectNode = (node) => {
  response.value.setText('{"tip": "响应结果,支持搜索"}')
  send_loading.value = false
}

onMounted(async () => {

  emitter.on('selectNode', selectNode)
  emitter.on('update_theme', themeChange)

  const loadedConfig = await GetConfig()
  let theme = 'ace/theme/jsoneditor'
  if (loadedConfig) {
    if (loadedConfig.theme !== 'light') {
      theme = 'ace/theme/monokai'
    }
    editor.value = new JSONEditor(document.getElementById('json_editor'), {
      mode: 'code',
      ace: ace,
      theme: theme,
      mainMenuBar: false,
      statusBar: false,
      showPrintMargin: false,
      placeholder: '请求body'
    });
    response.value = new JSONEditor(document.getElementById('json_view'), {
      mode: 'code',
      ace: ace,
      theme: theme,
      mainMenuBar: false,
      statusBar: false,
      showPrintMargin: false,
    });
    editor.value.setText(null)
    editor.value.aceEditor.setOptions({
      enableBasicAutocompletion: true,
      enableLiveAutocompletion: true
    })

    // 自定义补全器
    const customCompleter = {
      getCompletions: (editor, session, pos, prefix, callback) => {
        // 根据前缀过滤关键词
        const suggestions = keywords
            .filter(k => k.word.startsWith(prefix))
            .map(k => ({
              caption: k.word,
              value: k.word,
              meta: k.meta
            }));
        callback(null, suggestions);
      }
    };

    // 添加自定义补全器
    editor.value.aceEditor.completers = [customCompleter];

    response.value.setText('{"tip": "响应结果,支持搜索"}')
  }
  await read_history()

  await nextTick()
  initAce("输入rest api,以/开头;查询请用POST请求;GET不会携带body", loadedConfig.theme)
  await setAceIndex()

});


// =============== ace编辑器 =================
const ace_editor = ref(null)
const ace_editorId = "ace-editor"

// 初始化 Ace 编辑器
const initAce = (defaultValue, theme) => {
  ace.config.set('basePath', '/node_modules/ace-builds/src-noconflict')

  ace_editor.value = ace.edit(document.getElementById(ace_editorId), {
    mode: `ace/mode/text`,
    theme: theme === 'light'? 'ace/theme/textmate': 'ace/theme/monokai',
    placeholder: defaultValue,
    fontSize: 14,
    enableBasicAutocompletion: true,
    enableLiveAutocompletion: true,
    enableSnippets: true,
    showLineNumbers: false,
    maxLines: 1,
    minLines: 1,
    showGutter: false,
    showPrintMargin: false,
  })
}

const getAceValue = () => {
  return ace_editor.value?.getValue()
}

const setAceValue = (newValue) => {
  ace_editor.value?.setValue(newValue, -1)
}

// 定义提示词数据
// const completions = [
// {
//   caption: "console.log", // 用户看到的名称(可选,如果和 value 相同可以省略)
//   value: "console.log(${1:value})", // 实际插入的值
//   score: 100, // 权重(越高越靠前)
//   meta: "JavaScript" // 分类(如 "JavaScript"、"CSS"、"Custom")
// },
// ];
const setAceCompleter = (completions) => {
  const customCompleter = {
    getCompletions: function (editor, session, pos, prefix, callback) {
      callback(null, completions); // 返回提示词
    }
  };
  // 添加到编辑器的补全器列表
  ace_editor.value.completers = [customCompleter] // 覆盖默认补全器(不推荐)
  // ace_editor.value.completers.push(customCompleter); // 追加自定义补全器(推荐)
}
// ================ ace编辑器 完结 =================

const setAceIndex = async () => {
  const keywords = [
    '_search',         // 搜索API
    '_cluster',        // 集群API
    '_cat',            // Cat API
    '_nodes',          // 节点信息
    '_doc',            // 文档操作
    '_tasks',          // 任务管理
    '_flush',          // 刷新索引
    '_refresh',        // 刷新索引数据
    '_mapping',        // 获取/设置映射
    '_settings',       // 索引设置
    '_stats',          // 统计信息
    '_bulk',           // 批量操作
    '_update',         // 更新文档
    '_msearch',        // 多搜索
    '_alias',          // 别名操作
    '_rollover',       // 滚动索引
    '_reindex',        // 重新索引
    '_snapshot',       // 快照操作
    '_forcemerge',     // 强制合并段
    '_indices',        // 索引操作(补充)
    '_count',          // 计数API(补充)
    '_validate',       // 查询验证(补充)
    '_explain',        // 解释查询(补充)
    '_field_caps',     // 字段能力(补充)
    '_search_shards',  // 搜索分片信息(补充)
    '_analyze',        // 分析文本(补充)
    'pretty',                   // 美化输出
    'human',                    // 人类可读格式
    'master_timeout',           // 主节点超时
    'ignore_unavailable',       // 忽略不可用索引
    'allow_no_indices',         // 允许无索引
    'expand_wildcards',         // 通配符扩展
    'wait_for_active_shards',   // 等待活跃分片
    'wait_for_completion',      // 等待操作完成
    'format=json',              // 指定返回格式(通常是单独使用)
    'size',                    // 返回文档数(补充)
    'from',                    // 分页起始(补充)
    'q',                       // 查询字符串(补充)
    'scroll',                  // 滚动查询(补充)
    'routing',                 // 路由值(补充)
    'preference',              // 查询偏好(补充)
    'timeout',                 // 超时时间(补充)
    'filter_path',             // 过滤返回字段(补充)
  ];
  let completions = [];
  for (let k of keywords) {
    completions.push({value: k});
  }
  const key = 'es_king_indexes';
  const stored = localStorage.getItem(key);
  if (stored) {
    const values = JSON.parse(stored)
    for (let v of values) {
      completions.push({
        value: v,
      })
    }
  }
  if (completions.length > 0) {
    setAceCompleter(completions)
  }
}
const read_history = async () => {
  console.log("read_history")
  try {
    history.value = await GetHistory()
  } catch (e) {
    message.error(e.message)
  }
}
const write_history = async () => {
  console.log("write_history")
  try {
    // 从左侧插入history
    history.value.unshift({
      timestamp: Date.now(),
      method: method.value,
      path: getAceValue(),
      dsl: editor.value.getText()
    })
    // 只保留100条
    if (history.value.length > 100) {
      history.value = history.value.slice(0, 100)
    }
    const res = await SaveHistory(history.value)
    if (res !== "") {
      message.error("保存查询失败:" + res)
    }
  } catch (e) {
    message.error(e.message)
  }
}

// 填充历史记录
function handleHistoryClick(m, p, d) {
  method.value = m
  setAceValue(p)
  editor.value.setText(d)
  showHistoryDrawer.value = false
}

function themeChange(newTheme) {
  const new_editor_theme = newTheme.name === 'dark' ? 'ace/theme/monokai' : 'ace/theme/textmate'
  editor.value.aceEditor.setTheme(new_editor_theme)
  response.value.aceEditor.setTheme(new_editor_theme)
  ace_editor.value?.setTheme(new_editor_theme)

}

const formatDSL = (dsl) => {
  try {
    return JSON.stringify(JSON.parse(dsl), null, 2)
  } catch {
    return dsl
  }
}

const sendRequest = async () => {
  send_loading.value = true
  // 清空response
  response.value.set({})
  let path = getAceValue()
  if (!path.startsWith('/')) {
    setAceValue('/' + path);
  }
  try {
    const res = await Search(method.value, path, editor.value.getText())
    // 返回不是200也写入结果框
    console.log(res)
    if (res.err !== "") {
      try {
        response.value.set(JSON.parse(res.err))
      } catch {
        response.value.set(res.err)
      }
    } else {
      response.value.set(res.result)
      // 写入历史记录
      await write_history()
    }
  } catch (e) {
    message.error(e.message)
  }
  send_loading.value = false

}

const toTree = () => {
  editor.value.format();
}


function exportJson() {
  const blob = new Blob([response.value.getText()], {type: 'application/json'})
  const url = URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.download = 'response.json'
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)
  URL.revokeObjectURL(url)
}

// 过滤和分页逻辑
const filteredHistory = computed(() => {
  if (!searchText.value) {
    return history.value
  } else {
    return history.value.filter(item => {
      return item.method.includes(searchText.value) ||
          item.path.includes(searchText.value) ||
          item.dsl.includes(searchText.value)
    })
  }

})

const currentPageData = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  const end = start + pageSize.value
  return filteredHistory.value.slice(start, end)
})

const getMethodTagType = (method) => {
  const types = {
    'GET': 'success',
    'POST': 'info',
    'PUT': 'warning',
    'DELETE': 'error'
  }
  return types[method] || 'default'
}

const dslExamples = {
  term: JSON.stringify({
    "query": {
      "term": {
        "status": "active"
      }
    },
    "size": 10,
    "track_total_hits": true
  }, null, 2),

  terms: JSON.stringify({
    "query": {
      "terms": {
        "user_id": [1, 2, 3, 4]
      }
    },
    "size": 10
  }, null, 2),

  match: JSON.stringify({
    "query": {
      "match": {
        "description": "quick brown fox"
      }
    },
    "size": 20
  }, null, 2),

  matchPhrase: JSON.stringify({
    "query": {
      "match_phrase": {
        "description": {
          "query": "quick brown fox",
          "slop": 1
        }
      }
    }
  }, null, 2),

  range: JSON.stringify({
    "query": {
      "range": {
        "age": {
          "gte": 20,
          "lte": 30
        }
      }
    }
  }, null, 2),

  bool: JSON.stringify({
    "query": {
      "bool": {
        "must": [
          {"term": {"status": "active"}}
        ],
        "must_not": [
          {"term": {"type": "deleted"}}
        ],
        "should": [
          {"term": {"category": "electronics"}},
          {"term": {"category": "computers"}}
        ],
        "minimum_should_match": 1
      }
    }
  }, null, 2),

  termsAggs: JSON.stringify({
    "aggs": {
      "status_counts": {
        "terms": {
          "field": "status",
          "missing": "N/A",
          "size": 10
        }
      }
    },
    "size": 0
  }, null, 2),

  dateHistogram: JSON.stringify({
    "aggs": {
      "sales_over_time": {
        "date_histogram": {
          "field": "created_at",
          "calendar_interval": "1d",
          "format": "yyyy-MM-dd"
        }
      }
    },
    "size": 0
  }, null, 2),

  nested: JSON.stringify({
    "query": {
      "nested": {
        "path": "comments",
        "query": {
          "bool": {
            "must": [
              {"match": {"comments.text": "great"}},
              {"term": {"comments.rating": 5}}
            ]
          }
        }
      }
    }
  }, null, 2),

  exists: JSON.stringify({
    "query": {
      "exists": {
        "field": "email"
      }
    }
  }, null, 2),

  multiMatch: JSON.stringify({
    "query": {
      "multi_match": {
        "query": "quick brown fox",
        "fields": ["title", "description^2"],
        "type": "best_fields"
      }
    }
  }, null, 2),

  wildcard: JSON.stringify({
    "query": {
      "wildcard": {
        "email": "*@gmail.com"
      }
    }
  }, null, 2),

  metrics: JSON.stringify({
    "aggs": {
      "avg_price": {"avg": {"field": "price"}},
      "max_price": {"max": {"field": "price"}},
      "min_price": {"min": {"field": "price"}},
      "sum_quantity": {"sum": {"field": "quantity"}}
    },
    "size": 0
  }, null, 2),

  cardinality: JSON.stringify({
    "aggs": {
      "unique_users": {
        "cardinality": {
          "field": "user_id",
          "precision_threshold": 100
        }
      }
    },
    "size": 0
  }, null, 2),

  script: JSON.stringify({
    "query": {
      "script_score": {
        "query": {"match_all": {}},
        "script": {
          "source": "doc['price'].value * doc['rating'].value",
          "lang": "painless"
        }
      }
    }
  }, null, 2)
}

</script>

<style>
.editarea, .json_view {
  height: 72dvh;
}

/* 隐藏ace编辑器的脱离聚焦时携带的光标 */
.ace_editor:not(.ace_focus) .ace_cursor {
  opacity: 0 !important;
}

/* 使主题支持placeholder */
.ace_editor .ace_placeholder {
  position: absolute;
  z-index: 10;
}
</style>

================================================
FILE: app/frontend/src/components/Settings.vue
================================================
<!--
  - Copyright 2025 Bronya0 <tangssst@163.com>.
  - Author Github: https://github.com/Bronya0
  -
  - Licensed under the Apache License, Version 2.0 (the "License");
  - you may not use this file except in compliance with the License.
  - You may obtain a copy of the License at
  -
  -     https://www.apache.org/licenses/LICENSE-2.0
  -
  - Unless required by applicable law or agreed to in writing, software
  - distributed under the License is distributed on an "AS IS" BASIS,
  - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  - See the License for the specific language governing permissions and
  - limitations under the License.
  -->

<template>
  <!--  https://www.naiveui.com/zh-CN/os-theme/components/form  -->
  <n-flex justify="start" vertical>
    <h2>设置</h2>
    <n-form :model="config" label-placement="top" style="text-align: left;">

      <n-form-item label="窗口宽度">
        <n-input-number v-model:value="config.width" :max="1920" :min="800" :style="{ maxWidth: '120px' }"/>
      </n-form-item>
      <n-form-item label="窗口高度">
        <n-input-number v-model:value="config.height" :max="1080" :min="600" :style="{ maxWidth: '120px' }"/>
      </n-form-item>
      <n-form-item label="语言">
        <n-select v-model:value="config.language" :options="languageOptions" :style="{ maxWidth: '120px' }"/>
      </n-form-item>

      <n-form-item label="主题">
        <n-switch
            v-model:value="theme"
            :checked-value="darkTheme.name"
            :unchecked-value="lightTheme.name"
            @update:value="changeTheme"
        >
          <template #checked-icon>
            <n-icon :component="NightlightRoundFilled"/>
          </template>
          <template #unchecked-icon>
            <n-icon :component="WbSunnyOutlined"/>
          </template>
        </n-switch>
      </n-form-item>
      <n-form-item>
        <n-flex>
          <n-button strong type="primary" @click="saveConfig">保存设置</n-button>
          <n-tooltip>
            <template #trigger>
              <n-button style="width: 100px" @click="getSysInfo()">ProcessInfo</n-button>
            </template>
            <n-p style="white-space: pre-wrap; max-height: 400px; overflow: auto; text-align: left">{{ sys_info }}</n-p>
          </n-tooltip>
        </n-flex>
      </n-form-item>

    </n-form>
  </n-flex>
</template>

<script setup>
import {onMounted, ref, shallowRef} from 'vue'
import {
  darkTheme,
  lightTheme,
  NButton,
  NForm,
  NFormItem,
  NInputNumber,
  NSelect,
  useMessage,
} from 'naive-ui'
import {WbSunnyOutlined, NightlightRoundFilled} from '@vicons/material'

import {GetConfig, SaveConfig} from '../../wailsjs/go/config/AppConfig'
import {GetProcessInfo} from '../../wailsjs/go/system/Update'

import {WindowSetSize} from "../../wailsjs/runtime";
import emitter from "../utils/eventBus";

const message = useMessage()
let theme = lightTheme.name
const sys_info = ref("")


const config = ref({
  width: 1248,
  height: 768,
  language: 'zh-CN',
  theme: theme,
})
const languageOptions = [
  {label: '中文', value: 'zh-CN'},
  {label: 'English', value: 'en-US'}
]

const getSysInfo = async () => {
  sys_info.value = await GetProcessInfo()
}

onMounted(async () => {
  console.info("初始化settings……")

  // 从后端加载配置
  const loadedConfig = await GetConfig()
  console.log(loadedConfig)
  if (loadedConfig) {
    config.value = loadedConfig
    theme = loadedConfig.theme
  }
  await getSysInfo()

})


const saveConfig = async () => {
  config.value.theme = theme
  const err = await SaveConfig(config.value)
  if (err !== "") {
    message.error("保存失败:" + err)
    return
  }

  WindowSetSize(config.value.width, config.value.height)

  emitter.emit('update_theme', theme)
  // 可以添加保存成功的提示
  message.success("保存成功")
  config.value = await GetConfig()

}

const changeTheme = () => {
  emitter.emit('update_theme', theme)
}


</script>

================================================
FILE: app/frontend/src/components/Snapshot.vue
================================================
<template>
  <n-flex vertical>
    <n-flex align="center">
      <h2>快照管理</h2>
    </n-flex>
    <n-tabs type="line" animated>
      <!-- ==================== Tab 1: 仓库管理 ==================== -->
      <n-tab-pane name="repo" tab="仓库管理">
        <n-flex vertical>
          <n-flex align="center">
            <n-button :render-icon="renderIcon(RefreshOutlined)" text @click="getRepos">刷新</n-button>
            <n-button :render-icon="renderIcon(AddFilled)" @click="repoForm.show = true">创建仓库</n-button>
          </n-flex>
          <n-spin :show="repoLoading" description="加载中...">
            <n-data-table
                :bordered="false"
                :columns="repoColumns"
                :data="repoData"
                :max-height="550"
                size="small"
                striped
            />
          </n-spin>
        </n-flex>

        <!-- 创建仓库弹窗 -->
        <n-modal v-model:show="repoForm.show" preset="dialog" title="创建快照仓库" positive-text="确认" negative-text="取消"
                 @positive-click="handleCreateRepo">
          <n-form label-placement="left" label-width="auto">
            <n-form-item label="仓库名称">
              <n-input v-model:value="repoForm.name" placeholder="my_backup_repo"/>
            </n-form-item>
            <n-form-item label="仓库类型">
              <n-select v-model:value="repoForm.type" :options="repoTypeOptions"/>
            </n-form-item>
            <n-form-item label="Settings (JSON)">
              <n-input v-model:value="repoForm.settings" type="textarea" :autosize="{minRows: 3, maxRows: 8}"
                       placeholder='例如: {"location": "/mount/backups/my_backup"}' />
            </n-form-item>
          </n-form>
        </n-modal>
      </n-tab-pane>

      <!-- ==================== Tab 2: 快照管理 ==================== -->
      <n-tab-pane name="snapshot" tab="快照管理">
        <n-flex vertical>
          <n-flex align="center">
            <n-button :render-icon="renderIcon(RefreshOutlined)" text @click="getSnapshots">刷新</n-button>
            <n-button :render-icon="renderIcon(AddFilled)" @click="snapForm.show = true">创建快照</n-button>
          </n-flex>
          <n-spin :show="snapLoading" description="加载中...">
            <n-data-table
                :bordered="false"
                :columns="snapColumns"
                :data="snapData"
                :max-height="550"
                size="small"
                striped
            />
          </n-spin>
        </n-flex>

        <!-- 创建快照弹窗 -->
        <n-modal v-model:show="snapForm.show" preset="dialog" title="创建快照" positive-text="确认" negative-text="取消"
                 @positive-click="handleCreateSnapshot">
          <n-form label-placement="left" label-width="auto">
            <n-form-item label="仓库">
              <n-select v-model:value="snapForm.repository" :options="repoSelectOptions"
                        placeholder="选择目标仓库"/>
            </n-form-item>
            <n-form-item label="快照名称">
              <n-input v-model:value="snapForm.snapshot" placeholder="snapshot_2026"/>
            </n-form-item>
            <n-form-item label="索引(逗号分隔)">
              <n-input v-model:value="snapForm.indices" placeholder="留空表示所有索引"/>
            </n-form-item>
            <n-form-item label="包含全局状态">
              <n-switch v-model:value="snapForm.includeGlobalState"/>
            </n-form-item>
          </n-form>
          <n-text depth="3">快照将在后台异步创建,可在快照列表中查看进度。</n-text>
        </n-modal>

        <!-- 快照详情弹窗 -->
        <n-modal v-model:show="snapDetail.show" preset="card" title="快照详情" style="width: 600px;">
          <n-code :code="snapDetail.content" language="json" show-line-numbers/>
        </n-modal>
      </n-tab-pane>

      <!-- ==================== Tab 3: 快照恢复 ==================== -->
      <n-tab-pane name="restore" tab="快照恢复">
        <n-flex vertical>
          <n-flex align="center">
            <n-button :render-icon="renderIcon(RefreshOutlined)" text @click="getRestoreStatus">刷新恢复状态
            </n-button>
          </n-flex>

          <n-card title="恢复快照" size="small">
            <n-form label-placement="left" label-width="auto">
              <n-form-item label="仓库">
                <n-select v-model:value="restoreForm.repository" :options="repoSelectOptions"
                          placeholder="选择仓库" @update:value="onRestoreRepoChange"/>
              </n-form-item>
              <n-form-item label="快照">
                <n-select v-model:value="restoreForm.snapshot" :options="restoreSnapOptions"
                          placeholder="选择要恢复的快照"/>
              </n-form-item>
              <n-form-item label="索引(逗号分隔)">
                <n-input v-model:value="restoreForm.indices" placeholder="留空表示恢复所有索引"/>
              </n-form-item>
              <n-form-item label="重命名模式">
                <n-input v-model:value="restoreForm.renamePattern" placeholder="例如: index_(.+)"/>
              </n-form-item>
              <n-form-item label="重命名替换">
                <n-input v-model:value="restoreForm.renameReplacement" placeholder="例如: restored_index_$1"/>
              </n-form-item>
              <n-form-item label="包含全局状态">
                <n-switch v-model:value="restoreForm.includeGlobalState"/>
              </n-form-item>
            </n-form>
            <n-flex>
              <n-button type="warning" @click="handleRestore">执行恢复</n-button>
            </n-flex>
            <n-text depth="3">
              警告:恢复操作会在集群中创建索引,如果同名索引已存在且未关闭,恢复将失败。建议使用重命名功能避免冲突。
            </n-text>
          </n-card>

          <n-card title="恢复进度" size="small" style="margin-top: 12px;">
            <n-spin :show="restoreStatusLoading">
              <n-data-table
                  :bordered="false"
                  :columns="restoreColumns"
                  :data="restoreStatusData"
                  :max-height="300"
                  size="small"
                  striped
              />
            </n-spin>
          </n-card>
        </n-flex>
      </n-tab-pane>

      <!-- ==================== Tab 4: 自动策略 (SLM) ==================== -->
      <n-tab-pane name="slm" tab="自动策略 (SLM)">
        <n-flex vertical>
          <n-flex align="center">
            <n-button :render-icon="renderIcon(RefreshOutlined)" text @click="getSLMPolicies">刷新</n-button>
            <n-button :render-icon="renderIcon(AddFilled)" @click="slmForm.show = true">创建策略</n-button>
          </n-flex>
          <n-spin :show="slmLoading" description="加载中...">
            <n-data-table
                :bordered="false"
                :columns="slmColumns"
                :data="slmData"
                :max-height="550"
                size="small"
                striped
            />
          </n-spin>
        </n-flex>

        <!-- 创建SLM策略弹窗 -->
        <n-modal v-model:show="slmForm.show" preset="dialog" title="创建自动快照策略" positive-text="确认"
                 negative-text="取消" @positive-click="handleCreateSLM" style="width: 520px;">
          <n-form label-placement="left" label-width="auto">
            <n-form-item label="策略ID">
              <n-input v-model:value="slmForm.policyId" placeholder="daily-snapshots"/>
            </n-form-item>
            <n-form-item label="快照名称模板">
              <n-input v-model:value="slmForm.name" placeholder="<daily-snap-{now/d}>"/>
            </n-form-item>
            <n-form-item label="Cron 调度">
              <n-input v-model:value="slmForm.schedule" placeholder="0 30 1 * * ?(每天凌晨1:30)"/>
            </n-form-item>
            <n-form-item label="仓库">
              <n-select v-model:value="slmForm.repository" :options="repoSelectOptions"
                        placeholder="目标仓库"/>
            </n-form-item>
            <n-form-item label="索引(逗号分隔)">
              <n-input v-model:value="slmForm.indices" placeholder="留空表示所有索引"/>
            </n-form-item>
            <n-form-item label="过期时间">
              <n-input v-model:value="slmForm.expireAfter" placeholder="例如: 30d"/>
            </n-form-item>
            <n-form-item label="最少保留数">
              <n-input-number v-model:value="slmForm.minCount" :min="0" placeholder="5"/>
            </n-form-item>
            <n-form-item label="最多保留数">
              <n-input-number v-model:value="slmForm.maxCount" :min="0" placeholder="50"/>
            </n-form-item>
          </n-form>
        </n-modal>
      </n-tab-pane>
    </n-tabs>
  </n-flex>
</template>

<script setup>
import {computed, h, onMounted, ref} from "vue";
import emitter from "../utils/eventBus";
import {NButton, NTag, useDialog, useMessage} from 'naive-ui'
import {renderIcon} from "../utils/common";
import {RefreshOutlined, AddFilled, DeleteOutlined, PlayArrowFilled, VisibilityOutlined, VerifiedOutlined} from "@vicons/material";
import {
  GetSnapshots, GetSnapshotRepositories, CreateSnapshotRepository, DeleteSnapshotRepository,
  VerifySnapshotRepository, CreateSnapshot, DeleteSnapshot, GetSnapshotDetail,
  RestoreSnapshot, GetSnapshotRestoreStatus,
  GetSLMPolicies, CreateSLMPolicy, DeleteSLMPolicy, ExecuteSLMPolicy,
} from "../../wailsjs/go/service/ESService";

const message = useMessage()
const dialog = useDialog()

// ==================== 仓库管理 ====================
const repoLoading = ref(false)
const repoData = ref([])

const repoForm = ref({
  show: false,
  name: '',
  type: 'fs',
  settings: '',
})

const repoTypeOptions = [
  {label: 'fs(共享文件系统)', value: 'fs'},
  {label: 's3(AWS S3)', value: 's3'},
  {label: 'hdfs(Hadoop HDFS)', value: 'hdfs'},
  {label: 'azure(Azure Blob)', value: 'azure'},
  {label: 'gcs(Google Cloud Storage)', value: 'gcs'},
  {label: 'url(只读URL)', value: 'url'},
]

const repoSelectOptions = computed(() => {
  return repoData.value.map(r => ({label: r.name, value: r.name}))
})

const getRepos = async () => {
  repoLoading.value = true
  const res = await GetSnapshotRepositories()
  if (res.err !== "") {
    message.error(res.err)
  } else {
    repoData.value = res.results || []
  }
  repoLoading.value = false
}

const handleCreateRepo = async () => {
  const res = await CreateSnapshotRepository(repoForm.value.name, repoForm.value.type, repoForm.value.settings)
  if (res.err !== "") {
    message.error(res.err)
    return false
  }
  message.success("仓库创建成功")
  repoForm.value = {show: false, name: '', type: 'fs', settings: ''}
  await getRepos()
}

const handleDeleteRepo = (name) => {
  dialog.warning({
    title: '确认删除仓库',
    content: `确定要删除快照仓库「${name}」吗?这不会删除仓库中已有的快照数据文件,但会从ES集群中移除此仓库的注册信息。`,
    positiveText: '确认删除',
    negativeText: '取消',
    onPositiveClick: async () => {
      const res = await DeleteSnapshotRepository(name)
      if (res.err !== "") {
        message.error(res.err)
      } else {
        message.success("仓库已删除")
        await getRepos()
      }
    }
  })
}

const handleVerifyRepo = async (name) => {
  const res = await VerifySnapshotRepository(name)
  if (res.err !== "") {
    message.error("验证失败: " + res.err)
  } else {
    message.success("仓库验证通过,所有节点可访问")
  }
}

const repoColumns = [
  {title: '仓库名称', key: 'name', width: 180},
  {title: '类型', key: 'type', width: 100},
  {
    title: 'Settings',
    key: 'settings',
    render: (row) => row.settings ? JSON.stringify(row.settings) : '-',
  },
  {
    title: '操作',
    key: 'actions',
    width: 180,
    render: (row) => h('div', {style: 'display: flex; gap: 8px;'}, [
      h(NButton, {size: 'small', quaternary: true, type: 'info', onClick: () => handleVerifyRepo(row.name)},
          {default: () => '验证'}),
      h(NButton, {size: 'small', quaternary: true, type: 'error', onClick: () => handleDeleteRepo(row.name)},
          {default: () => '删除'}),
    ])
  }
]

// ==================== 快照管理 ====================
const snapLoading = ref(false)
const snapData = ref([])

const snapForm = ref({
  show: false,
  repository: null,
  snapshot: '',
  indices: '',
  includeGlobalState: false,
})

const snapDetail = ref({
  show: false,
  content: '',
})

const getSnapshots = async () => {
  snapLoading.value = true
  const res = await GetSnapshots()
  if (res.err !== "") {
    message.error(res.err)
  } else {
    snapData.value = (res.results || []).sort((a, b) => {
      return (b.start_time || '').localeCompare(a.start_time || '')
    })
  }
  snapLoading.value = false
}

const handleCreateSnapshot = async () => {
  const res = await CreateSnapshot(
      snapForm.value.repository,
      snapForm.value.snapshot,
      snapForm.value.indices,
      snapForm.value.includeGlobalState
  )
  if (res.err !== "") {
    message.error(res.err)
    return false
  }
  message.success("快照开始创建(异步),请稍后刷新查看状态")
  snapForm.value = {show: false, repository: null, snapshot: '', indices: '', includeGlobalState: false}
  await getSnapshots()
}

const handleDeleteSnapshot = (repository, snapshot) => {
  dialog.warning({
    title: '确认删除快照',
    content: `确定要删除仓库「${repository}」中的快照「${snapshot}」吗?此操作不可逆。`,
    positiveText: '确认删除',
    negativeText: '取消',
    onPositiveClick: async () => {
      const res = await DeleteSnapshot(repository, snapshot)
      if (res.err !== "") {
        message.error(res.err)
      } else {
        message.success("快照已删除")
        await getSnapshots()
      }
    }
  })
}

const handleViewDetail = async (repository, snapshot) => {
  const res = await GetSnapshotDetail(repository, snapshot)
  if (res.err !== "") {
    message.error(res.err)
  } else {
    snapDetail.value.content = JSON.stringify(res.result, null, 2)
    snapDetail.value.show = true
  }
}

const snapColumns = [
  {title: '快照名称', key: 'snapshot', width: 160},
  {title: '仓库', key: 'repository', width: 130},
  {
    title: '状态', key: 'state', width: 110,
    render: (row) => h(NTag, {
      size: 'small',
      type
Download .txt
gitextract_q841ds1i/

├── .github/
│   └── workflows/
│       └── build.yaml
├── .gitignore
├── LICENSE
├── app/
│   ├── app.go
│   ├── backend/
│   │   ├── common/
│   │   │   └── vars.go
│   │   ├── config/
│   │   │   └── app.go
│   │   ├── service/
│   │   │   └── es.go
│   │   ├── system/
│   │   │   └── update.go
│   │   └── types/
│   │       └── resp.go
│   ├── build/
│   │   ├── README.md
│   │   ├── darwin/
│   │   │   ├── Info.dev.plist
│   │   │   └── Info.plist
│   │   └── windows/
│   │       ├── info.json
│   │       ├── installer/
│   │       │   ├── project.nsi
│   │       │   └── wails_tools.nsh
│   │       └── wails.exe.manifest
│   ├── dev.bat
│   ├── frontend/
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── src/
│   │   │   ├── App.vue
│   │   │   ├── assets/
│   │   │   │   └── fonts/
│   │   │   │       └── OFL.txt
│   │   │   ├── components/
│   │   │   │   ├── About.vue
│   │   │   │   ├── Aside.vue
│   │   │   │   ├── Conn.vue
│   │   │   │   ├── Core.vue
│   │   │   │   ├── Header.vue
│   │   │   │   ├── Health.vue
│   │   │   │   ├── Index.vue
│   │   │   │   ├── Nodes.vue
│   │   │   │   ├── Rest.vue
│   │   │   │   ├── Settings.vue
│   │   │   │   ├── Snapshot.vue
│   │   │   │   └── Task.vue
│   │   │   ├── main.js
│   │   │   ├── style.css
│   │   │   └── utils/
│   │   │       ├── common.js
│   │   │       └── eventBus.js
│   │   └── vite.config.js
│   ├── go.mod
│   ├── go.sum
│   ├── main.go
│   └── wails.json
├── readme-en.md
└── readme.md
Download .txt
SYMBOL INDEX (113 symbols across 8 files)

FILE: app/app.go
  type App (line 32) | type App struct
    method Start (line 42) | func (a *App) Start(ctx context.Context) {
    method domReady (line 48) | func (a *App) domReady(ctx context.Context) {
    method beforeClose (line 71) | func (a *App) beforeClose(ctx context.Context) (prevent bool) {
    method shutdown (line 76) | func (a *App) shutdown(ctx context.Context) {
    method Greet (line 81) | func (a *App) Greet(name string) string {
  function NewApp (line 37) | func NewApp() *App {

FILE: app/backend/common/vars.go
  constant AppName (line 28) | AppName     = "ES-King"
  constant Width (line 29) | Width       = 1600
  constant Height (line 30) | Height      = 870
  constant Theme (line 31) | Theme       = "dark"
  constant ConfigDir (line 32) | ConfigDir   = ".es-king"
  constant ConfigPath (line 33) | ConfigPath  = "config.yaml"
  constant HistoryPath (line 34) | HistoryPath = "history.yaml"
  constant ErrLogPath (line 35) | ErrLogPath  = "error.log"
  constant Language (line 36) | Language    = "zh-CN"
  constant PingUrl (line 37) | PingUrl     = "https://ysboke.cn/api/kingTool/ping"

FILE: app/backend/config/app.go
  type AppConfig (line 33) | type AppConfig struct
    method Start (line 38) | func (a *AppConfig) Start(ctx context.Context) {
    method GetConfig (line 42) | func (a *AppConfig) GetConfig() *types.Config {
    method SaveConfig (line 62) | func (a *AppConfig) SaveConfig(config *types.Config) string {
    method SaveTheme (line 79) | func (a *AppConfig) SaveTheme(theme string) string {
    method getConfigPath (line 96) | func (a *AppConfig) getConfigPath() string {
    method GetHistory (line 114) | func (a *AppConfig) GetHistory() []*types.History {
    method SaveHistory (line 127) | func (a *AppConfig) SaveHistory(histories []types.History) string {
    method getHistoryPath (line 141) | func (a *AppConfig) getHistoryPath() string {
    method GetVersion (line 160) | func (a *AppConfig) GetVersion() string {
    method GetAppName (line 164) | func (a *AppConfig) GetAppName() string {
    method OpenFileDialog (line 168) | func (a *AppConfig) OpenFileDialog(options runtime.OpenDialogOptions) ...
    method LogErrToFile (line 172) | func (a *AppConfig) LogErrToFile(message string) {

FILE: app/backend/service/es.go
  constant FORMAT (line 38) | FORMAT          = "?format=json&pretty"
  constant StatsApi (line 39) | StatsApi        = "/_cluster/stats" + FORMAT
  constant AddDoc (line 40) | AddDoc          = "/_doc"
  constant HealthApi (line 41) | HealthApi       = "/_cluster/health"
  constant NodesApi (line 42) | NodesApi        = "/_nodes/stats/indices,os,fs,process,jvm"
  constant AllIndexApi (line 43) | AllIndexApi     = "/_cat/indices?format=json&pretty&bytes=b"
  constant ClusterSettings (line 44) | ClusterSettings = "/_cluster/settings"
  constant ForceMerge (line 45) | ForceMerge      = "/_forcemerge?wait_for_completion=false"
  constant REFRESH (line 46) | REFRESH         = "/_refresh"
  constant FLUSH (line 47) | FLUSH           = "/_flush"
  constant CacheClear (line 48) | CacheClear      = "/_cache/clear"
  constant TasksApi (line 49) | TasksApi        = "/_tasks" + FORMAT
  constant CancelTasksApi (line 50) | CancelTasksApi  = "/_tasks/%s/_cancel"
  type ESService (line 53) | type ESService struct
    method SetConnect (line 86) | func (es *ESService) SetConnect(key, host, username, password, CACert ...
    method TestClient (line 107) | func (es *ESService) TestClient(host, username, password, CACert strin...
    method AddDocument (line 126) | func (es *ESService) AddDocument(index, doc string) *types.ResultResp {
    method GetNodes (line 144) | func (es *ESService) GetNodes() *types.ResultResp {
    method GetHealth (line 159) | func (es *ESService) GetHealth() *types.ResultResp {
    method GetStats (line 174) | func (es *ESService) GetStats() *types.ResultResp {
    method GetIndexes (line 190) | func (es *ESService) GetIndexes(name string) *types.ResultsResp {
    method CreateIndex (line 213) | func (es *ESService) CreateIndex(name string, numberOfShards, numberOf...
    method GetIndexInfo (line 245) | func (es *ESService) GetIndexInfo(indexName string) *types.ResultResp {
    method DeleteIndex (line 261) | func (es *ESService) DeleteIndex(indexName string) *types.ResultResp {
    method OpenCloseIndex (line 277) | func (es *ESService) OpenCloseIndex(indexName, now string) *types.Resu...
    method GetIndexMappings (line 300) | func (es *ESService) GetIndexMappings(indexName string) *types.ResultR...
    method MergeSegments (line 317) | func (es *ESService) MergeSegments(indexName string) *types.ResultResp {
    method Refresh (line 334) | func (es *ESService) Refresh(indexName string) *types.ResultResp {
    method Flush (line 350) | func (es *ESService) Flush(indexName string) *types.ResultResp {
    method CacheClear (line 366) | func (es *ESService) CacheClear(indexName string) *types.ResultResp {
    method GetDoc10 (line 383) | func (es *ESService) GetDoc10(indexName string) *types.ResultResp {
    method Search (line 413) | func (es *ESService) Search(method, path string, body any) *types.Resu...
    method GetClusterSettings (line 433) | func (es *ESService) GetClusterSettings() *types.ResultResp {
    method GetIndexSettings (line 449) | func (es *ESService) GetIndexSettings(indexName string) *types.ResultR...
    method GetIndexAliases (line 465) | func (es *ESService) GetIndexAliases(indexNameList []string) *types.Re...
    method GetIndexSegments (line 499) | func (es *ESService) GetIndexSegments(indexName string) *types.ResultR...
    method GetTasks (line 515) | func (es *ESService) GetTasks() *types.ResultsResp {
    method CancelTasks (line 568) | func (es *ESService) CancelTasks(taskID string) *types.ResultResp {
    method GetSnapshots (line 588) | func (es *ESService) GetSnapshots() *types.ResultsResp {
    method GetSnapshotRepositories (line 657) | func (es *ESService) GetSnapshotRepositories() *types.ResultsResp {
    method CreateSnapshotRepository (line 692) | func (es *ESService) CreateSnapshotRepository(name, repoType, settings...
    method DeleteSnapshotRepository (line 727) | func (es *ESService) DeleteSnapshotRepository(name string) *types.Resu...
    method VerifySnapshotRepository (line 749) | func (es *ESService) VerifySnapshotRepository(name string) *types.Resu...
    method CreateSnapshot (line 771) | func (es *ESService) CreateSnapshot(repository, snapshot, indices stri...
    method DeleteSnapshot (line 801) | func (es *ESService) DeleteSnapshot(repository, snapshot string) *type...
    method GetSnapshotDetail (line 823) | func (es *ESService) GetSnapshotDetail(repository, snapshot string) *t...
    method RestoreSnapshot (line 845) | func (es *ESService) RestoreSnapshot(repository, snapshot, indices, re...
    method GetSnapshotRestoreStatus (line 881) | func (es *ESService) GetSnapshotRestoreStatus() *types.ResultResp {
    method GetSLMPolicies (line 900) | func (es *ESService) GetSLMPolicies() *types.ResultsResp {
    method CreateSLMPolicy (line 931) | func (es *ESService) CreateSLMPolicy(policyId, name, schedule, reposit...
    method DeleteSLMPolicy (line 981) | func (es *ESService) DeleteSLMPolicy(policyId string) *types.ResultResp {
    method ExecuteSLMPolicy (line 1003) | func (es *ESService) ExecuteSLMPolicy(policyId string) *types.ResultRe...
    method DownloadESIndex (line 1038) | func (es *ESService) DownloadESIndex(index string, queryDSL string, fi...
  function NewESService (line 59) | func NewESService() *ESService {
  function ConfigureSSL (line 70) | func ConfigureSSL(UseSSL, SkipSSLVerify bool, client *resty.Client, CACe...
  type SearchResponse (line 1025) | type SearchResponse struct

FILE: app/backend/system/update.go
  type Update (line 32) | type Update struct
    method Start (line 36) | func (obj *Update) Start(ctx context.Context) {
    method CheckUpdate (line 39) | func (obj *Update) CheckUpdate() *types.Tag {
    method GetProcessInfo (line 50) | func (obj *Update) GetProcessInfo() string {

FILE: app/backend/types/resp.go
  type Tag (line 20) | type Tag struct
  type Config (line 25) | type Config struct
  type History (line 32) | type History struct
  type ResultsResp (line 38) | type ResultsResp struct
  type ResultResp (line 42) | type ResultResp struct
  type Connect (line 46) | type Connect struct
  type H (line 56) | type H

FILE: app/frontend/src/utils/common.js
  function renderIcon (line 24) | function renderIcon(icon) {
  function openUrl (line 29) | function openUrl(url) {
  function flattenObject (line 34) | function flattenObject(obj, parentKey = '') {
  function formattedJson (line 55) | function formattedJson(value) {
  function isValidJson (line 61) | function isValidJson(jsonString) {
  function formatBytes (line 73) | function formatBytes(bytes, decimals = 2) {
  function createCsvContent (line 88) | function createCsvContent(allData, columns) {
  function download_file (line 109) | function download_file(content, fileName, type) {
  function formatDate (line 124) | function formatDate(date) {
  function formatTimestamp (line 134) | function formatTimestamp(timestamp) {
  function refColumns (line 148) | function refColumns(columns) {
  function calculateWidthByTitle (line 191) | function calculateWidthByTitle(title) {
  function formatMillis (line 202) | function formatMillis(ms) {
  function formatMillisToDays (line 217) | function formatMillisToDays(ms){
  function formatNumber (line 226) | function formatNumber(num) {

FILE: app/main.go
  function main (line 44) | func main() {
Condensed preview — 44 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (270K chars).
[
  {
    "path": ".github/workflows/build.yaml",
    "chars": 4829,
    "preview": "name: Wails build\n\non:\n  release:\n    types: [ created ]\n\nenv:\n  NODE_OPTIONS: \"--max-old-space-size=4096\"\n  APP_NAME: '"
  },
  {
    "path": ".gitignore",
    "chars": 123,
    "preview": "build/bin\n.vscode\n.idea\n/app/frontend/node_modules\n/app/frontend/wailsjs\n/app/frontend/dist\n/app/frontend/package.json.m"
  },
  {
    "path": "LICENSE",
    "chars": 11357,
    "preview": "                                 Apache License\n                           Version 2.0, January 2004\n                   "
  },
  {
    "path": "app/app.go",
    "chars": 2246,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/backend/common/vars.go",
    "chars": 1543,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/backend/config/app.go",
    "chars": 4414,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/backend/service/es.go",
    "chars": 30968,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/backend/system/update.go",
    "chars": 3247,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/backend/types/resp.go",
    "chars": 1667,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/build/README.md",
    "chars": 1591,
    "preview": "# Build Directory\n\nThe build directory is used to house all the build files and assets for your application. \n\nThe struc"
  },
  {
    "path": "app/build/darwin/Info.dev.plist",
    "chars": 2308,
    "preview": "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1"
  },
  {
    "path": "app/build/darwin/Info.plist",
    "chars": 2177,
    "preview": "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1"
  },
  {
    "path": "app/build/windows/info.json",
    "chars": 356,
    "preview": "{\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.Prod"
  },
  {
    "path": "app/build/windows/installer/project.nsi",
    "chars": 4862,
    "preview": "Unicode true\n\n####\n## Please note: Template replacements don't work in this file. They are provided with default defines"
  },
  {
    "path": "app/build/windows/installer/wails_tools.nsh",
    "chars": 7553,
    "preview": "# DO NOT EDIT - Generated automatically by `wails build`\n\n!include \"x64.nsh\"\n!include \"WinVer.nsh\"\n!include \"FileFunc.ns"
  },
  {
    "path": "app/build/windows/wails.exe.manifest",
    "chars": 1036,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<assembly manifestVersion=\"1.0\" xmlns=\"urn:schemas-microsoft-com"
  },
  {
    "path": "app/dev.bat",
    "chars": 21,
    "preview": "chcp 65001\n\nwails dev"
  },
  {
    "path": "app/frontend/index.html",
    "chars": 1012,
    "preview": "<!--\n  ~ Copyright 2025 Bronya0 <tangssst@163.com>.\n  ~ Author Github: https://github.com/Bronya0\n  ~\n  ~ Licensed under"
  },
  {
    "path": "app/frontend/package.json",
    "chars": 516,
    "preview": "{\n  \"name\": \"app\",\n  \"version\": \"1.0.0\",\n  \"description\": \"\",\n  \"main\": \"\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build"
  },
  {
    "path": "app/frontend/src/App.vue",
    "chars": 5740,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/assets/fonts/OFL.txt",
    "chars": 4371,
    "preview": "Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),\n\nThis Font Software is licensed under the SIL Open F"
  },
  {
    "path": "app/frontend/src/components/About.vue",
    "chars": 2147,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/components/Aside.vue",
    "chars": 1161,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/components/Conn.vue",
    "chars": 8013,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/components/Core.vue",
    "chars": 9442,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/components/Header.vue",
    "chars": 6674,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/components/Health.vue",
    "chars": 3652,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/components/Index.vue",
    "chars": 20040,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/components/Nodes.vue",
    "chars": 12949,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/components/Rest.vue",
    "chars": 22005,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/components/Settings.vue",
    "chars": 3903,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/components/Snapshot.vue",
    "chars": 22050,
    "preview": "<template>\n  <n-flex vertical>\n    <n-flex align=\"center\">\n      <h2>快照管理</h2>\n    </n-flex>\n    <n-tabs type=\"line\" ani"
  },
  {
    "path": "app/frontend/src/components/Task.vue",
    "chars": 6246,
    "preview": "<!--\n  - Copyright 2025 Bronya0 <tangssst@163.com>.\n  - Author Github: https://github.com/Bronya0\n  -\n  - Licensed under"
  },
  {
    "path": "app/frontend/src/main.js",
    "chars": 808,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/frontend/src/style.css",
    "chars": 1240,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/frontend/src/utils/common.js",
    "chars": 6491,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/frontend/src/utils/eventBus.js",
    "chars": 742,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/frontend/vite.config.js",
    "chars": 845,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/go.mod",
    "chars": 1553,
    "preview": "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"
  },
  {
    "path": "app/go.sum",
    "chars": 8166,
    "preview": "github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=\ngithub.com/bep/debounce v1.2.1/go.mod h1:"
  },
  {
    "path": "app/main.go",
    "chars": 3336,
    "preview": "/*\n * Copyright 2025 Bronya0 <tangssst@163.com>.\n * Author Github: https://github.com/Bronya0\n *\n * Licensed under the A"
  },
  {
    "path": "app/wails.json",
    "chars": 467,
    "preview": "{\n  \"name\": \"ES-King\",\n  \"outputfilename\": \"ES-King\",\n  \"frontend:install\": \"npm install\",\n  \"frontend:build\": \"npm run "
  },
  {
    "path": "readme-en.md",
    "chars": 2988,
    "preview": "![](docs/snap/2.png)\n\n<h1 align=\"center\">ES-King </h1>\n<h4 align=\"center\"><strong>简体中文</strong> | <a href=\"https://githu"
  },
  {
    "path": "readme.md",
    "chars": 2419,
    "preview": "<p align=\"center\">\n  <img src=\"app/build/appicon.png\" alt=\"图片标题\" width=\"200\">\n</p>\n<h1 align=\"center\">ES-King </h1>\n<h4 "
  }
]

About this extraction

This page contains the full source code of the Bronya0/ES-King GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 44 files (233.7 KB), approximately 70.0k tokens, and a symbol index with 113 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!