mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-05 17:46:37 +08:00
feat(node): add stable/test release channels for install and upgrade (#175)
## Summary - classify release tags by channel: pure numeric tags are treated as stable, while tags containing alpha/beta/rc (or other non-numeric formats) are treated as test releases - add channel-aware backend APIs for install/upgrade/release listing and pin install commands to the selected tag via `VERSION=<tag>` - update node page UI and API clients to let users pick stable vs test channel for install and upgrade workflows ## Verification - `go test ./internal/http/handler/...` - `npm run build`
This commit is contained in:
@@ -355,11 +355,28 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := idFromBody(r, w)
|
|
||||||
if id <= 0 {
|
var req struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
secret, err := h.repo.GetNodeSecret(id)
|
if req.ID <= 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := normalizeReleaseChannel(req.Channel)
|
||||||
|
version, err := resolveLatestReleaseByChannel(channel)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, err := h.repo.GetNodeSecret(req.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault("节点不存在"))
|
response.WriteJSON(w, response.ErrDefault("节点不存在"))
|
||||||
return
|
return
|
||||||
@@ -373,7 +390,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
|
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
|
||||||
response.WriteJSON(w, response.OK(cmd))
|
response.WriteJSON(w, response.OK(cmd))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,8 +20,104 @@ const (
|
|||||||
githubHTMLBase = "https://github.com"
|
githubHTMLBase = "https://github.com"
|
||||||
upgradeTimeout = 5 * time.Minute
|
upgradeTimeout = 5 * time.Minute
|
||||||
batchWorkers = 5
|
batchWorkers = 5
|
||||||
|
|
||||||
|
releaseChannelStable = "stable"
|
||||||
|
releaseChannelDev = "dev"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
stableVersionPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
|
||||||
|
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type githubRelease struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PublishedAt string `json:"published_at"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeReleaseChannel(channel string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(channel)) {
|
||||||
|
case releaseChannelDev:
|
||||||
|
return releaseChannelDev
|
||||||
|
default:
|
||||||
|
return releaseChannelStable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseChannelFromTag(tag string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(tag))
|
||||||
|
if normalized == "" {
|
||||||
|
return releaseChannelDev
|
||||||
|
}
|
||||||
|
if testKeywordPattern.MatchString(normalized) {
|
||||||
|
return releaseChannelDev
|
||||||
|
}
|
||||||
|
if stableVersionPattern.MatchString(normalized) {
|
||||||
|
return releaseChannelStable
|
||||||
|
}
|
||||||
|
|
||||||
|
return releaseChannelDev
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseChannelLabel(channel string) string {
|
||||||
|
if normalizeReleaseChannel(channel) == releaseChannelDev {
|
||||||
|
return "测试版"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "正式版"
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
|
||||||
|
if perPage <= 0 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get(fmt.Sprintf("%s/repos/%s/releases?per_page=%d", githubAPIBase, githubRepo, perPage))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var releases []githubRelease
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return releases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLatestReleaseByChannel(channel string) (string, error) {
|
||||||
|
normalizedChannel := normalizeReleaseChannel(channel)
|
||||||
|
releases, err := fetchGitHubReleases(50)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range releases {
|
||||||
|
if r.Draft {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tag := strings.TrimSpace(r.TagName)
|
||||||
|
if tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if releaseChannelFromTag(tag) == normalizedChannel {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
@@ -30,6 +127,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r.Body, &req); err != nil {
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
@@ -40,12 +138,13 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
channel := normalizeReleaseChannel(req.Channel)
|
||||||
version := strings.TrimSpace(req.Version)
|
version := strings.TrimSpace(req.Version)
|
||||||
if version == "" {
|
if version == "" {
|
||||||
var err error
|
var err error
|
||||||
version, err = resolveLatestRelease()
|
version, err = resolveLatestReleaseByChannel(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,61 +174,11 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveLatestRelease() (string, error) {
|
func resolveLatestRelease() (string, error) {
|
||||||
client := &http.Client{
|
return resolveLatestReleaseByChannel(releaseChannelStable)
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("请求GitHub失败: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
|
|
||||||
return resolveLatestReleaseAPI()
|
|
||||||
}
|
|
||||||
|
|
||||||
location := resp.Header.Get("Location")
|
|
||||||
if location == "" {
|
|
||||||
return resolveLatestReleaseAPI()
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(location, "/")
|
|
||||||
tag := parts[len(parts)-1]
|
|
||||||
if tag == "" || tag == "latest" {
|
|
||||||
return resolveLatestReleaseAPI()
|
|
||||||
}
|
|
||||||
|
|
||||||
return tag, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveLatestReleaseAPI() (string, error) {
|
func resolveLatestReleaseAPI() (string, error) {
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
return resolveLatestReleaseByChannel(releaseChannelStable)
|
||||||
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("请求GitHub API失败: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
||||||
return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var release struct {
|
|
||||||
TagName string `json:"tag_name"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
|
||||||
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(release.TagName) == "" {
|
|
||||||
return "", fmt.Errorf("无法从GitHub获取最新版本号")
|
|
||||||
}
|
|
||||||
|
|
||||||
return release.TagName, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -141,6 +190,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
IDs []int64 `json:"ids"`
|
IDs []int64 `json:"ids"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
@@ -151,12 +201,13 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
channel := normalizeReleaseChannel(req.Channel)
|
||||||
version := strings.TrimSpace(req.Version)
|
version := strings.TrimSpace(req.Version)
|
||||||
if version == "" {
|
if version == "" {
|
||||||
var err error
|
var err error
|
||||||
version, err = resolveLatestRelease()
|
version, err = resolveLatestReleaseByChannel(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,37 +263,28 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
var req struct {
|
||||||
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20")
|
Channel string `json:"channel"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := normalizeReleaseChannel(req.Channel)
|
||||||
|
|
||||||
|
releases, err := fetchGitHubReleases(50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
||||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var releases []struct {
|
|
||||||
TagName string `json:"tag_name"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
PublishedAt string `json:"published_at"`
|
|
||||||
Prerelease bool `json:"prerelease"`
|
|
||||||
Draft bool `json:"draft"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
|
||||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type releaseItem struct {
|
type releaseItem struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
PublishedAt string `json:"publishedAt"`
|
PublishedAt string `json:"publishedAt"`
|
||||||
Prerelease bool `json:"prerelease"`
|
Prerelease bool `json:"prerelease"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]releaseItem, 0, len(releases))
|
items := make([]releaseItem, 0, len(releases))
|
||||||
@@ -250,11 +292,20 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
|
|||||||
if r.Draft {
|
if r.Draft {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
tag := strings.TrimSpace(r.TagName)
|
||||||
|
if tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemChannel := releaseChannelFromTag(tag)
|
||||||
|
if itemChannel != channel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
items = append(items, releaseItem{
|
items = append(items, releaseItem{
|
||||||
Version: r.TagName,
|
Version: tag,
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
PublishedAt: r.PublishedAt,
|
PublishedAt: r.PublishedAt,
|
||||||
Prerelease: r.Prerelease,
|
Prerelease: itemChannel == releaseChannelDev,
|
||||||
|
Channel: itemChannel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestReleaseChannelFromTag(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tag string
|
||||||
|
expects string
|
||||||
|
}{
|
||||||
|
{name: "stable semantic version", tag: "2.1.4", expects: releaseChannelStable},
|
||||||
|
{name: "v prefix should be dev", tag: "v2.1.4", expects: releaseChannelDev},
|
||||||
|
{name: "rc release", tag: "2.1.4-rc2", expects: releaseChannelDev},
|
||||||
|
{name: "beta release", tag: "2.1.4-beta.1", expects: releaseChannelDev},
|
||||||
|
{name: "alpha release", tag: "2.1.4-alpha", expects: releaseChannelDev},
|
||||||
|
{name: "non numeric tag", tag: "nightly", expects: releaseChannelDev},
|
||||||
|
{name: "empty tag", tag: "", expects: releaseChannelDev},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := releaseChannelFromTag(tc.tag); got != tc.expects {
|
||||||
|
t.Fatalf("releaseChannelFromTag(%q) = %q, want %q", tc.tag, got, tc.expects)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeReleaseChannel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expects string
|
||||||
|
}{
|
||||||
|
{input: "", expects: releaseChannelStable},
|
||||||
|
{input: "stable", expects: releaseChannelStable},
|
||||||
|
{input: "dev", expects: releaseChannelDev},
|
||||||
|
{input: "DEV", expects: releaseChannelDev},
|
||||||
|
{input: "preview", expects: releaseChannelStable},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := normalizeReleaseChannel(tc.input); got != tc.expects {
|
||||||
|
t.Fatalf("normalizeReleaseChannel(%q) = %q, want %q", tc.input, got, tc.expects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ import axios from "axios";
|
|||||||
|
|
||||||
import Network from "./network";
|
import Network from "./network";
|
||||||
|
|
||||||
|
export type ReleaseChannel = "stable" | "dev";
|
||||||
|
|
||||||
// 登陆相关接口
|
// 登陆相关接口
|
||||||
export interface LoginData {
|
export interface LoginData {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -66,8 +68,10 @@ export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
|
|||||||
export const updateNode = (data: NodeMutationPayload) =>
|
export const updateNode = (data: NodeMutationPayload) =>
|
||||||
Network.post("/node/update", data);
|
Network.post("/node/update", data);
|
||||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||||
export const getNodeInstallCommand = (id: number) =>
|
export const getNodeInstallCommand = (
|
||||||
Network.post<string>("/node/install", { id });
|
id: number,
|
||||||
|
channel: ReleaseChannel = "stable",
|
||||||
|
) => Network.post<string>("/node/install", { id, channel });
|
||||||
export const updateNodeOrder = (data: {
|
export const updateNodeOrder = (data: {
|
||||||
nodes: Array<{ id: number; inx: number }>;
|
nodes: Array<{ id: number; inx: number }>;
|
||||||
}) => Network.post("/node/update-order", data);
|
}) => Network.post("/node/update-order", data);
|
||||||
@@ -77,20 +81,28 @@ export const checkNodeStatus = (nodeId?: number) => {
|
|||||||
return Network.post("/node/check-status", params);
|
return Network.post("/node/check-status", params);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const upgradeNode = (id: number, version?: string) =>
|
export const upgradeNode = (
|
||||||
|
id: number,
|
||||||
|
version?: string,
|
||||||
|
channel: ReleaseChannel = "stable",
|
||||||
|
) =>
|
||||||
Network.post(
|
Network.post(
|
||||||
"/node/upgrade",
|
"/node/upgrade",
|
||||||
{ id, version: version || "" },
|
{ id, version: version || "", channel },
|
||||||
{ timeout: 5 * 60 * 1000 },
|
{ timeout: 5 * 60 * 1000 },
|
||||||
);
|
);
|
||||||
export const batchUpgradeNodes = (ids: number[], version?: string) =>
|
export const batchUpgradeNodes = (
|
||||||
|
ids: number[],
|
||||||
|
version?: string,
|
||||||
|
channel: ReleaseChannel = "stable",
|
||||||
|
) =>
|
||||||
Network.post(
|
Network.post(
|
||||||
"/node/batch-upgrade",
|
"/node/batch-upgrade",
|
||||||
{ ids, version: version || "" },
|
{ ids, version: version || "", channel },
|
||||||
{ timeout: 15 * 60 * 1000 },
|
{ timeout: 15 * 60 * 1000 },
|
||||||
);
|
);
|
||||||
export const getNodeReleases = () =>
|
export const getNodeReleases = (channel: ReleaseChannel = "stable") =>
|
||||||
Network.post<NodeReleaseApiItem[]>("/node/releases");
|
Network.post<NodeReleaseApiItem[]>("/node/releases", { channel });
|
||||||
export const rollbackNode = (id: number) =>
|
export const rollbackNode = (id: number) =>
|
||||||
Network.post("/node/rollback", { id });
|
Network.post("/node/rollback", { id });
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export interface NodeReleaseApiItem {
|
|||||||
name: string;
|
name: string;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
prerelease: boolean;
|
prerelease: boolean;
|
||||||
|
channel: "stable" | "dev";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserPackageInfoApiData {
|
export interface UserPackageInfoApiData {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
batchUpgradeNodes,
|
batchUpgradeNodes,
|
||||||
getNodeReleases,
|
getNodeReleases,
|
||||||
rollbackNode,
|
rollbackNode,
|
||||||
|
type ReleaseChannel,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
||||||
import {
|
import {
|
||||||
@@ -183,6 +184,10 @@ export default function NodePage() {
|
|||||||
const [installCommandModal, setInstallCommandModal] = useState(false);
|
const [installCommandModal, setInstallCommandModal] = useState(false);
|
||||||
const [installCommand, setInstallCommand] = useState("");
|
const [installCommand, setInstallCommand] = useState("");
|
||||||
const [currentNodeName, setCurrentNodeName] = useState("");
|
const [currentNodeName, setCurrentNodeName] = useState("");
|
||||||
|
const [installSelectorOpen, setInstallSelectorOpen] = useState(false);
|
||||||
|
const [installTargetNode, setInstallTargetNode] = useState<Node | null>(null);
|
||||||
|
const [installChannel, setInstallChannel] =
|
||||||
|
useState<ReleaseChannel>("stable");
|
||||||
|
|
||||||
// 升级相关状态
|
// 升级相关状态
|
||||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||||
@@ -198,9 +203,12 @@ export default function NodePage() {
|
|||||||
name: string;
|
name: string;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
prerelease: boolean;
|
prerelease: boolean;
|
||||||
|
channel: ReleaseChannel;
|
||||||
}>
|
}>
|
||||||
>([]);
|
>([]);
|
||||||
const [releasesLoading, setReleasesLoading] = useState(false);
|
const [releasesLoading, setReleasesLoading] = useState(false);
|
||||||
|
const [releaseChannel, setReleaseChannel] =
|
||||||
|
useState<ReleaseChannel>("stable");
|
||||||
const [selectedVersion, setSelectedVersion] = useState("");
|
const [selectedVersion, setSelectedVersion] = useState("");
|
||||||
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
|
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
|
||||||
const [upgradeProgress, setUpgradeProgress] = useState<
|
const [upgradeProgress, setUpgradeProgress] = useState<
|
||||||
@@ -609,20 +617,31 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openInstallSelector = (node: Node) => {
|
||||||
|
setInstallTargetNode(node);
|
||||||
|
setInstallChannel("stable");
|
||||||
|
setInstallSelectorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// 复制安装命令
|
// 复制安装命令
|
||||||
const handleCopyInstallCommand = async (node: Node) => {
|
const handleCopyInstallCommand = async (
|
||||||
|
node: Node,
|
||||||
|
channel: ReleaseChannel,
|
||||||
|
) => {
|
||||||
setNodeList((prev) =>
|
setNodeList((prev) =>
|
||||||
prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),
|
prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await getNodeInstallCommand(node.id);
|
const res = await getNodeInstallCommand(node.id, channel);
|
||||||
|
|
||||||
if (res.code === 0 && res.data) {
|
if (res.code === 0 && res.data) {
|
||||||
const copied = await tryCopyInstallCommand(res.data);
|
const copied = await tryCopyInstallCommand(res.data);
|
||||||
|
|
||||||
if (copied) {
|
if (copied) {
|
||||||
toast.success("安装命令已复制到剪贴板");
|
toast.success(
|
||||||
|
`${channel === "stable" ? "正式版" : "测试版"}安装命令已复制到剪贴板`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setInstallCommand(res.data);
|
setInstallCommand(res.data);
|
||||||
setCurrentNodeName(node.name);
|
setCurrentNodeName(node.name);
|
||||||
@@ -640,6 +659,13 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmInstallCommand = async () => {
|
||||||
|
if (!installTargetNode) return;
|
||||||
|
|
||||||
|
setInstallSelectorOpen(false);
|
||||||
|
await handleCopyInstallCommand(installTargetNode, installChannel);
|
||||||
|
};
|
||||||
|
|
||||||
// 手动复制安装命令
|
// 手动复制安装命令
|
||||||
const handleManualCopy = async () => {
|
const handleManualCopy = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -651,18 +677,10 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 打开版本选择弹窗
|
const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {
|
||||||
const openUpgradeModal = async (
|
|
||||||
target: "single" | "batch",
|
|
||||||
nodeId?: number,
|
|
||||||
) => {
|
|
||||||
setUpgradeTarget(target);
|
|
||||||
setUpgradeTargetNodeId(nodeId || null);
|
|
||||||
setSelectedVersion("");
|
|
||||||
setUpgradeModalOpen(true);
|
|
||||||
setReleasesLoading(true);
|
setReleasesLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getNodeReleases();
|
const res = await getNodeReleases(channel);
|
||||||
|
|
||||||
if (res.code === 0 && Array.isArray(res.data)) {
|
if (res.code === 0 && Array.isArray(res.data)) {
|
||||||
setReleases(res.data);
|
setReleases(res.data);
|
||||||
@@ -674,6 +692,21 @@ export default function NodePage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setReleasesLoading(false);
|
setReleasesLoading(false);
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 打开版本选择弹窗
|
||||||
|
const openUpgradeModal = async (
|
||||||
|
target: "single" | "batch",
|
||||||
|
nodeId?: number,
|
||||||
|
) => {
|
||||||
|
const defaultChannel: ReleaseChannel = "stable";
|
||||||
|
|
||||||
|
setUpgradeTarget(target);
|
||||||
|
setUpgradeTargetNodeId(nodeId || null);
|
||||||
|
setReleaseChannel(defaultChannel);
|
||||||
|
setSelectedVersion("");
|
||||||
|
setUpgradeModalOpen(true);
|
||||||
|
await loadReleasesByChannel(defaultChannel);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确认升级(从版本弹窗)
|
// 确认升级(从版本弹窗)
|
||||||
@@ -692,7 +725,11 @@ export default function NodePage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const res = await upgradeNode(upgradeTargetNodeId, version);
|
const res = await upgradeNode(
|
||||||
|
upgradeTargetNodeId,
|
||||||
|
version,
|
||||||
|
releaseChannel,
|
||||||
|
);
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success(`节点升级命令已发送,节点将自动重启`);
|
toast.success(`节点升级命令已发送,节点将自动重启`);
|
||||||
@@ -712,7 +749,11 @@ export default function NodePage() {
|
|||||||
setBatchUpgradeLoading(true);
|
setBatchUpgradeLoading(true);
|
||||||
setUpgradeModalOpen(false);
|
setUpgradeModalOpen(false);
|
||||||
try {
|
try {
|
||||||
const res = await batchUpgradeNodes(Array.from(selectedIds), version);
|
const res = await batchUpgradeNodes(
|
||||||
|
Array.from(selectedIds),
|
||||||
|
version,
|
||||||
|
releaseChannel,
|
||||||
|
);
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
|
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
|
||||||
@@ -1367,7 +1408,7 @@ export default function NodePage() {
|
|||||||
isLoading={node.copyLoading}
|
isLoading={node.copyLoading}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleCopyInstallCommand(node)}
|
onPress={() => openInstallSelector(node)}
|
||||||
>
|
>
|
||||||
安装
|
安装
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1811,6 +1852,55 @@ export default function NodePage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
backdrop="blur"
|
||||||
|
isOpen={installSelectorOpen}
|
||||||
|
placement="center"
|
||||||
|
size="md"
|
||||||
|
onOpenChange={setInstallSelectorOpen}
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
<h2 className="text-xl font-bold">
|
||||||
|
选择安装通道
|
||||||
|
{installTargetNode ? ` - ${installTargetNode.name}` : ""}
|
||||||
|
</h2>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Select
|
||||||
|
label="版本通道"
|
||||||
|
selectedKeys={[installChannel]}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selected = Array.from(keys)[0] as ReleaseChannel;
|
||||||
|
|
||||||
|
setInstallChannel(selected || "stable");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key="stable" textValue="stable">
|
||||||
|
正式版(纯数字版本,如 2.1.4)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="dev" textValue="dev">
|
||||||
|
测试版(含 alpha / beta / rc)
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" onPress={handleConfirmInstallCommand}>
|
||||||
|
生成命令
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* 安装命令模态框 */}
|
{/* 安装命令模态框 */}
|
||||||
<Modal
|
<Modal
|
||||||
backdrop="blur"
|
backdrop="blur"
|
||||||
@@ -1891,9 +1981,28 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<Select
|
||||||
|
label="版本通道"
|
||||||
|
selectedKeys={[releaseChannel]}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selected =
|
||||||
|
(Array.from(keys)[0] as ReleaseChannel) || "stable";
|
||||||
|
|
||||||
|
setReleaseChannel(selected);
|
||||||
|
setSelectedVersion("");
|
||||||
|
void loadReleasesByChannel(selected);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key="stable" textValue="stable">
|
||||||
|
正式版(纯数字版本,如 2.1.4)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="dev" textValue="dev">
|
||||||
|
测试版(含 alpha / beta / rc)
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
label="选择版本"
|
label="选择版本"
|
||||||
placeholder="留空则使用最新版本"
|
placeholder="留空则使用当前通道最新版本"
|
||||||
selectedKeys={selectedVersion ? [selectedVersion] : []}
|
selectedKeys={selectedVersion ? [selectedVersion] : []}
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const selected = Array.from(keys)[0] as string;
|
const selected = Array.from(keys)[0] as string;
|
||||||
@@ -1909,14 +2018,14 @@ export default function NodePage() {
|
|||||||
{r.publishedAt
|
{r.publishedAt
|
||||||
? new Date(r.publishedAt).toLocaleDateString()
|
? new Date(r.publishedAt).toLocaleDateString()
|
||||||
: ""}
|
: ""}
|
||||||
{r.prerelease && (
|
{r.channel === "dev" && (
|
||||||
<Chip
|
<Chip
|
||||||
className="ml-1"
|
className="ml-1"
|
||||||
color="warning"
|
color="warning"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
预览
|
测试
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -1927,7 +2036,7 @@ export default function NodePage() {
|
|||||||
<p className="text-sm text-default-500">
|
<p className="text-sm text-default-500">
|
||||||
{selectedVersion
|
{selectedVersion
|
||||||
? `将升级到版本 ${selectedVersion}`
|
? `将升级到版本 ${selectedVersion}`
|
||||||
: "未选择版本,将自动使用最新稳定版"}
|
: `未选择版本,将自动使用最新${releaseChannel === "stable" ? "正式" : "测试"}版`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user