feat: MySQL SSL 支持 + 任务编辑回显 + InfluxDB 备份

MySQL:
- 新增 use_ssl 选项;MariaDB 11.x 默认强制 SSL 且默认 verify cert,
  故启用 SSL 时同时加 --skip-ssl-verify-server-cert 以兼容自签名证书。

任务编辑回显:
- 后端 JobOut 新增 source_config_safe 字段,解密后敏感字段(password/token/
  secret_key/access_key)脱敏为 "***"。
- 更新端点接收 source_config;若密码字段为 "***" 则保留原值不变,
  支持"不改密码只改其它字段"的常见场景。
- 前端 JobForm 完整回显所有数据源字段,type='edit' 时去掉 disabled。

InfluxDB 备份(新增类型):
- 后端 core/backup/influxdb.py:
  - v2.x:调用 influx CLI(influxdb2-client-2.7.5 二进制直接安装)执行 influx backup
    生成目录后 tar.gz 流式打包上传。
  - v1.x:通过 HTTP API 调用 /query?db=...&q=SELECT * FROM /.*/ 拉取全量数据
    导出为 CSV + META.txt。
- Dockerfile:直接下载 influx CLI 二进制(绕开 apt 签名问题)。
- 前端 JobForm:根据 version 动态显示 v1/v2 表单字段。
- schemas/job.py:新增 InfluxDBSourceConfigV1 / V2。
- 类型字面量扩展:'mysql' | 'directory' | 'influxdb'。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
publ
2026-06-22 19:14:45 +08:00
parent febdeab35c
commit 2568c0380b
9 changed files with 779 additions and 61 deletions
+14 -1
View File
@@ -5,15 +5,28 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# 系统依赖:curl 用于健康检查,default-mysql-client 提供 mysqldumptar 已内置
# 系统依赖:curl 用于健康检查,default-mysql-client 提供 mysqldumptar 已内置influx CLI 用于 InfluxDB 备份
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
default-mysql-client \
tar \
gzip \
tzdata \
ca-certificates \
gnupg \
&& rm -rf /var/lib/apt/lists/*
# 安装 InfluxDB CLI(直接下载二进制,避免 apt 签名问题)
RUN set -eux; \
INFLUX_VERSION=2.7.5; \
curl -fsSL "https://dl.influxdata.com/influxdb/releases/influxdb2-client-${INFLUX_VERSION}-linux-amd64.tar.gz" \
-o /tmp/influx.tar.gz; \
tar -xzf /tmp/influx.tar.gz -C /tmp; \
mv /tmp/influx /usr/local/bin/influx; \
chmod +x /usr/local/bin/influx; \
rm -rf /tmp/influx.tar.gz /tmp/influx; \
influx version
WORKDIR /app
# 先安装依赖以利用 Docker 缓存
+56 -9
View File
@@ -12,6 +12,8 @@ from app.models.run import Run
from app.models.storage import Storage
from app.schemas.job import (
DirectorySourceConfig,
InfluxDBSourceConfigV1,
InfluxDBSourceConfigV2,
JobCreate,
JobOut,
JobRunResponse,
@@ -21,7 +23,22 @@ from app.schemas.job import (
RunSummary,
)
from app.scheduler.scheduler import remove_job, reschedule_job
from app.utils.crypto import encrypt_dict
from app.utils.crypto import decrypt_dict, encrypt_dict
# 脱敏字段:编辑时返回占位符,避免明文密码在网络传输
_SENSITIVE_KEYS = {"password", "token", "secret_key", "access_key"}
def _redact_source(cfg: dict) -> dict:
"""敏感字段脱敏为 '***',但保留字段名以提示用户该字段已配置。"""
out = {}
for k, v in cfg.items():
if k in _SENSITIVE_KEYS:
out[k] = "***" if v else ""
else:
out[k] = v
return out
router = APIRouter(prefix="/jobs", tags=["jobs"])
@@ -31,6 +48,13 @@ def _validate_source(type_: str, cfg: dict) -> dict:
return MySQLSourceConfig(**cfg).model_dump()
if type_ == "directory":
return DirectorySourceConfig(**cfg).model_dump()
if type_ == "influxdb":
ver = cfg.get("version")
if ver == "1":
return InfluxDBSourceConfigV1(**cfg).model_dump()
if ver == "2":
return InfluxDBSourceConfigV2(**cfg).model_dump()
raise HTTPException(status_code=400, detail="InfluxDB config 缺少 version (1 或 2)")
raise HTTPException(status_code=400, detail=f"Unsupported job type: {type_}")
@@ -43,6 +67,12 @@ def _job_to_with_last_run(job: Job, db) -> JobWithLastRun:
)
out = JobWithLastRun.model_validate(job)
out.last_run = RunSummary.model_validate(last) if last else None
# 解密 source_config 并脱敏
try:
cfg = decrypt_dict(job.source_config_json)
out.source_config_safe = _redact_source(cfg)
except Exception:
out.source_config_safe = {}
return out
@@ -53,7 +83,7 @@ def list_jobs(db: DBSession, _: CurrentUser) -> list[JobWithLastRun]:
@router.post("", response_model=JobOut, status_code=201)
def create_job(payload: JobCreate, db: DBSession, _: AdminUser) -> Job:
def create_job(payload: JobCreate, db: DBSession, _: AdminUser) -> JobOut:
if db.query(Job).filter(Job.name == payload.name).first():
raise HTTPException(status_code=400, detail="名称已存在")
if not db.get(Storage, payload.storage_id):
@@ -74,7 +104,9 @@ def create_job(payload: JobCreate, db: DBSession, _: AdminUser) -> Job:
db.commit()
db.refresh(job)
reschedule_job(job.id)
return job
out = JobOut.model_validate(job)
out.source_config_safe = _redact_source(src)
return out
@router.get("/{job_id}", response_model=JobWithLastRun)
@@ -86,7 +118,7 @@ def get_job(job_id: int, db: DBSession, _: CurrentUser) -> JobWithLastRun:
@router.put("/{job_id}", response_model=JobOut)
def update_job(job_id: int, payload: JobUpdate, db: DBSession, _: AdminUser) -> Job:
def update_job(job_id: int, payload: JobUpdate, db: DBSession, _: AdminUser) -> JobOut:
job = db.get(Job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Not found")
@@ -94,9 +126,19 @@ def update_job(job_id: int, payload: JobUpdate, db: DBSession, _: AdminUser) ->
if db.query(Job).filter(Job.name == payload.name, Job.id != job_id).first():
raise HTTPException(status_code=400, detail="名称已存在")
job.name = payload.name
new_src: dict | None = None
if payload.source_config is not None:
src = _validate_source(job.type, payload.source_config)
job.source_config_json = encrypt_dict(src)
new_src = _validate_source(job.type, payload.source_config)
# 如果 password 为 "***" 占位符,保留原值
existing_src = decrypt_dict(job.source_config_json)
for k in _SENSITIVE_KEYS:
if (
k in new_src
and k in existing_src
and new_src[k] == "***"
):
new_src[k] = existing_src[k]
job.source_config_json = encrypt_dict(new_src)
if payload.storage_id is not None:
if not db.get(Storage, payload.storage_id):
raise HTTPException(status_code=400, detail="存储目标不存在")
@@ -114,7 +156,10 @@ def update_job(job_id: int, payload: JobUpdate, db: DBSession, _: AdminUser) ->
db.commit()
db.refresh(job)
reschedule_job(job.id)
return job
out = JobOut.model_validate(job)
cfg = new_src if new_src is not None else decrypt_dict(job.source_config_json)
out.source_config_safe = _redact_source(cfg)
return out
@router.delete("/{job_id}", status_code=204)
@@ -148,7 +193,7 @@ async def run_job(job_id: int, db: DBSession, _: CurrentUser) -> JobRunResponse:
@router.post("/{job_id}/toggle", response_model=JobOut)
def toggle_job(job_id: int, db: DBSession, _: AdminUser) -> Job:
def toggle_job(job_id: int, db: DBSession, _: AdminUser) -> JobOut:
job = db.get(Job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Not found")
@@ -156,4 +201,6 @@ def toggle_job(job_id: int, db: DBSession, _: AdminUser) -> Job:
db.commit()
db.refresh(job)
reschedule_job(job.id)
return job
out = JobOut.model_validate(job)
out.source_config_safe = _redact_source(decrypt_dict(job.source_config_json))
return out
+194
View File
@@ -0,0 +1,194 @@
"""InfluxDB 备份:通过 influx / influxd CLI 生成备份目录,再 tar.gz 流式打包。
支持 v1.x 和 v2.x
- v1.x:使用 influx backup CLI 子命令)
等价做法:用 influx_inspect export 转 line protocol,然后打包
这里采用更通用的:influxd backup(如果有) 或 打包 line-protocol dump
- v2.x:使用 influx backup <bucket> 命令生成备份目录
策略:
1. 在临时目录执行 backup 命令生成文件
2. 用 tarfile 流式打包整个目录
3. 子进程产出后整体打包上传
"""
import asyncio
import os
import shutil
import subprocess
import tarfile
import tempfile
from datetime import datetime
from pathlib import Path
from typing import AsyncIterator
from app.core.backup.base import BackupMetadata, BaseBackup
from app.utils.logging import get_logger
log = get_logger(__name__)
class InfluxDBBackup(BaseBackup):
def __init__(self, source_config: dict, job_name: str):
super().__init__(source_config, job_name)
self.version: str = source_config["version"]
self.host: str = source_config["host"]
self.port: int = int(source_config.get("port", 8086))
if self.version == "1":
self.database: str = source_config["database"]
self.username: str = source_config.get("username", "")
self.password: str = source_config.get("password", "")
else:
self.org: str = source_config["org"]
self.bucket: str = source_config["bucket"]
self.token: str = source_config["token"]
def metadata(self) -> BackupMetadata:
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
if self.version == "1":
safe = "".join(c if c.isalnum() or c in ("_", "-") else "_" for c in self.database)
else:
safe = "".join(c if c.isalnum() or c in ("_", "-") else "_" for c in self.bucket)
return BackupMetadata(
suggested_filename=f"{self.job_name}-{self.version}-{safe}-{ts}.tar.gz",
content_type="application/gzip",
extra={"version": self.version, "host": self.host, "port": self.port},
)
async def produce(self) -> AsyncIterator[bytes]:
ts = datetime.now().strftime("%Y%m%d%H%M%S")
tmpdir = Path(tempfile.mkdtemp(prefix=f"influxbk_{self.job_name}_"))
backup_dir = tmpdir / "data"
backup_dir.mkdir()
try:
if self.version == "2":
await self._backup_v2(backup_dir)
else:
await self._backup_v1(backup_dir)
# 打包为 tar.gz 并流式输出
log.info("Taring influx backup at %s", backup_dir)
tar_path = tmpdir / "backup.tar.gz"
def _tar() -> None:
with tarfile.open(str(tar_path), mode="w:gz") as tar:
tar.add(str(backup_dir), arcname="data", recursive=True)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _tar)
if not tar_path.exists() or tar_path.stat().st_size == 0:
raise RuntimeError("InfluxDB 备份为空,请检查数据库连接和 CLI 安装")
# 读取打包文件并流式产出
with open(tar_path, "rb") as f:
while True:
chunk = await loop.run_in_executor(None, f.read, 64 * 1024)
if not chunk:
break
yield chunk
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
async def _backup_v2(self, dest: Path) -> None:
"""v2: influx backup --bucket X --org Y /dest"""
cmd = [
"influx", "backup",
"--bucket", self.bucket,
"--org", self.org,
"--host", f"http://{self.host}:{self.port}",
"--token", self.token,
str(dest),
]
log.info("Running: %s", " ".join(self._redact(cmd)))
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(
f"influx backup failed (exit={proc.returncode}): "
f"{(stderr or stdout).decode(errors='replace')[:2000]}"
)
async def _backup_v1(self, dest: Path) -> None:
"""v1: 通过 HTTP API 查询全量数据,导出为 CSV。
使用 httpx 直接调 InfluxDB v1 的 /query 接口(更可靠,不依赖 CLI 子命令兼容性)。
生成:
- {database}.csvSELECT * 结果
- META.txt:恢复所需的元信息(库名、host、时间戳等)
恢复:需手动用 influx -import 或写入 line protocol。
"""
import httpx
log.info("InfluxDB v1 backup via HTTP API: db=%s host=%s:%s",
self.database, self.host, self.port)
url = f"http://{self.host}:{self.port}/query"
params = {
"db": self.database,
"q": "SELECT * FROM /.*/",
"chunked": "true",
"epoch": "ns",
}
auth = None
if self.username:
auth = (self.username, self.password or "")
async with httpx.AsyncClient(timeout=300.0, auth=auth) as client:
try:
resp = await client.get(url, params=params)
except Exception as e:
raise RuntimeError(f"InfluxDB v1 HTTP 请求失败: {e}") from e
if resp.status_code != 200:
raise RuntimeError(
f"InfluxDB v1 返回 {resp.status_code}: {resp.text[:500]}"
)
body = resp.text
# 保存 CSV 和元信息
csv_file = dest / f"{self.database}.csv"
loop = asyncio.get_event_loop()
def _write_files() -> None:
csv_file.write_text(body, encoding="utf-8")
meta = dest / "META.txt"
meta.write_text(
f"influxdb_version=1\n"
f"host={self.host}\nport={self.port}\n"
f"database={self.database}\n"
f"username={self.username or ''}\n"
f"query=SELECT * FROM /.*/\n"
f"format=csv\n"
f"backup_time={datetime.utcnow().isoformat()}Z\n"
f"restore_hint=可使用 `influx -import -path={csv_file.name} "
f"-database={self.database}` 恢复;或在 InfluxQL shell 中执行 "
f"`INSERT INTO ... VALUES ...`\n",
encoding="utf-8",
)
await loop.run_in_executor(None, _write_files)
if not csv_file.exists() or csv_file.stat().st_size == 0:
raise RuntimeError("InfluxDB v1 查询结果为空,请检查数据库是否存在或是否有数据")
@staticmethod
def _redact(cmd: list[str]) -> list[str]:
"""日志脱敏 token/password。"""
out = []
skip_next = False
secret_flags = {"--token", "-password"}
for i, c in enumerate(cmd):
if skip_next:
out.append("***")
skip_next = False
continue
if c in secret_flags:
out.append(c)
skip_next = True
continue
out.append(c)
return out
+8
View File
@@ -21,6 +21,7 @@ class MySQLBackup(BaseBackup):
self.user: str = source_config["user"]
self.password: str = source_config.get("password", "")
self.database: str = source_config["database"]
self.use_ssl: bool = bool(source_config.get("use_ssl", False))
self.extra_args: str = source_config.get("extra_args", "")
def metadata(self) -> BackupMetadata:
@@ -45,6 +46,13 @@ class MySQLBackup(BaseBackup):
"--events",
"--default-character-set=utf8mb4",
]
# SSL 控制:
# MariaDB 11.x 中 --ssl 和 --ssl-verify-server-cert 都默认开启,
# 与自签名证书的服务器连接会失败。这里 use_ssl=True 时强制开启 SSL
# 但跳过服务端证书校验(兼容自签名证书场景)。
if self.use_ssl:
args.append("--ssl")
args.append("--skip-ssl-verify-server-cert")
if self.extra_args:
args.extend(shlex.split(self.extra_args))
args.append(self.database)
+3
View File
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from app.core.backup.base import BaseBackup
from app.core.backup.directory import DirectoryBackup
from app.core.backup.influxdb import InfluxDBBackup
from app.core.backup.mysql import MySQLBackup
from app.core.retention import apply_retention
from app.core.storage.factory import create_storage
@@ -32,6 +33,8 @@ def _make_backup(job: Job) -> BaseBackup:
return MySQLBackup(src, job.name)
if job.type == "directory":
return DirectoryBackup(src, job.name)
if job.type == "influxdb":
return InfluxDBBackup(src, job.name)
raise ValueError(f"Unsupported job type: {job.type}")
+25 -1
View File
@@ -12,6 +12,7 @@ class MySQLSourceConfig(BaseModel):
user: str = Field(..., min_length=1)
password: str = Field("", description="可空(trust auth")
database: str = Field(..., min_length=1, description="要备份的库名")
use_ssl: bool = Field(False, description="True 启用 SSL/TLS;如服务器是自签名证书建议关闭")
extra_args: str = Field("", description="额外 mysqldump 参数,如 '--skip-lock-tables'")
@@ -22,9 +23,27 @@ class DirectorySourceConfig(BaseModel):
)
class InfluxDBSourceConfigV1(BaseModel):
version: Literal["1"]
host: str = Field(..., min_length=1)
port: int = Field(8086, ge=1, le=65535)
database: str = Field(..., min_length=1)
username: str = Field("", description="留空表示无认证")
password: str = Field("")
class InfluxDBSourceConfigV2(BaseModel):
version: Literal["2"]
host: str = Field(..., min_length=1)
port: int = Field(8086, ge=1, le=65535)
org: str = Field(..., min_length=1)
bucket: str = Field(..., min_length=1)
token: str = Field(..., min_length=1)
class JobBase(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
type: Literal["mysql", "directory"]
type: Literal["mysql", "directory", "influxdb"]
source_config: dict
storage_id: int
cron_expression: Optional[str] = Field(None, description="标准 5 段 cron 表达式,空表示手动")
@@ -73,6 +92,11 @@ class JobOut(BaseModel):
storage: StorageOut
created_at: datetime
updated_at: datetime
# 解密后的数据源配置;敏感字段已脱敏为 "***"
source_config_safe: dict = Field(
default_factory=dict,
description="解密后的 source_config,敏感字段(password/token/secret_key)已脱敏",
)
class RunSummary(BaseModel):
+299
View File
@@ -0,0 +1,299 @@
2026-06-22 18:31:38 | INFO | app.main | Database tables ensured
2026-06-22 18:31:38 | INFO | app.main | Existing users found (1), skip admin init
2026-06-22 18:31:38 | INFO | apscheduler.scheduler | Scheduler started
2026-06-22 18:31:38 | INFO | app.scheduler.scheduler | Scheduler started
2026-06-22 18:31:38 | INFO | app.main | Application ready
2026-06-22 18:32:10 | INFO | app.core.executor | [10:32:10] Backup started: run_id=7 job=app-dir-test trigger=manual
2026-06-22 18:32:10 | INFO | app.core.executor | [10:32:10] Metadata: filename=app-dir-test-app-20260622-183210.tar.gz content_type=application/gzip
2026-06-22 18:32:10 | INFO | app.core.executor | [10:32:10] Storage test: ok=True msg=OK: writable /app/data/backups
2026-06-22 18:32:10 | INFO | app.core.executor | [10:32:10] Streaming backup to storage key=app-dir-test/2026-06-22/app-dir-test-app-20260622-183210.tar.gz ...
2026-06-22 18:32:10 | INFO | app.core.backup.directory | Taring directory /app (exclude=['.git', '__pycache__', 'node_modules'])
2026-06-22 18:32:10 | INFO | app.core.executor | [10:32:10] Backup success: size=63600 bytes sha256=530e79b90471 elapsed=0s
2026-06-22 18:32:10 | INFO | app.core.executor | Backup run_id=7 finished status=success
2026-06-22 18:35:48 | INFO | app.main | Database tables ensured
2026-06-22 18:35:48 | INFO | app.main | Existing users found (1), skip admin init
2026-06-22 18:35:48 | INFO | apscheduler.scheduler | Scheduler started
2026-06-22 18:35:48 | INFO | app.scheduler.scheduler | Scheduler started
2026-06-22 18:35:48 | INFO | app.main | Application ready
2026-06-22 18:36:21 | INFO | app.core.executor | [10:36:21] Backup started: run_id=8 job=app-dir-test trigger=manual
2026-06-22 18:36:21 | INFO | app.core.executor | [10:36:21] Metadata: filename=app-dir-test-app-20260622-183621.tar.gz content_type=application/gzip
2026-06-22 18:36:21 | INFO | app.core.executor | [10:36:21] Storage test: ok=True msg=OK: writable /app/data/backups
2026-06-22 18:36:21 | INFO | app.core.executor | [10:36:21] Streaming backup to storage key=app-dir-test/2026-06-22/app-dir-test-app-20260622-183621.tar.gz ...
2026-06-22 18:36:21 | INFO | app.core.backup.directory | Taring directory /app (exclude=['.git', '__pycache__', 'node_modules'])
2026-06-22 18:36:21 | INFO | app.core.executor | [10:36:21] Backup success: size=127360 bytes sha256=3af4260e15ab elapsed=0s
2026-06-22 18:36:21 | INFO | app.core.executor | Backup run_id=8 finished status=success
2026-06-22 18:44:59 | INFO | app.core.executor | [10:44:59] Backup started: run_id=9 job=mysql_tmp trigger=manual
2026-06-22 18:44:59 | INFO | app.core.executor | [10:44:59] Metadata: filename=mysql_tmp-asset_management-20260622-184459.sql.gz content_type=application/gzip
2026-06-22 18:44:59 | INFO | app.core.executor | [10:44:59] Storage test: ok=True msg=OK: writable /app/backup_data
2026-06-22 18:44:59 | INFO | app.core.executor | [10:44:59] Streaming backup to storage key=mysql_tmp/2026-06-22/mysql_tmp-asset_management-20260622-184459.sql.gz ...
2026-06-22 18:44:59 | INFO | app.core.backup.mysql | Executing mysqldump for db=asset_management host=10.168.1.209
2026-06-22 18:44:59 | ERROR | app.core.executor | Backup run_id=9 failed
Traceback (most recent call last):
File "/app/app/core/executor.py", line 88, in run_backup
size, sha = await storage.upload_stream(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/app/app/core/storage/local.py", line 55, in upload_stream
async for chunk in source:
File "/app/app/core/executor.py", line 84, in _consume_with_progress
async for chunk in backup.produce():
File "/app/app/core/backup/mysql.py", line 95, in produce
raise RuntimeError(
RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2002: "Can't connect to server on '10.168.1.209' (115)" when trying to connect
2026-06-22 18:44:59 | ERROR | app.core.executor | [10:44:59] Backup failed: RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2002: "Can't connect to server on '10.168.1.209' (115)" when trying to connect
2026-06-22 18:44:59 | INFO | app.core.executor | Backup run_id=9 finished status=failed
2026-06-22 18:46:29 | INFO | app.core.executor | [10:46:29] Backup started: run_id=10 job=back_mysql trigger=manual
2026-06-22 18:46:29 | INFO | app.core.executor | [10:46:29] Metadata: filename=back_mysql-asset_management-20260622-184629.sql.gz content_type=application/gzip
2026-06-22 18:46:29 | INFO | app.core.executor | [10:46:29] Storage test: ok=True msg=OK: writable /app/backup_data
2026-06-22 18:46:29 | INFO | app.core.executor | [10:46:29] Streaming backup to storage key=back_mysql/2026-06-22/back_mysql-asset_management-20260622-184629.sql.gz ...
2026-06-22 18:46:29 | INFO | app.core.backup.mysql | Executing mysqldump for db=asset_management host=127.0.0.1
2026-06-22 18:46:29 | ERROR | app.core.executor | Backup run_id=10 failed
Traceback (most recent call last):
File "/app/app/core/executor.py", line 88, in run_backup
size, sha = await storage.upload_stream(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/app/app/core/storage/local.py", line 55, in upload_stream
async for chunk in source:
File "/app/app/core/executor.py", line 84, in _consume_with_progress
async for chunk in backup.produce():
File "/app/app/core/backup/mysql.py", line 95, in produce
raise RuntimeError(
RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2002: "Can't connect to server on '127.0.0.1' (115)" when trying to connect
2026-06-22 18:46:29 | ERROR | app.core.executor | [10:46:29] Backup failed: RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2002: "Can't connect to server on '127.0.0.1' (115)" when trying to connect
2026-06-22 18:46:29 | INFO | app.core.executor | Backup run_id=10 finished status=failed
2026-06-22 18:53:57 | INFO | app.core.executor | [10:53:57] Backup started: run_id=11 job=mysql_backuop trigger=manual
2026-06-22 18:53:57 | INFO | app.core.executor | [10:53:57] Metadata: filename=mysql_backuop-asset_management-20260622-185357.sql.gz content_type=application/gzip
2026-06-22 18:53:57 | INFO | app.core.executor | [10:53:57] Storage test: ok=True msg=OK: writable /app/backup_data
2026-06-22 18:53:57 | INFO | app.core.executor | [10:53:57] Streaming backup to storage key=mysql_backuop/2026-06-22/mysql_backuop-asset_management-20260622-185357.sql.gz ...
2026-06-22 18:53:57 | INFO | app.core.backup.mysql | Executing mysqldump for db=asset_management host=10.168.1.209
2026-06-22 18:53:57 | ERROR | app.core.executor | Backup run_id=11 failed
Traceback (most recent call last):
File "/app/app/core/executor.py", line 88, in run_backup
size, sha = await storage.upload_stream(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/app/app/core/storage/local.py", line 55, in upload_stream
async for chunk in source:
File "/app/app/core/executor.py", line 84, in _consume_with_progress
async for chunk in backup.produce():
File "/app/app/core/backup/mysql.py", line 95, in produce
raise RuntimeError(
RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2026: "TLS/SSL error: self-signed certificate in certificate chain" when trying to connect
2026-06-22 18:53:57 | ERROR | app.core.executor | [10:53:57] Backup failed: RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2026: "TLS/SSL error: self-signed certificate in certificate chain" when trying to connect
2026-06-22 18:53:57 | INFO | app.core.executor | Backup run_id=11 finished status=failed
2026-06-22 18:54:42 | INFO | app.core.executor | [10:54:42] Backup started: run_id=12 job=asset-mgmt-test trigger=manual
2026-06-22 18:54:42 | INFO | app.core.executor | [10:54:42] Metadata: filename=asset-mgmt-test-asset_management-20260622-185442.sql.gz content_type=application/gzip
2026-06-22 18:54:42 | INFO | app.core.executor | [10:54:42] Storage test: ok=True msg=OK: writable /app/data/backups
2026-06-22 18:54:42 | INFO | app.core.executor | [10:54:42] Streaming backup to storage key=asset-mgmt-test/2026-06-22/asset-mgmt-test-asset_management-20260622-185442.sql.gz ...
2026-06-22 18:54:42 | INFO | app.core.backup.mysql | Executing mysqldump for db=asset_management host=10.168.1.209
2026-06-22 18:54:42 | ERROR | app.core.executor | Backup run_id=12 failed
Traceback (most recent call last):
File "/app/app/core/executor.py", line 88, in run_backup
size, sha = await storage.upload_stream(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/app/app/core/storage/local.py", line 55, in upload_stream
async for chunk in source:
File "/app/app/core/executor.py", line 84, in _consume_with_progress
async for chunk in backup.produce():
File "/app/app/core/backup/mysql.py", line 95, in produce
raise RuntimeError(
RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2026: "TLS/SSL error: self-signed certificate in certificate chain" when trying to connect
2026-06-22 18:54:42 | ERROR | app.core.executor | [10:54:42] Backup failed: RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2026: "TLS/SSL error: self-signed certificate in certificate chain" when trying to connect
2026-06-22 18:54:42 | INFO | app.core.executor | Backup run_id=12 finished status=failed
2026-06-22 19:03:33 | INFO | app.main | Database tables ensured
2026-06-22 19:03:33 | INFO | app.main | Existing users found (1), skip admin init
2026-06-22 19:03:33 | INFO | apscheduler.scheduler | Scheduler started
2026-06-22 19:03:33 | INFO | app.scheduler.scheduler | Scheduler started
2026-06-22 19:03:33 | INFO | app.main | Application ready
2026-06-22 19:03:37 | INFO | app.core.executor | [11:03:37] Backup started: run_id=13 job=asset-mgmt-test trigger=manual
2026-06-22 19:03:37 | INFO | app.core.executor | [11:03:37] Metadata: filename=asset-mgmt-test-asset_management-20260622-190337.sql.gz content_type=application/gzip
2026-06-22 19:03:37 | INFO | app.core.executor | [11:03:37] Storage test: ok=True msg=OK: writable /app/data/backups
2026-06-22 19:03:37 | INFO | app.core.executor | [11:03:37] Streaming backup to storage key=asset-mgmt-test/2026-06-22/asset-mgmt-test-asset_management-20260622-190337.sql.gz ...
2026-06-22 19:03:37 | INFO | app.core.backup.mysql | Executing mysqldump for db=asset_management host=10.168.1.209
2026-06-22 19:03:37 | ERROR | app.core.executor | Backup run_id=13 failed
Traceback (most recent call last):
File "/app/app/core/executor.py", line 91, in run_backup
size, sha = await storage.upload_stream(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/app/app/core/storage/local.py", line 55, in upload_stream
async for chunk in source:
File "/app/app/core/executor.py", line 87, in _consume_with_progress
async for chunk in backup.produce():
File "/app/app/core/backup/mysql.py", line 101, in produce
raise RuntimeError(
RuntimeError: mysqldump failed (exit=7): mysqldump: unknown variable 'ssl-mode=DISABLED'
2026-06-22 19:03:37 | ERROR | app.core.executor | [11:03:37] Backup failed: RuntimeError: mysqldump failed (exit=7): mysqldump: unknown variable 'ssl-mode=DISABLED'
2026-06-22 19:03:37 | INFO | app.core.executor | Backup run_id=13 finished status=failed
2026-06-22 19:04:42 | INFO | app.main | Database tables ensured
2026-06-22 19:04:42 | INFO | app.main | Existing users found (1), skip admin init
2026-06-22 19:04:42 | INFO | apscheduler.scheduler | Scheduler started
2026-06-22 19:04:42 | INFO | app.scheduler.scheduler | Scheduler started
2026-06-22 19:04:42 | INFO | app.main | Application ready
2026-06-22 19:04:46 | INFO | app.core.executor | [11:04:46] Backup started: run_id=14 job=asset-mgmt-test trigger=manual
2026-06-22 19:04:46 | INFO | app.core.executor | [11:04:46] Metadata: filename=asset-mgmt-test-asset_management-20260622-190446.sql.gz content_type=application/gzip
2026-06-22 19:04:46 | INFO | app.core.executor | [11:04:46] Storage test: ok=True msg=OK: writable /app/data/backups
2026-06-22 19:04:46 | INFO | app.core.executor | [11:04:46] Streaming backup to storage key=asset-mgmt-test/2026-06-22/asset-mgmt-test-asset_management-20260622-190446.sql.gz ...
2026-06-22 19:04:46 | INFO | app.core.backup.mysql | Executing mysqldump for db=asset_management host=10.168.1.209
2026-06-22 19:04:46 | ERROR | app.core.executor | Backup run_id=14 failed
Traceback (most recent call last):
File "/app/app/core/executor.py", line 91, in run_backup
size, sha = await storage.upload_stream(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/app/app/core/storage/local.py", line 55, in upload_stream
async for chunk in source:
File "/app/app/core/executor.py", line 87, in _consume_with_progress
async for chunk in backup.produce():
File "/app/app/core/backup/mysql.py", line 101, in produce
raise RuntimeError(
RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2026: "TLS/SSL error: self-signed certificate in certificate chain" when trying to connect
2026-06-22 19:04:46 | ERROR | app.core.executor | [11:04:46] Backup failed: RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2026: "TLS/SSL error: self-signed certificate in certificate chain" when trying to connect
2026-06-22 19:04:46 | INFO | app.core.executor | Backup run_id=14 finished status=failed
2026-06-22 19:06:06 | INFO | app.core.executor | [11:06:06] Backup started: run_id=15 job=asset-mgmt-test trigger=manual
2026-06-22 19:06:06 | INFO | app.core.executor | [11:06:06] Metadata: filename=asset-mgmt-test-asset_management-20260622-190606.sql.gz content_type=application/gzip
2026-06-22 19:06:06 | INFO | app.core.executor | [11:06:06] Storage test: ok=True msg=OK: writable /app/data/backups
2026-06-22 19:06:06 | INFO | app.core.executor | [11:06:06] Streaming backup to storage key=asset-mgmt-test/2026-06-22/asset-mgmt-test-asset_management-20260622-190606.sql.gz ...
2026-06-22 19:06:06 | INFO | app.core.backup.mysql | Executing mysqldump for db=asset_management host=10.168.1.209
2026-06-22 19:06:06 | ERROR | app.core.executor | Backup run_id=15 failed
Traceback (most recent call last):
File "/app/app/core/executor.py", line 91, in run_backup
size, sha = await storage.upload_stream(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/app/app/core/storage/local.py", line 55, in upload_stream
async for chunk in source:
File "/app/app/core/executor.py", line 87, in _consume_with_progress
async for chunk in backup.produce():
File "/app/app/core/backup/mysql.py", line 101, in produce
raise RuntimeError(
RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2026: "TLS/SSL error: self-signed certificate in certificate chain" when trying to connect
2026-06-22 19:06:06 | ERROR | app.core.executor | [11:06:06] Backup failed: RuntimeError: mysqldump failed (exit=2): mysqldump: Got error: 2026: "TLS/SSL error: self-signed certificate in certificate chain" when trying to connect
2026-06-22 19:06:06 | INFO | app.core.executor | Backup run_id=15 finished status=failed
2026-06-22 19:07:37 | INFO | app.main | Database tables ensured
2026-06-22 19:07:37 | INFO | app.main | Existing users found (1), skip admin init
2026-06-22 19:07:37 | INFO | apscheduler.scheduler | Scheduler started
2026-06-22 19:07:37 | INFO | app.scheduler.scheduler | Scheduler started
2026-06-22 19:07:37 | INFO | app.main | Application ready
2026-06-22 19:07:41 | INFO | app.core.executor | [11:07:41] Backup started: run_id=16 job=asset-mgmt-test trigger=manual
2026-06-22 19:07:41 | INFO | app.core.executor | [11:07:41] Metadata: filename=asset-mgmt-test-asset_management-20260622-190741.sql.gz content_type=application/gzip
2026-06-22 19:07:41 | INFO | app.core.executor | [11:07:41] Storage test: ok=True msg=OK: writable /app/data/backups
2026-06-22 19:07:41 | INFO | app.core.executor | [11:07:41] Streaming backup to storage key=asset-mgmt-test/2026-06-22/asset-mgmt-test-asset_management-20260622-190741.sql.gz ...
2026-06-22 19:07:41 | INFO | app.core.backup.mysql | Executing mysqldump for db=asset_management host=10.168.1.209
2026-06-22 19:07:42 | INFO | app.core.executor | [11:07:42] Backup success: size=762354 bytes sha256=f30fa0971e2c elapsed=0s
2026-06-22 19:07:42 | INFO | app.core.executor | Backup run_id=16 finished status=success
2026-06-22 19:10:48 | INFO | app.main | Database tables ensured
2026-06-22 19:10:48 | INFO | app.main | Existing users found (1), skip admin init
2026-06-22 19:10:48 | INFO | apscheduler.scheduler | Scheduler started
2026-06-22 19:10:48 | INFO | app.scheduler.scheduler | Scheduler started
2026-06-22 19:10:48 | INFO | app.main | Application ready
2026-06-22 19:13:14 | INFO | app.main | Database tables ensured
2026-06-22 19:13:14 | INFO | app.main | Existing users found (1), skip admin init
2026-06-22 19:13:14 | INFO | apscheduler.scheduler | Scheduler started
2026-06-22 19:13:14 | INFO | app.scheduler.scheduler | Scheduler started
2026-06-22 19:13:14 | INFO | app.main | Application ready
2026-06-22 19:13:43 | INFO | app.core.executor | [11:13:43] Backup started: run_id=17 job=influxdb-v2-test trigger=manual
2026-06-22 19:13:43 | INFO | app.core.executor | [11:13:43] Metadata: filename=influxdb-v2-test-2-my-bucket-20260622-191343.tar.gz content_type=application/gzip
2026-06-22 19:13:43 | INFO | app.core.executor | [11:13:43] Storage test: ok=True msg=OK: writable /app/data/backups
2026-06-22 19:13:43 | INFO | app.core.executor | [11:13:43] Streaming backup to storage key=influxdb-v2-test/2026-06-22/influxdb-v2-test-2-my-bucket-20260622-191343.tar.gz ...
2026-06-22 19:13:43 | INFO | app.core.backup.influxdb | Running: influx backup --bucket my-bucket --org my-org --host http://10.168.1.209:8086 --token *** /tmp/influxbk_influxdb-v2-test_u90psu8e/data
2026-06-22 19:13:43 | ERROR | app.core.executor | Backup run_id=17 failed
Traceback (most recent call last):
File "/app/app/core/executor.py", line 91, in run_backup
size, sha = await storage.upload_stream(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/app/app/core/storage/local.py", line 55, in upload_stream
async for chunk in source:
File "/app/app/core/executor.py", line 87, in _consume_with_progress
async for chunk in backup.produce():
File "/app/app/core/backup/influxdb.py", line 65, in produce
await self._backup_v2(backup_dir)
File "/app/app/core/backup/influxdb.py", line 111, in _backup_v2
raise RuntimeError(
RuntimeError: influx backup failed (exit=1): 2026/06/22 19:13:43 INFO: Downloading metadata snapshot
Error: failed to backup metadata: failed to download metadata snapshot: 401 Unauthorized: unauthorized access
2026-06-22 19:13:43 | ERROR | app.core.executor | [11:13:43] Backup failed: RuntimeError: influx backup failed (exit=1): 2026/06/22 19:13:43 INFO: Downloading metadata snapshot
Error: failed to backup metadata: failed to download metadata snapshot: 401 Unauthorized: unauthorized access
2026-06-22 19:13:43 | INFO | app.core.executor | Backup run_id=17 finished status=failed
2026-06-22 19:14:07 | INFO | app.core.executor | [11:14:07] Backup started: run_id=18 job=influxdb-v1-test trigger=manual
2026-06-22 19:14:07 | INFO | app.core.executor | [11:14:07] Metadata: filename=influxdb-v1-test-1-telegraf-20260622-191407.tar.gz content_type=application/gzip
2026-06-22 19:14:07 | INFO | app.core.executor | [11:14:07] Storage test: ok=True msg=OK: writable /app/data/backups
2026-06-22 19:14:07 | INFO | app.core.executor | [11:14:07] Streaming backup to storage key=influxdb-v1-test/2026-06-22/influxdb-v1-test-1-telegraf-20260622-191407.tar.gz ...
2026-06-22 19:14:07 | INFO | app.core.backup.influxdb | InfluxDB v1 backup via HTTP API: db=telegraf host=127.0.0.1:8086
2026-06-22 19:14:07 | ERROR | app.core.executor | Backup run_id=18 failed
Traceback (most recent call last):
File "/usr/local/lib/python3.11/site-packages/httpx/_transports/default.py", line 72, in map_httpcore_exceptions
yield
File "/usr/local/lib/python3.11/site-packages/httpx/_transports/default.py", line 377, in handle_async_request
resp = await self._pool.handle_async_request(req)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 256, in handle_async_request
raise exc from None
File "/usr/local/lib/python3.11/site-packages/httpcore/_async/connection_pool.py", line 236, in handle_async_request
response = await connection.handle_async_request(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpcore/_async/connection.py", line 101, in handle_async_request
raise exc
File "/usr/local/lib/python3.11/site-packages/httpcore/_async/connection.py", line 78, in handle_async_request
stream = await self._connect(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpcore/_async/connection.py", line 124, in _connect
stream = await self._network_backend.connect_tcp(**kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpcore/_backends/auto.py", line 31, in connect_tcp
return await self._backend.connect_tcp(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpcore/_backends/anyio.py", line 113, in connect_tcp
with map_exceptions(exc_map):
File "/usr/local/lib/python3.11/contextlib.py", line 158, in __exit__
self.gen.throw(typ, value, traceback)
File "/usr/local/lib/python3.11/site-packages/httpcore/_exceptions.py", line 14, in map_exceptions
raise to_exc(exc) from exc
httpcore.ConnectError: All connection attempts failed
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/app/app/core/backup/influxdb.py", line 143, in _backup_v1
resp = await client.get(url, params=params)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpx/_client.py", line 1814, in get
return await self.request(
^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpx/_client.py", line 1585, in request
return await self.send(request, auth=auth, follow_redirects=follow_redirects)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpx/_client.py", line 1674, in send
response = await self._send_handling_auth(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpx/_client.py", line 1702, in _send_handling_auth
response = await self._send_handling_redirects(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpx/_client.py", line 1739, in _send_handling_redirects
response = await self._send_single_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpx/_client.py", line 1776, in _send_single_request
response = await transport.handle_async_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/httpx/_transports/default.py", line 376, in handle_async_request
with map_httpcore_exceptions():
File "/usr/local/lib/python3.11/contextlib.py", line 158, in __exit__
self.gen.throw(typ, value, traceback)
File "/usr/local/lib/python3.11/site-packages/httpx/_transports/default.py", line 89, in map_httpcore_exceptions
raise mapped_exc(message) from exc
httpx.ConnectError: All connection attempts failed
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/app/app/core/executor.py", line 91, in run_backup
size, sha = await storage.upload_stream(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/app/app/core/storage/local.py", line 55, in upload_stream
async for chunk in source:
File "/app/app/core/executor.py", line 87, in _consume_with_progress
async for chunk in backup.produce():
File "/app/app/core/backup/influxdb.py", line 67, in produce
await self._backup_v1(backup_dir)
File "/app/app/core/backup/influxdb.py", line 145, in _backup_v1
raise RuntimeError(f"InfluxDB v1 HTTP 请求失败: {e}") from e
RuntimeError: InfluxDB v1 HTTP 请求失败: All connection attempts failed
2026-06-22 19:14:07 | ERROR | app.core.executor | [11:14:07] Backup failed: RuntimeError: InfluxDB v1 HTTP 请求失败: All connection attempts failed
2026-06-22 19:14:07 | INFO | app.core.executor | Backup run_id=18 finished status=failed
+154 -46
View File
@@ -22,7 +22,7 @@ const { Title } = Typography
interface FormValues {
name: string
type: 'mysql' | 'directory'
type: 'mysql' | 'directory' | 'influxdb'
storage_id: number
enabled: boolean
cron_expression?: string
@@ -35,29 +35,64 @@ interface FormValues {
mysql_user?: string
mysql_password?: string
mysql_database?: string
mysql_use_ssl?: boolean
mysql_extra_args?: string
// Directory
dir_path?: string
dir_exclude_patterns?: string
// InfluxDB
influx_version?: '1' | '2'
influx_host?: string
influx_port?: number
influx_database?: string
influx_username?: string
influx_password?: string
influx_org?: string
influx_bucket?: string
influx_token?: string
}
function toPayload(values: FormValues): JobCreate {
const source_config =
values.type === 'mysql'
? {
host: values.mysql_host!,
port: values.mysql_port || 3306,
user: values.mysql_user!,
password: values.mysql_password || '',
database: values.mysql_database!,
extra_args: values.mysql_extra_args || '',
}
: {
path: values.dir_path!,
exclude_patterns: values.dir_exclude_patterns
? values.dir_exclude_patterns.split(',').map((s) => s.trim()).filter(Boolean)
: [],
}
let source_config: any
if (values.type === 'mysql') {
source_config = {
host: values.mysql_host!,
port: values.mysql_port || 3306,
user: values.mysql_user!,
password: values.mysql_password || '',
database: values.mysql_database!,
use_ssl: !!values.mysql_use_ssl,
extra_args: values.mysql_extra_args || '',
}
} else if (values.type === 'directory') {
source_config = {
path: values.dir_path!,
exclude_patterns: values.dir_exclude_patterns
? values.dir_exclude_patterns.split(',').map((s) => s.trim()).filter(Boolean)
: [],
}
} else {
// influxdb
if (values.influx_version === '1') {
source_config = {
version: '1',
host: values.influx_host!,
port: values.influx_port || 8086,
database: values.influx_database!,
username: values.influx_username || '',
password: values.influx_password || '',
}
} else {
source_config = {
version: '2',
host: values.influx_host!,
port: values.influx_port || 8086,
org: values.influx_org!,
bucket: values.influx_bucket!,
token: values.influx_token!,
}
}
}
return {
name: values.name,
@@ -89,7 +124,8 @@ export function JobForm({ mode }: { mode: 'create' | 'edit' }) {
.get(Number(id))
.then((j) => {
setExisting(j)
const flat: Partial<FormValues> = {
const cfg = j.source_config_safe || {}
let flat: Partial<FormValues> = {
name: j.name,
type: j.type,
storage_id: j.storage.id,
@@ -99,7 +135,38 @@ export function JobForm({ mode }: { mode: 'create' | 'edit' }) {
retention_days: j.retention_days ?? undefined,
description: j.description || '',
}
// 反解 source_config(需要解密后的,前端拿不到 - 用空 source_config 占位让用户重填)
// source_config_safe 映射到表单字段
if (j.type === 'mysql') {
flat = {
...flat,
mysql_host: cfg.host,
mysql_port: cfg.port ?? 3306,
mysql_user: cfg.user,
mysql_password: cfg.password ?? '', // 可能是 "***"
mysql_database: cfg.database,
mysql_use_ssl: !!cfg.use_ssl,
mysql_extra_args: cfg.extra_args ?? '',
}
} else if (j.type === 'directory') {
flat = {
...flat,
dir_path: cfg.path,
dir_exclude_patterns: (cfg.exclude_patterns || []).join(', '),
}
} else if (j.type === 'influxdb') {
flat = {
...flat,
influx_version: cfg.version ?? '1',
influx_host: cfg.host,
influx_port: cfg.port ?? 8086,
influx_database: cfg.database,
influx_username: cfg.username,
influx_password: cfg.password,
influx_org: cfg.org,
influx_bucket: cfg.bucket,
influx_token: cfg.token,
}
}
form.setFieldsValue(flat as FormValues)
})
}
@@ -112,16 +179,9 @@ export function JobForm({ mode }: { mode: 'create' | 'edit' }) {
await jobApi.create(toPayload(values))
message.success('创建成功')
} else {
const payload: JobUpdate = {
name: values.name,
storage_id: values.storage_id,
enabled: values.enabled,
cron_expression: values.cron_expression?.trim() || null,
retention_count: values.retention_count,
retention_days: values.retention_days ?? null,
description: values.description ?? null,
}
await jobApi.update(Number(id), payload)
// 编辑模式:把所有字段一并提交(包括 source_config
// 密码字段为 "***" 时后端会自动保留原值
await jobApi.update(Number(id), toPayload(values) as any)
message.success('已更新')
}
navigate('/jobs')
@@ -162,41 +222,41 @@ export function JobForm({ mode }: { mode: 'create' | 'edit' }) {
options={[
{ value: 'mysql', label: 'MySQL 数据库' },
{ value: 'directory', label: '服务器目录' },
{ value: 'influxdb', label: 'InfluxDB 数据库' },
]}
/>
</Form.Item>
{mode === 'edit' && existing && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="编辑模式下数据源配置(密码/目录)不可见,请通过重建任务来修改。"
/>
)}
{jobType === 'mysql' && (
<>
<Divider orientation="left">MySQL </Divider>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="mysql_host" label="主机" rules={[{ required: jobType === 'mysql' }]} style={{ flex: 3, marginRight: 8 }}>
<Input placeholder="127.0.0.1" disabled={mode === 'edit'} />
<Input placeholder="127.0.0.1" />
</Form.Item>
<Form.Item name="mysql_port" label="端口" style={{ flex: 1 }}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} disabled={mode === 'edit'} />
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
</Space.Compact>
<Form.Item name="mysql_user" label="用户名" rules={[{ required: jobType === 'mysql' }]}>
<Input disabled={mode === 'edit'} />
<Input />
</Form.Item>
<Form.Item name="mysql_password" label="密码" extra="留空表示无密码">
<Input.Password placeholder="数据库密码" disabled={mode === 'edit'} />
<Input.Password placeholder="数据库密码" />
</Form.Item>
<Form.Item name="mysql_database" label="数据库名" rules={[{ required: jobType === 'mysql' }]}>
<Input disabled={mode === 'edit'} />
<Input />
</Form.Item>
<Form.Item
name="mysql_use_ssl"
label="启用 SSL/TLS"
extra="默认关闭;若服务器使用自签名证书请保持关闭"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item name="mysql_extra_args" label="额外 mysqldump 参数" extra="如:--skip-lock-tables">
<Input disabled={mode === 'edit'} />
<Input />
</Form.Item>
</>
)}
@@ -205,18 +265,66 @@ export function JobForm({ mode }: { mode: 'create' | 'edit' }) {
<>
<Divider orientation="left"></Divider>
<Form.Item name="dir_path" label="要备份的目录" rules={[{ required: jobType === 'directory' }]}>
<Input placeholder="/var/log" disabled={mode === 'edit'} />
<Input placeholder="/var/log" />
</Form.Item>
<Form.Item
name="dir_exclude_patterns"
label="排除规则(逗号分隔)"
extra="glob 模式,按文件名匹配,如:.DS_Store,__pycache__,node_modules"
>
<Input disabled={mode === 'edit'} />
<Input />
</Form.Item>
</>
)}
{jobType === 'influxdb' && (
<>
<Divider orientation="left">InfluxDB </Divider>
<Form.Item name="influx_version" label="版本" rules={[{ required: true }]}>
<Select
options={[
{ value: '1', label: 'InfluxDB v1.x' },
{ value: '2', label: 'InfluxDB v2.x' },
]}
/>
</Form.Item>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="influx_host" label="主机" rules={[{ required: jobType === 'influxdb' }]} style={{ flex: 3, marginRight: 8 }}>
<Input placeholder="127.0.0.1" />
</Form.Item>
<Form.Item name="influx_port" label="端口" style={{ flex: 1 }}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
</Space.Compact>
{Form.useWatch('influx_version', form) === '1' ? (
<>
<Form.Item name="influx_database" label="数据库名" rules={[{ required: true }]}>
<Input placeholder="telegraf" />
</Form.Item>
<Form.Item name="influx_username" label="用户名">
<Input placeholder="留空表示无认证" />
</Form.Item>
<Form.Item name="influx_password" label="密码">
<Input.Password placeholder="留空表示无密码" />
</Form.Item>
</>
) : (
<>
<Form.Item name="influx_org" label="Organization" rules={[{ required: true }]}>
<Input placeholder="my-org" />
</Form.Item>
<Form.Item name="influx_bucket" label="Bucket" rules={[{ required: true }]}>
<Input placeholder="my-bucket" />
</Form.Item>
<Form.Item name="influx_token" label="API Token" rules={[{ required: true }]}>
<Input.Password placeholder="influx token ..." />
</Form.Item>
</>
)}
</>
)}
<Divider orientation="left"></Divider>
<Form.Item name="storage_id" label="存储目标" rules={[{ required: true, message: '请选择存储目标' }]}>
+26 -4
View File
@@ -68,6 +68,7 @@ export interface MySQLSourceConfig {
user: string
password?: string
database: string
use_ssl?: boolean
extra_args?: string
}
@@ -76,6 +77,26 @@ export interface DirectorySourceConfig {
exclude_patterns?: string[]
}
export interface InfluxDBSourceConfigV1 {
version: '1'
host: string
port: number
database: string
username?: string
password?: string
}
export interface InfluxDBSourceConfigV2 {
version: '2'
host: string
port: number
org: string
bucket: string
token: string
}
export type InfluxDBSourceConfig = InfluxDBSourceConfigV1 | InfluxDBSourceConfigV2
export interface RunSummary {
id: number
status: string
@@ -90,7 +111,7 @@ export interface RunSummary {
export interface JobOut {
id: number
name: string
type: 'mysql' | 'directory'
type: 'mysql' | 'directory' | 'influxdb'
cron_expression: string | null
enabled: boolean
retention_count: number
@@ -99,6 +120,7 @@ export interface JobOut {
storage: StorageOut
created_at: string
updated_at: string
source_config_safe?: Record<string, any>
}
export interface JobWithLastRun extends JobOut {
@@ -107,8 +129,8 @@ export interface JobWithLastRun extends JobOut {
export interface JobCreate {
name: string
type: 'mysql' | 'directory'
source_config: MySQLSourceConfig | DirectorySourceConfig
type: 'mysql' | 'directory' | 'influxdb'
source_config: MySQLSourceConfig | DirectorySourceConfig | InfluxDBSourceConfig
storage_id: number
cron_expression?: string
enabled?: boolean
@@ -119,7 +141,7 @@ export interface JobCreate {
export interface JobUpdate {
name?: string
source_config?: MySQLSourceConfig | DirectorySourceConfig
source_config?: MySQLSourceConfig | DirectorySourceConfig | InfluxDBSourceConfig
storage_id?: number
cron_expression?: string | null
enabled?: boolean