feat: sync httpdns sdk/platform updates without large binaries

This commit is contained in:
robin
2026-03-04 17:59:14 +08:00
parent 853897a6f8
commit 532891fad0
700 changed files with 6096 additions and 2712 deletions

View File

@@ -1,4 +1,28 @@
#!/usr/bin/env bash
set -e
function verify_components_bundle() {
local file_path="$1"
if [ ! -f "$file_path" ]; then
echo "[error] components.js not found: $file_path"
return 1
fi
local file_size
file_size=$(wc -c < "$file_path")
if [ "$file_size" -lt 100000 ]; then
echo "[error] components.js looks too small ($file_size bytes), generate likely failed"
return 1
fi
if ! grep -q 'Vue.component("csrf-token"' "$file_path"; then
echo "[error] components.js missing csrf-token component, generate likely failed"
return 1
fi
echo "verify components.js: ok ($file_size bytes)"
return 0
}
function build() {
ROOT=$(dirname "$0")
@@ -58,7 +82,7 @@ function build() {
# generate files
echo "generating files ..."
env CGO_ENABLED=0 go run -tags $TAG "$ROOT"/../cmd/edge-admin/main.go generate
env TEAROOT="$ROOT" CGO_ENABLED=0 go run -tags "$TAG" "$ROOT"/../cmd/edge-admin/main.go generate
if [ "$(which uglifyjs)" ]; then
echo "compress to component.js ..."
uglifyjs --compress --mangle -- "${JS_ROOT}"/components.src.js > "${JS_ROOT}"/components.js
@@ -69,6 +93,8 @@ function build() {
cp "${JS_ROOT}"/utils.js "${JS_ROOT}"/utils.min.js
fi
verify_components_bundle "${JS_ROOT}/components.js"
# create dir & copy files
echo "copying ..."
if [ ! -d "$DIST" ]; then

View File

@@ -1,22 +1,49 @@
#!/usr/bin/env bash
set -e
JS_ROOT=../web/public/js
ROOT=$(cd "$(dirname "$0")" && pwd)
JS_ROOT="$ROOT"/../web/public/js
function verify_components_bundle() {
local file_path="$1"
if [ ! -f "$file_path" ]; then
echo "[error] components.js not found: $file_path"
return 1
fi
local file_size
file_size=$(wc -c < "$file_path")
if [ "$file_size" -lt 100000 ]; then
echo "[error] components.js looks too small ($file_size bytes), generate likely failed"
return 1
fi
if ! grep -q 'Vue.component("csrf-token"' "$file_path"; then
echo "[error] components.js missing csrf-token component, generate likely failed"
return 1
fi
echo "verify components.js: ok ($file_size bytes)"
return 0
}
echo "generating component.src.js ..."
env CGO_ENABLED=0 go run -tags=community ../cmd/edge-admin/main.go generate
env TEAROOT="$ROOT" CGO_ENABLED=0 go run -tags=community "$ROOT"/../cmd/edge-admin/main.go generate
if [ "$(which uglifyjs)" ]; then
echo "compress to component.js ..."
uglifyjs --compress --mangle -- ${JS_ROOT}/components.src.js > ${JS_ROOT}/components.js
uglifyjs --compress --mangle -- "${JS_ROOT}"/components.src.js > "${JS_ROOT}"/components.js
echo "compress to utils.min.js ..."
uglifyjs --compress --mangle -- ${JS_ROOT}/utils.js > ${JS_ROOT}/utils.min.js
uglifyjs --compress --mangle -- "${JS_ROOT}"/utils.js > "${JS_ROOT}"/utils.min.js
else
echo "copy to component.js ..."
cp ${JS_ROOT}/components.src.js ${JS_ROOT}/components.js
cp "${JS_ROOT}"/components.src.js "${JS_ROOT}"/components.js
echo "copy to utils.min.js ..."
cp ${JS_ROOT}/utils.js ${JS_ROOT}/utils.min.js
cp "${JS_ROOT}"/utils.js "${JS_ROOT}"/utils.min.js
fi
echo "ok"
verify_components_bundle "${JS_ROOT}/components.js"
echo "ok"

View File

@@ -22,6 +22,7 @@ import (
"log"
"os"
"os/exec"
"path/filepath"
"time"
)
@@ -112,10 +113,12 @@ func main() {
}
})
app.On("generate", func() {
prepareGenerateRoot()
err := gen.Generate()
if err != nil {
fmt.Println("generate failed: " + err.Error())
return
os.Exit(1)
}
})
app.On("dev", func() {
@@ -214,3 +217,32 @@ func main() {
adminNode.Run()
})
}
func prepareGenerateRoot() {
wd, err := os.Getwd()
if err != nil {
return
}
candidates := []string{
wd,
filepath.Clean(filepath.Join(wd, "..")),
}
for _, root := range candidates {
componentsDir := filepath.Join(root, "web", "public", "js", "components")
stat, statErr := os.Stat(componentsDir)
if statErr != nil || !stat.IsDir() {
continue
}
// In testing mode, generator reads from Tea.Root + "/../web/...",
// so keep Root under build dir to make relative path stable.
buildRoot := filepath.Join(root, "build")
Tea.UpdateRoot(buildRoot)
Tea.SetPublicDir(filepath.Join(root, "web", "public"))
Tea.SetViewsDir(filepath.Join(root, "web", "views"))
Tea.SetTmpDir(filepath.Join(root, "web", "tmp"))
return
}
}

View File

@@ -0,0 +1,69 @@
package configloaders
import (
"encoding/json"
"github.com/TeaOSLab/EdgeAdmin/internal/rpc"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/systemconfigs"
)
const UpgradeSettingName = "upgradeConfig"
var sharedUpgradeConfig *systemconfigs.UpgradeConfig
func LoadUpgradeConfig() (*systemconfigs.UpgradeConfig, error) {
locker.Lock()
defer locker.Unlock()
if sharedUpgradeConfig != nil {
return sharedUpgradeConfig, nil
}
rpcClient, err := rpc.SharedRPC()
if err != nil {
return nil, err
}
resp, err := rpcClient.SysSettingRPC().ReadSysSetting(rpcClient.Context(0), &pb.ReadSysSettingRequest{
Code: UpgradeSettingName,
})
if err != nil {
return nil, err
}
if len(resp.ValueJSON) == 0 {
sharedUpgradeConfig = systemconfigs.NewUpgradeConfig()
return sharedUpgradeConfig, nil
}
config := systemconfigs.NewUpgradeConfig()
err = json.Unmarshal(resp.ValueJSON, config)
if err != nil {
sharedUpgradeConfig = systemconfigs.NewUpgradeConfig()
return sharedUpgradeConfig, nil
}
sharedUpgradeConfig = config
return sharedUpgradeConfig, nil
}
func UpdateUpgradeConfig(config *systemconfigs.UpgradeConfig) error {
locker.Lock()
defer locker.Unlock()
rpcClient, err := rpc.SharedRPC()
if err != nil {
return err
}
valueJSON, err := json.Marshal(config)
if err != nil {
return err
}
_, err = rpcClient.SysSettingRPC().UpdateSysSetting(rpcClient.Context(0), &pb.UpdateSysSettingRequest{
Code: UpgradeSettingName,
ValueJSON: valueJSON,
})
if err != nil {
return err
}
sharedUpgradeConfig = config
return nil
}

View File

@@ -1,9 +1,9 @@
package teaconst
const (
Version = "1.4.8" //1.3.9
Version = "1.4.9" //1.3.9
APINodeVersion = "1.4.8" //1.3.9
APINodeVersion = "1.4.9" //1.3.9
ProductName = "Edge Admin"
ProcessName = "edge-admin"

View File

@@ -28,19 +28,20 @@ func (this *SdkCheckAction) RunGet(params struct {
return
}
version := strings.TrimSpace(params.Version)
t := strings.ToLower(strings.TrimSpace(params.Type))
if t == "doc" {
docPath := findUploadedSDKDocPath(platform, params.Version)
docPath := findUploadedSDKDocPath(platform, version)
if len(docPath) == 0 {
this.Data["exists"] = false
this.Data["message"] = "Documentation is unavailable, please upload first"
this.Data["message"] = "当前平台/版本尚未上传集成文档"
this.Success()
return
}
downloadURL := "/httpdns/apps/sdk/doc?platform=" + url.QueryEscape(platform)
if len(strings.TrimSpace(params.Version)) > 0 {
downloadURL += "&version=" + url.QueryEscape(strings.TrimSpace(params.Version))
if len(version) > 0 {
downloadURL += "&version=" + url.QueryEscape(version)
}
this.Data["exists"] = true
this.Data["url"] = downloadURL
@@ -48,17 +49,17 @@ func (this *SdkCheckAction) RunGet(params struct {
return
}
archivePath := findSDKArchivePath(filename, params.Version)
archivePath := findSDKArchivePath(filename, version)
if len(archivePath) == 0 {
this.Data["exists"] = false
this.Data["message"] = "SDK package is unavailable, please upload first"
this.Data["message"] = "当前平台/版本尚未上传 SDK 安装包"
this.Success()
return
}
downloadURL := "/httpdns/apps/sdk/download?platform=" + url.QueryEscape(platform)
if len(strings.TrimSpace(params.Version)) > 0 {
downloadURL += "&version=" + url.QueryEscape(strings.TrimSpace(params.Version))
if len(version) > 0 {
downloadURL += "&version=" + url.QueryEscape(version)
}
downloadURL += "&raw=1"
this.Data["exists"] = true

View File

@@ -3,7 +3,6 @@ package apps
import (
"os"
"path/filepath"
"strings"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
)
@@ -20,7 +19,7 @@ func (this *SdkDocAction) RunGet(params struct {
Platform string
Version string
}) {
platform, _, readmeRelativePath, _, err := resolveSDKPlatform(params.Platform)
platform, _, _, _, err := resolveSDKPlatform(params.Platform)
if err != nil {
this.Data["exists"] = false
this.Data["message"] = err.Error()
@@ -28,35 +27,25 @@ func (this *SdkDocAction) RunGet(params struct {
return
}
var data []byte
uploadedDocPath := findUploadedSDKDocPath(platform, params.Version)
if len(uploadedDocPath) > 0 {
data, err = os.ReadFile(uploadedDocPath)
}
sdkRoot, sdkRootErr := findSDKRoot()
if len(data) == 0 && sdkRootErr == nil {
readmePath := filepath.Join(sdkRoot, readmeRelativePath)
data, err = os.ReadFile(readmePath)
}
if len(data) == 0 {
localDocPath := findLocalSDKDocPath(platform)
if len(localDocPath) > 0 {
data, err = os.ReadFile(localDocPath)
}
}
if len(data) == 0 || err != nil {
docPath := findUploadedSDKDocPath(platform, params.Version)
if len(docPath) == 0 {
this.Data["exists"] = false
this.Data["message"] = "SDK documentation is not found on server, please upload first"
this.Data["message"] = "当前平台/版本尚未上传集成文档"
this.Success()
return
}
downloadName := filepath.Base(uploadedDocPath)
data, err := os.ReadFile(docPath)
if err != nil || len(data) == 0 {
this.Data["exists"] = false
this.Data["message"] = "读取集成文档失败"
this.Success()
return
}
downloadName := filepath.Base(docPath)
if len(downloadName) == 0 || downloadName == "." || downloadName == string(filepath.Separator) {
downloadName = "httpdns-sdk-" + strings.ToLower(platform) + ".md"
downloadName = "sdk-doc.md"
}
this.AddHeader("Content-Type", "text/markdown; charset=utf-8")

View File

@@ -32,7 +32,7 @@ func (this *SdkDownloadAction) RunGet(params struct {
archivePath := findSDKArchivePath(filename, params.Version)
if len(archivePath) == 0 {
this.Data["exists"] = false
this.Data["message"] = "SDK archive not found on server, please upload first: " + filename
this.Data["message"] = "当前平台/版本尚未上传 SDK 安装包"
this.Success()
return
}
@@ -40,7 +40,7 @@ func (this *SdkDownloadAction) RunGet(params struct {
fp, err := os.Open(archivePath)
if err != nil {
this.Data["exists"] = false
this.Data["message"] = "failed to open SDK archive: " + err.Error()
this.Data["message"] = "打开 SDK 安装包失败: " + err.Error()
this.Success()
return
}

View File

@@ -1,16 +1,183 @@
package apps
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/iwind/TeaGo/Tea"
)
type sdkUploadMeta struct {
Platform string `json:"platform"`
Version string `json:"version"`
FileType string `json:"fileType"` // sdk | doc
Filename string `json:"filename"`
UpdatedAt int64 `json:"updatedAt"`
}
type sdkUploadMetaRecord struct {
Meta sdkUploadMeta
Dir string
FilePath string
}
func sdkUploadMetaFilename(platform string, version string, fileType string) string {
platform = strings.ToLower(strings.TrimSpace(platform))
version = strings.TrimSpace(version)
fileType = strings.ToLower(strings.TrimSpace(fileType))
return ".httpdns-sdk-meta-" + platform + "-v" + version + "-" + fileType + ".json"
}
func isSDKUploadMetaFile(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
return strings.HasPrefix(name, ".httpdns-sdk-meta-") && strings.HasSuffix(name, ".json")
}
func parseSDKPlatformFromDownloadFilename(downloadFilename string) string {
name := strings.ToLower(strings.TrimSpace(downloadFilename))
if !strings.HasPrefix(name, "httpdns-sdk-") || !strings.HasSuffix(name, ".zip") {
return ""
}
platform := strings.TrimSuffix(strings.TrimPrefix(name, "httpdns-sdk-"), ".zip")
switch platform {
case "android", "ios", "flutter":
return platform
default:
return ""
}
}
func listSDKUploadMetaRecords() []sdkUploadMetaRecord {
type wrapped struct {
record sdkUploadMetaRecord
modTime time.Time
}
byKey := map[string]wrapped{}
for _, dir := range sdkUploadSearchDirs() {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !isSDKUploadMetaFile(name) {
continue
}
metaPath := filepath.Join(dir, name)
data, err := os.ReadFile(metaPath)
if err != nil || len(data) == 0 {
continue
}
var meta sdkUploadMeta
if err = json.Unmarshal(data, &meta); err != nil {
continue
}
meta.Platform = strings.ToLower(strings.TrimSpace(meta.Platform))
meta.Version = strings.TrimSpace(meta.Version)
meta.FileType = strings.ToLower(strings.TrimSpace(meta.FileType))
meta.Filename = filepath.Base(strings.TrimSpace(meta.Filename))
if len(meta.Platform) == 0 || len(meta.Version) == 0 || len(meta.Filename) == 0 {
continue
}
if meta.FileType != "sdk" && meta.FileType != "doc" {
continue
}
if strings.Contains(meta.Filename, "..") || strings.Contains(meta.Filename, "/") || strings.Contains(meta.Filename, "\\") {
continue
}
filePath := filepath.Join(dir, meta.Filename)
fileStat, err := os.Stat(filePath)
if err != nil || fileStat.IsDir() || fileStat.Size() <= 0 {
continue
}
metaStat, err := os.Stat(metaPath)
if err != nil {
continue
}
if meta.UpdatedAt <= 0 {
meta.UpdatedAt = metaStat.ModTime().Unix()
}
key := meta.Platform + "|" + meta.Version + "|" + meta.FileType
current := wrapped{
record: sdkUploadMetaRecord{
Meta: meta,
Dir: dir,
FilePath: filePath,
},
modTime: metaStat.ModTime(),
}
old, ok := byKey[key]
if !ok ||
current.record.Meta.UpdatedAt > old.record.Meta.UpdatedAt ||
(current.record.Meta.UpdatedAt == old.record.Meta.UpdatedAt && current.modTime.After(old.modTime)) ||
(current.record.Meta.UpdatedAt == old.record.Meta.UpdatedAt && current.modTime.Equal(old.modTime) && current.record.FilePath > old.record.FilePath) {
byKey[key] = current
}
}
}
result := make([]sdkUploadMetaRecord, 0, len(byKey))
for _, item := range byKey {
result = append(result, item.record)
}
return result
}
func findSDKUploadFileByMeta(platform string, version string, fileType string) string {
platform = strings.ToLower(strings.TrimSpace(platform))
version = strings.TrimSpace(version)
fileType = strings.ToLower(strings.TrimSpace(fileType))
if len(platform) == 0 || len(version) == 0 {
return ""
}
for _, record := range listSDKUploadMetaRecords() {
if record.Meta.Platform == platform && record.Meta.Version == version && record.Meta.FileType == fileType {
return record.FilePath
}
}
return ""
}
func findNewestSDKUploadFileByMeta(platform string, fileType string) string {
platform = strings.ToLower(strings.TrimSpace(platform))
fileType = strings.ToLower(strings.TrimSpace(fileType))
if len(platform) == 0 {
return ""
}
var foundPath string
var foundUpdatedAt int64
for _, record := range listSDKUploadMetaRecords() {
if record.Meta.Platform != platform || record.Meta.FileType != fileType {
continue
}
if len(foundPath) == 0 || record.Meta.UpdatedAt > foundUpdatedAt || (record.Meta.UpdatedAt == foundUpdatedAt && record.FilePath > foundPath) {
foundPath = record.FilePath
foundUpdatedAt = record.Meta.UpdatedAt
}
}
return foundPath
}
func sdkUploadDir() string {
dirs := sdkUploadDirs()
if len(dirs) > 0 {
@@ -27,6 +194,7 @@ func sdkUploadDirs() []string {
filepath.Clean(Tea.Root + "/../edge-user/data/httpdns/sdk"),
filepath.Clean(Tea.Root + "/../../data/httpdns/sdk"),
}
results := make([]string, 0, len(candidates))
seen := map[string]bool{}
for _, dir := range candidates {
@@ -54,67 +222,6 @@ func sdkUploadSearchDirs() []string {
return results
}
func findFirstExistingDir(paths []string) string {
for _, path := range paths {
stat, err := os.Stat(path)
if err == nil && stat.IsDir() {
return path
}
}
return ""
}
func findFirstExistingFile(paths []string) string {
for _, path := range paths {
stat, err := os.Stat(path)
if err == nil && !stat.IsDir() && stat.Size() > 0 {
return path
}
}
return ""
}
func findNewestExistingFile(paths []string) string {
type fileInfo struct {
path string
modTime time.Time
}
result := fileInfo{}
for _, path := range paths {
stat, err := os.Stat(path)
if err != nil || stat.IsDir() {
continue
}
if stat.Size() <= 0 {
continue
}
if len(result.path) == 0 || stat.ModTime().After(result.modTime) || (stat.ModTime().Equal(result.modTime) && path > result.path) {
result.path = path
result.modTime = stat.ModTime()
}
}
return result.path
}
func findSDKRoot() (string, error) {
candidates := []string{
filepath.Clean(Tea.Root + "/EdgeHttpDNS/sdk"),
filepath.Clean(Tea.Root + "/edge-httpdns/sdk"),
filepath.Clean(Tea.Root + "/edge-httpdns/edge-httpdns/sdk"),
filepath.Clean(Tea.Root + "/../EdgeHttpDNS/sdk"),
filepath.Clean(Tea.Root + "/../../EdgeHttpDNS/sdk"),
filepath.Clean(Tea.Root + "/../edge-httpdns/sdk"),
filepath.Clean(Tea.Root + "/../../edge-httpdns/sdk"),
}
dir := findFirstExistingDir(candidates)
if len(dir) > 0 {
return dir, nil
}
return "", errors.New("SDK files are not found on current server")
}
func resolveSDKPlatform(platform string) (key string, relativeDir string, readmeRelativePath string, downloadFilename string, err error) {
switch strings.ToLower(strings.TrimSpace(platform)) {
case "android":
@@ -122,52 +229,23 @@ func resolveSDKPlatform(platform string) (key string, relativeDir string, readme
case "ios":
return "ios", "ios", "ios/README.md", "httpdns-sdk-ios.zip", nil
case "flutter":
return "flutter", "flutter/aliyun_httpdns", "flutter/aliyun_httpdns/README.md", "httpdns-sdk-flutter.zip", nil
return "flutter", "flutter/new_httpdns", "flutter/new_httpdns/README.md", "httpdns-sdk-flutter.zip", nil
default:
return "", "", "", "", errors.New("invalid platform, expected one of: android, ios, flutter")
return "", "", "", "", errors.New("不支持的平台,可选值:androidiosflutter")
}
}
func findSDKArchivePath(downloadFilename string, version string) string {
searchDirs := sdkUploadSearchDirs()
normalizedVersion := strings.TrimSpace(version)
base := strings.TrimSuffix(downloadFilename, ".zip")
if len(normalizedVersion) > 0 {
versionFiles := []string{}
for _, dir := range searchDirs {
versionFiles = append(versionFiles, filepath.Join(dir, base+"-v"+normalizedVersion+".zip"))
}
if path := findFirstExistingFile(versionFiles); len(path) > 0 {
return path
}
platform := parseSDKPlatformFromDownloadFilename(downloadFilename)
if len(platform) == 0 {
return ""
}
patternName := base + "-v*.zip"
matches := []string{}
for _, dir := range searchDirs {
found, _ := filepath.Glob(filepath.Join(dir, patternName))
for _, file := range found {
stat, err := os.Stat(file)
if err == nil && !stat.IsDir() {
matches = append(matches, file)
}
}
normalizedVersion := strings.TrimSpace(version)
if len(normalizedVersion) > 0 {
return findSDKUploadFileByMeta(platform, normalizedVersion, "sdk")
}
if len(matches) > 0 {
return findNewestExistingFile(matches)
}
exactFiles := []string{}
for _, dir := range searchDirs {
exactFiles = append(exactFiles, filepath.Join(dir, downloadFilename))
}
if path := findFirstExistingFile(exactFiles); len(path) > 0 {
return path
}
return ""
return findNewestSDKUploadFileByMeta(platform, "sdk")
}
func findUploadedSDKDocPath(platform string, version string) string {
@@ -176,42 +254,9 @@ func findUploadedSDKDocPath(platform string, version string) string {
return ""
}
searchDirs := sdkUploadSearchDirs()
normalizedVersion := strings.TrimSpace(version)
if len(normalizedVersion) > 0 {
exactVersion := []string{}
for _, dir := range searchDirs {
exactVersion = append(exactVersion, filepath.Join(dir, "httpdns-sdk-"+platform+"-v"+normalizedVersion+".md"))
}
if file := findFirstExistingFile(exactVersion); len(file) > 0 {
return file
}
return ""
return findSDKUploadFileByMeta(platform, normalizedVersion, "doc")
}
matches := []string{}
for _, dir := range searchDirs {
pattern := filepath.Join(dir, "httpdns-sdk-"+platform+"-v*.md")
found, _ := filepath.Glob(pattern)
matches = append(matches, found...)
}
if len(matches) > 0 {
sort.Strings(matches)
return findNewestExistingFile(matches)
}
exact := []string{}
for _, dir := range searchDirs {
exact = append(exact, filepath.Join(dir, "httpdns-sdk-"+platform+".md"))
}
return findFirstExistingFile(exact)
}
func findLocalSDKDocPath(platform string) string {
filename := strings.ToLower(strings.TrimSpace(platform)) + ".md"
candidates := []string{
filepath.Clean(Tea.Root + "/edge-admin/web/views/@default/httpdns/apps/docs/" + filename),
filepath.Clean(Tea.Root + "/EdgeAdmin/web/views/@default/httpdns/apps/docs/" + filename),
}
return findFirstExistingFile(candidates)
return findNewestSDKUploadFileByMeta(platform, "doc")
}

View File

@@ -1,6 +1,7 @@
package apps
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -14,6 +15,8 @@ import (
"github.com/iwind/TeaGo/actions"
)
const sdkUploadMaxFileSize = 20 * 1024 * 1024 // 20MB
type SdkUploadAction struct {
actionutils.ParentAction
}
@@ -52,7 +55,7 @@ func (this *SdkUploadAction) RunPost(params struct {
}) {
params.Must.Field("appId", params.AppId).Gt(0, "请选择应用")
platform, _, _, downloadFilename, err := resolveSDKPlatform(params.Platform)
platform, _, _, _, err := resolveSDKPlatform(params.Platform)
if err != nil {
this.Fail(err.Error())
return
@@ -70,57 +73,21 @@ func (this *SdkUploadAction) RunPost(params struct {
}
uploadDir := sdkUploadDir()
err = os.MkdirAll(uploadDir, 0755)
if err != nil {
if err = os.MkdirAll(uploadDir, 0755); err != nil {
this.Fail("创建上传目录失败: " + err.Error())
return
}
if params.SdkFile != nil {
filename := strings.ToLower(strings.TrimSpace(params.SdkFile.Filename))
if !strings.HasSuffix(filename, ".zip") {
this.Fail("SDK 包仅支持 .zip 文件")
return
}
sdkData, readErr := params.SdkFile.Read()
if readErr != nil {
this.Fail("读取 SDK 包失败: " + readErr.Error())
return
}
if len(sdkData) == 0 {
this.Fail("SDK 包文件为空,请重新上传")
return
}
baseName := strings.TrimSuffix(downloadFilename, ".zip")
err = saveSDKUploadFile(uploadDir, baseName+"-v"+version+".zip", sdkData)
if err != nil {
this.Fail("保存 SDK 包失败: " + err.Error())
if err = this.saveUploadedItem(uploadDir, platform, version, "sdk", params.SdkFile); err != nil {
this.Fail(err.Error())
return
}
}
if params.DocFile != nil {
docName := strings.ToLower(strings.TrimSpace(params.DocFile.Filename))
if !strings.HasSuffix(docName, ".md") {
this.Fail("集成文档仅支持 .md 文件")
return
}
docData, readErr := params.DocFile.Read()
if readErr != nil {
this.Fail("读取集成文档失败: " + readErr.Error())
return
}
if len(docData) == 0 {
this.Fail("集成文档文件为空,请重新上传")
return
}
err = saveSDKUploadFile(uploadDir, "httpdns-sdk-"+platform+"-v"+version+".md", docData)
if err != nil {
this.Fail("保存集成文档失败: " + err.Error())
if err = this.saveUploadedItem(uploadDir, platform, version, "doc", params.DocFile); err != nil {
this.Fail(err.Error())
return
}
}
@@ -128,6 +95,52 @@ func (this *SdkUploadAction) RunPost(params struct {
this.Success()
}
func (this *SdkUploadAction) saveUploadedItem(uploadDir string, platform string, version string, fileType string, file *actions.File) error {
expectedExt := ".md"
displayType := "集成文档"
if fileType == "sdk" {
expectedExt = ".zip"
displayType = "SDK 包"
}
filename, err := normalizeUploadedFilename(file.Filename, expectedExt)
if err != nil {
return err
}
if file.Size > sdkUploadMaxFileSize {
return errors.New(displayType + "文件不能超过 20MB")
}
data, err := file.Read()
if err != nil {
return errors.New("读取" + displayType + "失败: " + err.Error())
}
if len(data) == 0 {
return errors.New(displayType + "文件为空,请重新上传")
}
if len(data) > sdkUploadMaxFileSize {
return errors.New(displayType + "文件不能超过 20MB")
}
if err = saveSDKUploadFile(uploadDir, filename, data); err != nil {
return errors.New("保存" + displayType + "失败: " + err.Error())
}
err = saveSDKUploadMetaRecord(uploadDir, sdkUploadMeta{
Platform: platform,
Version: version,
FileType: fileType,
Filename: filename,
UpdatedAt: time.Now().Unix(),
})
if err != nil {
return errors.New("保存上传元信息失败: " + err.Error())
}
return nil
}
func normalizeSDKVersion(version string) (string, error) {
version = strings.TrimSpace(version)
if len(version) == 0 {
@@ -142,6 +155,26 @@ func normalizeSDKVersion(version string) (string, error) {
return version, nil
}
func normalizeUploadedFilename(raw string, expectedExt string) (string, error) {
filename := filepath.Base(strings.TrimSpace(raw))
if len(filename) == 0 || filename == "." || filename == string(filepath.Separator) {
return "", errors.New("文件名不能为空")
}
if strings.Contains(filename, "/") || strings.Contains(filename, "\\") || strings.Contains(filename, "..") {
return "", errors.New("文件名不合法")
}
actualExt := strings.ToLower(filepath.Ext(filename))
if actualExt != strings.ToLower(expectedExt) {
if expectedExt == ".zip" {
return "", errors.New("SDK 包仅支持 .zip 文件")
}
return "", errors.New("集成文档仅支持 .md 文件")
}
return filename, nil
}
func saveSDKUploadFile(baseDir string, filename string, data []byte) error {
targetPath := filepath.Join(baseDir, filename)
tmpPath := targetPath + ".tmp"
@@ -152,6 +185,35 @@ func saveSDKUploadFile(baseDir string, filename string, data []byte) error {
return os.Rename(tmpPath, targetPath)
}
func saveSDKUploadMetaRecord(baseDir string, meta sdkUploadMeta) error {
meta.Platform = strings.ToLower(strings.TrimSpace(meta.Platform))
meta.Version = strings.TrimSpace(meta.Version)
meta.FileType = strings.ToLower(strings.TrimSpace(meta.FileType))
meta.Filename = filepath.Base(strings.TrimSpace(meta.Filename))
if len(meta.Platform) == 0 || len(meta.Version) == 0 || len(meta.FileType) == 0 || len(meta.Filename) == 0 {
return errors.New("上传元信息不完整")
}
metaFilename := sdkUploadMetaFilename(meta.Platform, meta.Version, meta.FileType)
metaPath := filepath.Join(baseDir, metaFilename)
if data, err := os.ReadFile(metaPath); err == nil && len(data) > 0 {
var oldMeta sdkUploadMeta
if json.Unmarshal(data, &oldMeta) == nil {
oldFile := filepath.Base(strings.TrimSpace(oldMeta.Filename))
if len(oldFile) > 0 && oldFile != meta.Filename {
_ = os.Remove(filepath.Join(baseDir, oldFile))
}
}
}
data, err := json.Marshal(meta)
if err != nil {
return err
}
return saveSDKUploadFile(baseDir, metaFilename, data)
}
func listUploadedSDKFiles() []map[string]interface{} {
type item struct {
Name string
@@ -162,45 +224,26 @@ func listUploadedSDKFiles() []map[string]interface{} {
UpdatedAt int64
}
byName := map[string]item{}
for _, dir := range sdkUploadSearchDirs() {
entries, err := os.ReadDir(dir)
if err != nil {
items := make([]item, 0)
for _, record := range listSDKUploadMetaRecords() {
stat, err := os.Stat(record.FilePath)
if err != nil || stat.IsDir() {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
platform, version, fileType, ok := parseSDKUploadFilename(name)
if !ok {
continue
}
info, statErr := entry.Info()
if statErr != nil {
continue
}
current := item{
Name: name,
Platform: platform,
FileType: fileType,
Version: version,
SizeBytes: info.Size(),
UpdatedAt: info.ModTime().Unix(),
}
old, exists := byName[name]
if !exists || current.UpdatedAt >= old.UpdatedAt {
byName[name] = current
}
fileType := "SDK 包"
if record.Meta.FileType == "doc" {
fileType = "集成文档"
}
}
items := make([]item, 0, len(byName))
for _, it := range byName {
items = append(items, it)
items = append(items, item{
Name: filepath.Base(record.FilePath),
Platform: record.Meta.Platform,
FileType: fileType,
Version: record.Meta.Version,
SizeBytes: stat.Size(),
UpdatedAt: stat.ModTime().Unix(),
})
}
sort.Slice(items, func(i, j int) bool {
@@ -224,55 +267,21 @@ func listUploadedSDKFiles() []map[string]interface{} {
return result
}
func parseSDKUploadFilename(filename string) (platform string, version string, fileType string, ok bool) {
if !strings.HasPrefix(filename, "httpdns-sdk-") {
return "", "", "", false
}
ext := ""
switch {
case strings.HasSuffix(filename, ".zip"):
ext = ".zip"
fileType = "SDK包"
case strings.HasSuffix(filename, ".md"):
ext = ".md"
fileType = "集成文档"
default:
return "", "", "", false
}
main := strings.TrimSuffix(strings.TrimPrefix(filename, "httpdns-sdk-"), ext)
version = ""
if idx := strings.Index(main, "-v"); idx > 0 && idx+2 < len(main) {
version = main[idx+2:]
main = main[:idx]
}
main = strings.ToLower(strings.TrimSpace(main))
switch main {
case "android", "ios", "flutter":
platform = main
if len(version) == 0 {
version = "-"
}
return platform, version, fileType, true
default:
return "", "", "", false
}
}
func formatSDKFileSize(size int64) string {
if size < 1024 {
return strconv.FormatInt(size, 10) + " B"
}
sizeKB := float64(size) / 1024
if sizeKB < 1024 {
return strconv.FormatFloat(sizeKB, 'f', 1, 64) + " KB"
}
sizeMB := sizeKB / 1024
if sizeMB < 1024 {
return strconv.FormatFloat(sizeMB, 'f', 1, 64) + " MB"
}
sizeGB := sizeMB / 1024
return strconv.FormatFloat(sizeGB, 'f', 1, 64) + " GB"
}

View File

@@ -1,6 +1,7 @@
package apps
import (
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -34,19 +35,16 @@ func (this *SdkUploadDeleteAction) RunPost(params struct {
this.Fail("文件名不合法")
return
}
if !strings.HasPrefix(filename, "httpdns-sdk-") {
this.Fail("不允许删除该文件")
return
}
if !(strings.HasSuffix(filename, ".zip") || strings.HasSuffix(filename, ".md")) {
this.Fail("不允许删除该文件")
lowName := strings.ToLower(filename)
if !strings.HasSuffix(lowName, ".zip") && !strings.HasSuffix(lowName, ".md") {
this.Fail("仅允许删除 .zip 或 .md 文件")
return
}
for _, dir := range sdkUploadDirs() {
fullPath := filepath.Join(dir, filename)
_, err := os.Stat(fullPath)
if err != nil {
stat, err := os.Stat(fullPath)
if err != nil || stat.IsDir() {
continue
}
if err = os.Remove(fullPath); err != nil {
@@ -55,5 +53,38 @@ func (this *SdkUploadDeleteAction) RunPost(params struct {
}
}
// 删除引用该文件的元数据
for _, dir := range sdkUploadDirs() {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if !isSDKUploadMetaFile(entry.Name()) {
continue
}
metaPath := filepath.Join(dir, entry.Name())
data, err := os.ReadFile(metaPath)
if err != nil || len(data) == 0 {
continue
}
var meta sdkUploadMeta
if err = json.Unmarshal(data, &meta); err != nil {
continue
}
if filepath.Base(strings.TrimSpace(meta.Filename)) != filename {
continue
}
_ = os.Remove(metaPath)
}
}
this.Success()
}

View File

@@ -1,9 +1,11 @@
package node
import (
"strings"
"time"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
timeutil "github.com/iwind/TeaGo/utils/time"
)
@@ -50,7 +52,22 @@ func (this *IndexAction) RunGet(params struct {
this.Data["nodeDatetime"] = nodeDatetime
this.Data["nodeTimeDiff"] = nodeTimeDiff
this.Data["shouldUpgrade"] = false
this.Data["newVersion"] = ""
osName := strings.TrimSpace(status.GetString("os"))
if len(osName) > 0 {
checkVersionResp, err := this.RPC().HTTPDNSNodeRPC().CheckHTTPDNSNodeLatestVersion(this.AdminContext(), &pb.CheckHTTPDNSNodeLatestVersionRequest{
Os: osName,
Arch: strings.TrimSpace(status.GetString("arch")),
CurrentVersion: strings.TrimSpace(status.GetString("buildVersion")),
})
if err != nil {
this.ErrorPage(err)
return
}
this.Data["shouldUpgrade"] = checkVersionResp.GetHasNewVersion()
this.Data["newVersion"] = checkVersionResp.GetNewVersion()
} else {
this.Data["shouldUpgrade"] = false
this.Data["newVersion"] = ""
}
this.Show()
}

View File

@@ -22,14 +22,14 @@ func findHTTPDNSClusterMap(parent *actionutils.ParentAction, clusterID int64) (m
return maps.Map{
"id": clusterID,
"name": "",
"installDir": "/opt/edge-httpdns",
"installDir": "/root/edge-httpdns",
}, nil
}
cluster := resp.GetCluster()
installDir := strings.TrimSpace(cluster.GetInstallDir())
if len(installDir) == 0 {
installDir = "/opt/edge-httpdns"
installDir = "/root/edge-httpdns"
}
return maps.Map{
"id": cluster.GetId(),
@@ -93,7 +93,7 @@ func findHTTPDNSNodeMap(parent *actionutils.ParentAction, nodeID int64) (maps.Ma
installDir := strings.TrimSpace(node.GetInstallDir())
if len(installDir) == 0 {
installDir = "/opt/edge-httpdns"
installDir = "/root/edge-httpdns"
}
clusterMap, err := findHTTPDNSClusterMap(parent, node.GetClusterId())
@@ -137,22 +137,22 @@ func findHTTPDNSNodeMap(parent *actionutils.ParentAction, nodeID int64) (maps.Ma
}
return maps.Map{
"id": node.GetId(),
"clusterId": node.GetClusterId(),
"name": node.GetName(),
"isOn": node.GetIsOn(),
"isUp": node.GetIsUp(),
"isInstalled": node.GetIsInstalled(),
"isActive": node.GetIsActive(),
"uniqueId": node.GetUniqueId(),
"secret": node.GetSecret(),
"installDir": installDir,
"status": statusMap,
"id": node.GetId(),
"clusterId": node.GetClusterId(),
"name": node.GetName(),
"isOn": node.GetIsOn(),
"isUp": node.GetIsUp(),
"isInstalled": node.GetIsInstalled(),
"isActive": node.GetIsActive(),
"uniqueId": node.GetUniqueId(),
"secret": node.GetSecret(),
"installDir": installDir,
"status": statusMap,
"installStatus": installStatusMap,
"cluster": clusterMap,
"login": loginMap,
"apiNodeAddrs": []string{},
"ipAddresses": ipAddresses,
"cluster": clusterMap,
"login": loginMap,
"apiNodeAddrs": []string{},
"ipAddresses": ipAddresses,
}, nil
}
@@ -165,21 +165,23 @@ func decodeNodeStatus(raw []byte) maps.Map {
memText := fmt.Sprintf("%.2f%%", status.MemoryUsage*100)
return maps.Map{
"isActive": status.IsActive,
"updatedAt": status.UpdatedAt,
"hostname": status.Hostname,
"hostIP": status.HostIP,
"cpuUsage": status.CPUUsage,
"cpuUsageText": cpuText,
"memUsage": status.MemoryUsage,
"memUsageText": memText,
"load1m": status.Load1m,
"load5m": status.Load5m,
"load15m": status.Load15m,
"buildVersion": status.BuildVersion,
"cpuPhysicalCount": status.CPUPhysicalCount,
"cpuLogicalCount": status.CPULogicalCount,
"exePath": status.ExePath,
"isActive": status.IsActive,
"updatedAt": status.UpdatedAt,
"os": status.OS,
"arch": status.Arch,
"hostname": status.Hostname,
"hostIP": status.HostIP,
"cpuUsage": status.CPUUsage,
"cpuUsageText": cpuText,
"memUsage": status.MemoryUsage,
"memUsageText": memText,
"load1m": status.Load1m,
"load5m": status.Load5m,
"load15m": status.Load15m,
"buildVersion": status.BuildVersion,
"cpuPhysicalCount": status.CPUPhysicalCount,
"cpuLogicalCount": status.CPULogicalCount,
"exePath": status.ExePath,
"apiSuccessPercent": status.APISuccessPercent,
"apiAvgCostSeconds": status.APIAvgCostSeconds,
}

View File

@@ -195,7 +195,7 @@ func (this *UpdateAction) RunPost(params struct {
installDir := strings.TrimSpace(node.GetInstallDir())
if len(installDir) == 0 {
installDir = "/opt/edge-httpdns"
installDir = "/root/edge-httpdns"
}
_, err = this.RPC().HTTPDNSNodeRPC().UpdateHTTPDNSNode(this.AdminContext(), &pb.UpdateHTTPDNSNodeRequest{

View File

@@ -1,17 +1,20 @@
package clusters
package clusters
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
"github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs"
"github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs/sslconfigs"
"github.com/iwind/TeaGo/actions"
"github.com/iwind/TeaGo/maps"
"google.golang.org/grpc/metadata"
)
type ClusterSettingsAction struct {
@@ -41,14 +44,15 @@ func (this *ClusterSettingsAction) RunGet(params struct {
}
settings := maps.Map{
"name": cluster.GetString("name"),
"gatewayDomain": cluster.GetString("gatewayDomain"),
"cacheTtl": cluster.GetInt("defaultTTL"),
"fallbackTimeout": cluster.GetInt("fallbackTimeout"),
"installDir": cluster.GetString("installDir"),
"isOn": cluster.GetBool("isOn"),
"autoRemoteStart": cluster.GetBool("autoRemoteStart"),
"accessLogIsOn": cluster.GetBool("accessLogIsOn"),
"name": cluster.GetString("name"),
"gatewayDomain": cluster.GetString("gatewayDomain"),
"cacheTtl": cluster.GetInt("defaultTTL"),
"fallbackTimeout": cluster.GetInt("fallbackTimeout"),
"installDir": cluster.GetString("installDir"),
"isOn": cluster.GetBool("isOn"),
"autoRemoteStart": cluster.GetBool("autoRemoteStart"),
"accessLogIsOn": cluster.GetBool("accessLogIsOn"),
"timeZone": cluster.GetString("timeZone"),
}
if settings.GetInt("cacheTtl") <= 0 {
settings["cacheTtl"] = 60
@@ -57,7 +61,10 @@ func (this *ClusterSettingsAction) RunGet(params struct {
settings["fallbackTimeout"] = 300
}
if len(settings.GetString("installDir")) == 0 {
settings["installDir"] = "/opt/edge-httpdns"
settings["installDir"] = "/root/edge-httpdns"
}
if len(settings.GetString("timeZone")) == 0 {
settings["timeZone"] = "Asia/Shanghai"
}
listenAddresses := []*serverconfigs.NetworkAddressConfig{
@@ -101,19 +108,29 @@ func (this *ClusterSettingsAction) RunGet(params struct {
"listen": listenAddresses,
"sslPolicy": sslPolicy,
}
this.Data["timeZoneGroups"] = nodeconfigs.FindAllTimeZoneGroups()
this.Data["timeZoneLocations"] = nodeconfigs.FindAllTimeZoneLocations()
timeZoneStr := settings.GetString("timeZone")
if len(timeZoneStr) == 0 {
timeZoneStr = nodeconfigs.DefaultTimeZoneLocation
}
this.Data["timeZoneLocation"] = nodeconfigs.FindTimeZoneLocation(timeZoneStr)
this.Show()
}
func (this *ClusterSettingsAction) RunPost(params struct {
ClusterId int64
Name string
GatewayDomain string
CacheTtl int32
FallbackTimeout int32
InstallDir string
IsOn bool
ClusterId int64
Name string
GatewayDomain string
CacheTtl int32
FallbackTimeout int32
InstallDir string
IsOn bool
AutoRemoteStart bool
AccessLogIsOn bool
AccessLogIsOn bool
TimeZone string
Addresses []byte
SslPolicyJSON []byte
@@ -129,6 +146,31 @@ func (this *ClusterSettingsAction) RunPost(params struct {
params.Must.Field("name", params.Name).Require("请输入集群名称")
params.Must.Field("gatewayDomain", params.GatewayDomain).Require("请输入服务域名")
cluster, err := findClusterMap(this.Parent(), params.ClusterId)
if err != nil {
this.ErrorPage(err)
return
}
// 开关项按请求值强制覆盖:未提交/空值都视为 false支持取消勾选
autoRemoteStartRaw := strings.ToLower(strings.TrimSpace(this.ParamString("autoRemoteStart")))
params.AutoRemoteStart = autoRemoteStartRaw == "1" || autoRemoteStartRaw == "true" || autoRemoteStartRaw == "on" || autoRemoteStartRaw == "yes" || autoRemoteStartRaw == "enabled"
accessLogIsOnRaw := strings.ToLower(strings.TrimSpace(this.ParamString("accessLogIsOn")))
params.AccessLogIsOn = accessLogIsOnRaw == "1" || accessLogIsOnRaw == "true" || accessLogIsOnRaw == "on" || accessLogIsOnRaw == "yes" || accessLogIsOnRaw == "enabled"
isOnRaw := strings.ToLower(strings.TrimSpace(this.ParamString("isOn")))
params.IsOn = isOnRaw == "1" || isOnRaw == "true" || isOnRaw == "on" || isOnRaw == "yes" || isOnRaw == "enabled"
// 时区为空时继承当前值,再兜底默认值
params.TimeZone = strings.TrimSpace(this.ParamString("timeZone"))
if len(params.TimeZone) == 0 {
params.TimeZone = strings.TrimSpace(cluster.GetString("timeZone"))
}
if len(params.TimeZone) == 0 {
params.TimeZone = "Asia/Shanghai"
}
if params.CacheTtl <= 0 {
params.CacheTtl = 60
}
@@ -136,20 +178,13 @@ func (this *ClusterSettingsAction) RunPost(params struct {
params.FallbackTimeout = 300
}
if len(params.InstallDir) == 0 {
params.InstallDir = "/opt/edge-httpdns"
}
cluster, err := findClusterMap(this.Parent(), params.ClusterId)
if err != nil {
this.ErrorPage(err)
return
params.InstallDir = "/root/edge-httpdns"
}
tlsConfig := maps.Map{}
if rawTLS := strings.TrimSpace(cluster.GetString("tlsPolicyJSON")); len(rawTLS) > 0 {
_ = json.Unmarshal([]byte(rawTLS), &tlsConfig)
}
if len(params.Addresses) > 0 {
var addresses []*serverconfigs.NetworkAddressConfig
if err := json.Unmarshal(params.Addresses, &addresses); err != nil {
@@ -158,7 +193,6 @@ func (this *ClusterSettingsAction) RunPost(params struct {
}
tlsConfig["listen"] = addresses
}
if len(params.SslPolicyJSON) > 0 {
sslPolicy := &sslconfigs.SSLPolicy{}
if err := json.Unmarshal(params.SslPolicyJSON, sslPolicy); err != nil {
@@ -177,7 +211,7 @@ func (this *ClusterSettingsAction) RunPost(params struct {
}
}
_, err = this.RPC().HTTPDNSClusterRPC().UpdateHTTPDNSCluster(this.AdminContext(), &pb.UpdateHTTPDNSClusterRequest{
updateReq := &pb.UpdateHTTPDNSClusterRequest{
ClusterId: params.ClusterId,
Name: params.Name,
ServiceDomain: params.GatewayDomain,
@@ -189,7 +223,16 @@ func (this *ClusterSettingsAction) RunPost(params struct {
IsDefault: false,
AutoRemoteStart: params.AutoRemoteStart,
AccessLogIsOn: params.AccessLogIsOn,
})
TimeZone: params.TimeZone,
}
updateCtx := metadata.AppendToOutgoingContext(
this.AdminContext(),
"x-httpdns-auto-remote-start", fmt.Sprintf("%t", updateReq.GetAutoRemoteStart()),
"x-httpdns-access-log-is-on", fmt.Sprintf("%t", updateReq.GetAccessLogIsOn()),
"x-httpdns-time-zone", updateReq.GetTimeZone(),
)
_, err = this.RPC().HTTPDNSClusterRPC().UpdateHTTPDNSCluster(updateCtx, updateReq)
if err != nil {
this.ErrorPage(err)
return

View File

@@ -1,6 +1,7 @@
package clusters
import (
"strconv"
"strings"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
@@ -36,7 +37,7 @@ func (this *CreateAction) RunPost(params struct {
params.GatewayDomain = strings.TrimSpace(params.GatewayDomain)
params.InstallDir = strings.TrimSpace(params.InstallDir)
if len(params.InstallDir) == 0 {
params.InstallDir = "/opt/edge-httpdns"
params.InstallDir = "/root/edge-httpdns"
}
if params.CacheTtl <= 0 {
params.CacheTtl = 60
@@ -56,6 +57,9 @@ func (this *CreateAction) RunPost(params struct {
InstallDir: params.InstallDir,
IsOn: params.IsOn,
IsDefault: false,
AutoRemoteStart: true,
AccessLogIsOn: true,
TimeZone: "Asia/Shanghai",
})
if err != nil {
this.ErrorPage(err)
@@ -63,5 +67,12 @@ func (this *CreateAction) RunPost(params struct {
}
this.Data["clusterId"] = resp.GetClusterId()
// fallback: if frontend JS doesn't intercept form submit, redirect instead of showing raw JSON
if len(this.Request.Header.Get("X-Requested-With")) == 0 {
this.RedirectURL("/httpdns/clusters/cluster?clusterId=" + strconv.FormatInt(resp.GetClusterId(), 10))
return
}
this.Success()
}

View File

@@ -51,7 +51,7 @@ func (this *CreateNodeAction) RunPost(params struct {
params.InstallDir = strings.TrimSpace(cluster.GetString("installDir"))
}
if len(params.InstallDir) == 0 {
params.InstallDir = "/opt/edge-httpdns"
params.InstallDir = "/root/edge-httpdns"
}
}

View File

@@ -22,7 +22,9 @@ func init() {
// Node level
GetPost("/createNode", new(CreateNodeAction)).
Post("/deleteNode", new(DeleteNodeAction)).
Get("/upgradeRemote", new(UpgradeRemoteAction)).
GetPost("/cluster/upgradeRemote", new(UpgradeRemoteAction)).
GetPost("/upgradeRemote", new(UpgradeRemoteAction)).
Post("/upgradeStatus", new(UpgradeStatusAction)).
GetPost("/updateNodeSSH", new(UpdateNodeSSHAction)).
Post("/checkPorts", new(CheckPortsAction)).

View File

@@ -9,6 +9,10 @@ import (
"github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/iwind/TeaGo/maps"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func listClusterMaps(parent *actionutils.ParentAction, keyword string) ([]maps.Map, error) {
@@ -37,6 +41,11 @@ func listClusterMaps(parent *actionutils.ParentAction, keyword string) ([]maps.M
}
}
countUpgradeNodes, err := countUpgradeHTTPDNSNodes(parent, cluster.GetId())
if err != nil {
return nil, err
}
port := "443"
if rawTLS := cluster.GetTlsPolicyJSON(); len(rawTLS) > 0 {
tlsConfig := maps.Map{}
@@ -56,32 +65,87 @@ func listClusterMaps(parent *actionutils.ParentAction, keyword string) ([]maps.M
apiAddress := "https://" + cluster.GetServiceDomain() + ":" + port
result = append(result, maps.Map{
"id": cluster.GetId(),
"name": cluster.GetName(),
"gatewayDomain": cluster.GetServiceDomain(),
"apiAddress": apiAddress,
"defaultTTL": cluster.GetDefaultTTL(),
"fallbackTimeout": cluster.GetFallbackTimeoutMs(),
"installDir": cluster.GetInstallDir(),
"isOn": cluster.GetIsOn(),
"isDefault": cluster.GetIsDefault(),
"countAllNodes": countAllNodes,
"countActiveNodes": countActiveNodes,
"id": cluster.GetId(),
"name": cluster.GetName(),
"gatewayDomain": cluster.GetServiceDomain(),
"apiAddress": apiAddress,
"defaultTTL": cluster.GetDefaultTTL(),
"fallbackTimeout": cluster.GetFallbackTimeoutMs(),
"installDir": cluster.GetInstallDir(),
"timeZone": cluster.GetTimeZone(),
"isOn": cluster.GetIsOn(),
"isDefault": cluster.GetIsDefault(),
"autoRemoteStart": cluster.GetAutoRemoteStart(),
"accessLogIsOn": cluster.GetAccessLogIsOn(),
"tlsPolicyJSON": cluster.GetTlsPolicyJSON(),
"countAllNodes": countAllNodes,
"countActiveNodes": countActiveNodes,
"countUpgradeNodes": countUpgradeNodes,
})
}
return result, nil
}
func countUpgradeHTTPDNSNodes(parent *actionutils.ParentAction, clusterID int64) (int64, error) {
countResp, err := parent.RPC().HTTPDNSNodeRPC().CountAllUpgradeHTTPDNSNodesWithClusterId(parent.AdminContext(), &pb.CountAllUpgradeHTTPDNSNodesWithClusterIdRequest{
ClusterId: clusterID,
})
if err == nil {
return countResp.GetCount(), nil
}
grpcStatus, ok := status.FromError(err)
if !ok || grpcStatus.Code() != codes.Unimplemented {
return 0, err
}
// Compatibility fallback: old edge-api may not implement countAllUpgradeHTTPDNSNodesWithClusterId yet.
listResp, listErr := parent.RPC().HTTPDNSNodeRPC().FindAllUpgradeHTTPDNSNodesWithClusterId(parent.AdminContext(), &pb.FindAllUpgradeHTTPDNSNodesWithClusterIdRequest{
ClusterId: clusterID,
})
if listErr == nil {
return int64(len(listResp.GetNodes())), nil
}
listStatus, ok := status.FromError(listErr)
if ok && listStatus.Code() == codes.Unimplemented {
// Compatibility fallback: both methods missing on old edge-api, don't block page rendering.
return 0, nil
}
return 0, listErr
}
func findClusterMap(parent *actionutils.ParentAction, clusterID int64) (maps.Map, error) {
if clusterID > 0 {
var headerMD metadata.MD
resp, err := parent.RPC().HTTPDNSClusterRPC().FindHTTPDNSCluster(parent.AdminContext(), &pb.FindHTTPDNSClusterRequest{
ClusterId: clusterID,
})
}, grpc.Header(&headerMD))
if err != nil {
return nil, err
}
if resp.GetCluster() != nil {
cluster := resp.GetCluster()
autoRemoteStart := cluster.GetAutoRemoteStart()
accessLogIsOn := cluster.GetAccessLogIsOn()
timeZone := cluster.GetTimeZone()
// Compatibility fallback:
// Some deployed admin binaries may decode newly-added protobuf fields incorrectly.
// Read values from grpc response headers as a source of truth.
if values := headerMD.Get("x-httpdns-auto-remote-start"); len(values) > 0 {
autoRemoteStart = parseBoolLike(values[0], autoRemoteStart)
}
if values := headerMD.Get("x-httpdns-access-log-is-on"); len(values) > 0 {
accessLogIsOn = parseBoolLike(values[0], accessLogIsOn)
}
if values := headerMD.Get("x-httpdns-time-zone"); len(values) > 0 {
if tz := strings.TrimSpace(values[0]); len(tz) > 0 {
timeZone = tz
}
}
return maps.Map{
"id": cluster.GetId(),
"name": cluster.GetName(),
@@ -92,8 +156,9 @@ func findClusterMap(parent *actionutils.ParentAction, clusterID int64) (maps.Map
"isOn": cluster.GetIsOn(),
"isDefault": cluster.GetIsDefault(),
"tlsPolicyJSON": cluster.GetTlsPolicyJSON(),
"autoRemoteStart": cluster.GetAutoRemoteStart(),
"accessLogIsOn": cluster.GetAccessLogIsOn(),
"autoRemoteStart": autoRemoteStart,
"accessLogIsOn": accessLogIsOn,
"timeZone": timeZone,
}, nil
}
}
@@ -109,12 +174,25 @@ func findClusterMap(parent *actionutils.ParentAction, clusterID int64) (maps.Map
"gatewayDomain": "",
"defaultTTL": 60,
"fallbackTimeout": 300,
"installDir": "/opt/edge-httpdns",
"installDir": "/root/edge-httpdns",
"timeZone": "Asia/Shanghai",
}, nil
}
return clusters[0], nil
}
func parseBoolLike(raw string, defaultValue bool) bool {
s := strings.ToLower(strings.TrimSpace(raw))
switch s {
case "1", "true", "on", "yes", "enabled":
return true
case "0", "false", "off", "no", "disabled":
return false
default:
return defaultValue
}
}
func listNodeMaps(parent *actionutils.ParentAction, clusterID int64) ([]maps.Map, error) {
resp, err := parent.RPC().HTTPDNSNodeRPC().ListHTTPDNSNodes(parent.AdminContext(), &pb.ListHTTPDNSNodesRequest{
ClusterId: clusterID,
@@ -132,21 +210,21 @@ func listNodeMaps(parent *actionutils.ParentAction, clusterID int64) ([]maps.Map
ip = parsed
}
nodeMap := maps.Map{
"id": node.GetId(),
"clusterId": node.GetClusterId(),
"name": node.GetName(),
"isOn": node.GetIsOn(),
"isUp": node.GetIsUp(),
"isInstalled": node.GetIsInstalled(),
"isActive": node.GetIsActive(),
"installDir": node.GetInstallDir(),
"uniqueId": node.GetUniqueId(),
"secret": node.GetSecret(),
"status": statusMap,
"id": node.GetId(),
"clusterId": node.GetClusterId(),
"name": node.GetName(),
"isOn": node.GetIsOn(),
"isUp": node.GetIsUp(),
"isInstalled": node.GetIsInstalled(),
"isActive": node.GetIsActive(),
"installDir": node.GetInstallDir(),
"uniqueId": node.GetUniqueId(),
"secret": node.GetSecret(),
"status": statusMap,
"installStatus": installStatusMap,
"region": nil,
"login": nil,
"apiNodeAddrs": []string{},
"region": nil,
"login": nil,
"apiNodeAddrs": []string{},
"cluster": maps.Map{
"id": node.GetClusterId(),
"installDir": node.GetInstallDir(),
@@ -227,21 +305,21 @@ func decodeNodeStatus(raw []byte) maps.Map {
cpuText := fmt.Sprintf("%.2f%%", status.CPUUsage*100)
memText := fmt.Sprintf("%.2f%%", status.MemoryUsage*100)
return maps.Map{
"isActive": status.IsActive,
"updatedAt": status.UpdatedAt,
"hostname": status.Hostname,
"hostIP": status.HostIP,
"cpuUsage": status.CPUUsage,
"cpuUsageText": cpuText,
"memUsage": status.MemoryUsage,
"memUsageText": memText,
"load1m": status.Load1m,
"load5m": status.Load5m,
"load15m": status.Load15m,
"buildVersion": status.BuildVersion,
"isActive": status.IsActive,
"updatedAt": status.UpdatedAt,
"hostname": status.Hostname,
"hostIP": status.HostIP,
"cpuUsage": status.CPUUsage,
"cpuUsageText": cpuText,
"memUsage": status.MemoryUsage,
"memUsageText": memText,
"load1m": status.Load1m,
"load5m": status.Load5m,
"load15m": status.Load15m,
"buildVersion": status.BuildVersion,
"cpuPhysicalCount": status.CPUPhysicalCount,
"cpuLogicalCount": status.CPULogicalCount,
"exePath": status.ExePath,
"cpuLogicalCount": status.CPULogicalCount,
"exePath": status.ExePath,
}
}

View File

@@ -1,16 +1,108 @@
package clusters
import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
import (
"encoding/json"
"strings"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/httpdns/httpdnsutils"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/iwind/TeaGo/actions"
"github.com/iwind/TeaGo/maps"
)
type UpgradeRemoteAction struct {
actionutils.ParentAction
}
func (this *UpgradeRemoteAction) Init() {
this.Nav("httpdns", "cluster", "index")
}
func (this *UpgradeRemoteAction) RunGet(params struct {
NodeId int64
ClusterId int64
}) {
this.Data["nodeId"] = params.NodeId
httpdnsutils.AddLeftMenu(this.Parent())
cluster, err := findClusterMap(this.Parent(), params.ClusterId)
if err != nil {
this.ErrorPage(err)
return
}
httpdnsutils.AddClusterTabbar(this.Parent(), cluster.GetString("name"), params.ClusterId, "node")
this.Data["clusterId"] = params.ClusterId
this.Data["cluster"] = cluster
resp, err := this.RPC().HTTPDNSNodeRPC().FindAllUpgradeHTTPDNSNodesWithClusterId(this.AdminContext(), &pb.FindAllUpgradeHTTPDNSNodesWithClusterIdRequest{
ClusterId: params.ClusterId,
})
if err != nil {
this.ErrorPage(err)
return
}
nodes := make([]maps.Map, 0, len(resp.GetNodes()))
for _, upgradeNode := range resp.GetNodes() {
node := upgradeNode.Node
if node == nil {
continue
}
loginParams := maps.Map{}
if node.GetNodeLogin() != nil && len(node.GetNodeLogin().GetParams()) > 0 {
_ = json.Unmarshal(node.GetNodeLogin().GetParams(), &loginParams)
}
status := decodeNodeStatus(node.GetStatusJSON())
accessIP := strings.TrimSpace(status.GetString("hostIP"))
if len(accessIP) == 0 {
accessIP = strings.TrimSpace(node.GetName())
}
nodes = append(nodes, maps.Map{
"id": node.GetId(),
"name": node.GetName(),
"accessIP": accessIP,
"oldVersion": upgradeNode.OldVersion,
"newVersion": upgradeNode.NewVersion,
"login": node.GetNodeLogin(),
"loginParams": loginParams,
"installStatus": decodeUpgradeInstallStatus(node.GetInstallStatusJSON()),
})
}
this.Data["nodes"] = nodes
this.Show()
}
func (this *UpgradeRemoteAction) RunPost(params struct {
NodeId int64
Must *actions.Must
}) {
_, err := this.RPC().HTTPDNSNodeRPC().UpgradeHTTPDNSNode(this.AdminContext(), &pb.UpgradeHTTPDNSNodeRequest{
NodeId: params.NodeId,
})
if err != nil {
this.ErrorPage(err)
return
}
this.Success()
}
func decodeUpgradeInstallStatus(raw []byte) maps.Map {
result := maps.Map{
"isRunning": false,
"isFinished": false,
"isOk": false,
"error": "",
"errorCode": "",
}
if len(raw) == 0 {
return result
}
_ = json.Unmarshal(raw, &result)
return result
}

View File

@@ -0,0 +1,48 @@
package clusters
import (
"encoding/json"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
)
type UpgradeStatusAction struct {
actionutils.ParentAction
}
func (this *UpgradeStatusAction) RunPost(params struct {
NodeId int64
}) {
resp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{
NodeId: params.NodeId,
})
if err != nil {
this.ErrorPage(err)
return
}
if resp.GetNode() == nil {
this.Data["status"] = nil
this.Success()
return
}
this.Data["status"] = decodeUpgradeInstallStatusMap(resp.GetNode().GetInstallStatusJSON())
this.Success()
}
func decodeUpgradeInstallStatusMap(raw []byte) map[string]interface{} {
result := map[string]interface{}{
"isRunning": false,
"isFinished": false,
"isOk": false,
"error": "",
"errorCode": "",
}
if len(raw) == 0 {
return result
}
_ = json.Unmarshal(raw, &result)
return result
}

View File

@@ -39,6 +39,7 @@ func (this *Helper) BeforeAction(actionPtr actions.ActionWrapper) (goNext bool)
if configloaders.AllowModule(adminId, configloaders.AdminModuleCodeSetting) {
tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabAdminServer), "", "/settings/server", "", this.tab == "server")
tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabAdminUI), "", "/settings/ui", "", this.tab == "ui")
tabbar.Add("升级设置", "", "/settings/upgrade", "", this.tab == "upgrade")
tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabAdminSecuritySettings), "", "/settings/security", "", this.tab == "security")
if teaconst.IsPlus {
tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabIPLibrary), "", "/settings/ip-library", "", this.tab == "ipLibrary")

View File

@@ -42,6 +42,7 @@ func (this *Helper) BeforeAction(actionPtr actions.ActionWrapper) (goNext bool)
if teaconst.IsPlus {
tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabUserUI), "", "/settings/user-ui", "", this.tab == "userUI")
}
tabbar.Add("升级设置", "", "/settings/upgrade", "", this.tab == "upgrade")
tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabAdminSecuritySettings), "", "/settings/security", "", this.tab == "security")
tabbar.Add(this.Lang(actionPtr, codes.AdminSetting_TabIPLibrary), "", "/settings/ip-library", "", this.tab == "ipLibrary")
}

View File

@@ -56,9 +56,6 @@ func (this *IndexAction) RunPost(params struct {
TimeZone string
DnsResolverType string
SupportModuleCDN bool
SupportModuleNS bool
Must *actions.Must
CSRF *actionutils.CSRF
}) {
@@ -93,13 +90,7 @@ func (this *IndexAction) RunPost(params struct {
config.DefaultPageSize = 10
}
config.Modules = []userconfigs.UserModule{}
if params.SupportModuleCDN {
config.Modules = append(config.Modules, userconfigs.UserModuleCDN)
}
if params.SupportModuleNS {
config.Modules = append(config.Modules, userconfigs.UserModuleNS)
}
config.Modules = []userconfigs.UserModule{userconfigs.UserModuleCDN, userconfigs.UserModuleNS}
// 上传Favicon文件
if params.FaviconFile != nil {

View File

@@ -0,0 +1,23 @@
//go:build !plus
package upgrade
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/iwind/TeaGo/maps"
)
// loadDNSUpgradeModules 非Plus版本不支持DNS模块
func loadDNSUpgradeModules(parent *actionutils.ParentAction) []maps.Map {
return nil
}
// upgradeDNSNode 非Plus版本不支持DNS节点升级
func upgradeDNSNode(parent *actionutils.ParentAction, nodeId int64) error {
return nil
}
// loadDNSNodeStatus 非Plus版本不支持DNS节点状态查询
func loadDNSNodeStatus(parent *actionutils.ParentAction, nodeIds []int64) []maps.Map {
return nil
}

View File

@@ -0,0 +1,100 @@
//go:build plus
package upgrade
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/iwind/TeaGo/maps"
)
// loadDNSUpgradeModules 加载DNS模块的待升级节点信息
func loadDNSUpgradeModules(parent *actionutils.ParentAction) []maps.Map {
clustersResp, err := parent.RPC().NSClusterRPC().ListNSClusters(parent.AdminContext(), &pb.ListNSClustersRequest{
Offset: 0,
Size: 10000,
})
if err != nil {
return nil
}
var clusterMaps []maps.Map
for _, cluster := range clustersResp.NsClusters {
nodesResp, err := parent.RPC().NSNodeRPC().FindAllUpgradeNSNodesWithNSClusterId(parent.AdminContext(), &pb.FindAllUpgradeNSNodesWithNSClusterIdRequest{
NsClusterId: cluster.Id,
})
if err != nil {
continue
}
var nodeMaps []maps.Map
for _, nodeUpgrade := range nodesResp.Nodes {
if nodeUpgrade.NsNode == nil {
continue
}
installStatusMap := decodeInstallStatusFromPB(nodeUpgrade.NsNode.InstallStatus)
accessIP, login, loginParams := decodeNodeAccessInfo(nodeUpgrade.NsNode.StatusJSON, nodeUpgrade.NsNode.NodeLogin, nodeUpgrade.NsNode.Name)
nodeMaps = append(nodeMaps, maps.Map{
"id": nodeUpgrade.NsNode.Id,
"name": nodeUpgrade.NsNode.Name,
"os": nodeUpgrade.Os,
"arch": nodeUpgrade.Arch,
"oldVersion": nodeUpgrade.OldVersion,
"newVersion": nodeUpgrade.NewVersion,
"isOn": nodeUpgrade.NsNode.IsOn,
"isUp": nodeUpgrade.NsNode.IsUp,
"accessIP": accessIP,
"login": login,
"loginParams": loginParams,
"installStatus": installStatusMap,
})
}
if len(nodeMaps) == 0 {
continue
}
clusterMaps = append(clusterMaps, maps.Map{
"id": cluster.Id,
"name": cluster.Name,
"nodes": nodeMaps,
"count": len(nodeMaps),
})
}
return clusterMaps
}
// upgradeDNSNode 升级DNS节点
func upgradeDNSNode(parent *actionutils.ParentAction, nodeId int64) error {
_, err := parent.RPC().NSNodeRPC().UpgradeNSNode(parent.AdminContext(), &pb.UpgradeNSNodeRequest{
NsNodeId: nodeId,
})
return err
}
// loadDNSNodeStatus 加载DNS节点安装状态
func loadDNSNodeStatus(parent *actionutils.ParentAction, nodeIds []int64) []maps.Map {
var result []maps.Map
for _, nodeId := range nodeIds {
resp, err := parent.RPC().NSNodeRPC().FindNSNode(parent.AdminContext(), &pb.FindNSNodeRequest{
NsNodeId: nodeId,
})
if err != nil || resp.NsNode == nil {
continue
}
var installStatusMap maps.Map
if resp.NsNode.InstallStatus != nil {
installStatusMap = maps.Map{
"isRunning": resp.NsNode.InstallStatus.IsRunning,
"isFinished": resp.NsNode.InstallStatus.IsFinished,
"isOk": resp.NsNode.InstallStatus.IsOk,
"error": resp.NsNode.InstallStatus.Error,
"errorCode": resp.NsNode.InstallStatus.ErrorCode,
"updatedAt": resp.NsNode.InstallStatus.UpdatedAt,
}
}
result = append(result, maps.Map{
"id": nodeId,
"installStatus": installStatusMap,
})
}
return result
}

View File

@@ -1,6 +1,15 @@
package upgrade
import "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
import (
"encoding/json"
"strings"
"github.com/TeaOSLab/EdgeAdmin/internal/configloaders"
teaconst "github.com/TeaOSLab/EdgeAdmin/internal/const"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/iwind/TeaGo/maps"
)
type IndexAction struct {
actionutils.ParentAction
@@ -11,5 +20,264 @@ func (this *IndexAction) Init() {
}
func (this *IndexAction) RunGet(params struct{}) {
// 加载升级配置
config, err := configloaders.LoadUpgradeConfig()
if err != nil {
this.ErrorPage(err)
return
}
this.Data["config"] = config
// 模块列表
modules := []maps.Map{}
// 1. 边缘节点 (EdgeNode)
nodeModule := this.loadEdgeNodeModule()
if nodeModule != nil {
modules = append(modules, nodeModule)
}
// 2. DNS节点 (EdgeDNS) — 仅Plus版本
if teaconst.IsPlus {
dnsClusters := loadDNSUpgradeModules(&this.ParentAction)
if len(dnsClusters) > 0 {
totalCount := 0
for _, c := range dnsClusters {
totalCount += c.GetInt("count")
}
modules = append(modules, maps.Map{
"name": "DNS节点",
"code": "dns",
"clusters": dnsClusters,
"count": totalCount,
})
}
}
// 3. HTTPDNS节点
httpdnsModule := this.loadHTTPDNSModule()
if httpdnsModule != nil {
modules = append(modules, httpdnsModule)
}
this.Data["modules"] = modules
this.Show()
}
func (this *IndexAction) RunPost(params struct {
AutoUpgrade bool
}) {
config, err := configloaders.LoadUpgradeConfig()
if err != nil {
this.ErrorPage(err)
return
}
config.AutoUpgrade = params.AutoUpgrade
err = configloaders.UpdateUpgradeConfig(config)
if err != nil {
this.ErrorPage(err)
return
}
this.Success()
}
// loadEdgeNodeModule 加载边缘节点模块的待升级信息
func (this *IndexAction) loadEdgeNodeModule() maps.Map {
// 获取所有集群
clustersResp, err := this.RPC().NodeClusterRPC().ListEnabledNodeClusters(this.AdminContext(), &pb.ListEnabledNodeClustersRequest{
Offset: 0,
Size: 10000,
})
if err != nil {
return nil
}
var clusterMaps []maps.Map
totalCount := 0
for _, cluster := range clustersResp.NodeClusters {
resp, err := this.RPC().NodeRPC().FindAllUpgradeNodesWithNodeClusterId(this.AdminContext(), &pb.FindAllUpgradeNodesWithNodeClusterIdRequest{
NodeClusterId: cluster.Id,
})
if err != nil {
continue
}
if len(resp.Nodes) == 0 {
continue
}
var nodeMaps []maps.Map
for _, nodeUpgrade := range resp.Nodes {
if nodeUpgrade.Node == nil {
continue
}
installStatusMap := decodeInstallStatusFromPB(nodeUpgrade.Node.InstallStatus)
accessIP, login, loginParams := decodeNodeAccessInfo(nodeUpgrade.Node.StatusJSON, nodeUpgrade.Node.NodeLogin, nodeUpgrade.Node.Name)
nodeMaps = append(nodeMaps, maps.Map{
"id": nodeUpgrade.Node.Id,
"name": nodeUpgrade.Node.Name,
"os": nodeUpgrade.Os,
"arch": nodeUpgrade.Arch,
"oldVersion": nodeUpgrade.OldVersion,
"newVersion": nodeUpgrade.NewVersion,
"isOn": nodeUpgrade.Node.IsOn,
"isUp": nodeUpgrade.Node.IsUp,
"accessIP": accessIP,
"login": login,
"loginParams": loginParams,
"installStatus": installStatusMap,
})
}
totalCount += len(nodeMaps)
clusterMaps = append(clusterMaps, maps.Map{
"id": cluster.Id,
"name": cluster.Name,
"nodes": nodeMaps,
"count": len(nodeMaps),
})
}
if len(clusterMaps) == 0 {
return nil
}
return maps.Map{
"name": "边缘节点",
"code": "node",
"clusters": clusterMaps,
"count": totalCount,
}
}
// loadHTTPDNSModule 加载HTTPDNS模块的待升级信息
func (this *IndexAction) loadHTTPDNSModule() maps.Map {
clustersResp, err := this.RPC().HTTPDNSClusterRPC().ListHTTPDNSClusters(this.AdminContext(), &pb.ListHTTPDNSClustersRequest{
Offset: 0,
Size: 10000,
})
if err != nil {
return nil
}
var clusterMaps []maps.Map
totalCount := 0
for _, cluster := range clustersResp.Clusters {
resp, err := this.RPC().HTTPDNSNodeRPC().FindAllUpgradeHTTPDNSNodesWithClusterId(this.AdminContext(), &pb.FindAllUpgradeHTTPDNSNodesWithClusterIdRequest{
ClusterId: cluster.Id,
})
if err != nil {
continue
}
if len(resp.Nodes) == 0 {
continue
}
var nodeMaps []maps.Map
for _, nodeUpgrade := range resp.Nodes {
if nodeUpgrade.Node == nil {
continue
}
installStatusMap := decodeInstallStatusFromJSON(nodeUpgrade.Node.InstallStatusJSON)
accessIP, login, loginParams := decodeNodeAccessInfo(nodeUpgrade.Node.StatusJSON, nodeUpgrade.Node.NodeLogin, nodeUpgrade.Node.Name)
nodeMaps = append(nodeMaps, maps.Map{
"id": nodeUpgrade.Node.Id,
"name": nodeUpgrade.Node.Name,
"os": nodeUpgrade.Os,
"arch": nodeUpgrade.Arch,
"oldVersion": nodeUpgrade.OldVersion,
"newVersion": nodeUpgrade.NewVersion,
"isOn": nodeUpgrade.Node.IsOn,
"isUp": nodeUpgrade.Node.IsUp,
"accessIP": accessIP,
"login": login,
"loginParams": loginParams,
"installStatus": installStatusMap,
})
}
totalCount += len(nodeMaps)
clusterMaps = append(clusterMaps, maps.Map{
"id": cluster.Id,
"name": cluster.Name,
"nodes": nodeMaps,
"count": len(nodeMaps),
})
}
if len(clusterMaps) == 0 {
return nil
}
return maps.Map{
"name": "HTTPDNS节点",
"code": "httpdns",
"clusters": clusterMaps,
"count": totalCount,
}
}
// decodeInstallStatusFromPB 从 protobuf InstallStatus 解码安装状态
func decodeInstallStatusFromPB(status *pb.NodeInstallStatus) maps.Map {
if status == nil {
return nil
}
// 历史成功状态,在待升级列表中忽略
if status.IsFinished && status.IsOk {
return nil
}
return maps.Map{
"isRunning": status.IsRunning,
"isFinished": status.IsFinished,
"isOk": status.IsOk,
"error": status.Error,
"errorCode": status.ErrorCode,
"updatedAt": status.UpdatedAt,
}
}
// decodeInstallStatusFromJSON 从 JSON 字节解码安装状态
func decodeInstallStatusFromJSON(raw []byte) maps.Map {
if len(raw) == 0 {
return nil
}
result := maps.Map{}
_ = json.Unmarshal(raw, &result)
isFinished, _ := result["isFinished"].(bool)
isOk, _ := result["isOk"].(bool)
if isFinished && isOk {
return nil
}
return result
}
// decodeNodeAccessInfo 从节点状态和登录信息中提取 accessIP、login、loginParams
func decodeNodeAccessInfo(statusJSON []byte, nodeLogin *pb.NodeLogin, nodeName string) (accessIP string, login maps.Map, loginParams maps.Map) {
// 从 statusJSON 中提取 hostIP 作为 accessIP
if len(statusJSON) > 0 {
statusMap := maps.Map{}
_ = json.Unmarshal(statusJSON, &statusMap)
accessIP = strings.TrimSpace(statusMap.GetString("hostIP"))
}
if len(accessIP) == 0 {
accessIP = strings.TrimSpace(nodeName)
}
// 解码 login 信息
if nodeLogin != nil {
login = maps.Map{
"id": nodeLogin.Id,
"name": nodeLogin.Name,
"type": nodeLogin.Type,
}
if len(nodeLogin.Params) > 0 {
loginParams = maps.Map{}
_ = json.Unmarshal(nodeLogin.Params, &loginParams)
}
}
return
}

View File

@@ -13,7 +13,9 @@ func init() {
Helper(helpers.NewUserMustAuth(configloaders.AdminModuleCodeSetting)).
Helper(settingutils.NewHelper("upgrade")).
Prefix("/settings/upgrade").
Get("", new(IndexAction)).
GetPost("", new(IndexAction)).
Post("/upgradeNode", new(UpgradeNodeAction)).
Post("/status", new(StatusAction)).
EndAll()
})
}

View File

@@ -0,0 +1,80 @@
package upgrade
import (
"encoding/json"
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/iwind/TeaGo/maps"
)
type StatusAction struct {
actionutils.ParentAction
}
func (this *StatusAction) RunPost(params struct {
NodeIdsJSON []byte // JSON: {"node": [1,2], "dns": [3], "httpdns": [4,5]}
}) {
var nodeIdsMap map[string][]int64
if len(params.NodeIdsJSON) > 0 {
_ = json.Unmarshal(params.NodeIdsJSON, &nodeIdsMap)
}
result := maps.Map{}
// EdgeNode 状态
if nodeIds, ok := nodeIdsMap["node"]; ok && len(nodeIds) > 0 {
var nodeStatuses []maps.Map
for _, nodeId := range nodeIds {
resp, err := this.RPC().NodeRPC().FindEnabledNode(this.AdminContext(), &pb.FindEnabledNodeRequest{NodeId: nodeId})
if err != nil || resp.Node == nil {
continue
}
var installStatusMap maps.Map
if resp.Node.InstallStatus != nil {
installStatusMap = maps.Map{
"isRunning": resp.Node.InstallStatus.IsRunning,
"isFinished": resp.Node.InstallStatus.IsFinished,
"isOk": resp.Node.InstallStatus.IsOk,
"error": resp.Node.InstallStatus.Error,
"errorCode": resp.Node.InstallStatus.ErrorCode,
"updatedAt": resp.Node.InstallStatus.UpdatedAt,
}
}
nodeStatuses = append(nodeStatuses, maps.Map{
"id": nodeId,
"installStatus": installStatusMap,
})
}
result["node"] = nodeStatuses
}
// DNS 状态
if nodeIds, ok := nodeIdsMap["dns"]; ok && len(nodeIds) > 0 {
result["dns"] = loadDNSNodeStatus(&this.ParentAction, nodeIds)
}
// HTTPDNS 状态
if nodeIds, ok := nodeIdsMap["httpdns"]; ok && len(nodeIds) > 0 {
var nodeStatuses []maps.Map
for _, nodeId := range nodeIds {
resp, err := this.RPC().HTTPDNSNodeRPC().FindHTTPDNSNode(this.AdminContext(), &pb.FindHTTPDNSNodeRequest{NodeId: nodeId})
if err != nil || resp.Node == nil {
continue
}
var installStatusMap maps.Map
if len(resp.Node.InstallStatusJSON) > 0 {
installStatusMap = maps.Map{}
_ = json.Unmarshal(resp.Node.InstallStatusJSON, &installStatusMap)
}
nodeStatuses = append(nodeStatuses, maps.Map{
"id": nodeId,
"installStatus": installStatusMap,
})
}
result["httpdns"] = nodeStatuses
}
this.Data["statuses"] = result
this.Success()
}

View File

@@ -0,0 +1,150 @@
package upgrade
import (
"github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils"
"github.com/TeaOSLab/EdgeCommon/pkg/rpc/pb"
"github.com/iwind/TeaGo/maps"
)
type UpgradeNodeAction struct {
actionutils.ParentAction
}
func (this *UpgradeNodeAction) RunPost(params struct {
Module string // node, dns, httpdns
Scope string // all, module, cluster, node
ClusterId int64
NodeId int64
}) {
switch params.Scope {
case "node":
err := this.upgradeSingleNode(params.Module, params.NodeId)
if err != nil {
this.ErrorPage(err)
return
}
case "cluster":
err := this.upgradeCluster(params.Module, params.ClusterId)
if err != nil {
this.ErrorPage(err)
return
}
case "module":
err := this.upgradeModule(params.Module)
if err != nil {
this.ErrorPage(err)
return
}
case "all":
_ = this.upgradeModule("node")
_ = this.upgradeModule("dns")
_ = this.upgradeModule("httpdns")
}
this.Success()
}
func (this *UpgradeNodeAction) upgradeSingleNode(module string, nodeId int64) error {
switch module {
case "node":
_, err := this.RPC().NodeRPC().UpgradeNode(this.AdminContext(), &pb.UpgradeNodeRequest{NodeId: nodeId})
return err
case "dns":
return upgradeDNSNode(&this.ParentAction, nodeId)
case "httpdns":
_, err := this.RPC().HTTPDNSNodeRPC().UpgradeHTTPDNSNode(this.AdminContext(), &pb.UpgradeHTTPDNSNodeRequest{NodeId: nodeId})
return err
}
return nil
}
func (this *UpgradeNodeAction) upgradeCluster(module string, clusterId int64) error {
switch module {
case "node":
resp, err := this.RPC().NodeRPC().FindAllUpgradeNodesWithNodeClusterId(this.AdminContext(), &pb.FindAllUpgradeNodesWithNodeClusterIdRequest{
NodeClusterId: clusterId,
})
if err != nil {
return err
}
for _, nodeUpgrade := range resp.Nodes {
if nodeUpgrade.Node == nil {
continue
}
_, _ = this.RPC().NodeRPC().UpgradeNode(this.AdminContext(), &pb.UpgradeNodeRequest{NodeId: nodeUpgrade.Node.Id})
}
case "dns":
this.upgradeDNSCluster(clusterId)
case "httpdns":
resp, err := this.RPC().HTTPDNSNodeRPC().FindAllUpgradeHTTPDNSNodesWithClusterId(this.AdminContext(), &pb.FindAllUpgradeHTTPDNSNodesWithClusterIdRequest{
ClusterId: clusterId,
})
if err != nil {
return err
}
for _, nodeUpgrade := range resp.Nodes {
if nodeUpgrade.Node == nil {
continue
}
_, _ = this.RPC().HTTPDNSNodeRPC().UpgradeHTTPDNSNode(this.AdminContext(), &pb.UpgradeHTTPDNSNodeRequest{NodeId: nodeUpgrade.Node.Id})
}
}
return nil
}
func (this *UpgradeNodeAction) upgradeModule(module string) error {
switch module {
case "node":
clustersResp, err := this.RPC().NodeClusterRPC().ListEnabledNodeClusters(this.AdminContext(), &pb.ListEnabledNodeClustersRequest{
Offset: 0,
Size: 10000,
})
if err != nil {
return err
}
for _, cluster := range clustersResp.NodeClusters {
_ = this.upgradeCluster("node", cluster.Id)
}
case "dns":
dnsClusters := loadDNSUpgradeModules(&this.ParentAction)
for _, c := range dnsClusters {
this.upgradeDNSClusterFromMap(c)
}
case "httpdns":
clustersResp, err := this.RPC().HTTPDNSClusterRPC().ListHTTPDNSClusters(this.AdminContext(), &pb.ListHTTPDNSClustersRequest{
Offset: 0,
Size: 10000,
})
if err != nil {
return err
}
for _, cluster := range clustersResp.Clusters {
_ = this.upgradeCluster("httpdns", cluster.Id)
}
}
return nil
}
// upgradeDNSCluster 根据集群ID升级DNS节点
func (this *UpgradeNodeAction) upgradeDNSCluster(clusterId int64) {
dnsClusters := loadDNSUpgradeModules(&this.ParentAction)
for _, c := range dnsClusters {
if c.GetInt64("id") == clusterId {
this.upgradeDNSClusterFromMap(c)
break
}
}
}
// upgradeDNSClusterFromMap 从maps.Map中提取节点ID并升级
func (this *UpgradeNodeAction) upgradeDNSClusterFromMap(c maps.Map) {
nodesVal := c.Get("nodes")
if nodeMaps, ok := nodesVal.([]maps.Map); ok {
for _, n := range nodeMaps {
nodeId := n.GetInt64("id")
if nodeId > 0 {
_ = upgradeDNSNode(&this.ParentAction, nodeId)
}
}
}
}

View File

@@ -25,20 +25,16 @@ func (this *CreatePopupAction) Init() {
func (this *CreatePopupAction) RunGet(params struct{}) {
// 检查是否启用了 HTTPDNS 功能(全局用户注册设置中)
var hasHTTPDNSFeature = false
var defaultHttpdnsClusterId int64 = 0
resp, err := this.RPC().SysSettingRPC().ReadSysSetting(this.AdminContext(), &pb.ReadSysSettingRequest{Code: systemconfigs.SettingCodeUserRegisterConfig})
if err == nil && len(resp.ValueJSON) > 0 {
var config = userconfigs.DefaultUserRegisterConfig()
if json.Unmarshal(resp.ValueJSON, config) == nil {
hasHTTPDNSFeature = config.HTTPDNSIsOn
if len(config.HTTPDNSDefaultClusterIds) > 0 {
defaultHttpdnsClusterId = config.HTTPDNSDefaultClusterIds[0]
}
}
}
this.Data["hasHTTPDNSFeature"] = hasHTTPDNSFeature
this.Data["httpdnsClusterId"] = defaultHttpdnsClusterId
this.Data["httpdnsClusterId"] = 0
// 加载所有 HTTPDNS 集群
var httpdnsClusters = []maps.Map{}

View File

@@ -22046,15 +22046,16 @@ Vue.component("ssl-certs-box", {
<span class="grey" v-if="description.length > 0">{{description}}</span>
<div class="ui divider" v-if="buttonsVisible()"></div>
</div>
<div v-if="buttonsVisible()">
<div v-if="buttonsVisible()" style="display:flex;align-items:center;gap:0.5rem;flex-wrap:nowrap;white-space:nowrap;">
<button class="ui button tiny" type="button" @click.prevent="selectCert()">选择已有证书</button> &nbsp;
<span class="disabled">|</span> &nbsp;
<span class="disabled" style="margin:0 0.5rem;">|</span>
<button class="ui button tiny" type="button" @click.prevent="uploadCert()">上传新证书</button> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="uploadBatch()">批量上传证书</button> &nbsp;
</div>
</div>`
})
Vue.component("ssl-certs-view", {
props: ["v-certs"],
data: function () {
@@ -22518,12 +22519,16 @@ Vue.component("ssl-config-box", {
<span class="red">选择或上传证书后<span v-if="vProtocol == 'https'">HTTPS</span><span v-if="vProtocol == 'tls'">TLS</span>服务才能生效。</span>
<div class="ui divider"></div>
</div>
<button class="ui button tiny" type="button" @click.prevent="selectCert()">选择已有证书</button> &nbsp;
<span class="disabled">|</span> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="uploadCert()">上传新证书</button> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="uploadBatch()">批量上传证书</button> &nbsp;
<span class="disabled">|</span> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="requestCert()" v-if="vServerId != null && vServerId > 0">申请免费证书</button>
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: nowrap; white-space: nowrap;">
<button class="ui button tiny" type="button" @click.prevent="selectCert()">选择已有证书</button>
<span class="disabled" style="margin: 0 0.5rem;">|</span>
<button class="ui button tiny" type="button" @click.prevent="uploadCert()">上传证书</button>
<button class="ui button tiny" type="button" @click.prevent="uploadBatch()">批量上传证书</button>
<template v-if="vServerId != null && vServerId > 0">
<span class="disabled" style="margin: 0 0.5rem;">|</span>
<button class="ui button tiny" type="button" @click.prevent="requestCert()">申请免费证书</button>
</template>
</div>
</td>
</tr>
<tr>

View File

@@ -22046,15 +22046,16 @@ Vue.component("ssl-certs-box", {
<span class="grey" v-if="description.length > 0">{{description}}</span>
<div class="ui divider" v-if="buttonsVisible()"></div>
</div>
<div v-if="buttonsVisible()">
<div v-if="buttonsVisible()" style="display:flex;align-items:center;gap:0.5rem;flex-wrap:nowrap;white-space:nowrap;">
<button class="ui button tiny" type="button" @click.prevent="selectCert()">选择已有证书</button> &nbsp;
<span class="disabled">|</span> &nbsp;
<span class="disabled" style="margin:0 0.5rem;">|</span>
<button class="ui button tiny" type="button" @click.prevent="uploadCert()">上传新证书</button> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="uploadBatch()">批量上传证书</button> &nbsp;
</div>
</div>`
})
Vue.component("ssl-certs-view", {
props: ["v-certs"],
data: function () {
@@ -22518,12 +22519,16 @@ Vue.component("ssl-config-box", {
<span class="red">选择或上传证书后<span v-if="vProtocol == 'https'">HTTPS</span><span v-if="vProtocol == 'tls'">TLS</span>服务才能生效。</span>
<div class="ui divider"></div>
</div>
<button class="ui button tiny" type="button" @click.prevent="selectCert()">选择已有证书</button> &nbsp;
<span class="disabled">|</span> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="uploadCert()">上传新证书</button> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="uploadBatch()">批量上传证书</button> &nbsp;
<span class="disabled">|</span> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="requestCert()" v-if="vServerId != null && vServerId > 0">申请免费证书</button>
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: nowrap; white-space: nowrap;">
<button class="ui button tiny" type="button" @click.prevent="selectCert()">选择已有证书</button>
<span class="disabled" style="margin: 0 0.5rem;">|</span>
<button class="ui button tiny" type="button" @click.prevent="uploadCert()">上传证书</button>
<button class="ui button tiny" type="button" @click.prevent="uploadBatch()">批量上传证书</button>
<template v-if="vServerId != null && vServerId > 0">
<span class="disabled" style="margin: 0 0.5rem;">|</span>
<button class="ui button tiny" type="button" @click.prevent="requestCert()">申请免费证书</button>
</template>
</div>
</td>
</tr>
<tr>

View File

@@ -160,11 +160,11 @@ Vue.component("ssl-certs-box", {
<span class="grey" v-if="description.length > 0">{{description}}</span>
<div class="ui divider" v-if="buttonsVisible()"></div>
</div>
<div v-if="buttonsVisible()">
<div v-if="buttonsVisible()" style="display:flex;align-items:center;gap:0.5rem;flex-wrap:nowrap;white-space:nowrap;">
<button class="ui button tiny" type="button" @click.prevent="selectCert()">选择已有证书</button> &nbsp;
<span class="disabled">|</span> &nbsp;
<span class="disabled" style="margin:0 0.5rem;">|</span>
<button class="ui button tiny" type="button" @click.prevent="uploadCert()">上传新证书</button> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="uploadBatch()">批量上传证书</button> &nbsp;
</div>
</div>`
})
})

View File

@@ -427,12 +427,16 @@ Vue.component("ssl-config-box", {
<span class="red">选择或上传证书后<span v-if="vProtocol == 'https'">HTTPS</span><span v-if="vProtocol == 'tls'">TLS</span>服务才能生效。</span>
<div class="ui divider"></div>
</div>
<button class="ui button tiny" type="button" @click.prevent="selectCert()">选择已有证书</button> &nbsp;
<span class="disabled">|</span> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="uploadCert()">上传新证书</button> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="uploadBatch()">批量上传证书</button> &nbsp;
<span class="disabled">|</span> &nbsp;
<button class="ui button tiny" type="button" @click.prevent="requestCert()" v-if="vServerId != null && vServerId > 0">申请免费证书</button>
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: nowrap; white-space: nowrap;">
<button class="ui button tiny" type="button" @click.prevent="selectCert()">选择已有证书</button>
<span class="disabled" style="margin: 0 0.5rem;">|</span>
<button class="ui button tiny" type="button" @click.prevent="uploadCert()">上传证书</button>
<button class="ui button tiny" type="button" @click.prevent="uploadBatch()">批量上传证书</button>
<template v-if="vServerId != null && vServerId > 0">
<span class="disabled" style="margin: 0 0.5rem;">|</span>
<button class="ui button tiny" type="button" @click.prevent="requestCert()">申请免费证书</button>
</template>
</div>
</td>
</tr>
<tr>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,16 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<title>{$ htmlEncode .teaTitle}</title>
<meta charset="UTF-8"/>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0">
{$if eq .teaFaviconFileId 0}
<link rel="shortcut icon" href="/images/favicon.png"/>
<link rel="shortcut icon" href="/images/favicon.png" />
{$else}
<link rel="shortcut icon" href="/ui/image/{$.teaFaviconFileId}"/>
<link rel="shortcut icon" href="/ui/image/{$.teaFaviconFileId}" />
{$end}
<link rel="stylesheet" type="text/css" href="/_/@default/@layout.css" media="all"/>
<link rel="stylesheet" type="text/css" href="/_/@default/@layout.css" media="all" />
{$TEA.SEMANTIC}
{$TEA.VUE}
@@ -20,7 +21,7 @@
window.BRAND_DOCS_SITE = {$ jsonEncode .brandConfig.docsSite};
window.BRAND_DOCS_PREFIX = {$ jsonEncode .brandConfig.docsPathPrefix};
window.BRAND_PRODUCT_NAME = {$ jsonEncode .brandConfig.productName};
// 确保 teaName 和 teaVersion 在 Vue 初始化前可用
if (typeof window.TEA === "undefined") {
window.TEA = {};
@@ -36,122 +37,152 @@
</script>
<script type="text/javascript" src="/js/config/brand.js"></script>
<script type="text/javascript" src="/_/@default/@layout.js"></script>
<script type="text/javascript" src="/js/components.js"></script>
<script type="text/javascript" src="/js/utils.min.js"></script>
<script type="text/javascript" src="/js/sweetalert2/dist/sweetalert2.all.min.js" async></script>
<script type="text/javascript" src="/js/date.tea.js"></script>
<script type="text/javascript" src="/js/components.js"></script>
<script type="text/javascript" src="/js/utils.min.js"></script>
<script type="text/javascript" src="/js/sweetalert2/dist/sweetalert2.all.min.js" async></script>
<script type="text/javascript" src="/js/date.tea.js"></script>
<script type="text/javascript" src="/js/langs/base.js?v={$ .teaVersion}"></script>
<script type="text/javascript" src="/js/langs/{$.teaLang}.js?v={$ .teaVersion}"></script>
<link rel="stylesheet" type="text/css" href="/js/langs/{$.teaLang}.css?v={$ .teaVersion}"/>
<link rel="stylesheet" type="text/css" href="/js/langs/{$.teaLang}.css?v={$ .teaVersion}" />
<link rel="stylesheet" type="text/css" href="/_/@default/@layout_override.css" media="all"/>
<link rel="stylesheet" type="text/css" href="/_/@default/@layout_override.css" media="all" />
</head>
<body>
<div>
<!-- 顶部导航 -->
<div class="ui menu top-nav blue inverted small borderless" :class="(teaTheme == null || teaTheme.length == 0) ? 'theme2': teaTheme" v-cloak="">
<a href="/" class="item">
<i class="ui icon leaf" v-if="teaLogoFileId == 0"></i><img alt="logo" v-if="teaLogoFileId > 0" :src="'/ui/image/' + teaLogoFileId" style="width: auto;height: 1.6em"/> &nbsp; {{teaTitle}}&nbsp;<sup v-if="teaShowVersion">v{{teaVersion}}<span v-if="teaVersion.split('.').length == 4" title="当前版本为测试版,再次感谢您参与测试"> beta</span></sup> &nbsp;
</a>
<div class="right menu">
<!-- 集群同步 -->
<a href="" class="item" v-if="teaCheckNodeTasks && doingNodeTasks.isUpdated" @click.prevent="showNodeTasks()">
<span v-if="!doingNodeTasks.isDoing && !doingNodeTasks.hasError" class="hover-span"><i class="icon cloud disabled"></i><span class="disabled">已同步节点</span></span>
<span v-if="doingNodeTasks.isDoing && !doingNodeTasks.hasError" class="hover-span rotate"><i class="icon cloud"></i><span>正在同步节点...</span></span>
<span v-if="doingNodeTasks.hasError" class="red"><i class="icon cloud"></i>节点同步失败</span>
<div>
<!-- 顶部导航 -->
<div class="ui menu top-nav blue inverted small borderless"
:class="(teaTheme == null || teaTheme.length == 0) ? 'theme2': teaTheme" v-cloak="">
<a href="/" class="item">
<i class="ui icon leaf" v-if="teaLogoFileId == 0"></i><img alt="logo" v-if="teaLogoFileId > 0"
:src="'/ui/image/' + teaLogoFileId" style="width: auto;height: 1.6em" /> &nbsp;
{{teaTitle}}&nbsp;<sup v-if="teaShowVersion">v{{teaVersion}}<span
v-if="teaVersion.split('.').length == 4" title="当前版本为测试版,再次感谢您参与测试"> beta</span></sup> &nbsp;
</a>
<!-- DNS同步 -->
<a href="" class="item" v-if="teaCheckDNSTasks && doingDNSTasks.isUpdated" @click.prevent="showDNSTasks()">
<span v-if="!doingDNSTasks.isDoing && !doingDNSTasks.hasError" class="hover-span"><i class="icon globe disabled"></i><span class="disabled">已同步DNS</span></span>
<span v-if="doingDNSTasks.isDoing && !doingDNSTasks.hasError" class="hover-span rotate"><i class="icon globe"></i><span>正在同步DNS...</span></span>
<span v-if="doingDNSTasks.hasError" class="red"><i class="icon globe"></i>DNS同步失败</span>
</a>
<div class="right menu">
<!-- 集群同步 -->
<a href="" class="item" v-if="teaCheckNodeTasks && doingNodeTasks.isUpdated"
@click.prevent="showNodeTasks()">
<span v-if="!doingNodeTasks.isDoing && !doingNodeTasks.hasError" class="hover-span"><i
class="icon cloud disabled"></i><span class="disabled">已同步节点</span></span>
<span v-if="doingNodeTasks.isDoing && !doingNodeTasks.hasError" class="hover-span rotate"><i
class="icon cloud"></i><span>正在同步节点...</span></span>
<span v-if="doingNodeTasks.hasError" class="red"><i class="icon cloud"></i>节点同步失败</span>
</a>
<!-- 消息 -->
<a href="" class="item" :class="{active:teaMenu == 'message'}" @click.prevent="showMessages()">
<span v-if="globalMessageBadge > 0" class="blink hover-span"><i class="icon bell"></i><span>消息({{globalMessageBadge}}) </span></span>
<span v-if="globalMessageBadge == 0" class="hover-span"><i class="icon bell disabled"></i><span class="disabled">消息(0)</span></span>
</a>
<!-- DNS同步 -->
<a href="" class="item" v-if="teaCheckDNSTasks && doingDNSTasks.isUpdated"
@click.prevent="showDNSTasks()">
<span v-if="!doingDNSTasks.isDoing && !doingDNSTasks.hasError" class="hover-span"><i
class="icon globe disabled"></i><span class="disabled">已同步DNS</span></span>
<span v-if="doingDNSTasks.isDoing && !doingDNSTasks.hasError" class="hover-span rotate"><i
class="icon globe"></i><span>正在同步DNS...</span></span>
<span v-if="doingDNSTasks.hasError" class="red"><i class="icon globe"></i>DNS同步失败</span>
</a>
<!-- 用户信-->
<a href="/settings/profile" class="item">
<i class="icon user" v-if="teaUserAvatar.length == 0"></i>
<img class="avatar" alt="" :src="teaUserAvatar" v-if="teaUserAvatar.length > 0"/>
<span class="hover-span"><span class="disabled">{{teaUsername}}</span></span>
</a>
<!-- -->
<a href="" class="item" :class="{active:teaMenu == 'message'}" @click.prevent="showMessages()">
<span v-if="globalMessageBadge > 0" class="blink hover-span"><i
class="icon bell"></i><span>消息({{globalMessageBadge}}) </span></span>
<span v-if="globalMessageBadge == 0" class="hover-span"><i class="icon bell disabled"></i><span
class="disabled">消息(0)</span></span>
</a>
<a href="" class="item" title="switch language" @click.prevent="switchLang" v-show="false"><i class="icon language"></i> </a>
<!-- 用户信息 -->
<a href="/settings/profile" class="item">
<i class="icon user" v-if="teaUserAvatar.length == 0"></i>
<img class="avatar" alt="" :src="teaUserAvatar" v-if="teaUserAvatar.length > 0" />
<span class="hover-span"><span class="disabled">{{teaUsername}}</span></span>
</a>
<!-- 背景颜色 -->
<a href="" class="item" title="点击切换界面风格" @click.prevent="changeTheme()" v-if="false"><i class="icon adjust"></i></a>
<a href="" class="item" title="switch language" @click.prevent="switchLang" v-show="false"><i
class="icon language"></i> </a>
<!-- 企业版 -->
<!-- <a :href="'/settings/authority'" class="item" title="商业版" :v-if="teaIsPlus"><i class="icon gem outline yellow"></i></a>-->
<!-- 背景颜色 -->
<a href="" class="item" title="点击切换界面风格" @click.prevent="changeTheme()" v-if="false"><i
class="icon adjust"></i></a>
<!-- 退出登录 -->
<a :href="Tea.url('logout')" class="item" title="安全退出登录"><i class="icon sign out"></i>
<span class="hover-span"><span class="disabled">退出登录</span></span>
</a>
<!-- 企业版 -->
<!-- <a :href="'/settings/authority'" class="item" title="商业版" :v-if="teaIsPlus"><i class="icon gem outline yellow"></i></a>-->
<!-- 退出登录 -->
<a :href="Tea.url('logout')" class="item" title="安全退出登录"><i class="icon sign out"></i>
<span class="hover-span"><span class="disabled">退出登录</span></span>
</a>
</div>
</div>
</div>
<!-- 左侧主菜单 -->
<div class="main-menu" :class="(teaTheme == null || teaTheme.length == 0) ? 'theme2': teaTheme" v-cloak="">
<div class="ui labeled menu vertical blue inverted tiny borderless">
<div class="item"></div>
<!--<a :href="Tea.url('dashboard')" class="item" :class="{active:teaMenu == 'dashboard'}">
<!-- 左侧主菜单 -->
<div class="main-menu" :class="(teaTheme == null || teaTheme.length == 0) ? 'theme2': teaTheme" v-cloak="">
<div class="ui labeled menu vertical blue inverted tiny borderless">
<div class="item"></div>
<!--<a :href="Tea.url('dashboard')" class="item" :class="{active:teaMenu == 'dashboard'}">
<i class="ui dashboard icon"></i>
<span>仪表板</span>
</a>-->
<!-- 模块 -->
<div v-for="module in teaModules">
<a class="item" :href="Tea.url(module.code)" :class="{active:teaMenu == module.code && teaSubMenu.length == 0, separator:module.code.length == 0, expend: teaMenu == module.code}" :style="(teaMenu == module.code && teaSubMenu.length == 0) ? 'background: rgba(230, 230, 230, 0.45) !important;' : ''" v-if="module.isOn !== false">
<span v-if="module.code.length > 0">
<i class="window restore outline icon" v-if="module.icon == null"></i>
<i class="ui icon" v-if="module.icon != null" :class="module.icon"></i>
<span class="module-name">{{module.name}}</span>
</span>
<div class="subtitle" v-if="module.subtitle != null && module.subtitle.length > 0">{{module.subtitle}}</div>
</a>
<div v-if="teaMenu == module.code" class="sub-items">
<a class="item" :class="{separator:subItem.name == '-', active: subItem.code == teaSubMenu}" v-for="subItem in module.subItems" v-if="subItem.isOn !== false" :href="subItem.url" :style="(subItem.code == teaSubMenu) ? 'background: rgba(230, 230, 230, 0.55) !important;' : ''"><i class="icon angle right" v-if="subItem.name != '-' && subItem.code == teaSubMenu"></i> <span v-if="subItem.name != '-'">{{subItem.name}}</span>
<span class="ui label tiny red" v-if="subItem.badge != null && subItem.badge > 0">
<span v-if="subItem.badge < 100">{{subItem.badge}}</span>
<span v-else>99+</span>
<!-- 模块 -->
<div v-for="module in teaModules">
<a class="item" :href="Tea.url(module.code)"
:class="{active:teaMenu == module.code && teaSubMenu.length == 0, separator:module.code.length == 0, expend: teaMenu == module.code}"
:style="(teaMenu == module.code && teaSubMenu.length == 0) ? 'background: rgba(230, 230, 230, 0.45) !important;' : ''"
v-if="module.isOn !== false">
<span v-if="module.code.length > 0">
<i class="window restore outline icon" v-if="module.icon == null"></i>
<i class="ui icon" v-if="module.icon != null" :class="module.icon"></i>
<span class="module-name">{{module.name}}</span>
</span>
<div class="subtitle" v-if="module.subtitle != null && module.subtitle.length > 0">
{{module.subtitle}}</div>
</a>
</div>
</div>
<div v-if="teaMenu == module.code" class="sub-items">
<a class="item" :class="{separator:subItem.name == '-', active: subItem.code == teaSubMenu}"
v-for="subItem in module.subItems" v-if="subItem.isOn !== false" :href="subItem.url"
:style="(subItem.code == teaSubMenu) ? 'background: rgba(230, 230, 230, 0.55) !important;' : ''"><i
class="icon angle right" v-if="subItem.name != '-' && subItem.code == teaSubMenu"></i>
<span v-if="subItem.name != '-'">{{subItem.name}}</span>
<span class="ui label tiny red" v-if="subItem.badge != null && subItem.badge > 0">
<span v-if="subItem.badge < 100">{{subItem.badge}}</span>
<span v-else>99+</span>
</span>
</a>
</div>
</div>
</div>
</div>
<!-- 右侧主操作栏 -->
<div class="main"
:class="{'without-menu':teaSubMenus.menus == null || teaSubMenus.menus.length == 0 || (teaSubMenus.menus.length == 1 && teaSubMenus.menus[0].alwaysActive), 'without-secondary-menu':teaSubMenus.alwaysMenu == null || teaSubMenus.alwaysMenu.items.length <= 1, 'without-footer':!teaShowOpenSourceInfo}"
v-cloak="">
<!-- 操作菜单 -->
<div class="ui top menu tabular tab-menu small" v-if="teaTabbar.length > 1">
<a class="item" v-for="item in teaTabbar"
:class="{'active':item.isActive && !item.isDisabled, right:item.isRight, title: item.isTitle, icon: item.icon != null && item.icon.length > 0, disabled: item.isDisabled}"
:href="item.url">
<var>{{item.name}}<span v-if="item.subName.length > 0">({{item.subName}})</span><i
class="icon small" :class="item.icon" v-if="item.icon != null && item.icon.length > 0"></i>
</var>
<var v-if="item.isTitle && typeof _data.node == 'object'">{{node.name}}</var>
<div class="bottom-indicator" v-if="item.isActive && !item.isTitle"></div>
</a>
</div>
<!-- 功能区 -->
<div class="main-box">
{$TEA.VIEW}
<div class="clear"></div>
</div>
</div>
<!-- 底部 -->
{$template "/footer"}
</div>
<!-- 右侧主操作栏 -->
<div class="main" :class="{'without-menu':teaSubMenus.menus == null || teaSubMenus.menus.length == 0 || (teaSubMenus.menus.length == 1 && teaSubMenus.menus[0].alwaysActive), 'without-secondary-menu':teaSubMenus.alwaysMenu == null || teaSubMenus.alwaysMenu.items.length <= 1, 'without-footer':!teaShowOpenSourceInfo}" v-cloak="">
<!-- 操作菜单 -->
<div class="ui top menu tabular tab-menu small" v-if="teaTabbar.length > 1">
<a class="item" v-for="item in teaTabbar" :class="{'active':item.isActive && !item.isDisabled, right:item.isRight, title: item.isTitle, icon: item.icon != null && item.icon.length > 0, disabled: item.isDisabled}" :href="item.url">
<var>{{item.name}}<span v-if="item.subName.length > 0">({{item.subName}})</span><i class="icon small" :class="item.icon" v-if="item.icon != null && item.icon.length > 0"></i> </var>
<var v-if="item.isTitle && typeof _data.node == 'object'">{{node.name}}</var>
<div class="bottom-indicator" v-if="item.isActive && !item.isTitle"></div>
</a>
</div>
<!-- 功能区 -->
<div class="main-box">
{$TEA.VIEW}
<div class="clear"></div>
</div>
</div>
<!-- 底部 -->
{$template "/footer"}
</div>
{$echo "footer"}
{$echo "footer"}
</body>
</html>

View File

@@ -3,60 +3,76 @@
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
scrollbar-width: thin;
}
.clear {
clear: both;
}
.hidden {
display: none;
}
pre {
white-space: pre-wrap;
}
a.disabled,
a.disabled:hover,
a.disabled:active,
span.disabled {
color: #ccc !important;
}
a.enabled,
span.enabled,
span.green {
color: #21ba45;
}
span.grey,
label.grey,
p.grey {
color: grey !important;
}
p.grey {
margin-top: 0.8em;
}
span.red,
pre.red {
color: #db2828;
}
span.blue {
color: #4183c4;
}
pre:not(.CodeMirror-line) {
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif !important;
}
tbody {
background: transparent;
}
.table.width30 {
width: 30em !important;
}
.table.width35 {
width: 35em !important;
}
.table.width40 {
width: 40em !important;
}
.table th,
.table td {
font-size: 0.9em !important;
}
p.comment,
div.comment {
color: #959da6;
@@ -65,37 +81,46 @@ div.comment {
word-break: break-all;
line-height: 1.8;
}
p.comment em,
div.comment em {
font-style: italic !important;
}
.truncate {
white-space: nowrap;
-ms-text-overflow: ellipsis;
overflow: hidden;
text-overflow: ellipsis;
}
div.margin,
p.margin {
margin-top: 1em;
}
/** 操作按钮容器 **/
.op.one {
width: 4em;
}
.op.two {
width: 7.4em;
}
.op.three {
width: 9em;
}
.op.four {
width: 10em;
}
/** 扩展UI **/
.field.text {
padding: 0.5em;
}
/** 右侧主操作区 **/
.main {
position: absolute;
@@ -105,166 +130,210 @@ p.margin {
padding-right: 1em;
right: 1em;
}
@media screen and (max-width: 512px) {
.main {
left: 4em;
}
}
.main.without-menu {
left: 9em;
}
.main.without-secondary-menu {
top: 2.9em;
}
@media screen and (max-width: 512px) {
.main.without-menu {
left: 4em;
}
}
.main table td.title {
width: 10em;
}
.main table td.middle-title {
width: 14em;
}
.main table td {
vertical-align: top;
}
table td.color-border {
border-left: 1px #276ac6 solid !important;
}
.main table td.vertical-top {
vertical-align: top;
}
.main table td.vertical-middle {
vertical-align: middle;
}
.main table td[colspan="2"] a {
font-weight: normal;
}
.main table td em {
font-weight: normal;
font-style: normal;
font-size: 0.9em;
}
.main h3 {
font-weight: normal;
margin-top: 0.5em !important;
}
.main h3 span {
font-size: 0.8em;
}
.main h3 span.label {
color: #6435c9;
}
.main h3 a {
margin-left: 1em;
font-size: 14px !important;
right: 1em;
}
.main h3 a::before {
content: "[";
}
.main h3 a::after {
content: "]";
}
.main h4 {
font-weight: normal;
}
.main td span.small {
font-size: 0.8em;
}
.main .button.mini {
font-size: 0.8em;
padding: 0.2em;
margin-left: 1em;
}
/** 右侧文本子菜单 **/
.text.menu {
overflow-x: auto;
}
.text.menu::-webkit-scrollbar {
width: 4px;
height: 4px;
}
/** Vue **/
[v-cloak] {
display: none !important;
}
/** auto complete **/
.autocomplete-box .menu {
background: #eee !important;
}
.autocomplete-box .menu::-webkit-scrollbar {
width: 4px;
}
.autocomplete-box .menu .item {
border-top: none !important;
}
select.auto-width {
width: auto !important;
}
/** column **/
@media screen and (max-width: 512px) {
.column:not(.one) {
width: 100% !important;
}
}
/** label **/
label[for] {
cursor: pointer !important;
}
label.blue {
color: #2185d0 !important;
}
/** Menu **/
.first-menu .menu.text {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.first-menu .divider {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.second-menu .menu.text {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.second-menu .menu.text em {
font-style: normal;
}
.second-menu .divider {
margin-top: 0 !important;
}
.menu a {
outline: none;
}
/** var **/
span.olive,
var.olive {
color: #b5cc18 !important;
}
span.dash {
border-bottom: 1px dashed grey;
}
span.hover:hover {
background: #eee;
}
/** Message **/
.message .gopher {
width: 30px;
margin-right: 10px;
}
/** checkbox **/
.checkbox label a,
.checkbox label {
font-size: 0.8em !important;
}
/** page **/
.page {
margin-top: 1em;
border-left: 1px solid #ddd;
}
.page a {
display: inline-block;
background: #fafafa;
@@ -275,40 +344,50 @@ span.hover:hover {
border: 1px solid #ddd;
border-left: 0;
}
.page a.active {
background: #2185d0 !important;
color: white;
}
.page a:hover {
background: #eee;
}
.page select {
padding-top: 0.3em !important;
padding-bottom: 0.3em !important;
}
.swal2-html-container {
overflow-x: hidden;
}
.swal2-confirm:focus,
.swal2-cancel:focus,
.swal2-close:focus {
border: 3px #ddd solid !important;
}
.swal2-confirm,
.swal2-cancel,
.swal2-close {
border: 3px #fff solid !important;
}
.swal2-cancel {
margin-left: 2em !important;
}
input.error {
border: 1px #e0b4b4 solid !important;
}
textarea.wide-code {
font-family: Menlo, Monaco, "Courier New", monospace !important;
line-height: 1.6 !important;
}
.combo-box .menu {
max-height: 17em;
overflow-y: auto;
@@ -317,9 +396,11 @@ textarea.wide-code {
border-top: 0;
z-index: 100;
}
.combo-box .menu::-webkit-scrollbar {
width: 4px;
}
code-label {
background: #fff;
border: 1px solid rgba(34, 36, 38, 0.15);
@@ -333,4 +414,62 @@ code-label {
font-weight: 700;
vertical-align: baseline;
}
/*# sourceMappingURL=@layout_popup.css.map */
/*# sourceMappingURL=@layout_popup.css.map */
/* Override Primary Button Color for WAF Platform */
.ui.primary.button,
.ui.primary.buttons .button {
background-color: #0f2c54 !important;
color: #ffffff !important;
}
.ui.primary.button:hover,
.ui.primary.buttons .button:hover {
background-color: #0a1f3a !important;
}
.ui.primary.button:focus,
.ui.primary.buttons .button:focus {
background-color: #08192e !important;
}
.ui.primary.button:active,
.ui.primary.buttons .button:active,
.ui.primary.active.button {
background-color: #050d18 !important;
}
.text-primary,
.blue {
color: #0f2c54 !important;
}
/* Override Semantic UI Default Blue */
.ui.blue.button,
.ui.blue.buttons .button {
background-color: #0f2c54 !important;
color: #ffffff !important;
}
.ui.blue.button:hover,
.ui.blue.buttons .button:hover {
background-color: #0a1f3a !important;
}
.ui.basic.blue.button,
.ui.basic.blue.buttons .button {
box-shadow: 0 0 0 1px #0f2c54 inset !important;
color: #0f2c54 !important;
}
.ui.basic.blue.button:hover,
.ui.basic.blue.buttons .button:hover {
background: transparent !important;
box-shadow: 0 0 0 1px #0a1f3a inset !important;
color: #0a1f3a !important;
}
.ui.menu .active.item {
border-color: #2185d0 !important;
color: #2185d0 !important;
}

View File

@@ -8,16 +8,10 @@
<div class="item"><strong>上传 SDK</strong></div>
</second-menu>
<form method="post"
enctype="multipart/form-data"
class="ui form"
data-tea-action="$"
data-tea-timeout="300"
data-tea-before="beforeUpload"
data-tea-done="doneUpload"
data-tea-success="successUpload">
<form method="post" enctype="multipart/form-data" class="ui form" data-tea-action="$" data-tea-timeout="300"
data-tea-before="beforeUpload" data-tea-done="doneUpload" data-tea-success="successUpload">
<csrf-token></csrf-token>
<input type="hidden" name="appId" :value="app.id"/>
<input type="hidden" name="appId" :value="app.id" />
<table class="ui table selectable definition">
<tr>
@@ -33,22 +27,22 @@
<tr>
<td class="title">版本号 *</td>
<td>
<input type="text" name="version" v-model="version" maxlength="32"/>
<input type="text" name="version" v-model="version" maxlength="32" />
<p class="comment">默认 `1.0.0`。同平台+同版本再次上传会覆盖该版本文件。</p>
</td>
</tr>
<tr>
<td class="title">SDK 包</td>
<td>
<input type="file" name="sdkFile" accept=".zip"/>
<p class="comment">支持 zip 包,例如 `httpdns-sdk-android.zip`。</p>
<input type="file" name="sdkFile" accept=".zip" />
<p class="comment">支持 zip 包,例如 `httpdns-sdk-android.zip`,单文件最大 20MB</p>
</td>
</tr>
<tr>
<td class="title">集成文档</td>
<td>
<input type="file" name="docFile" accept=".md,text/markdown"/>
<p class="comment">支持 Markdown 文件(`.md`)。</p>
<input type="file" name="docFile" accept=".md,text/markdown" />
<p class="comment">支持 Markdown 文件(`.md`,单文件最大 20MB</p>
</td>
</tr>
</table>
@@ -65,26 +59,26 @@
<h4 style="margin-top: 1.5em">已上传文件</h4>
<table class="ui table selectable celled" v-if="uploadedFiles.length > 0">
<thead>
<tr>
<th>平台</th>
<th>类型</th>
<th>版本</th>
<th>文件名</th>
<th>大小</th>
<th>更新时间</th>
<th class="one wide">操作</th>
</tr>
<tr>
<th>平台</th>
<th>类型</th>
<th>版本</th>
<th>文件名</th>
<th>大小</th>
<th>更新时间</th>
<th class="one wide">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="file in uploadedFiles">
<td>{{file.platform}}</td>
<td>{{file.fileType}}</td>
<td>{{file.version}}</td>
<td>{{file.name}}</td>
<td>{{file.sizeText}}</td>
<td>{{file.updatedAt}}</td>
<td><a href="" @click.prevent="deleteUploadedFile(file.name)">删除</a></td>
</tr>
<tr v-for="file in uploadedFiles">
<td>{{file.platform}}</td>
<td>{{file.fileType}}</td>
<td>{{file.version}}</td>
<td>{{file.name}}</td>
<td>{{file.sizeText}}</td>
<td>{{file.updatedAt}}</td>
<td><a href="" @click.prevent="deleteUploadedFile(file.name)">删除</a></td>
</tr>
</tbody>
</table>
<div class="ui message" v-else>

View File

@@ -8,6 +8,17 @@
}
this.beforeUpload = function () {
var maxSize = 20 * 1024 * 1024; // 20MB
var inputs = document.querySelectorAll("input[type=file]");
for (var i = 0; i < inputs.length; i++) {
var files = inputs[i].files;
if (files != null && files.length > 0) {
if (files[0].size > maxSize) {
teaweb.warn("文件 \"" + files[0].name + "\" 超过 20MB 限制(" + (files[0].size / 1024 / 1024).toFixed(1) + "MB请压缩后重试");
return false;
}
}
}
this.isUploading = true;
};

View File

@@ -127,7 +127,7 @@
<td>
v{{node.status.buildVersion}}
&nbsp;
<a :href="'/httpdns/clusters/upgradeRemote?clusterId=' + clusterId + '&nodeId=' + node.id" v-if="shouldUpgrade"><span class="red">发现新版本 v{{newVersion}} &raquo;</span></a>
<a :href="'/httpdns/clusters/cluster/upgradeRemote?clusterId=' + clusterId" v-if="shouldUpgrade"><span class="red">发现新版本 v{{newVersion}} &raquo;</span></a>
</td>
</tr>
<tr v-if="node.status.exePath != null && node.status.exePath.length > 0">

View File

@@ -66,30 +66,37 @@
<tr>
<td>自动远程启动</td>
<td>
<div class="ui checkbox">
<input type="checkbox" name="autoRemoteStart" value="1" v-model="settings.autoRemoteStart" />
<label></label>
</div>
<checkbox name="autoRemoteStart" v-model="settings.autoRemoteStart"></checkbox>
<p class="comment">当检测到节点离线时自动尝试远程启动前提是节点已经设置了SSH登录认证</p>
</td>
</tr>
<tr>
<td>访问日志</td>
<td>
<div class="ui checkbox">
<input type="checkbox" name="accessLogIsOn" value="1" v-model="settings.accessLogIsOn" />
<label></label>
</div>
<checkbox name="accessLogIsOn" v-model="settings.accessLogIsOn"></checkbox>
<p class="comment">启用后HTTPDNS 节点将会记录客户端的请求访问日志。</p>
</td>
</tr>
<tr>
<td>时区</td>
<td>
<div>
<span class="ui label basic small" v-if="timeZoneLocation != null">当前:{{timeZoneLocation.name}}
({{timeZoneLocation.offset}})</span>
</div>
<div style="margin-top: 0.5em">
<select class="ui dropdown auto-width" name="timeZone" v-model="settings.timeZone">
<option v-for="tz in timeZoneLocations" :value="tz.name">{{tz.name}} ({{tz.offset}})
</option>
</select>
</div>
<p class="comment">HTTPDNS 节点进程使用的时区设置,默认为 Asia/Shanghai。</p>
</td>
</tr>
<tr>
<td>启用当前集群</td>
<td>
<div class="ui checkbox">
<input type="checkbox" name="isOn" value="1" v-model="settings.isOn" />
<label></label>
</div>
<checkbox name="isOn" v-model="settings.isOn"></checkbox>
<p class="comment">取消启用后,该集群不会参与 HTTPDNS 服务。</p>
</td>
</tr>
@@ -110,4 +117,4 @@
<submit-btn></submit-btn>
</form>
</div>
</div>

View File

@@ -7,4 +7,27 @@ Tea.context(function () {
if (!this.settings) {
this.settings = {}
}
let toBool = function (v) {
if (typeof v === "boolean") {
return v
}
if (typeof v === "number") {
return v === 1
}
if (typeof v === "string") {
let s = v.toLowerCase().trim()
return s === "1" || s === "true" || s === "on" || s === "yes" || s === "enabled"
}
return false
}
this.settings.autoRemoteStart = toBool(this.settings.autoRemoteStart)
this.settings.accessLogIsOn = toBool(this.settings.accessLogIsOn)
this.settings.isOn = toBool(this.settings.isOn)
if (!this.settings.timeZone || this.settings.timeZone.length == 0) {
this.settings.timeZone = "Asia/Shanghai"
}
})

View File

@@ -43,7 +43,7 @@
<tr>
<td>节点安装根目录</td>
<td>
<input type="text" name="installDir" maxlength="255" value="/opt/edge-httpdns" />
<input type="text" name="installDir" maxlength="255" value="/root/edge-httpdns" />
<p class="comment">边缘节点安装 HTTPDNS 服务的默认目录。</p>
</td>
</tr>
@@ -59,4 +59,4 @@
</tr>
</table>
<submit-btn></submit-btn>
</form>
</form>

View File

@@ -19,7 +19,7 @@
<tr>
<td>安装目录</td>
<td>
<input type="text" name="installDir" maxlength="100" :value="cluster.installDir || '/opt/edge-httpdns'" />
<input type="text" name="installDir" maxlength="100" :value="cluster.installDir || '/root/edge-httpdns'" />
<p class="comment">默认使用集群配置目录。</p>
</td>
</tr>

View File

@@ -51,6 +51,12 @@
:class="{red:cluster.countAllNodes > cluster.countActiveNodes}">{{cluster.countAllNodes}}</span>
</a>
<span class="disabled" v-else="">-</span>
<div v-if="cluster.countUpgradeNodes > 0" style="margin-top:0.5em">
<a :href="'/httpdns/clusters/cluster/upgradeRemote?clusterId=' + cluster.id" title="点击进入远程升级页面">
<span class="red">有{{cluster.countUpgradeNodes}}个节点需要升级</span>
</a>
</div>
</td>
<td class="center">
<a :href="'/httpdns/clusters/cluster?clusterId=' + cluster.id" v-if="cluster.countActiveNodes > 0">
@@ -67,4 +73,4 @@
</table>
<div class="page" v-html="page"></div>
</div>
</div>

View File

@@ -1,3 +1,62 @@
{$layout}
{$layout}
<p class="ui message error">姝ゅ姛鑳芥殏鏈紑鏀俱€?/p>
<p class="comment" v-if="nodes.length == 0">暂时没有需要升级的节点。</p>
<div v-if="nodes.length > 0">
<h3>
所有需要升级的节点
<button class="ui button primary tiny" v-if="countCheckedNodes() > 0" @click.prevent="installBatch()">批量升级({{countCheckedNodes()}})</button>
</h3>
<table class="ui table selectable celled">
<thead>
<tr>
<th style="width:3em">
<checkbox @input="checkNodes"></checkbox>
</th>
<th>节点名</th>
<th>访问IP</th>
<th>SSH地址</th>
<th>版本变化</th>
<th class="four wide">节点状态</th>
<th class="two op">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="node in nodes">
<td>
<checkbox v-model="node.isChecked" v-if="node.installStatus == null || !node.installStatus.isOk"></checkbox>
</td>
<td>
<a :href="'/httpdns/clusters/cluster/node?clusterId=' + clusterId + '&nodeId=' + node.id">{{node.name}}</a>
<a :href="'/httpdns/clusters/cluster/node?clusterId=' + clusterId + '&nodeId=' + node.id" title="节点详情" style="margin-left: 0.4em">[详情]</a>
</td>
<td>
<span v-if="node.accessIP.length > 0" class="ui label tiny basic">{{node.accessIP}}</span>
<span v-else class="disabled">-</span>
</td>
<td>
<span v-if="node.login != null && node.login.type == 'ssh' && node.loginParams != null && node.loginParams.host != null && node.loginParams.host.length > 0">
{{node.loginParams.host}}:{{node.loginParams.port}}
</span>
<span v-else class="disabled">没有设置</span>
</td>
<td>v{{node.oldVersion}} -&gt; v{{node.newVersion}}</td>
<td>
<div v-if="node.installStatus != null && (node.installStatus.isRunning || node.installStatus.isFinished)">
<div v-if="node.installStatus.isRunning" class="blue">升级中...</div>
<div v-if="node.installStatus.isFinished">
<span v-if="node.installStatus.isOk" class="green">已升级成功</span>
<span v-if="!node.installStatus.isOk" class="red">升级过程中发生错误:{{node.installStatus.error}}</span>
</div>
</div>
<span v-else class="disabled">等待升级</span>
</td>
<td>
<a href="" @click.prevent="installNode(node)" v-if="!isInstalling">升级</a>
<span v-if="isInstalling && node.isInstalling">升级中...</span>
<span v-if="isInstalling && !node.isInstalling" class="disabled">升级</span>
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,147 @@
Tea.context(function () {
this.isInstalling = false
this.isBatch = false
let installingNode = null
this.nodes.forEach(function (v) {
v.isChecked = false
})
this.$delay(function () {
this.reload()
})
let that = this
this.checkNodes = function (isChecked) {
this.nodes.forEach(function (v) {
v.isChecked = isChecked
})
}
this.countCheckedNodes = function () {
return that.nodes.$count(function (k, v) {
return v.isChecked
})
}
this.installNode = function (node) {
let that = this
if (this.isBatch) {
installingNode = node
that.isInstalling = true
node.isInstalling = true
that.$post("$")
.params({
nodeId: node.id
})
} else {
teaweb.confirm("确定要开始升级此节点吗?", function () {
installingNode = node
that.isInstalling = true
node.isInstalling = true
that.$post("$")
.params({
nodeId: node.id
})
})
}
}
this.installBatch = function () {
let that = this
this.isBatch = true
teaweb.confirm("确定要批量升级选中的节点吗?", function () {
that.installNext()
})
}
this.installNext = function () {
let nextNode = this.nodes.$find(function (k, v) {
return v.isChecked
})
if (nextNode == null) {
teaweb.success("全部升级成功", function () {
teaweb.reload()
})
} else {
this.installNode(nextNode)
}
return
}
this.reload = function () {
let that = this
if (installingNode != null) {
this.$post("/httpdns/clusters/upgradeStatus")
.params({
nodeId: installingNode.id
})
.success(function (resp) {
if (resp.data.status != null) {
installingNode.installStatus = resp.data.status
if (installingNode.installStatus.isFinished) {
if (installingNode.installStatus.isOk) {
installingNode.isChecked = false
installingNode = null
if (that.isBatch) {
that.installNext()
} else {
teaweb.success("升级成功", function () {
teaweb.reload()
})
}
} else {
let nodeId = installingNode.id
let errMsg = installingNode.installStatus.error
that.isInstalling = false
installingNode.isInstalling = false
installingNode = null
switch (resp.data.status.errorCode) {
case "EMPTY_LOGIN":
case "EMPTY_SSH_HOST":
case "EMPTY_SSH_PORT":
case "EMPTY_GRANT":
teaweb.warn("需要填写SSH登录信息", function () {
teaweb.popup("/httpdns/clusters/updateNodeSSH?nodeId=" + nodeId, {
height: "20em",
callback: function () {
teaweb.reload()
}
})
})
return
case "CREATE_ROOT_DIRECTORY_FAILED":
teaweb.warn("创建根目录失败,请检查目录权限或手工创建:" + errMsg)
return
case "INSTALL_HELPER_FAILED":
teaweb.warn("安装助手失败:" + errMsg)
return
case "TEST_FAILED":
teaweb.warn("环境测试失败:" + errMsg)
return
case "RPC_TEST_FAILED":
teaweb.confirm("html:要升级的节点到API服务之间的RPC通讯测试失败具体错误" + errMsg + "<br/>现在修改API信息", function () {
window.location = "/settings/api"
})
return
default:
teaweb.warn("升级失败:" + errMsg)
}
}
}
}
})
.done(function () {
setTimeout(this.reload, 3000)
})
} else {
setTimeout(this.reload, 3000)
}
}
})

View File

@@ -52,7 +52,7 @@
</thead>
<tr v-for="node in nodes">
<td class="node-name-td"><a :href="'/ns/clusters/cluster/node?clusterId=' + clusterId + '&nodeId=' + node.id"><keyword :v-word="keyword">{{node.name}}</keyword></a>
<div v-if="node.status != null && node.status.version.length > 0 && node.status.version != latestVersion">
<div v-if="node.status != null && node.status.version.length > 0 && latestVersion.length > 0 && Tea.versionCompare(latestVersion, node.status.version) > 0">
<span class="small red">v{{node.status.version}} -&gt; v{{latestVersion}}</span>
</div>
@@ -112,4 +112,4 @@
</table>
<div class="page" v-html="page"></div>
<div class="page" v-html="page"></div>

View File

@@ -97,4 +97,4 @@
</tr>
</table>
<p class="comment">数据更新于{{key.updatedTime}}。</p>
</div>
</div>

View File

@@ -1,45 +1,38 @@
{$layout}
<form class="ui form" method="post" data-tea-action="$" data-tea-success="success">
<csrf-token></csrf-token>
<csrf-token></csrf-token>
<table class="ui table definition selectable">
<tr>
<td class="title">产品名称 *</td>
<td>
<input type="text" name="productName" v-model="config.productName" maxlength="100"/>
<p class="comment">可以使用变量<code-label>${product.name}</code-label>在网页里展示此名称,建议仅包含英文和数字字符。</p>
</td>
</tr>
<tr>
<td>管理员系统名称 *</td>
<td>
<input type="text" name="adminSystemName" v-model="config.adminSystemName" maxlength="100"/>
<p class="comment">当前管理系统界面上显示的名称。</p>
</td>
</tr>
<tr>
<td>显示版本号</td>
<td>
<checkbox name="showVersion" v-model="config.showVersion"></checkbox>
<p class="comment">选中后,在界面中显示系统版本号。</p>
</td>
</tr>
<tr>
<td>定制版本号</td>
<td>
<input type="text" name="version" v-model="config.version" maxlength="100"/>
<p class="comment">定制自己的版本号,留空表示使用系统自带的版本号。</p>
</td>
</tr>
<tr v-show="teaIsPlus">
<td>显示模块</td>
<table class="ui table definition selectable">
<tr>
<td class="title">产品名称 *</td>
<td>
<checkbox name="supportModuleCDN" v-model="supportModuleCDN">CDN</checkbox> &nbsp; &nbsp; &nbsp;
<checkbox name="supportModuleNS" v-model="supportModuleNS" v-show="nsIsVisible">智能DNS</checkbox>
<p class="comment">当前管理系统中可以显示的模块,不能为空。</p>
<input type="text" name="productName" v-model="config.productName" maxlength="100" />
<p class="comment">可以使用变量<code-label>${product.name}</code-label>在网页里展示此名称,建议仅包含英文和数字字符。</p>
</td>
</tr>
<tr>
<td>管理员系统名称 *</td>
<td>
<input type="text" name="adminSystemName" v-model="config.adminSystemName" maxlength="100" />
<p class="comment">当前管理系统界面上显示的名称。</p>
</td>
</tr>
<tr>
<td>显示版本号</td>
<td>
<checkbox name="showVersion" v-model="config.showVersion"></checkbox>
<p class="comment">选中后,在界面中显示系统版本号。</p>
</td>
</tr>
<tr>
<td>定制版本号</td>
<td>
<input type="text" name="version" v-model="config.version" maxlength="100" />
<p class="comment">定制自己的版本号,留空表示使用系统自带的版本号。</p>
</td>
</tr>
<tr>
<td>显示财务相关功能</td>
<td>
@@ -51,13 +44,14 @@
<td>浏览器图标</td>
<td>
<div v-if="config.faviconFileId > 0">
<a :href="'/ui/image/' + config.faviconFileId" target="_blank"><img alt="" :src="'/ui/image/' + config.faviconFileId" style="width:32px;border:1px #ccc solid;"/></a>
<a :href="'/ui/image/' + config.faviconFileId" target="_blank"><img alt=""
:src="'/ui/image/' + config.faviconFileId" style="width:32px;border:1px #ccc solid;" /></a>
</div>
<div v-else>
<span class="disabled">还没有上传。</span>
</div>
<div style="margin-top: 0.8em">
<input type="file" name="faviconFile" accept=".png"/>
<input type="file" name="faviconFile" accept=".png" />
</div>
<p class="comment">在浏览器标签栏显示的图标请使用PNG格式。</p>
</td>
@@ -66,25 +60,27 @@
<td>Logo</td>
<td>
<div v-if="config.logoFileId > 0">
<a :href="'/ui/image/' + config.logoFileId" target="_blank"><img :src="'/ui/image/' + config.logoFileId" style="width:32px;border:1px #ccc solid;"/></a>
<a :href="'/ui/image/' + config.logoFileId" target="_blank"><img
:src="'/ui/image/' + config.logoFileId" style="width:32px;border:1px #ccc solid;" /></a>
</div>
<div v-else>
<span class="disabled">还没有上传。</span>
</div>
<div style="margin-top: 0.8em">
<input type="file" name="logoFile" accept=".png"/>
<input type="file" name="logoFile" accept=".png" />
</div>
<p class="comment">显示在系统界面上的图标请使用PNG格式。</p>
</td>
</tr>
</table>
</table>
<h4>其他</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">每页显示数</td>
<td>
<input type="text" name="defaultPageSize" v-model="config.defaultPageSize" maxlength="3" style="width: 4em"/>
<input type="text" name="defaultPageSize" v-model="config.defaultPageSize" maxlength="3"
style="width: 4em" />
<p class="comment">在有分页的地方每页显示数量不能超过100。</p>
</td>
</tr>
@@ -94,12 +90,15 @@
<div class="ui fields inline">
<div class="ui field">
<select class="ui dropdown" v-model="timeZoneGroupCode">
<option v-for="timeZoneGroup in timeZoneGroups" :value="timeZoneGroup.code">{{timeZoneGroup.name}}</option>
<option v-for="timeZoneGroup in timeZoneGroups" :value="timeZoneGroup.code">
{{timeZoneGroup.name}}</option>
</select>
</div>
<div class="ui field">
<select class="ui dropdown" name="timeZone" v-model="config.timeZone">
<option v-for="timeZoneLocation in timeZoneLocations" :value="timeZoneLocation.name" v-if="timeZoneLocation.group == timeZoneGroupCode">{{timeZoneLocation.name}} ({{timeZoneLocation.offset}})</option>
<option v-for="timeZoneLocation in timeZoneLocations" :value="timeZoneLocation.name"
v-if="timeZoneLocation.group == timeZoneGroupCode">{{timeZoneLocation.name}}
({{timeZoneLocation.offset}})</option>
</select>
</div>
</div>
@@ -119,5 +118,5 @@
</tr>
</table>
<submit-btn></submit-btn>
<submit-btn></submit-btn>
</form>

View File

@@ -1,3 +1,139 @@
{$layout}
<p class="comment">此功能暂未开放,敬请期待。</p>
<div class="margin"></div>
<!-- 自动升级设置 -->
<div class="ui segment">
<h3>自动升级</h3>
<table class="ui table definition">
<tr>
<td class="title">开启自动升级</td>
<td>
<div class="ui checkbox">
<input type="checkbox" name="autoUpgrade" v-model="config.autoUpgrade" @change="updateAutoUpgrade">
<label></label>
</div>
<p class="comment">开启后边缘节点、DNS节点、HTTPDNS节点每分钟检查新版本并自动下载升级。关闭后节点不会自动升级但管理员仍可在下方手动升级。</p>
</td>
</tr>
</table>
</div>
<div class="margin"></div>
<!-- 手动升级 -->
<div class="ui segment">
<h3 style="display: flex; justify-content: space-between; align-items: center;">
<span>手动升级</span>
<button class="ui button primary tiny" v-if="totalUpgradeCount > 0"
@click.prevent="upgradeAll()">全部升级({{totalUpgradeCount}})</button>
</h3>
<div v-if="modules.length == 0">
<p class="comment">暂无需要升级的节点。</p>
</div>
<div v-for="mod in modules" class="ui segment" style="margin-bottom: 1em; padding: 0;">
<h4 style="cursor: pointer; display: flex; justify-content: space-between; align-items: center; padding: 0.8em 1em; margin: 0; background: #f9fafb; border-bottom: 1px solid rgba(34,36,38,.15);"
@click.prevent="mod.expanded = !mod.expanded">
<div>
<i class="icon angle down" v-if="mod.expanded"></i>
<i class="icon angle right" v-else></i>
{{mod.name}}
<span class="ui label tiny basic" v-if="mod.count > 0"
style="margin-left: 0.5em;">{{mod.count}}个待升级</span>
</div>
<button class="ui button primary tiny" v-if="mod.count > 0"
@click.stop.prevent="upgradeModule(mod.code)">升级所有{{mod.name}}</button>
</h4>
<div v-show="mod.expanded" style="padding: 1em;">
<div v-for="cluster in mod.clusters" style="margin-bottom: 1em;">
<h5 style="cursor: pointer; display: flex; justify-content: space-between; align-items: center; padding: 0.6em; background: #f3f4f5; border-radius: 4px; margin: 0;"
@click.prevent="cluster.expanded = !cluster.expanded">
<div>
<i class="icon angle down" v-if="cluster.expanded"></i>
<i class="icon angle right" v-else></i>
{{cluster.name}}
<span class="ui label tiny basic" style="margin-left: 0.5em;">{{cluster.count}}个待升级</span>
</div>
<div>
<button class="ui button tiny primary" v-if="countCheckedNodesInCluster(cluster) > 0"
@click.stop.prevent="upgradeBatchInCluster(mod.code, cluster)">批量升级({{countCheckedNodesInCluster(cluster)}})</button>
<button class="ui button tiny"
@click.stop.prevent="upgradeCluster(mod.code, cluster.id)">升级集群内所有节点</button>
</div>
</h5>
<div v-show="cluster.expanded" style="margin-top: 0.5em;">
<table class="ui table selectable celled small" style="margin: 0;">
<thead>
<tr>
<th style="width:3em">
<div class="ui checkbox" @click.prevent="toggleCheckAll(cluster)">
<input type="checkbox" :checked="isAllChecked(cluster)">
<label></label>
</div>
</th>
<th>节点名</th>
<th>访问IP</th>
<th>SSH地址</th>
<th>版本变化</th>
<th class="four wide">节点状态</th>
<th class="two op">操作</th>
</tr>
</thead>
<tr v-for="node in cluster.nodes">
<td>
<div class="ui checkbox" v-if="!isNodeUpgradeFinished(node)">
<input type="checkbox" v-model="node.isChecked">
<label></label>
</div>
</td>
<td>
{{node.name}}
<a :href="nodeDetailURL(mod.code, cluster.id, node.id)" title="节点详情" style="margin-left: 0.4em"><i class="icon external alternate small"></i></a>
</td>
<td>
<span v-if="node.accessIP && node.accessIP.length > 0" class="ui label tiny basic">{{node.accessIP}}</span>
<span v-else class="disabled">-</span>
</td>
<td>
<span v-if="node.login != null && node.login.type == 'ssh' && node.loginParams != null && node.loginParams.host != null && node.loginParams.host.length > 0">
{{node.loginParams.host}}:{{node.loginParams.port}}
</span>
<span v-else class="disabled">没有设置</span>
</td>
<td>
<span v-if="node.oldVersion">v{{node.oldVersion}}</span>
<span v-else class="ui label tiny">未知</span>
-&gt; <strong>v{{node.newVersion}}</strong>
</td>
<td>
<div v-if="node.installStatus != null && (node.installStatus.isRunning || node.installStatus.isFinished)">
<div v-if="node.installStatus.isRunning && !node.installStatus.isFinished"
class="blue">
<i class="notched circle loading icon"></i> 升级中...
</div>
<div v-if="node.installStatus.isFinished">
<span v-if="node.installStatus.isOk" class="green"><i
class="icon check circle"></i>
已升级成功</span>
<span v-if="!node.installStatus.isOk" class="red"><i
class="icon warning circle"></i>
升级过程中发生错误:{{node.installStatus.error}}</span>
</div>
</div>
<span v-else class="disabled">等待升级</span>
</td>
<td>
<a href="" @click.prevent="upgradeNode(mod.code, node)" v-if="!node.isUpgrading">升级</a>
<span v-if="node.isUpgrading" class="blue">升级中...</span>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,457 @@
Tea.context(function () {
let that = this
// 计算总待升级数
this.totalUpgradeCount = 0
this.modules.forEach(function (mod) {
mod.expanded = true
that.totalUpgradeCount += mod.count
if (mod.clusters != null) {
mod.clusters.forEach(function (cluster) {
cluster.expanded = true
if (cluster.nodes != null) {
cluster.nodes.forEach(function (node) {
node.isUpgrading = false
node.isChecked = false
})
}
})
}
})
// 正在升级的节点ID集合按模块
this.upgradingNodeIds = {
"node": [],
"dns": [],
"httpdns": []
}
// 启动状态轮询
this.$delay(function () {
this.pollStatus()
})
/**
* 获取节点详情页URL
*/
this.nodeDetailURL = function (moduleCode, clusterId, nodeId) {
switch (moduleCode) {
case "node":
return "/clusters/cluster/node?clusterId=" + clusterId + "&nodeId=" + nodeId
case "dns":
return "/ns/clusters/cluster/node?clusterId=" + clusterId + "&nodeId=" + nodeId
case "httpdns":
return "/httpdns/clusters/cluster/node?clusterId=" + clusterId + "&nodeId=" + nodeId
default:
return "#"
}
}
/**
* 判断节点升级是否已完成(成功)
*/
this.isNodeUpgradeFinished = function (node) {
return node.installStatus != null && node.installStatus.isFinished && node.installStatus.isOk
}
/**
* 全选/取消全选
*/
this.toggleCheckAll = function (cluster) {
if (cluster.nodes == null) return
let allChecked = that.isAllChecked(cluster)
cluster.nodes.forEach(function (node) {
if (!that.isNodeUpgradeFinished(node)) {
node.isChecked = !allChecked
}
})
}
/**
* 是否全部选中
*/
this.isAllChecked = function (cluster) {
if (cluster.nodes == null || cluster.nodes.length == 0) return false
let checkableNodes = cluster.nodes.filter(function (node) {
return !that.isNodeUpgradeFinished(node)
})
if (checkableNodes.length == 0) return false
return checkableNodes.every(function (node) { return node.isChecked })
}
/**
* 计算集群内选中的节点数
*/
this.countCheckedNodesInCluster = function (cluster) {
if (cluster.nodes == null) return 0
return cluster.nodes.filter(function (node) { return node.isChecked }).length
}
/**
* 全部升级
*/
this.upgradeAll = function () {
var vue = this
teaweb.confirm("确定要升级所有模块的所有待升级节点吗?", function () {
vue.$post("/settings/upgrade/upgradeNode")
.params({
module: "",
scope: "all",
clusterId: 0,
nodeId: 0
})
.success(function () {
// 标记所有节点为升级中
that.modules.forEach(function (mod) {
if (mod.clusters != null) {
mod.clusters.forEach(function (cluster) {
if (cluster.nodes != null) {
cluster.nodes.forEach(function (node) {
node.installStatus = {
isRunning: true,
isFinished: false,
isOk: false,
error: "",
errorCode: ""
}
node.isUpgrading = true
that.trackNode(mod.code, node.id)
})
}
})
}
})
})
.fail(function (resp) {
teaweb.warn("升级请求失败:" + resp.message)
})
})
}
/**
* 按模块升级
*/
this.upgradeModule = function (moduleCode) {
var vue = this
teaweb.confirm("确定要升级该模块的所有待升级节点吗?", function () {
vue.$post("/settings/upgrade/upgradeNode")
.params({
module: moduleCode,
scope: "module",
clusterId: 0,
nodeId: 0
})
.success(function () {
that.markModuleUpgrading(moduleCode)
})
.fail(function (resp) {
teaweb.warn("升级请求失败:" + resp.message)
})
})
}
/**
* 按集群升级
*/
this.upgradeCluster = function (moduleCode, clusterId) {
var vue = this
teaweb.confirm("确定要升级该集群的所有待升级节点吗?", function () {
vue.$post("/settings/upgrade/upgradeNode")
.params({
module: moduleCode,
scope: "cluster",
clusterId: clusterId,
nodeId: 0
})
.success(function () {
that.markClusterUpgrading(moduleCode, clusterId)
})
.fail(function (resp) {
teaweb.warn("升级请求失败:" + resp.message)
})
})
}
/**
* 批量升级集群内选中的节点
*/
this.upgradeBatchInCluster = function (moduleCode, cluster) {
if (cluster.nodes == null) return
var checkedNodes = cluster.nodes.filter(function (node) { return node.isChecked })
if (checkedNodes.length == 0) return
var vue = this
teaweb.confirm("确定要批量升级选中的 " + checkedNodes.length + " 个节点吗?", function () {
checkedNodes.forEach(function (node) {
node.installStatus = {
isRunning: true,
isFinished: false,
isOk: false,
error: "",
errorCode: ""
}
node.isUpgrading = true
node.isChecked = false
that.trackNode(moduleCode, node.id)
vue.$post("/settings/upgrade/upgradeNode")
.params({
module: moduleCode,
scope: "node",
clusterId: 0,
nodeId: node.id
})
})
})
}
/**
* 升级单个节点
*/
this.upgradeNode = function (moduleCode, node) {
var vue = this
teaweb.confirm("确定要升级节点 \"" + node.name + "\" 吗?", function () {
node.installStatus = {
isRunning: true,
isFinished: false,
isOk: false,
error: "",
errorCode: ""
}
node.isUpgrading = true
that.trackNode(moduleCode, node.id)
vue.$post("/settings/upgrade/upgradeNode")
.params({
module: moduleCode,
scope: "node",
clusterId: 0,
nodeId: node.id
})
.success(function () { })
.fail(function (resp) {
node.isUpgrading = false
teaweb.warn("升级请求失败:" + resp.message)
})
})
}
/**
* 标记模块下所有节点为升级中
*/
this.markModuleUpgrading = function (moduleCode) {
that.modules.forEach(function (mod) {
if (mod.code == moduleCode && mod.clusters != null) {
mod.clusters.forEach(function (cluster) {
if (cluster.nodes != null) {
cluster.nodes.forEach(function (node) {
node.installStatus = {
isRunning: true,
isFinished: false,
isOk: false,
error: "",
errorCode: ""
}
node.isUpgrading = true
that.trackNode(moduleCode, node.id)
})
}
})
}
})
}
/**
* 标记集群下所有节点为升级中
*/
this.markClusterUpgrading = function (moduleCode, clusterId) {
that.modules.forEach(function (mod) {
if (mod.code == moduleCode && mod.clusters != null) {
mod.clusters.forEach(function (cluster) {
if (cluster.id == clusterId && cluster.nodes != null) {
cluster.nodes.forEach(function (node) {
node.installStatus = {
isRunning: true,
isFinished: false,
isOk: false,
error: "",
errorCode: ""
}
node.isUpgrading = true
that.trackNode(moduleCode, node.id)
})
}
})
}
})
}
/**
* 追踪节点升级状态
*/
this.trackNode = function (moduleCode, nodeId) {
if (that.upgradingNodeIds[moduleCode] == null) {
that.upgradingNodeIds[moduleCode] = []
}
if (that.upgradingNodeIds[moduleCode].indexOf(nodeId) < 0) {
that.upgradingNodeIds[moduleCode].push(nodeId)
}
}
/**
* 状态轮询
*/
this.pollStatus = function () {
var vue = this
// 检查是否有正在升级的节点
let hasUpgrading = false
for (let key in that.upgradingNodeIds) {
if (that.upgradingNodeIds[key].length > 0) {
hasUpgrading = true
break
}
}
if (!hasUpgrading) {
setTimeout(function () { that.pollStatus() }, 5000)
return
}
vue.$post("/settings/upgrade/status")
.params({
nodeIdsJSON: JSON.stringify(that.upgradingNodeIds)
})
.success(function (resp) {
let statuses = resp.data.statuses
if (statuses == null) {
return
}
// 更新各模块节点状态
for (let moduleCode in statuses) {
let nodeStatuses = statuses[moduleCode]
if (nodeStatuses == null) {
continue
}
nodeStatuses.forEach(function (ns) {
that.updateNodeStatus(moduleCode, ns.id, ns.installStatus)
})
}
})
.done(function () {
setTimeout(function () { that.pollStatus() }, 3000)
})
}
/**
* 更新节点安装状态
*/
this.updateNodeStatus = function (moduleCode, nodeId, installStatus) {
that.modules.forEach(function (mod) {
if (mod.code == moduleCode && mod.clusters != null) {
mod.clusters.forEach(function (cluster) {
if (cluster.nodes != null) {
cluster.nodes.forEach(function (node) {
if (node.id == nodeId && installStatus != null) {
node.installStatus = installStatus
// 升级完成后移除跟踪
if (installStatus.isFinished) {
node.isUpgrading = false
let idx = that.upgradingNodeIds[moduleCode].indexOf(nodeId)
if (idx >= 0) {
that.upgradingNodeIds[moduleCode].splice(idx, 1)
}
if (installStatus.isOk) {
// 升级成功,延迟后从列表中移除
setTimeout(function () {
let nIdx = cluster.nodes.indexOf(node)
if (nIdx >= 0) {
cluster.nodes.splice(nIdx, 1)
cluster.count--
mod.count--
that.totalUpgradeCount--
if (cluster.count <= 0) {
let cIdx = mod.clusters.indexOf(cluster)
if (cIdx >= 0) {
mod.clusters.splice(cIdx, 1)
}
}
if (mod.count <= 0) {
let mIdx = that.modules.indexOf(mod)
if (mIdx >= 0) {
that.modules.splice(mIdx, 1)
}
}
}
}, 2000)
} else {
// 升级失败,根据 errorCode 给出具体提示
that.handleUpgradeError(moduleCode, node, installStatus)
}
}
}
})
}
})
}
})
}
/**
* 处理升级错误,根据 errorCode 提供更友好的提示
*/
this.handleUpgradeError = function (moduleCode, node, installStatus) {
let errorCode = installStatus.errorCode || ""
let errMsg = installStatus.error || ""
switch (errorCode) {
case "EMPTY_LOGIN":
case "EMPTY_SSH_HOST":
case "EMPTY_SSH_PORT":
case "EMPTY_GRANT":
// SSH 信息未配置的错误,不弹窗(页面上已有"没有设置"提示)
break
case "CREATE_ROOT_DIRECTORY_FAILED":
teaweb.warn("节点 \"" + node.name + "\" 创建根目录失败:" + errMsg)
break
case "INSTALL_HELPER_FAILED":
teaweb.warn("节点 \"" + node.name + "\" 安装助手失败:" + errMsg)
break
case "TEST_FAILED":
teaweb.warn("节点 \"" + node.name + "\" 环境测试失败:" + errMsg)
break
case "RPC_TEST_FAILED":
teaweb.warn("节点 \"" + node.name + "\" RPC通讯测试失败" + errMsg)
break
}
}
/**
* 更新自动升级状态
*/
this.updateAutoUpgrade = function () {
var vue = this
// @change 已经翻转了值,先记录新值并立即恢复
let newValue = that.config.autoUpgrade
that.config.autoUpgrade = !newValue
let msg = newValue ? "确定要开启自动升级功能吗?开启后节点会自动下载安装新版本。" : "确定要关闭自动升级功能吗?关闭后只能通过这里手动执行升级。"
teaweb.confirm(msg, function () {
vue.$post("/settings/upgrade")
.params({
autoUpgrade: newValue ? 1 : 0
})
.success(function () {
that.config.autoUpgrade = newValue
teaweb.successToast("设置保存成功")
})
.fail(function (resp) {
teaweb.warn("设置保存失败:" + resp.message)
})
})
}
})

View File

@@ -39,7 +39,7 @@
</td>
</tr>
<tr v-if="hasHTTPDNSFeature">
<td>HTTPDNS关联集群</td>
<td style="white-space: nowrap">HTTPDNS关联集群</td>
<td>
<select class="ui dropdown auto-width" name="httpdnsClusterId" v-model="httpdnsClusterId">
<option value="0">[未选择]</option>

View File

@@ -1,3 +1,6 @@
.ui.table.definition td.title {
min-width: 12em;
}
.feature-boxes .feature-box {
margin-bottom: 1em;
width: 24em;

View File

@@ -6,7 +6,7 @@
<table class="ui table definition selectable">
<tr>
<td class="title">允许注册</td>
<td class="title" style="white-space: nowrap">允许注册</td>
<td>
<checkbox name="isOn" v-model="config.isOn"></checkbox>
<p class="comment">选中表示允许用户自行注册。</p>
@@ -40,7 +40,7 @@
<h4>登录设置</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">检查客户端区域</td>
<td class="title" style="white-space: nowrap">检查客户端区域</td>
<td>
<checkbox name="checkClientRegion" v-model="config.checkClientRegion"></checkbox>
<p class="comment">选中后,表示每次用户访问时都检查客户端所在地理区域是否和登录时一致,以提升安全性;如果当前系统下游有反向代理设置,请在<a
@@ -53,7 +53,7 @@
<h4>电子邮箱相关</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">启用电子邮箱绑定功能</td>
<td class="title" style="white-space: nowrap">启用电子邮箱绑定功能</td>
<td>
<checkbox name="emailVerificationIsOn" v-model="config.emailVerification.isOn"></checkbox>
<p class="comment">选中后,电子邮箱需要激活之后可以使用邮箱登录、找回密码等。此功能需要事先设置 <a href="/users/setting/email"
@@ -107,7 +107,7 @@
<h4>通过邮箱找回密码</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">启用找回密码功能</td>
<td class="title" style="white-space: nowrap">启用找回密码功能</td>
<td>
<checkbox name="emailResetPasswordIsOn" v-model="config.emailResetPassword.isOn"></checkbox>
<p class="comment">选中后,用户可以通过已绑定的电子邮箱找回密码;此功能需要同时开启电子邮箱绑定功能。</p>
@@ -146,7 +146,7 @@
<h4>手机号码相关</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">启用手机号码绑定功能</td>
<td class="title" style="white-space: nowrap">启用手机号码绑定功能</td>
<td>
<checkbox name="mobileVerificationIsOn" v-model="config.mobileVerification.isOn"></checkbox>
<p class="comment">选中后,手机号码需要激活之后可以使用手机号码、找回密码等。此功能需要事先设置 <a href="/users/setting/sms"
@@ -196,7 +196,7 @@
<h4>CDN服务</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">开通CDN服务</td>
<td class="title" style="white-space: nowrap">开通CDN服务</td>
<td>
<checkbox name="cdnIsOn" v-model="config.cdnIsOn"></checkbox>
<p class="comment">选中表示自动为用户开通CDN服务。 </p>
@@ -254,7 +254,7 @@
<h4>DDoS高防</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">开通DDoS高防管理</td>
<td class="title" style="white-space: nowrap">开通DDoS高防管理</td>
<td>
<checkbox name="adIsOn" v-model="config.adIsOn"></checkbox>
<p class="comment">选中表示自动为用户开通DDoS高防IP使用服务。</p>
@@ -269,7 +269,7 @@
<h4>智能DNS服务</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">开通智能DNS服务</td>
<td class="title" style="white-space: nowrap">开通智能DNS服务</td>
<td>
<checkbox name="nsIsOn" v-model="config.nsIsOn"></checkbox>
<p class="comment">选中表示自动为用户开通智能DNS服务。使用默认集群资源如需使用其他集群请到用户新增和业务设置页面中进行选择。</p>
@@ -284,7 +284,7 @@
<h4>HTTPDNS服务</h4>
<table class="ui table definition selectable">
<tr>
<td class="title">开通HTTPDNS服务</td>
<td class="title" style="white-space: nowrap">开通HTTPDNS服务</td>
<td>
<checkbox name="httpdnsIsOn" v-model="config.httpdnsIsOn"></checkbox>
<p class="comment">选中表示自动为用户开通HTTPDNS服务。使用默认集群资源如需使用其他集群请到用户新增和业务设置页面中进行选择。</p>