diff --git a/.github/workflows/agent-release.yml b/.github/workflows/agent-release.yml new file mode 100644 index 00000000..99b2ba99 --- /dev/null +++ b/.github/workflows/agent-release.yml @@ -0,0 +1,66 @@ +name: Agent Release +permissions: + contents: write + +on: + push: + tags: + - "*" + workflow_dispatch: + inputs: + reason: + description: "Reason for manual trigger" + required: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: ">=1.18.0" + + - name: Build Agent (linux/amd64) + working-directory: atsf_agent + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ../atsflare-agent-linux-amd64 ./cmd/agent + + - name: Build Agent (linux/arm64) + working-directory: atsf_agent + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o ../atsflare-agent-linux-arm64 ./cmd/agent + + - name: Build Agent (darwin/arm64) + working-directory: atsf_agent + run: | + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o ../atsflare-agent-darwin-arm64 ./cmd/agent + + - name: Determine prerelease + id: prerelease + run: | + TAG="${GITHUB_REF#refs/tags/}" + if [[ "$TAG" == *alpha* ]] || [[ "$TAG" == *beta* ]] || [[ "$TAG" == *rc* ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + files: | + atsflare-agent-linux-amd64 + atsflare-agent-linux-arm64 + atsflare-agent-darwin-arm64 + draft: false + prerelease: ${{ steps.prerelease.outputs.is_prerelease }} + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 50d642c6..fc3d4f13 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -5,8 +5,7 @@ permissions: on: push: tags: - - '*' - - '!*-alpha*' + - "*" jobs: release: runs-on: ubuntu-latest @@ -22,24 +21,36 @@ jobs: env: CI: "" run: | - cd web + cd atsf_server/web npm install REACT_APP_VERSION=$(git describe --tags) npm run build - cd .. + cd ../.. - name: Set up Go uses: actions/setup-go@v3 with: - go-version: '>=1.18.0' - - name: Build Backend (amd64) + go-version: ">=1.18.0" + - name: Build Server (amd64) + working-directory: atsf_server run: | go mod download - go build -ldflags "-s -w -X 'atsflare/common.Version=$(git describe --tags)' -extldflags '-static'" -o atsflare + go build -ldflags "-s -w -X 'atsflare/common.Version=$(git describe --tags)' -extldflags '-static'" -o ../atsflare - - name: Build Backend (arm64) + - name: Build Server (arm64) + working-directory: atsf_server run: | sudo apt-get update sudo apt-get install gcc-aarch64-linux-gnu - CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'atsflare/common.Version=$(git describe --tags)' -extldflags '-static'" -o atsflare-arm64 + CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'atsflare/common.Version=$(git describe --tags)' -extldflags '-static'" -o ../atsflare-arm64 + + - name: Determine prerelease + id: prerelease + run: | + TAG="${GITHUB_REF#refs/tags/}" + if [[ "$TAG" == *alpha* ]] || [[ "$TAG" == *beta* ]] || [[ "$TAG" == *rc* ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi - name: Release uses: softprops/action-gh-release@v1 @@ -49,6 +60,7 @@ jobs: atsflare atsflare-arm64 draft: false + prerelease: ${{ steps.prerelease.outputs.is_prerelease }} generate_release_notes: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/VERSION b/VERSION index 341cf11f..9325c3cc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 \ No newline at end of file +0.3.0 \ No newline at end of file diff --git a/atsf_agent/cmd/agent/main.go b/atsf_agent/cmd/agent/main.go index ec24d939..51bcd173 100644 --- a/atsf_agent/cmd/agent/main.go +++ b/atsf_agent/cmd/agent/main.go @@ -14,6 +14,7 @@ import ( "atsflare-agent/internal/nginx" "atsflare-agent/internal/state" syncservice "atsflare-agent/internal/sync" + "atsflare-agent/internal/updater" ) func main() { @@ -58,6 +59,7 @@ func main() { NginxCertDir: cfg.NginxCertDir, }), }, stateStore), + Updater: updater.New(), } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) diff --git a/atsf_agent/internal/agent/runner.go b/atsf_agent/internal/agent/runner.go index b7c03358..f184b975 100644 --- a/atsf_agent/internal/agent/runner.go +++ b/atsf_agent/internal/agent/runner.go @@ -14,7 +14,7 @@ import ( type HeartbeatService interface { Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) - Heartbeat(ctx context.Context, payload protocol.NodePayload) error + Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.AgentSettings, error) SetToken(token string) } @@ -23,11 +23,19 @@ type SyncService interface { SyncOnce(ctx context.Context) error } +type Updater interface { + CheckAndUpdate(ctx context.Context, repo string) error +} + type Runner struct { Config *config.Config StateStore *state.Store HeartbeatService HeartbeatService SyncService SyncService + Updater Updater + + autoUpdate bool + updateRepo string } func (r *Runner) Run(ctx context.Context) error { @@ -43,10 +51,12 @@ func (r *Runner) Run(ctx context.Context) error { } else { log.Printf("agent startup sync completed") } - if err = r.HeartbeatService.Heartbeat(ctx, r.nodePayload(nodeID)); err != nil { - log.Printf("agent startup heartbeat failed: %v", err) + settings, hbErr := r.HeartbeatService.Heartbeat(ctx, r.nodePayload(nodeID)) + if hbErr != nil { + log.Printf("agent startup heartbeat failed: %v", hbErr) } else { log.Printf("agent startup heartbeat succeeded: node_id=%s", nodeID) + r.applySettings(settings) } } else if err = r.tryRegister(ctx, &nodeID); err != nil { log.Printf("agent initial discovery register failed: %v", err) @@ -69,8 +79,15 @@ func (r *Runner) Run(ctx context.Context) error { } continue } - if err = r.HeartbeatService.Heartbeat(ctx, r.nodePayload(nodeID)); err != nil { - log.Printf("agent heartbeat failed: %v", err) + settings, hbErr := r.HeartbeatService.Heartbeat(ctx, r.nodePayload(nodeID)) + if hbErr != nil { + log.Printf("agent heartbeat failed: %v", hbErr) + } else { + if changed := r.applySettings(settings); changed { + heartbeatTicker.Reset(r.Config.HeartbeatInterval.Duration()) + syncTicker.Reset(r.Config.SyncInterval.Duration()) + } + r.tryAutoUpdate(ctx) } case <-syncTicker.C: if !r.hasAgentToken() { @@ -91,6 +108,41 @@ func (r *Runner) hasAgentToken() bool { return strings.TrimSpace(r.Config.AgentToken) != "" } +func (r *Runner) applySettings(settings *protocol.AgentSettings) bool { + if settings == nil { + return false + } + changed := false + if settings.HeartbeatInterval > 0 { + newInterval := config.MillisecondDuration(time.Duration(settings.HeartbeatInterval) * time.Millisecond) + if newInterval != r.Config.HeartbeatInterval { + log.Printf("agent heartbeat interval updated: %s -> %s", r.Config.HeartbeatInterval, newInterval) + r.Config.HeartbeatInterval = newInterval + changed = true + } + } + if settings.SyncInterval > 0 { + newInterval := config.MillisecondDuration(time.Duration(settings.SyncInterval) * time.Millisecond) + if newInterval != r.Config.SyncInterval { + log.Printf("agent sync interval updated: %s -> %s", r.Config.SyncInterval, newInterval) + r.Config.SyncInterval = newInterval + changed = true + } + } + r.autoUpdate = settings.AutoUpdate + r.updateRepo = settings.UpdateRepo + return changed +} + +func (r *Runner) tryAutoUpdate(ctx context.Context) { + if !r.autoUpdate || r.Updater == nil || r.updateRepo == "" { + return + } + if err := r.Updater.CheckAndUpdate(ctx, r.updateRepo); err != nil { + log.Printf("agent auto-update check failed: %v", err) + } +} + func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error { if strings.TrimSpace(r.Config.DiscoveryToken) == "" { return errors.New("agent_token 为空且未配置 discovery_token") diff --git a/atsf_agent/internal/agent/runner_test.go b/atsf_agent/internal/agent/runner_test.go index 194bd7c5..07fc94b6 100644 --- a/atsf_agent/internal/agent/runner_test.go +++ b/atsf_agent/internal/agent/runner_test.go @@ -32,7 +32,7 @@ func (f *fakeHeartbeatService) Register(ctx context.Context, payload protocol.No return f.registerResp, f.registerErr } -func (f *fakeHeartbeatService) Heartbeat(ctx context.Context, payload protocol.NodePayload) error { +func (f *fakeHeartbeatService) Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.AgentSettings, error) { f.mu.Lock() f.heartbeatCalls++ callIndex := f.heartbeatCalls @@ -45,7 +45,7 @@ func (f *fakeHeartbeatService) Heartbeat(ctx context.Context, payload protocol.N if onHeartbeat != nil { onHeartbeat(callIndex) } - return err + return nil, err } func (f *fakeHeartbeatService) SetToken(token string) { diff --git a/atsf_agent/internal/config/version.go b/atsf_agent/internal/config/version.go index ee64199d..720c68f1 100644 --- a/atsf_agent/internal/config/version.go +++ b/atsf_agent/internal/config/version.go @@ -1,3 +1,3 @@ package config -const AgentVersion = "0.2.0" +const AgentVersion = "0.3.0" diff --git a/atsf_agent/internal/heartbeat/service.go b/atsf_agent/internal/heartbeat/service.go index faf91828..a9e733d1 100644 --- a/atsf_agent/internal/heartbeat/service.go +++ b/atsf_agent/internal/heartbeat/service.go @@ -8,7 +8,7 @@ import ( type Client interface { RegisterNode(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) - Heartbeat(ctx context.Context, payload protocol.NodePayload) error + Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.AgentSettings, error) SetToken(token string) } @@ -24,7 +24,7 @@ func (s *Service) Register(ctx context.Context, payload protocol.NodePayload) (* return s.client.RegisterNode(ctx, payload) } -func (s *Service) Heartbeat(ctx context.Context, payload protocol.NodePayload) error { +func (s *Service) Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.AgentSettings, error) { return s.client.Heartbeat(ctx, payload) } diff --git a/atsf_agent/internal/httpclient/client.go b/atsf_agent/internal/httpclient/client.go index 2f842fa4..cd744e50 100644 --- a/atsf_agent/internal/httpclient/client.go +++ b/atsf_agent/internal/httpclient/client.go @@ -42,8 +42,15 @@ func (c *Client) RegisterNode(ctx context.Context, payload protocol.NodePayload) return &resp.Data, nil } -func (c *Client) Heartbeat(ctx context.Context, payload protocol.NodePayload) error { - return c.postJSON(ctx, "/api/agent/nodes/heartbeat", payload, nil) +func (c *Client) Heartbeat(ctx context.Context, payload protocol.NodePayload) (*protocol.AgentSettings, error) { + resp := protocol.HeartbeatAPIResponse{} + if err := c.postJSON(ctx, "/api/agent/nodes/heartbeat", payload, &resp); err != nil { + return nil, err + } + if !resp.Success { + return nil, errors.New(resp.Message) + } + return resp.AgentSettings, nil } func (c *Client) GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error) { diff --git a/atsf_agent/internal/protocol/agent_api.go b/atsf_agent/internal/protocol/agent_api.go index 47db8804..22835c6e 100644 --- a/atsf_agent/internal/protocol/agent_api.go +++ b/atsf_agent/internal/protocol/agent_api.go @@ -6,6 +6,20 @@ type APIResponse[T any] struct { Data T `json:"data"` } +type HeartbeatAPIResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data any `json:"data"` + AgentSettings *AgentSettings `json:"agent_settings,omitempty"` +} + +type AgentSettings struct { + HeartbeatInterval int `json:"heartbeat_interval"` + SyncInterval int `json:"sync_interval"` + AutoUpdate bool `json:"auto_update"` + UpdateRepo string `json:"update_repo"` +} + type NodePayload struct { NodeID string `json:"node_id"` Name string `json:"name"` diff --git a/atsf_agent/internal/updater/updater.go b/atsf_agent/internal/updater/updater.go new file mode 100644 index 00000000..fa81efdc --- /dev/null +++ b/atsf_agent/internal/updater/updater.go @@ -0,0 +1,195 @@ +package updater + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "runtime" + "strings" + "syscall" + "time" + + "atsflare-agent/internal/config" +) + +type Service struct { + httpClient *http.Client + lastCheckTag string +} + +func New() *Service { + return &Service{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []githubAsset `json:"assets"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func (s *Service) CheckAndUpdate(ctx context.Context, repo string) error { + release, err := s.getLatestRelease(ctx, repo) + if err != nil { + return fmt.Errorf("check latest release: %w", err) + } + if release == nil || release.TagName == "" { + return nil + } + + remoteVersion := normalizeVersion(release.TagName) + localVersion := normalizeVersion(config.AgentVersion) + + if remoteVersion == localVersion || remoteVersion == s.lastCheckTag { + return nil + } + if !isNewer(localVersion, remoteVersion) { + s.lastCheckTag = remoteVersion + return nil + } + + log.Printf("agent update available: %s -> %s", localVersion, remoteVersion) + assetName := fmt.Sprintf("atsflare-agent-%s-%s", runtime.GOOS, runtime.GOARCH) + + var downloadURL string + for _, asset := range release.Assets { + if asset.Name == assetName { + downloadURL = asset.BrowserDownloadURL + break + } + } + if downloadURL == "" { + s.lastCheckTag = remoteVersion + return fmt.Errorf("no matching asset %q in release %s", assetName, release.TagName) + } + + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("get executable path: %w", err) + } + if err = s.downloadAndReplace(ctx, downloadURL, execPath); err != nil { + return fmt.Errorf("download and replace: %w", err) + } + + log.Printf("agent binary updated, restarting...") + return s.restart(execPath) +} + +func (s *Service) getLatestRelease(ctx context.Context, repo string) (*githubRelease, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github api returned %s", resp.Status) + } + + var release githubRelease + if err = json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + return &release, nil +} + +func (s *Service) downloadAndReplace(ctx context.Context, url string, targetPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned %s", resp.Status) + } + + tmpPath := targetPath + ".update" + tmpFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + if _, err = io.Copy(tmpFile, resp.Body); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return err + } + tmpFile.Close() + + backupPath := targetPath + ".bak" + os.Remove(backupPath) + if err = os.Rename(targetPath, backupPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("backup current binary: %w", err) + } + if err = os.Rename(tmpPath, targetPath); err != nil { + // Attempt to restore backup + os.Rename(backupPath, targetPath) + return fmt.Errorf("replace binary: %w", err) + } + os.Remove(backupPath) + return nil +} + +func (s *Service) restart(execPath string) error { + argv := os.Args + if err := syscall.Exec(execPath, argv, os.Environ()); err != nil { + return fmt.Errorf("exec restart: %w", err) + } + return errors.New("unreachable after exec") +} + +func normalizeVersion(v string) string { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + return v +} + +func isNewer(local, remote string) bool { + localParts := strings.Split(local, ".") + remoteParts := strings.Split(remote, ".") + maxLen := len(localParts) + if len(remoteParts) > maxLen { + maxLen = len(remoteParts) + } + for i := 0; i < maxLen; i++ { + lp, rp := "0", "0" + if i < len(localParts) { + lp = localParts[i] + } + if i < len(remoteParts) { + rp = remoteParts[i] + } + if rp > lp { + return true + } + if rp < lp { + return false + } + } + return false +} diff --git a/atsf_server/common/constants.go b/atsf_server/common/constants.go index e07c07ba..794d4f68 100644 --- a/atsf_server/common/constants.go +++ b/atsf_server/common/constants.go @@ -50,6 +50,12 @@ var AgentToken = "" var AgentDiscoveryToken = "" var NodeOfflineThreshold = 2 * time.Minute +// V3 operational settings (hot-reloadable via Option table) +var AgentHeartbeatInterval = 30000 // milliseconds +var AgentSyncInterval = 30000 // milliseconds +var AgentAutoUpdate = false +var AgentUpdateRepo = "Rain-kl/ATSFlare" + const ( RoleGuestUser = 0 RoleCommonUser = 1 diff --git a/atsf_server/controller/agent.go b/atsf_server/controller/agent.go index e6417acb..fe2dd094 100644 --- a/atsf_server/controller/agent.go +++ b/atsf_server/controller/agent.go @@ -4,8 +4,9 @@ import ( "atsflare/model" "atsflare/service" "encoding/json" - "github.com/gin-gonic/gin" "net/http" + + "github.com/gin-gonic/gin" ) func AgentRegister(c *gin.Context) { @@ -66,9 +67,10 @@ func AgentHeartbeat(c *gin.Context) { return } c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": node, + "success": true, + "message": "", + "data": node.Node, + "agent_settings": node.AgentSettings, }) } diff --git a/atsf_server/model/option.go b/atsf_server/model/option.go index 20d3efb4..9478e8fe 100644 --- a/atsf_server/model/option.go +++ b/atsf_server/model/option.go @@ -4,6 +4,7 @@ import ( "atsflare/common" "strconv" "strings" + "time" ) type Option struct { @@ -50,6 +51,11 @@ func InitOptionMap() { common.OptionMap["TurnstileSiteKey"] = "" common.OptionMap["TurnstileSecretKey"] = "" common.OptionMap["AgentDiscoveryToken"] = "" + common.OptionMap["AgentHeartbeatInterval"] = strconv.Itoa(common.AgentHeartbeatInterval) + common.OptionMap["AgentSyncInterval"] = strconv.Itoa(common.AgentSyncInterval) + common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds())) + common.OptionMap["AgentAutoUpdate"] = strconv.FormatBool(common.AgentAutoUpdate) + common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo common.OptionMapRWMutex.Unlock() options, _ := AllOption() for _, option := range options { @@ -144,5 +150,23 @@ func updateOptionMap(key string, value string) { common.TurnstileSecretKey = value case "AgentDiscoveryToken": common.AgentDiscoveryToken = value + case "AgentHeartbeatInterval": + if v, err := strconv.Atoi(value); err == nil && v > 0 { + common.AgentHeartbeatInterval = v + } + case "AgentSyncInterval": + if v, err := strconv.Atoi(value); err == nil && v > 0 { + common.AgentSyncInterval = v + } + case "NodeOfflineThreshold": + if v, err := strconv.Atoi(value); err == nil && v > 0 { + common.NodeOfflineThreshold = time.Duration(v) * time.Millisecond + } + case "AgentAutoUpdate": + common.AgentAutoUpdate = value == "true" + case "AgentUpdateRepo": + if value != "" { + common.AgentUpdateRepo = value + } } } diff --git a/atsf_server/service/agent.go b/atsf_server/service/agent.go index c21ad13b..4d2f44f1 100644 --- a/atsf_server/service/agent.go +++ b/atsf_server/service/agent.go @@ -44,6 +44,18 @@ type AgentConfigResponse struct { CreatedAt time.Time `json:"created_at"` } +type AgentSettings struct { + HeartbeatInterval int `json:"heartbeat_interval"` + SyncInterval int `json:"sync_interval"` + AutoUpdate bool `json:"auto_update"` + UpdateRepo string `json:"update_repo"` +} + +type HeartbeatResponse struct { + Node *model.Node `json:"node"` + AgentSettings *AgentSettings `json:"agent_settings"` +} + type NodeView struct { ID uint `json:"id"` NodeID string `json:"node_id"` @@ -68,7 +80,7 @@ func RegisterNode(node *model.Node, payload AgentNodePayload) (*AgentRegistratio return RegisterNodeWithAgentToken(node, payload) } -func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*model.Node, error) { +func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatResponse, error) { common.SysLog("agent heartbeat received: node_id=" + node.NodeID + " current_version=" + strings.TrimSpace(payload.CurrentVersion)) payload.NodeID = node.NodeID payload = normalizeAgentNodePayload(payload) @@ -79,7 +91,15 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*model.Node, err if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error").Updates(node).Error; err != nil { return nil, err } - return node, nil + return &HeartbeatResponse{ + Node: node, + AgentSettings: &AgentSettings{ + HeartbeatInterval: common.AgentHeartbeatInterval, + SyncInterval: common.AgentSyncInterval, + AutoUpdate: common.AgentAutoUpdate, + UpdateRepo: common.AgentUpdateRepo, + }, + }, nil } func GetActiveConfigForAgent() (*AgentConfigResponse, error) { diff --git a/atsf_server/web/src/components/OperationSetting.js b/atsf_server/web/src/components/OperationSetting.js new file mode 100644 index 00000000..2cfc1d27 --- /dev/null +++ b/atsf_server/web/src/components/OperationSetting.js @@ -0,0 +1,266 @@ +import React, { useEffect, useState } from 'react'; +import { + Button, + Divider, + Form, + Grid, + Header, + Icon, + Label, + Message, + Segment, +} from 'semantic-ui-react'; +import { API, copy, showError, showSuccess } from '../helpers'; + +const OperationSetting = () => { + const [inputs, setInputs] = useState({ + AgentHeartbeatInterval: '30000', + AgentSyncInterval: '30000', + NodeOfflineThreshold: '120000', + AgentAutoUpdate: 'false', + AgentUpdateRepo: 'Rain-kl/ATSFlare', + ServerAddress: '', + AgentDiscoveryToken: '', + }); + const [loading, setLoading] = useState(false); + + const getOptions = async () => { + const res = await API.get('/api/option/'); + const { success, message, data } = res.data; + if (success) { + let newInputs = {}; + data.forEach((item) => { + if (item.key in inputs) { + newInputs[item.key] = item.value; + } + }); + setInputs((prev) => ({ ...prev, ...newInputs })); + } else { + showError(message); + } + }; + + useEffect(() => { + getOptions(); + getDiscoveryToken(); + }, []); + + const getDiscoveryToken = async () => { + try { + const res = await API.get('/api/nodes/bootstrap-token'); + const { success, data } = res.data; + if (success && data) { + setInputs((prev) => ({ + ...prev, + AgentDiscoveryToken: data.discovery_token, + })); + } + } catch (e) { + // ignore + } + }; + + const updateOption = async (key, value) => { + setLoading(true); + const res = await API.put('/api/option', { key, value }); + const { success, message } = res.data; + if (success) { + showSuccess('设置已保存'); + setInputs((prev) => ({ ...prev, [key]: value })); + } else { + showError(message); + } + setLoading(false); + }; + + const handleInputChange = (e, { name, value }) => { + setInputs((prev) => ({ ...prev, [name]: value })); + }; + + const handleCheckboxChange = (e, { name, checked }) => { + setInputs((prev) => ({ ...prev, [name]: checked ? 'true' : 'false' })); + }; + + const submitAgentIntervals = async () => { + const hb = parseInt(inputs.AgentHeartbeatInterval, 10); + const sync = parseInt(inputs.AgentSyncInterval, 10); + const offline = parseInt(inputs.NodeOfflineThreshold, 10); + if (isNaN(hb) || hb < 5000) { + showError('心跳间隔不能小于 5000 毫秒'); + return; + } + if (isNaN(sync) || sync < 5000) { + showError('同步间隔不能小于 5000 毫秒'); + return; + } + if (isNaN(offline) || offline < 10000) { + showError('离线阈值不能小于 10000 毫秒'); + return; + } + await updateOption('AgentHeartbeatInterval', String(hb)); + await updateOption('AgentSyncInterval', String(sync)); + await updateOption('NodeOfflineThreshold', String(offline)); + }; + + const submitAutoUpdate = async () => { + await updateOption('AgentAutoUpdate', inputs.AgentAutoUpdate); + await updateOption('AgentUpdateRepo', inputs.AgentUpdateRepo); + }; + + const rotateDiscoveryToken = async () => { + setLoading(true); + try { + const res = await API.post('/api/nodes/bootstrap-token/rotate'); + const { success, message, data } = res.data; + if (success) { + showSuccess('Discovery Token 已重新生成'); + setInputs((prev) => ({ + ...prev, + AgentDiscoveryToken: data.discovery_token, + })); + } else { + showError(message); + } + } catch (e) { + showError('操作失败'); + } + setLoading(false); + }; + + const formatMs = (ms) => { + const val = parseInt(ms, 10); + if (isNaN(val)) return ms; + if (val >= 60000) return `${val / 60000} 分钟`; + return `${val / 1000} 秒`; + }; + + const serverAddr = inputs.ServerAddress || window.location.origin; + const curlCommand = inputs.AgentDiscoveryToken + ? `curl -fsSL https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh | bash -s -- --server-url ${serverAddr} --discovery-token ${inputs.AgentDiscoveryToken}` + : ''; + + return ( + + +
+
Agent 运行参数
+ +

这些参数通过心跳响应下发到所有 Agent,修改后下次心跳即生效。

+
+ + + + + + + 保存运行参数 + + + +
Agent 自动更新
+ + + + + + + + 保存更新设置 + + + +
节点接入
+ + + + + + + + + + + + {curlCommand && ( + <> +
Agent 一键部署命令
+ +
+                  {curlCommand}
+                
+
+ + + )} + +
+
+ ); +}; + +export default OperationSetting; diff --git a/atsf_server/web/src/helpers/utils.js b/atsf_server/web/src/helpers/utils.js index a7470953..8cd4e347 100644 --- a/atsf_server/web/src/helpers/utils.js +++ b/atsf_server/web/src/helpers/utils.js @@ -25,7 +25,7 @@ export function getFooterHTML() { return localStorage.getItem('footer_html'); } -export async function copy(text) { +export async function copy(text, label) { let okay = true; try { await navigator.clipboard.writeText(text); @@ -33,6 +33,9 @@ export async function copy(text) { okay = false; console.error(e); } + if (okay && label) { + showSuccess(`${label} 已复制`); + } return okay; } @@ -151,3 +154,15 @@ export function formatDateTime(value) { if (Number.isNaN(date.getTime())) return value; return date.toLocaleString('zh-CN', { hour12: false }); } + +export function timeAgo(value) { + if (!value) return '暂无'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const diff = Math.floor((Date.now() - date.getTime()) / 1000); + if (diff < 0) return '刚刚'; + if (diff < 60) return `${diff} 秒前`; + if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`; + if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`; + return `${Math.floor(diff / 86400)} 天前`; +} diff --git a/atsf_server/web/src/pages/Node/index.js b/atsf_server/web/src/pages/Node/index.js index e8be8b8f..fa16d4df 100644 --- a/atsf_server/web/src/pages/Node/index.js +++ b/atsf_server/web/src/pages/Node/index.js @@ -1,6 +1,6 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Button, Form, Header, Label, Segment, Table } from 'semantic-ui-react'; -import { API, formatDateTime, showError, showSuccess } from '../../helpers'; +import { API, showError, showSuccess, timeAgo } from '../../helpers'; const initialForm = { name: '', @@ -33,9 +33,11 @@ const Node = () => { const [submitting, setSubmitting] = useState(false); const [form, setForm] = useState(initialForm); const [editingId, setEditingId] = useState(null); + const [, setTick] = useState(0); + const refreshTimer = useRef(null); - const loadNodes = async () => { - setLoading(true); + const loadNodes = useCallback(async (silent) => { + if (!silent) setLoading(true); const res = await API.get('/api/nodes/'); const { success, message, data } = res.data; if (success) { @@ -43,8 +45,8 @@ const Node = () => { } else { showError(message); } - setLoading(false); - }; + if (!silent) setLoading(false); + }, []); const loadBootstrapToken = async () => { const res = await API.get('/api/nodes/bootstrap-token'); @@ -71,8 +73,14 @@ const Node = () => { useEffect(() => { loadBootstrapToken().then(); - loadNodes().then(); - }, []); + loadNodes(false).then(); + // Refresh node list and relative time every 30s + refreshTimer.current = setInterval(() => { + loadNodes(true); + setTick((t) => t + 1); + }, 30000); + return () => clearInterval(refreshTimer.current); + }, [loadNodes]); const resetForm = () => { setForm(initialForm); @@ -91,7 +99,7 @@ const Node = () => { if (success) { showSuccess(editingId ? '节点已更新' : '节点已创建'); resetForm(); - await loadNodes(); + await loadNodes(false); } else { showError(message); } @@ -106,7 +114,11 @@ const Node = () => { }; const deleteNode = async (node) => { - if (!window.confirm(`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`)) { + if ( + !window.confirm( + `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。` + ) + ) { return; } const res = await API.delete(`/api/nodes/${node.id}`); @@ -116,7 +128,7 @@ const Node = () => { if (editingId === node.id) { resetForm(); } - await loadNodes(); + await loadNodes(false); } else { showError(message); } @@ -125,7 +137,10 @@ const Node = () => { return (
节点管理
-

创建节点后会直接生成节点专属 auth token;批量部署时可复用全局 discovery token 自动注册。

+

+ 创建节点后会直接生成节点专属 auth token;批量部署时可复用全局 discovery + token 自动注册。 +

@@ -183,20 +198,37 @@ const Node = () => { {node.agent_token ? ( <> - {node.pending ? : } -
{node.agent_token}
+ {node.pending ? ( + + ) : ( + + )} +
+ {node.agent_token} +
- ) : '暂无'} + ) : ( + '暂无' + )}
{node.ip} {renderStatus(node.status)} - {node.agent_version} / {node.nginx_version || 'unknown'} + + {node.agent_version} / {node.nginx_version || 'unknown'} + {node.current_version || '未应用'} {renderApply(node.latest_apply_result)} -
{node.latest_apply_message || '暂无记录'}
+
+ {node.latest_apply_message || '暂无记录'} +
+
+ + {node.last_seen_at ? timeAgo(node.last_seen_at) : '暂无'} - {node.last_seen_at ? formatDateTime(node.last_seen_at) : '暂无'} {node.last_error || '无'}