mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-25 11:33:42 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a42b09ba | ||
|
|
bc8742977e |
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -110,6 +110,11 @@ jobs:
|
||||
cp -r web/dist "${ARCHIVE_NAME}/web"
|
||||
cp server/config.example.yaml "${ARCHIVE_NAME}/"
|
||||
cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true
|
||||
# v2.2+: 随发布包提供 Grafana dashboard 与 nginx.conf 模板
|
||||
if [ -d deploy/grafana ]; then
|
||||
cp -r deploy/grafana "${ARCHIVE_NAME}/grafana"
|
||||
fi
|
||||
cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" 2>/dev/null || true
|
||||
tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
|
||||
@@ -19,6 +19,25 @@ server {
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# Agent one-click install endpoints.
|
||||
# Some external reverse proxies strip the /api prefix before reaching this
|
||||
# container, so /install/ must be proxied here instead of falling through to
|
||||
# the SPA index.html.
|
||||
location /install/ {
|
||||
proxy_pass http://127.0.0.1:8341/install/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
location = /health { proxy_pass http://127.0.0.1:8341/health; }
|
||||
location = /ready { proxy_pass http://127.0.0.1:8341/ready; }
|
||||
location = /metrics { proxy_pass http://127.0.0.1:8341/metrics; }
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
34
deploy/grafana/README.md
Normal file
34
deploy/grafana/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# BackupX Grafana Dashboard
|
||||
|
||||
对接 BackupX v2.1+ 暴露的 Prometheus `/metrics` 端点。
|
||||
|
||||
## 导入步骤
|
||||
|
||||
1. 在 Grafana 配置 Prometheus 数据源指向你的 Prometheus(例如 `http://prometheus:9090`)
|
||||
2. 在 Prometheus 配置抓取 BackupX:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'backupx'
|
||||
scrape_interval: 30s
|
||||
static_configs:
|
||||
- targets: ['backupx-master:8340']
|
||||
```
|
||||
|
||||
3. Grafana → Dashboards → Import → 上传 `backupx-dashboard.json` → 选 Prometheus 数据源 → Import
|
||||
|
||||
## 面板内容
|
||||
|
||||
- 当前运行任务数 / SLA 违约数 / 在线节点 / 24h 成功率 / 应用版本
|
||||
- 任务执行速率(按 success/failed 堆叠)
|
||||
- 任务耗时 P50/P95/P99(按任务类型)
|
||||
- 任务产出字节速率
|
||||
- 存储目标用量 TopN 柱状图
|
||||
- 节点在线状态表(红/绿标色)
|
||||
- 验证 / 恢复 / 复制的成功率时间线
|
||||
|
||||
## 自定义建议
|
||||
|
||||
- 将 `backupx_sla_breach_tasks > 0` 配为 AlertManager 告警
|
||||
- `sum(backupx_node_online) < N` 触发集群容量告警(N 为你集群的最少节点数)
|
||||
- P99 任务耗时突变可用于发现慢任务和资源压力
|
||||
193
deploy/grafana/backupx-dashboard.json
Normal file
193
deploy/grafana/backupx-dashboard.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "BackupX v2.1+ 核心指标面板。对接 /metrics 端点,抓取周期建议 30s(与服务端 Gauge collector 同步)。",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [
|
||||
{
|
||||
"title": "BackupX 文档",
|
||||
"url": "https://awuqing.github.io/BackupX/",
|
||||
"type": "link",
|
||||
"targetBlank": true
|
||||
}
|
||||
],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "正在运行的任务",
|
||||
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "backupx_task_running", "refId": "A"}],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 5}]}
|
||||
}
|
||||
},
|
||||
"options": {"colorMode": "value", "graphMode": "area", "textMode": "auto"}
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "SLA 违约任务数",
|
||||
"gridPos": {"h": 4, "w": 4, "x": 4, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "backupx_sla_breach_tasks", "refId": "A"}],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "在线节点",
|
||||
"gridPos": {"h": 4, "w": 4, "x": 8, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "sum(backupx_node_online)", "refId": "A"}],
|
||||
"fieldConfig": {
|
||||
"defaults": {"unit": "short", "color": {"mode": "thresholds"}, "thresholds": {"steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "24h 任务成功率",
|
||||
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{
|
||||
"expr": "sum(rate(backupx_task_run_total{status=\"success\"}[24h])) / sum(rate(backupx_task_run_total[24h])) * 100",
|
||||
"refId": "A"
|
||||
}],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent", "decimals": 2,
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "yellow", "value": 95}, {"color": "green", "value": 99}]}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "应用版本",
|
||||
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "backupx_app_info", "refId": "A", "format": "table", "instant": true}],
|
||||
"options": {"textMode": "value_and_name", "reduceOptions": {"calcs": ["last"], "fields": "/^version$/"}}
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "任务执行速率(按状态)",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 4},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{
|
||||
"expr": "sum by (status) (rate(backupx_task_run_total[5m]))",
|
||||
"refId": "A",
|
||||
"legendFormat": "{{status}}"
|
||||
}],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ops",
|
||||
"custom": {"drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 10, "stacking": {"mode": "normal"}}
|
||||
},
|
||||
"overrides": [
|
||||
{"matcher": {"id": "byName", "options": "success"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}]},
|
||||
{"matcher": {"id": "byName", "options": "failed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}]}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "任务耗时 P50 / P95 / P99",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 4},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [
|
||||
{"expr": "histogram_quantile(0.50, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "A", "legendFormat": "P50 {{task_type}}"},
|
||||
{"expr": "histogram_quantile(0.95, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "B", "legendFormat": "P95 {{task_type}}"},
|
||||
{"expr": "histogram_quantile(0.99, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "C", "legendFormat": "P99 {{task_type}}"}
|
||||
],
|
||||
"fieldConfig": {"defaults": {"unit": "s"}}
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "任务产出字节速率",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 12},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "sum by (task_type) (rate(backupx_task_bytes_total[5m]))", "refId": "A", "legendFormat": "{{task_type}}"}],
|
||||
"fieldConfig": {"defaults": {"unit": "Bps"}}
|
||||
},
|
||||
{
|
||||
"type": "bargauge",
|
||||
"title": "存储目标用量 TopN",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 12},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "topk(10, backupx_storage_used_bytes)", "refId": "A", "legendFormat": "{{target_name}} ({{target_type}})"}],
|
||||
"fieldConfig": {"defaults": {"unit": "bytes"}},
|
||||
"options": {"orientation": "horizontal", "displayMode": "gradient"}
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"title": "节点在线状态",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 20},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "backupx_node_online", "refId": "A", "format": "table", "instant": true}],
|
||||
"transformations": [
|
||||
{"id": "organize", "options": {"excludeByName": {"Time": true, "__name__": true, "job": true, "instance": true}, "indexByName": {"node_name": 0, "role": 1, "Value": 2}, "renameByName": {"Value": "online"}}}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"overrides": [{
|
||||
"matcher": {"id": "byName", "options": "online"},
|
||||
"properties": [{"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "离线", "color": "red"}, "1": {"text": "在线", "color": "green"}}}]}]
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "验证 / 恢复 / 复制成功率",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 20},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [
|
||||
{"expr": "sum by (status) (rate(backupx_verify_run_total[15m]))", "refId": "A", "legendFormat": "verify {{status}}"},
|
||||
{"expr": "sum by (status) (rate(backupx_restore_run_total[15m]))", "refId": "B", "legendFormat": "restore {{status}}"},
|
||||
{"expr": "sum by (status) (rate(backupx_replication_run_total[15m]))", "refId": "C", "legendFormat": "replication {{status}}"}
|
||||
],
|
||||
"fieldConfig": {"defaults": {"unit": "ops"}}
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["backupx", "backup", "sre"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
|
||||
"label": "Datasource",
|
||||
"name": "DS_PROMETHEUS",
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {"from": "now-6h", "to": "now"},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "BackupX Overview",
|
||||
"uid": "backupx-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -34,15 +34,11 @@ In the Web Console → **Node Management** → **Add Node**. You'll see a three-
|
||||
|
||||
- **Step 1 — Node info.** Give the node a name, or switch to batch mode and paste multiple names (one per line, max 50).
|
||||
- **Step 2 — Deploy options.** Pick install mode (`systemd` recommended, `docker`, or `foreground` for debugging), architecture (auto-detect by default), agent version (defaults to the master's version), TTL for the install link (5 min / 15 min / 1 h / 24 h), and download source (`github` direct, or the `ghproxy` mirror for mainland China).
|
||||
- **Step 3 — Copy the command.** A single `curl ... | sudo sh` line is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges.
|
||||
- **Step 3 — Copy the command.** A one-line install command is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges. The default command embeds the rendered installer, so the target host does not need to fetch `/api/install/:token` through your reverse proxy. The public install URL is still available as a fallback.
|
||||
|
||||
### 2. One-line install on the target host
|
||||
|
||||
Example (systemd mode):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
|
||||
```
|
||||
Use the command generated by the Web Console. It writes the installer to a temporary file, validates the `BACKUPX_AGENT_INSTALL_V1` marker, then runs it with root privileges.
|
||||
|
||||
The script runs automatically and:
|
||||
|
||||
@@ -53,6 +49,8 @@ The script runs automatically and:
|
||||
5. Runs `systemctl enable --now backupx-agent`
|
||||
6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s)
|
||||
|
||||
If you choose the URL-based fallback command and `curl` prints HTML or the shell reports `Syntax error: newline unexpected`, the install URL is being served by the web console instead of the backend. Ensure either `/api/install/` or `/install/` is forwarded to the BackupX backend, or use the embedded command generated by the console.
|
||||
|
||||
Reruns are idempotent — to upgrade or re-provision, simply generate a new install command and run it again. The one-time install link expires after its TTL or after first consumption, whichever is sooner.
|
||||
|
||||
### 3. Rotate agent tokens at any time
|
||||
|
||||
@@ -34,15 +34,11 @@ Web 控制台 → **节点管理** → **添加节点**,打开三步向导:
|
||||
|
||||
- **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个)
|
||||
- **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker`、`前台运行` 调试用)、架构(默认自动检测)、Agent 版本(默认跟随 Master 版本)、有效期(5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者)
|
||||
- **第三步 · 安装命令**:一行 `curl ... | sudo sh` 命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行
|
||||
- **第三步 · 安装命令**:一条一键安装命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行。默认命令会嵌入已渲染的安装脚本,目标机无需再通过反向代理访问 `/api/install/:token`;公开安装 URL 仍作为备用路径保留。
|
||||
|
||||
### 2. 目标机一条命令完成
|
||||
|
||||
示例(systemd 模式):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
|
||||
```
|
||||
请直接使用 Web 控制台生成的命令。该命令会把安装脚本写入临时文件,校验 `BACKUPX_AGENT_INSTALL_V1` 魔数,再以 root 权限执行。
|
||||
|
||||
脚本会自动:
|
||||
|
||||
@@ -53,6 +49,8 @@ curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
|
||||
5. 执行 `systemctl enable --now backupx-agent`
|
||||
6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒)
|
||||
|
||||
如果使用 URL 备用命令时 `curl` 输出 HTML,或 shell 报 `Syntax error: newline unexpected`,说明安装 URL 被 Web 控制台接管而不是转发到后端。需要确保 `/api/install/` 或 `/install/` 至少一个路径能转发到 BackupX 后端,或改用控制台生成的嵌入式命令。
|
||||
|
||||
脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。
|
||||
|
||||
### 3. 随时轮换 Agent Token
|
||||
|
||||
@@ -3,6 +3,7 @@ package http
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -153,6 +154,8 @@ func TestOneClickInstallFlow(t *testing.T) {
|
||||
Data struct {
|
||||
InstallToken string `json:"installToken"`
|
||||
URL string `json:"url"`
|
||||
FallbackURL string `json:"fallbackUrl"`
|
||||
ScriptBase64 string `json:"scriptBase64"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
|
||||
@@ -161,6 +164,16 @@ func TestOneClickInstallFlow(t *testing.T) {
|
||||
if genResp.Data.InstallToken == "" {
|
||||
t.Fatalf("missing installToken")
|
||||
}
|
||||
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
|
||||
t.Fatalf("missing fallback install URL, got %q", genResp.Data.FallbackURL)
|
||||
}
|
||||
decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("scriptBase64 should be valid base64: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(decodedScript), "BACKUPX_AGENT_INSTALL_V1") {
|
||||
t.Fatalf("scriptBase64 should contain rendered install script")
|
||||
}
|
||||
|
||||
// 3. 公开端点消费
|
||||
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
||||
@@ -241,6 +254,8 @@ func TestInstallScriptAliasUnderAPI(t *testing.T) {
|
||||
Data struct {
|
||||
InstallToken string `json:"installToken"`
|
||||
URL string `json:"url"`
|
||||
FallbackURL string `json:"fallbackUrl"`
|
||||
ScriptBase64 string `json:"scriptBase64"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(genRec.Body.Bytes(), &genResp)
|
||||
@@ -249,6 +264,12 @@ func TestInstallScriptAliasUnderAPI(t *testing.T) {
|
||||
if !strings.Contains(genResp.Data.URL, "/api/install/") {
|
||||
t.Errorf("new install URL should use /api/install/ prefix, got %s", genResp.Data.URL)
|
||||
}
|
||||
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
|
||||
t.Errorf("fallback install URL should use /install/ prefix, got %s", genResp.Data.FallbackURL)
|
||||
}
|
||||
if genResp.Data.ScriptBase64 == "" {
|
||||
t.Errorf("new install response should include scriptBase64 for proxy-independent commands")
|
||||
}
|
||||
|
||||
// 3. /api/install/:token 必须可消费(与 /install/:token 等价)
|
||||
aliasReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
|
||||
|
||||
@@ -59,16 +59,7 @@ func (h *InstallHandler) Script(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
h.recordConsumeAudit(c, consumed, "script")
|
||||
script, err := installscript.RenderScript(installscript.Context{
|
||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
||||
AgentToken: consumed.Node.Token,
|
||||
AgentVersion: consumed.Record.AgentVer,
|
||||
Mode: consumed.Record.Mode,
|
||||
Arch: consumed.Record.Arch,
|
||||
DownloadBase: installscript.DownloadBaseFor(consumed.Record.DownloadSrc),
|
||||
InstallPrefix: "/opt/backupx-agent",
|
||||
NodeID: consumed.Node.ID,
|
||||
})
|
||||
script, err := renderInstallScript(resolveMasterURL(c, h.externalURL), consumed.Node, consumed.Record)
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "render error\n")
|
||||
return
|
||||
@@ -141,6 +132,19 @@ func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.Co
|
||||
})
|
||||
}
|
||||
|
||||
func renderInstallScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) {
|
||||
return installscript.RenderScript(installscript.Context{
|
||||
MasterURL: masterURL,
|
||||
AgentToken: node.Token,
|
||||
AgentVersion: record.AgentVer,
|
||||
Mode: record.Mode,
|
||||
Arch: record.Arch,
|
||||
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
|
||||
InstallPrefix: "/opt/backupx-agent",
|
||||
NodeID: node.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveMasterURL 按优先级推导 Master URL:外部配置 > X-Forwarded-* > Request.Host。
|
||||
// 此为包级 helper,供 install_handler 和 node_handler 共用。
|
||||
func resolveMasterURL(c *gin.Context, externalURL string) string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
stdhttp "net/http"
|
||||
"strconv"
|
||||
@@ -262,16 +263,28 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
|
||||
fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds))
|
||||
|
||||
masterURL := resolveMasterURL(c, h.externalURL)
|
||||
script, err := renderInstallScript(masterURL, out.Node, out.Record)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
// 使用 /api/install/... 而非 /install/... —— 让反向代理的 /api/ 转发规则
|
||||
// 自动接管,避免 SPA fallback 把请求当成前端路由返回 index.html(issue #46)。
|
||||
// 同时返回 /install/... 备用地址,兼容会剥离 /api 前缀的外层反向代理。
|
||||
// scriptBase64 让前端可以生成不依赖公开下载路径的嵌入式命令,解决 Lucky 等代理
|
||||
// 把 /api/install/* 也 fallback 到 index.html 的场景。
|
||||
body := gin.H{
|
||||
"installToken": out.Token,
|
||||
"expiresAt": out.ExpiresAt,
|
||||
"url": masterURL + "/api/install/" + out.Token,
|
||||
"composeUrl": "",
|
||||
"installToken": out.Token,
|
||||
"expiresAt": out.ExpiresAt,
|
||||
"url": masterURL + "/api/install/" + out.Token,
|
||||
"fallbackUrl": masterURL + "/install/" + out.Token,
|
||||
"scriptBase64": base64.StdEncoding.EncodeToString([]byte(script)),
|
||||
"composeUrl": "",
|
||||
"fallbackComposeUrl": "",
|
||||
}
|
||||
if input.Mode == "docker" {
|
||||
body["composeUrl"] = masterURL + "/api/install/" + out.Token + "/compose.yml"
|
||||
body["fallbackComposeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml"
|
||||
}
|
||||
response.Success(c, body)
|
||||
}
|
||||
|
||||
@@ -36,3 +36,20 @@ func TestRenderScriptBashBootstrap(t *testing.T) {
|
||||
t.Errorf("script missing exec bash fallback:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptCreatesBackupXUserAndGroup(t *testing.T) {
|
||||
got, err := RenderScript(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("render err: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"getent group backupx",
|
||||
"groupadd --system backupx",
|
||||
"useradd --system --gid backupx",
|
||||
"Group=backupx",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("script missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,27 @@ tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
|
||||
|
||||
# 4. 安装二进制 + 用户
|
||||
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
|
||||
id backupx >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
|
||||
if ! getent group backupx >/dev/null 2>&1; then
|
||||
if command -v groupadd >/dev/null 2>&1; then
|
||||
groupadd --system backupx
|
||||
elif command -v addgroup >/dev/null 2>&1; then
|
||||
addgroup --system backupx
|
||||
else
|
||||
echo "需要 groupadd 或 addgroup 来创建 backupx 组" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if ! id backupx >/dev/null 2>&1; then
|
||||
if command -v useradd >/dev/null 2>&1; then
|
||||
useradd --system --gid backupx --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
|
||||
elif command -v adduser >/dev/null 2>&1; then
|
||||
adduser --system --ingroup backupx --home "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
|
||||
else
|
||||
echo "需要 useradd 或 adduser 来创建 backupx 用户" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
id backupx >/dev/null 2>&1 || { echo "backupx 用户创建失败" >&2; exit 1; }
|
||||
install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent
|
||||
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
|
||||
{{end}}
|
||||
@@ -66,6 +86,7 @@ Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=backupx
|
||||
Group=backupx
|
||||
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
|
||||
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
|
||||
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent
|
||||
|
||||
@@ -39,6 +39,10 @@ type BackupTask struct {
|
||||
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
|
||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||
Node Node `json:"node,omitempty"`
|
||||
// NodePoolTag 节点池标签(可选)。非空且 NodeID=0 时,调度器会从 Node.Labels 包含该 tag
|
||||
// 的在线节点中动态挑选一台执行(按运行中任务数最少原则),失败会 best-effort 切换到下一个候选。
|
||||
// 典型场景:NodePoolTag="db" 让 MySQL 备份任务在任意标有 "db" 的数据库节点执行。
|
||||
NodePoolTag string `gorm:"column:node_pool_tag;size:64;index" json:"nodePoolTag"`
|
||||
Tags string `gorm:"column:tags;size:500" json:"tags"`
|
||||
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
|
||||
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
NodeStatusOnline = "online"
|
||||
@@ -29,8 +32,42 @@ type Node struct {
|
||||
// BandwidthLimit 该节点上传带宽上限(rclone 可识别格式:10M / 1G / 0=不限)。
|
||||
// 对集群感知的上传场景有效(Master 本地与 Agent 运行时均会应用)。
|
||||
BandwidthLimit string `gorm:"column:bandwidth_limit;size:32" json:"bandwidthLimit"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// Labels 节点标签(CSV,如 "prod,db-host,high-mem")。
|
||||
// 用于任务调度的节点池选择:任务配置 NodePoolTag 时,调度器会从 Labels 包含该 tag 的
|
||||
// 在线节点中自动挑选一台执行(按当前运行中任务数升序)。单节点可属多个池。
|
||||
Labels string `gorm:"column:labels;size:500" json:"labels"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// LabelSet 把 CSV Labels 解析为 set,便于做成员判定。
|
||||
// 空白与空 token 自动忽略。
|
||||
func (n *Node) LabelSet() map[string]struct{} {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]struct{})
|
||||
for _, raw := range strings.Split(n.Labels, ",") {
|
||||
label := strings.TrimSpace(raw)
|
||||
if label != "" {
|
||||
out[label] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HasLabel 判断节点是否属于指定池。nil/空 tag 返回 false。
|
||||
func (n *Node) HasLabel(tag string) bool {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if n == nil || tag == "" {
|
||||
return false
|
||||
}
|
||||
for _, raw := range strings.Split(n.Labels, ",") {
|
||||
if strings.TrimSpace(raw) == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (Node) TableName() string {
|
||||
|
||||
47
server/internal/model/node_label_test.go
Normal file
47
server/internal/model/node_label_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNodeHasLabel(t *testing.T) {
|
||||
cases := []struct {
|
||||
labels string
|
||||
tag string
|
||||
want bool
|
||||
}{
|
||||
{"prod,db,high-mem", "prod", true},
|
||||
{"prod,db,high-mem", "db", true},
|
||||
{"prod,db,high-mem", "backup", false},
|
||||
{" prod , db ", "db", true}, // trim 空白
|
||||
{"", "prod", false},
|
||||
{"prod", "", false}, // 空 tag 不匹配
|
||||
}
|
||||
for _, c := range cases {
|
||||
n := &Node{Labels: c.labels}
|
||||
if got := n.HasLabel(c.tag); got != c.want {
|
||||
t.Errorf("labels=%q tag=%q want %v got %v", c.labels, c.tag, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeLabelSet(t *testing.T) {
|
||||
n := &Node{Labels: "prod, db ,,high-mem,prod"}
|
||||
set := n.LabelSet()
|
||||
for _, want := range []string{"prod", "db", "high-mem"} {
|
||||
if _, ok := set[want]; !ok {
|
||||
t.Errorf("expected label %q in set", want)
|
||||
}
|
||||
}
|
||||
if len(set) != 3 {
|
||||
t.Errorf("duplicates not deduped, got %v", set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilNodeHasLabelSafe(t *testing.T) {
|
||||
var n *Node
|
||||
if n.HasLabel("anything") {
|
||||
t.Error("nil node should never match any label")
|
||||
}
|
||||
if s := n.LabelSet(); s != nil {
|
||||
t.Errorf("nil node LabelSet should be nil, got %v", s)
|
||||
}
|
||||
}
|
||||
@@ -335,16 +335,29 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b
|
||||
nil)
|
||||
}
|
||||
}
|
||||
// 节点池动态选择:task.NodeID=0 且 NodePoolTag 非空时,从匹配的在线节点中挑一台。
|
||||
// 选择策略:正在运行任务数最少者优先;并列时按 ID 升序稳定。
|
||||
// 选中节点仅影响本次运行(task.NodeID 不持久化改动),保证任务在池内轮转。
|
||||
resolvedNodeID := task.NodeID
|
||||
if task.NodeID == 0 && strings.TrimSpace(task.NodePoolTag) != "" {
|
||||
if pooled, perr := s.selectPoolNode(ctx, task.NodePoolTag); perr == nil && pooled != nil {
|
||||
resolvedNodeID = pooled.ID
|
||||
} else if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
}
|
||||
startedAt := s.now()
|
||||
// 取第一个存储目标 ID 做兼容
|
||||
primaryTargetID := task.StorageTargetID
|
||||
if tids := collectTargetIDs(task); len(tids) > 0 {
|
||||
primaryTargetID = tids[0]
|
||||
}
|
||||
record := &model.BackupRecord{TaskID: task.ID, StorageTargetID: primaryTargetID, NodeID: task.NodeID, Status: "running", StartedAt: startedAt}
|
||||
record := &model.BackupRecord{TaskID: task.ID, StorageTargetID: primaryTargetID, NodeID: resolvedNodeID, Status: "running", StartedAt: startedAt}
|
||||
if err := s.records.Create(ctx, record); err != nil {
|
||||
return nil, apperror.Internal("BACKUP_RECORD_CREATE_FAILED", "无法创建备份记录", err)
|
||||
}
|
||||
// 用池选出的节点 ID 复写 task 副本,使后续路由/执行沿用
|
||||
task.NodeID = resolvedNodeID
|
||||
task.LastRunAt = &startedAt
|
||||
task.LastStatus = "running"
|
||||
if err := s.tasks.Update(ctx, task); err != nil {
|
||||
@@ -414,6 +427,64 @@ func (s *BackupExecutionService) shouldNotify(ctx context.Context, task *model.B
|
||||
return true
|
||||
}
|
||||
|
||||
// selectPoolNode 从所有 Labels 包含 poolTag 的在线节点中选择"当前运行中任务最少"的一台。
|
||||
// 返回 (nil, error) 表示硬错误(仓储访问失败);(nil, nil) 表示没有匹配节点(退化走本机 Master)。
|
||||
// 本方法不修改任何持久化状态,仅做选择。
|
||||
func (s *BackupExecutionService) selectPoolNode(ctx context.Context, poolTag string) (*model.Node, error) {
|
||||
if s.nodeRepo == nil {
|
||||
// 没接入集群依赖时,降级为让调用方走本机 Master
|
||||
return nil, nil
|
||||
}
|
||||
nodes, err := s.nodeRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("NODE_LIST_FAILED", "无法枚举节点池", err)
|
||||
}
|
||||
candidates := make([]*model.Node, 0)
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
if n.Status != model.NodeStatusOnline {
|
||||
continue
|
||||
}
|
||||
if !n.HasLabel(poolTag) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, n)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, apperror.BadRequest("NODE_POOL_EMPTY",
|
||||
fmt.Sprintf("节点池 %q 下无在线节点,任务无法调度", poolTag), nil)
|
||||
}
|
||||
// 运行中记录数越少越优先。并列按 ID 升序(稳定、可预期)。
|
||||
best := candidates[0]
|
||||
bestLoad := s.countRunningOnNode(ctx, best.ID)
|
||||
for _, n := range candidates[1:] {
|
||||
load := s.countRunningOnNode(ctx, n.ID)
|
||||
if load < bestLoad || (load == bestLoad && n.ID < best.ID) {
|
||||
best = n
|
||||
bestLoad = load
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// countRunningOnNode 近似返回节点当前 running 记录数。失败按 0 处理(不影响功能,仅退化调度精度)。
|
||||
func (s *BackupExecutionService) countRunningOnNode(ctx context.Context, nodeID uint) int {
|
||||
if s.records == nil {
|
||||
return 0
|
||||
}
|
||||
items, err := s.records.List(ctx, repository.BackupRecordListOptions{Status: model.BackupRecordStatusRunning})
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for i := range items {
|
||||
if items[i].NodeID == nodeID {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// effectiveBandwidth 返回当前上下文应用的带宽限速字符串。
|
||||
// 优先级:Node.BandwidthLimit(非空) > 全局 s.bandwidthLimit。
|
||||
func (s *BackupExecutionService) effectiveBandwidth(ctx context.Context, nodeID uint) string {
|
||||
|
||||
@@ -35,7 +35,9 @@ type BackupTaskUpsertInput struct {
|
||||
DBPath string `json:"dbPath" binding:"max=500"`
|
||||
StorageTargetID uint `json:"storageTargetId"` // deprecated: 向后兼容
|
||||
StorageTargetIDs []uint `json:"storageTargetIds"` // 新增:多存储目标
|
||||
NodeID uint `json:"nodeId"` // 执行节点(0 = 本机 Master)
|
||||
NodeID uint `json:"nodeId"` // 执行节点(0 = 本机 Master 或节点池)
|
||||
// NodePoolTag 节点池标签。NodeID=0 且本字段非空时,调度器动态从 Labels 命中的在线节点中选负载最低者。
|
||||
NodePoolTag string `json:"nodePoolTag" binding:"max=64"`
|
||||
Tags string `json:"tags" binding:"max=500"` // 逗号分隔标签
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
Compression string `json:"compression" binding:"omitempty,oneof=gzip none"`
|
||||
@@ -74,6 +76,7 @@ type BackupTaskSummary struct {
|
||||
StorageTargetNames []string `json:"storageTargetNames"`
|
||||
NodeID uint `json:"nodeId"`
|
||||
NodeName string `json:"nodeName,omitempty"`
|
||||
NodePoolTag string `json:"nodePoolTag,omitempty"`
|
||||
Tags string `json:"tags"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
Compression string `json:"compression"`
|
||||
@@ -494,6 +497,11 @@ func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.B
|
||||
return apperror.BadRequest("BACKUP_TASK_INVALID", "所选执行节点不存在", nil)
|
||||
}
|
||||
}
|
||||
// 节点池与固定节点互斥:固定节点已确定执行位置,不再动态调度
|
||||
if input.NodeID > 0 && strings.TrimSpace(input.NodePoolTag) != "" {
|
||||
return apperror.BadRequest("BACKUP_TASK_INVALID",
|
||||
"固定执行节点与节点池标签只能选其一", nil)
|
||||
}
|
||||
if input.RetentionDays < 0 {
|
||||
return apperror.BadRequest("BACKUP_TASK_INVALID", "保留天数不能小于 0", nil)
|
||||
}
|
||||
@@ -648,6 +656,7 @@ func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTa
|
||||
StorageTargetID: primaryTargetID,
|
||||
StorageTargets: storageTargets,
|
||||
NodeID: input.NodeID,
|
||||
NodePoolTag: strings.TrimSpace(input.NodePoolTag),
|
||||
Tags: strings.TrimSpace(input.Tags),
|
||||
RetentionDays: input.RetentionDays,
|
||||
Compression: compression,
|
||||
@@ -738,6 +747,7 @@ func toBackupTaskSummary(item *model.BackupTask) BackupTaskSummary {
|
||||
StorageTargetNames: targetNames,
|
||||
NodeID: item.NodeID,
|
||||
NodeName: item.Node.Name,
|
||||
NodePoolTag: item.NodePoolTag,
|
||||
Tags: item.Tags,
|
||||
RetentionDays: item.RetentionDays,
|
||||
Compression: item.Compression,
|
||||
|
||||
83
server/internal/service/node_pool_scheduler_test.go
Normal file
83
server/internal/service/node_pool_scheduler_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/model"
|
||||
)
|
||||
|
||||
// nodeRepoStub 返回预设节点切片;仅关注 List/FindByID。
|
||||
// 其余方法返回零值,避免在调度路径被调用到。
|
||||
type nodeRepoStub struct {
|
||||
nodes []model.Node
|
||||
}
|
||||
|
||||
func (s *nodeRepoStub) List(context.Context) ([]model.Node, error) { return s.nodes, nil }
|
||||
func (s *nodeRepoStub) FindByID(_ context.Context, id uint) (*model.Node, error) {
|
||||
for i := range s.nodes {
|
||||
if s.nodes[i].ID == id {
|
||||
return &s.nodes[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (s *nodeRepoStub) FindByToken(context.Context, string) (*model.Node, error) { return nil, nil }
|
||||
func (s *nodeRepoStub) FindLocal(context.Context) (*model.Node, error) { return nil, nil }
|
||||
func (s *nodeRepoStub) Create(context.Context, *model.Node) error { return nil }
|
||||
func (s *nodeRepoStub) BatchCreate(context.Context, []*model.Node) error { return nil }
|
||||
func (s *nodeRepoStub) Update(context.Context, *model.Node) error { return nil }
|
||||
func (s *nodeRepoStub) Delete(context.Context, uint) error { return nil }
|
||||
func (s *nodeRepoStub) MarkStaleOffline(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func TestSelectPoolNode_PicksLeastLoaded(t *testing.T) {
|
||||
nodes := []model.Node{
|
||||
{ID: 1, Name: "node-a", Status: model.NodeStatusOnline, Labels: "prod,db"},
|
||||
{ID: 2, Name: "node-b", Status: model.NodeStatusOnline, Labels: "prod,db"},
|
||||
{ID: 3, Name: "node-offline", Status: model.NodeStatusOffline, Labels: "prod,db"},
|
||||
{ID: 4, Name: "node-other-pool", Status: model.NodeStatusOnline, Labels: "staging"},
|
||||
}
|
||||
svc := &BackupExecutionService{
|
||||
nodeRepo: &nodeRepoStub{nodes: nodes},
|
||||
records: nil, // 触发 countRunningOnNode 返回 0,节点并列时按 ID 升序
|
||||
}
|
||||
chosen, err := svc.selectPoolNode(context.Background(), "db")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if chosen == nil || chosen.ID != 1 {
|
||||
t.Fatalf("expected node-a (ID=1), got %#v", chosen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectPoolNode_EmptyPoolReturnsError(t *testing.T) {
|
||||
svc := &BackupExecutionService{
|
||||
nodeRepo: &nodeRepoStub{nodes: []model.Node{
|
||||
{ID: 1, Status: model.NodeStatusOnline, Labels: "prod"},
|
||||
}},
|
||||
}
|
||||
_, err := svc.selectPoolNode(context.Background(), "missing-pool")
|
||||
if err == nil {
|
||||
t.Fatal("expected empty-pool error")
|
||||
}
|
||||
var apperr *apperror.AppError
|
||||
if !errors.As(err, &apperr) || apperr.Code != "NODE_POOL_EMPTY" {
|
||||
t.Errorf("expected NODE_POOL_EMPTY, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectPoolNode_NilRepoDegradesGracefully(t *testing.T) {
|
||||
svc := &BackupExecutionService{}
|
||||
got, err := svc.selectPoolNode(context.Background(), "any")
|
||||
if err != nil {
|
||||
t.Errorf("nil repo should degrade silently, got err %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("nil repo should return nil node, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ type NodeSummary struct {
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
MaxConcurrent int `json:"maxConcurrent"`
|
||||
BandwidthLimit string `json:"bandwidthLimit"`
|
||||
Labels string `json:"labels"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -47,6 +48,8 @@ type NodeUpdateInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
MaxConcurrent int `json:"maxConcurrent"`
|
||||
BandwidthLimit string `json:"bandwidthLimit" binding:"max=32"`
|
||||
// Labels CSV;同时作为调度器的节点池标签(task.NodePoolTag 对齐的值)。
|
||||
Labels string `json:"labels" binding:"max=500"`
|
||||
}
|
||||
|
||||
// NodeService manages the cluster nodes.
|
||||
@@ -132,6 +135,7 @@ func (s *NodeService) List(ctx context.Context) ([]NodeSummary, error) {
|
||||
LastSeen: n.LastSeen,
|
||||
MaxConcurrent: n.MaxConcurrent,
|
||||
BandwidthLimit: n.BandwidthLimit,
|
||||
Labels: n.Labels,
|
||||
CreatedAt: n.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -159,6 +163,7 @@ func (s *NodeService) Get(ctx context.Context, id uint) (*NodeSummary, error) {
|
||||
LastSeen: node.LastSeen,
|
||||
MaxConcurrent: node.MaxConcurrent,
|
||||
BandwidthLimit: node.BandwidthLimit,
|
||||
Labels: node.Labels,
|
||||
CreatedAt: node.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
@@ -320,12 +325,32 @@ func (s *NodeService) Update(ctx context.Context, id uint, input NodeUpdateInput
|
||||
}
|
||||
node.MaxConcurrent = input.MaxConcurrent
|
||||
node.BandwidthLimit = strings.TrimSpace(input.BandwidthLimit)
|
||||
node.Labels = normalizeLabels(input.Labels)
|
||||
if err := s.repo.Update(ctx, node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
// normalizeLabels 规整 CSV labels:去空白、去空 token、去重、保持首次出现顺序。
|
||||
// 输入 " prod, db , prod ,high-mem " → "prod,db,high-mem"
|
||||
func normalizeLabels(raw string) string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0)
|
||||
for _, token := range strings.Split(raw, ",") {
|
||||
label := strings.TrimSpace(token)
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[label]; dup {
|
||||
continue
|
||||
}
|
||||
seen[label] = struct{}{}
|
||||
out = append(out, label)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// DirEntry represents a file or directory in a node's file system.
|
||||
type DirEntry struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -59,6 +59,7 @@ function createEmptyDraft(storageTargets?: StorageTargetSummary[]): BackupTaskPa
|
||||
storageTargetId: defaultIds[0] ?? 0,
|
||||
storageTargetIds: defaultIds,
|
||||
nodeId: 0,
|
||||
nodePoolTag: '',
|
||||
tags: '',
|
||||
retentionDays: 30,
|
||||
compression: 'gzip',
|
||||
@@ -127,6 +128,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
storageTargetId: editTargetIds[0] ?? 0,
|
||||
storageTargetIds: editTargetIds,
|
||||
nodeId: (initialValue as any).nodeId ?? 0,
|
||||
nodePoolTag: (initialValue as any).nodePoolTag ?? '',
|
||||
tags: initialValue.tags ?? '',
|
||||
retentionDays: initialValue.retentionDays,
|
||||
compression: initialValue.compression,
|
||||
@@ -297,12 +299,28 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
<Select
|
||||
value={draft.nodeId ?? 0}
|
||||
options={nodeOptions}
|
||||
onChange={(value) => updateDraft({ nodeId: Number(value ?? 0) })}
|
||||
onChange={(value) => {
|
||||
const nodeId = Number(value ?? 0)
|
||||
// 固定节点与节点池互斥:切到固定节点时清空 NodePoolTag
|
||||
updateDraft(nodeId > 0 ? { nodeId, nodePoolTag: '' } : { nodeId })
|
||||
}}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
任务在所选节点上执行备份与恢复;源路径/数据库以该节点视角解析。远程节点需先在"节点管理"中安装 Agent。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>节点池标签(可选)</Typography.Text>
|
||||
<Input
|
||||
placeholder="填写标签后从节点池动态调度(与固定节点互斥)"
|
||||
value={draft.nodePoolTag ?? ''}
|
||||
disabled={(draft.nodeId ?? 0) > 0}
|
||||
onChange={(value) => updateDraft({ nodePoolTag: value })}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
执行节点选"本机 / 未指定"时可启用;从节点 Labels 命中此 tag 的在线节点中按当前运行任务数最少的挑选一台执行。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>Cron 表达式</Typography.Text>
|
||||
<CronInput value={draft.cronExpr} onChange={(value) => updateDraft({ cronExpr: value })} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Step3CommandPreview } from './wizard/Step3CommandPreview'
|
||||
import { BatchCommandTable, type BatchCommandRow } from './BatchCommandTable'
|
||||
import { batchCreateNodes, createInstallToken } from '../../services/nodes'
|
||||
import type { InstallTokenResult } from '../../types/nodes'
|
||||
import { buildAgentInstallCommand } from './installCommands'
|
||||
|
||||
const Step = Steps.Step
|
||||
|
||||
@@ -162,7 +163,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
const rows: BatchCommandRow[] = tokens.map(({ c, tok }) => ({
|
||||
nodeId: c.id,
|
||||
nodeName: c.name,
|
||||
command: `curl -fsSL ${tok.url} | sudo bash`,
|
||||
command: buildAgentInstallCommand(tok.url, tok.fallbackUrl, tok.scriptBase64),
|
||||
expiresAt: tok.expiresAt,
|
||||
}))
|
||||
if (mountedRef.current) setBatchRows(rows)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Table, Button, Space, Tag, Typography, PageHeader, Modal, Input, Message, Badge, Popconfirm, Card,
|
||||
Empty, Dropdown, Menu,
|
||||
Empty, Dropdown, Menu, Tooltip, InputNumber,
|
||||
} from '@arco-design/web-react'
|
||||
import {
|
||||
IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit, IconMore,
|
||||
@@ -25,6 +25,9 @@ export default function NodesPage() {
|
||||
const [editVisible, setEditVisible] = useState(false)
|
||||
const [editNode, setEditNode] = useState<NodeSummary | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editLabels, setEditLabels] = useState('')
|
||||
const [editMaxConcurrent, setEditMaxConcurrent] = useState<number>(0)
|
||||
const [editBandwidthLimit, setEditBandwidthLimit] = useState('')
|
||||
|
||||
const fetchNodes = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -63,7 +66,12 @@ export default function NodesPage() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateNode(editNode.id, { name: editName.trim() })
|
||||
await updateNode(editNode.id, {
|
||||
name: editName.trim(),
|
||||
labels: editLabels.trim(),
|
||||
maxConcurrent: editMaxConcurrent,
|
||||
bandwidthLimit: editBandwidthLimit.trim(),
|
||||
})
|
||||
Message.success('节点更新成功')
|
||||
setEditVisible(false)
|
||||
fetchNodes()
|
||||
@@ -117,7 +125,18 @@ export default function NodesPage() {
|
||||
render: (_: string, record: NodeSummary) => record.os
|
||||
? <Tag bordered>{record.os}/{record.arch}</Tag> : '-',
|
||||
},
|
||||
{ title: 'Agent 版本', dataIndex: 'agentVersion', width: 100, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: 'Agent 版本', dataIndex: 'agentVersion', width: 140,
|
||||
render: (v: string) => renderAgentVersion(v, masterVersion),
|
||||
},
|
||||
{
|
||||
title: '标签 / 节点池', dataIndex: 'labels', width: 180,
|
||||
render: (v: string) => {
|
||||
const tags = (v || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
if (tags.length === 0) return <Text type="secondary">-</Text>
|
||||
return <Space wrap size={4}>{tags.map(tag => <Tag key={tag} color="arcoblue">{tag}</Tag>)}</Space>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '最后活跃', dataIndex: 'lastSeen', width: 170,
|
||||
render: (v: string) => v ? new Date(v).toLocaleString('zh-CN') : '-',
|
||||
@@ -127,7 +146,13 @@ export default function NodesPage() {
|
||||
render: (_: unknown, record: NodeSummary) => (
|
||||
<Space>
|
||||
<Button type="text" icon={<IconEdit />} size="small"
|
||||
onClick={() => { setEditNode(record); setEditName(record.name); setEditVisible(true) }} />
|
||||
onClick={() => {
|
||||
setEditNode(record); setEditName(record.name)
|
||||
setEditLabels(record.labels || '')
|
||||
setEditMaxConcurrent(record.maxConcurrent || 0)
|
||||
setEditBandwidthLimit(record.bandwidthLimit || '')
|
||||
setEditVisible(true)
|
||||
}} />
|
||||
{!record.isLocal && (
|
||||
<>
|
||||
<Dropdown trigger="click" droplist={(
|
||||
@@ -181,12 +206,46 @@ export default function NodesPage() {
|
||||
|
||||
<Modal title="编辑节点" visible={editVisible}
|
||||
onCancel={() => setEditVisible(false)} onOk={handleEdit}
|
||||
okText="保存" cancelText="取消">
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text type="secondary">节点名称</Text>
|
||||
</div>
|
||||
okText="保存" cancelText="取消" style={{ width: 520 }}>
|
||||
<div style={{ marginBottom: 8 }}><Text type="secondary">节点名称</Text></div>
|
||||
<Input placeholder="输入节点名称" value={editName} onChange={setEditName} />
|
||||
|
||||
<div style={{ margin: '16px 0 8px 0' }}>
|
||||
<Text type="secondary">标签 / 节点池</Text>
|
||||
<Tooltip content="以英文逗号分隔,如 prod,db,high-mem。任务配置节点池标签时会从命中的在线节点中按负载最低选一台执行。">
|
||||
<Text type="secondary" style={{ marginLeft: 8, cursor: 'help' }}>ⓘ</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input placeholder="例如:prod,db,high-mem" value={editLabels} onChange={setEditLabels} />
|
||||
|
||||
<div style={{ margin: '16px 0 8px 0' }}><Text type="secondary">最大并发任务数(0 = 不限)</Text></div>
|
||||
<InputNumber min={0} max={64} value={editMaxConcurrent} onChange={v => setEditMaxConcurrent(v ?? 0)} style={{ width: '100%' }} />
|
||||
|
||||
<div style={{ margin: '16px 0 8px 0' }}>
|
||||
<Text type="secondary">带宽限速</Text>
|
||||
<Tooltip content="rclone 格式,如 10M 表示 10MB/s,留空走全局默认">
|
||||
<Text type="secondary" style={{ marginLeft: 8, cursor: 'help' }}>ⓘ</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input placeholder="例如:10M 或 1G;留空使用全局默认" value={editBandwidthLimit} onChange={setEditBandwidthLimit} />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Agent 版本 + 与 Master 的漂移状态。
|
||||
* 空版本 → "-"(未上报)
|
||||
* 与 Master 相同 → 原样显示
|
||||
* 不同(且非本机) → 红色 Tag + 提示升级
|
||||
*/
|
||||
function renderAgentVersion(agentVer: string, masterVer: string | null): React.ReactNode {
|
||||
if (!agentVer) return <Text type="secondary">-</Text>
|
||||
if (!masterVer) return agentVer
|
||||
if (agentVer === masterVer) return agentVer
|
||||
return (
|
||||
<Tooltip content={`Master 版本 ${masterVer},建议重新生成安装命令升级 Agent`}>
|
||||
<Tag color="orangered" style={{ cursor: 'help' }}>{agentVer} ≠ {masterVer}</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
37
web/src/pages/nodes/installCommands.test.ts
Normal file
37
web/src/pages/nodes/installCommands.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAgentDownloadCommand, buildAgentInstallCommand } from './installCommands'
|
||||
|
||||
describe('install command builders', () => {
|
||||
it('adds script marker validation and fallback install path', () => {
|
||||
const cmd = buildAgentInstallCommand('https://master.example.com/api/install/abc')
|
||||
|
||||
expect(cmd).toContain('BACKUPX_AGENT_INSTALL_V1')
|
||||
expect(cmd).toContain("'https://master.example.com/api/install/abc'")
|
||||
expect(cmd).toContain("'https://master.example.com/install/abc'")
|
||||
expect(cmd).toContain('sh "$tmp"')
|
||||
})
|
||||
|
||||
it('uses explicit fallback URL when provided', () => {
|
||||
const cmd = buildAgentDownloadCommand(
|
||||
'https://master.example.com/api/install/abc',
|
||||
'https://master.example.com/install/abc',
|
||||
)
|
||||
|
||||
expect(cmd).toContain('/tmp/bx-agent-install.sh')
|
||||
expect(cmd).toContain("'https://master.example.com/install/abc'")
|
||||
expect(cmd).toContain('non-script content')
|
||||
})
|
||||
|
||||
it('prefers embedded script content when available', () => {
|
||||
const cmd = buildAgentInstallCommand(
|
||||
'https://master.example.com/api/install/abc',
|
||||
'https://master.example.com/install/abc',
|
||||
'IyEvYmluL3NoCg==',
|
||||
)
|
||||
|
||||
expect(cmd).toContain('base64 -d')
|
||||
expect(cmd).toContain('base64 -D')
|
||||
expect(cmd).toContain("'IyEvYmluL3NoCg=='")
|
||||
expect(cmd).not.toContain('https://master.example.com/api/install/abc')
|
||||
})
|
||||
})
|
||||
67
web/src/pages/nodes/installCommands.ts
Normal file
67
web/src/pages/nodes/installCommands.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
const INSTALL_MAGIC_MARKER = 'BACKUPX_AGENT_INSTALL_V1'
|
||||
|
||||
function shellQuote(value: string) {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
function legacyInstallUrl(url: string) {
|
||||
return url.replace('/api/install/', '/install/')
|
||||
}
|
||||
|
||||
function runScriptCommand(path: string) {
|
||||
return `if [ "$(id -u)" -eq 0 ]; then sh ${path}; else sudo sh ${path}; fi`
|
||||
}
|
||||
|
||||
export function buildAgentInstallCommand(url: string, fallbackUrl?: string, scriptBase64?: string) {
|
||||
if (scriptBase64?.trim()) {
|
||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||
return [
|
||||
'enc=$(mktemp)',
|
||||
'tmp=$(mktemp)',
|
||||
`printf %s ${shellQuote(scriptBase64.trim())} > "$enc"`,
|
||||
'(base64 -d < "$enc" > "$tmp" 2>/dev/null || base64 -D < "$enc" > "$tmp")',
|
||||
`{ grep -q ${marker} "$tmp" || { echo 'BackupX embedded installer is invalid.' >&2; head -5 "$tmp" >&2; false; }; }`,
|
||||
runScriptCommand('"$tmp"'),
|
||||
].join(' && ') + '; rc=$?; rm -f "$enc" "$tmp"; test $rc -eq 0'
|
||||
}
|
||||
|
||||
const primary = url.trim()
|
||||
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
|
||||
const urls = fallback && fallback !== primary ? [primary, fallback] : [primary]
|
||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||
const fetchScript = urls.length > 1
|
||||
? `(curl -fsSL ${shellQuote(urls[0])} -o "$tmp" && grep -q ${marker} "$tmp" || curl -fsSL ${shellQuote(urls[1])} -o "$tmp")`
|
||||
: `(curl -fsSL ${shellQuote(urls[0])} -o "$tmp" && grep -q ${marker} "$tmp")`
|
||||
|
||||
return [
|
||||
'tmp=$(mktemp)',
|
||||
fetchScript,
|
||||
`{ grep -q ${marker} "$tmp" || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 "$tmp" >&2; false; }; }`,
|
||||
runScriptCommand('"$tmp"'),
|
||||
].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0'
|
||||
}
|
||||
|
||||
export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, scriptBase64?: string) {
|
||||
if (scriptBase64?.trim()) {
|
||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||
return [
|
||||
`printf %s ${shellQuote(scriptBase64.trim())} > /tmp/bx-agent-install.b64`,
|
||||
'(base64 -d < /tmp/bx-agent-install.b64 > /tmp/bx-agent-install.sh 2>/dev/null || base64 -D < /tmp/bx-agent-install.b64 > /tmp/bx-agent-install.sh)',
|
||||
`{ grep -q ${marker} /tmp/bx-agent-install.sh || { echo 'BackupX embedded installer is invalid.' >&2; head -5 /tmp/bx-agent-install.sh >&2; false; }; }`,
|
||||
runScriptCommand('/tmp/bx-agent-install.sh'),
|
||||
].join(' && ')
|
||||
}
|
||||
|
||||
const primary = url.trim()
|
||||
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
|
||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||
const fetchScript = fallback && fallback !== primary
|
||||
? `(curl -fsSL ${shellQuote(primary)} -o /tmp/bx-agent-install.sh && grep -q ${marker} /tmp/bx-agent-install.sh || curl -fsSL ${shellQuote(fallback)} -o /tmp/bx-agent-install.sh)`
|
||||
: `(curl -fsSL ${shellQuote(primary)} -o /tmp/bx-agent-install.sh && grep -q ${marker} /tmp/bx-agent-install.sh)`
|
||||
|
||||
return [
|
||||
fetchScript,
|
||||
`{ grep -q ${marker} /tmp/bx-agent-install.sh || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 /tmp/bx-agent-install.sh >&2; false; }; }`,
|
||||
runScriptCommand('/tmp/bx-agent-install.sh'),
|
||||
].join(' && ')
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-d
|
||||
import { IconCopy, IconRefresh } from '@arco-design/web-react/icon'
|
||||
import { fetchScriptPreview } from '../../../services/nodes'
|
||||
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
|
||||
import { buildAgentDownloadCommand, buildAgentInstallCommand } from '../installCommands'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -29,11 +30,8 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
|
||||
}, [token.expiresAt])
|
||||
|
||||
const expired = remaining === 0
|
||||
// 使用 bash 管道执行:避开 Debian/Ubuntu 默认 /bin/sh=dash 的差异,
|
||||
// 同时让反向代理 / CDN 不再按 "sh" 的脚本类型做内容识别(issue #46)。
|
||||
const command = `curl -fsSL ${token.url} | sudo bash`
|
||||
// 备用命令:若当前机器无 bash,或中间代理过滤了管道响应,可先落盘再执行。
|
||||
const fallbackCommand = `curl -fsSL ${token.url} -o /tmp/bx-agent-install.sh && sudo sh /tmp/bx-agent-install.sh`
|
||||
const command = buildAgentInstallCommand(token.url, token.fallbackUrl, token.scriptBase64)
|
||||
const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl, token.scriptBase64)
|
||||
const dockerComposeCmd = mode === 'docker' && token.composeUrl
|
||||
? `curl -fsSL ${token.composeUrl} -o docker-compose.yml && docker-compose up -d`
|
||||
: null
|
||||
@@ -82,7 +80,7 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
|
||||
|
||||
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
或先下载再执行(当目标机无 bash / 反向代理过滤管道响应时):
|
||||
或固定下载到 /tmp 后执行:
|
||||
</Text>
|
||||
<Text style={{
|
||||
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
|
||||
@@ -110,7 +108,7 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
|
||||
)}
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
|
||||
命令仅显示一次,复制后请尽快在目标机执行。token 一经消费立即作废。
|
||||
安装命令包含节点 token,请仅在目标机执行并妥善保存;公开安装链接会在 TTL 到期或首次消费后作废。
|
||||
</Text>
|
||||
|
||||
<Collapse bordered={false} onChange={(_key, keys) => {
|
||||
|
||||
@@ -16,7 +16,14 @@ export async function createNode(name: string) {
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function updateNode(id: number, data: { name: string }) {
|
||||
export interface NodeUpdateInput {
|
||||
name: string
|
||||
labels?: string
|
||||
maxConcurrent?: number
|
||||
bandwidthLimit?: string
|
||||
}
|
||||
|
||||
export async function updateNode(id: number, data: NodeUpdateInput) {
|
||||
const response = await http.put<ApiEnvelope<NodeSummary>>(`/nodes/${id}`, data)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface BackupTaskSummary {
|
||||
storageTargetNames: string[]
|
||||
nodeId: number
|
||||
nodeName?: string
|
||||
/** 节点池标签(summary):当任务绑定节点池而非固定节点时显示 */
|
||||
nodePoolTag?: string
|
||||
tags: string
|
||||
retentionDays: number
|
||||
compression: BackupCompression
|
||||
@@ -64,6 +66,8 @@ export interface BackupTaskPayload {
|
||||
storageTargetId: number
|
||||
storageTargetIds: number[]
|
||||
nodeId: number
|
||||
/** 节点池标签(创建/更新)。与 nodeId 互斥:nodeId=0 且本字段非空时触发动态调度。 */
|
||||
nodePoolTag?: string
|
||||
tags: string
|
||||
retentionDays: number
|
||||
compression: BackupCompression
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface NodeSummary {
|
||||
arch: string
|
||||
agentVersion: string
|
||||
lastSeen: string
|
||||
maxConcurrent?: number
|
||||
bandwidthLimit?: string
|
||||
/** CSV 节点标签;任务的 NodePoolTag 命中这里任一即会被调度到本节点 */
|
||||
labels?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -40,5 +44,8 @@ export interface InstallTokenResult {
|
||||
installToken: string
|
||||
expiresAt: string
|
||||
url: string
|
||||
fallbackUrl?: string
|
||||
scriptBase64?: string
|
||||
composeUrl: string
|
||||
fallbackComposeUrl?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user