Compare commits
20 Commits
b11360015d
...
c9e08799f9
| Author | SHA1 | Date | |
|---|---|---|---|
| c9e08799f9 | |||
| 89865ec690 | |||
| ba69bc3355 | |||
| 49f159a8ce | |||
| 4e1347ba45 | |||
| 61b8d7d419 | |||
| 954bdbd972 | |||
| 314bece418 | |||
| 687e059a86 | |||
| baacdb0252 | |||
| 58bdf820dc | |||
| dd66d99f2d | |||
| 107b089b16 | |||
| 1f5daebeed | |||
| 4ba45abf38 | |||
| 5a56e5a8cb | |||
| 850038a3ec | |||
| beb802802a | |||
| 476d87c678 | |||
| 49f88a2cd8 |
@@ -12,6 +12,7 @@ FastAPI + Async SQLAlchemy + Redis · Vue 3 + Vuetify 4 SPA(14 页)· Vite
|
||||
- 生产: `https://api.synaglobal.vip` · 端口 8600 · `ssh nexus`
|
||||
- 本地: `~/Nexus` 或 `$NEXUS_ROOT` · 见 [linux-dev-paths.md](docs/project/linux-dev-paths.md)
|
||||
- 凭据: 仅 `.env`(不入 git)
|
||||
- **Gitea**:clone/pull 可匿名;**push** 用 `bash scripts/git-push.sh`(凭据在 `deploy/nexus-1panel.secrets.sh`,已 gitignore,勿提交)
|
||||
|
||||
## 关键路径
|
||||
|
||||
|
||||
+29
-85
@@ -1,102 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nexus — MySQL Auto-Backup Script
|
||||
# Usage: Add to crontab for daily 3AM backup
|
||||
# 0 3 * * * /path/to/nexus/deploy/db_backup.sh >> /var/log/nexus_backup.log 2>&1
|
||||
# Nexus — MySQL 定时备份(Docker 1Panel / 宿主机)
|
||||
#
|
||||
# Reads DATABASE_URL from .env for connection parameters.
|
||||
# Retains backups for 30 days, auto-deletes older ones.
|
||||
|
||||
# 0 3 * * * NEXUS_ROOT=/opt/nexus /opt/nexus/deploy/db_backup.sh >> /var/log/nexus_backup.log 2>&1
|
||||
#
|
||||
# 亦可通过: sudo bash deploy/install_ops_cron.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ENV_FILE="$PROJECT_DIR/.env"
|
||||
BACKUP_DIR="$PROJECT_DIR/backups"
|
||||
RETENTION_DAYS=30
|
||||
NEXUS_ROOT="${NEXUS_ROOT:-$(dirname "$SCRIPT_DIR")}"
|
||||
BACKUP_DIR="${NEXUS_BACKUP_DIR:-/var/backups/nexus}"
|
||||
RETENTION_DAYS="${NEXUS_BACKUP_RETENTION_DAYS:-30}"
|
||||
DUMP_SH="${SCRIPT_DIR}/mysql_dump_to_file.sh"
|
||||
|
||||
# ── Load .env ──
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "[$(date)] ERROR: .env not found at $ENV_FILE"
|
||||
if [[ ! -f "$DUMP_SH" ]]; then
|
||||
echo "[$(date)] ERROR: missing $DUMP_SH"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$DUMP_SH" 2>/dev/null || true
|
||||
|
||||
# Parse DATABASE_URL from .env
|
||||
# Format: mysql+asyncmy://user:password@host:port/dbname
|
||||
DB_URL=$(grep -E '^NEXUS_DATABASE_URL=' "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'")
|
||||
if [ -z "$DB_URL" ]; then
|
||||
DB_URL=$(grep -E '^DATABASE_URL=' "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'")
|
||||
fi
|
||||
|
||||
if [ -z "$DB_URL" ]; then
|
||||
echo "[$(date)] ERROR: DATABASE_URL not found in .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse mysql URL: mysql+asyncmy://user:pass@host:port/dbname → user pass host port dbname
|
||||
# Strip scheme
|
||||
CONN="${DB_URL#*://}"
|
||||
# Extract user:pass
|
||||
USERPASS="${CONN%%@*}"
|
||||
DB_USER="${USERPASS%%:*}"
|
||||
DB_PASS="${USERPASS#*:}"
|
||||
# Extract host:port/dbname
|
||||
HOSTDB="${CONN#*@}"
|
||||
DB_HOST="${HOSTDB%%:*}"
|
||||
PORTDB="${HOSTDB#*:}"
|
||||
DB_PORT="${PORTDB%%/*}"
|
||||
DB_NAME="${PORTDB#*/}"
|
||||
# Remove query params
|
||||
DB_NAME="${DB_NAME%%\?*}"
|
||||
|
||||
if [ -z "$DB_NAME" ] || [ -z "$DB_HOST" ] || [ -z "$DB_USER" ]; then
|
||||
echo "[$(date)] ERROR: Failed to parse DATABASE_URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Create backup directory ──
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
RAW_FILE="${BACKUP_DIR}/nexus_daily_${TIMESTAMP}.sql"
|
||||
BACKUP_FILE="${RAW_FILE}.gz"
|
||||
|
||||
# ── Backup filename ──
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="$BACKUP_DIR/nexus_${DB_NAME}_${TIMESTAMP}.sql.gz"
|
||||
|
||||
# ── Run mysqldump ──
|
||||
echo "[$(date)] Starting backup: $DB_NAME @ $DB_HOST"
|
||||
|
||||
if [ -n "$DB_PASS" ]; then
|
||||
MYSQL_PWD="$DB_PASS" mysqldump \
|
||||
-h "$DB_HOST" \
|
||||
-P "${DB_PORT:-3306}" \
|
||||
-u "$DB_USER" \
|
||||
--single-transaction \
|
||||
--routines \
|
||||
--triggers \
|
||||
--set-gtid-purged=OFF \
|
||||
"$DB_NAME" | gzip > "$BACKUP_FILE"
|
||||
else
|
||||
mysqldump \
|
||||
-h "$DB_HOST" \
|
||||
-P "${DB_PORT:-3306}" \
|
||||
-u "$DB_USER" \
|
||||
--single-transaction \
|
||||
--routines \
|
||||
--triggers \
|
||||
--set-gtid-purged=OFF \
|
||||
"$DB_NAME" | gzip > "$BACKUP_FILE"
|
||||
fi
|
||||
|
||||
# ── Verify backup ──
|
||||
if [ -f "$BACKUP_FILE" ]; then
|
||||
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
echo "[$(date)] Backup completed: $BACKUP_FILE ($SIZE)"
|
||||
else
|
||||
echo "[$(date)] ERROR: Backup file not created!"
|
||||
echo "[$(date)] Starting backup → $BACKUP_FILE"
|
||||
if ! NEXUS_ROOT="$NEXUS_ROOT" bash "$DUMP_SH" --output "$RAW_FILE" --root "$NEXUS_ROOT"; then
|
||||
echo "[$(date)] ERROR: mysqldump failed"
|
||||
rm -f "$RAW_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Cleanup old backups (retain 30 days) ──
|
||||
DELETED=$(find "$BACKUP_DIR" -name "nexus_*.sql.gz" -mtime +${RETENTION_DAYS} -delete -print | wc -l)
|
||||
if [ "$DELETED" -gt 0 ]; then
|
||||
gzip -f "$RAW_FILE"
|
||||
if [[ ! -f "$BACKUP_FILE" ]]; then
|
||||
echo "[$(date)] ERROR: backup file not created"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SIZE="$(du -h "$BACKUP_FILE" | cut -f1)"
|
||||
echo "[$(date)] Backup completed: $BACKUP_FILE ($SIZE)"
|
||||
|
||||
DELETED="$(find "$BACKUP_DIR" -name 'nexus_*.sql.gz' -mtime +"${RETENTION_DAYS}" -delete -print 2>/dev/null | wc -l)"
|
||||
if [[ "$DELETED" -gt 0 ]]; then
|
||||
echo "[$(date)] Cleaned up $DELETED old backup(s) (>${RETENTION_DAYS} days)"
|
||||
fi
|
||||
|
||||
|
||||
+70
-33
@@ -1,58 +1,95 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# deploy-frontend.sh — Build Vuetify frontend and deploy to server
|
||||
#
|
||||
# Docker 生产:前端在镜像内构建,远程执行 nexus-1panel.sh upgrade
|
||||
# Supervisor:tar 上传到 web/app + supervisorctl restart
|
||||
#
|
||||
# Usage: bash deploy/deploy-frontend.sh
|
||||
# Run from the Nexus project root directory.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NEXUS_SSH_HOST="${NEXUS_SSH:-nexus}"
|
||||
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=15)
|
||||
if [[ -n "${NEXUS_SSH_KEY:-}" ]]; then
|
||||
SSH_OPTS+=(-i "${NEXUS_SSH_KEY}")
|
||||
fi
|
||||
ssh_cmd() { ssh "${SSH_OPTS[@]}" "${NEXUS_SSH_HOST}" "$@"; }
|
||||
|
||||
echo "═══ Nexus Frontend Deploy ═══"
|
||||
|
||||
# 1. Build
|
||||
echo "▶ Building frontend..."
|
||||
REMOTE_RUNTIME=$(ssh_cmd 'bash -s' <<'REMOTE'
|
||||
set -euo pipefail
|
||||
for d in /opt/nexus /www/wwwroot/api.synaglobal.vip; do
|
||||
[[ -d "$d/server" ]] || continue
|
||||
if [[ -f "$d/docker/.env.prod" ]] && docker compose version >/dev/null 2>&1; then
|
||||
prof="$d/docker/.host-profile"
|
||||
[[ -f "$prof" ]] || prof="$d/docker/profiles/2c8g.env"
|
||||
if [[ -f "$prof" ]]; then
|
||||
args=(-f "$d/docker/docker-compose.prod.yml")
|
||||
docker network inspect 1panel-network >/dev/null 2>&1 && \
|
||||
[[ -f "$d/docker/docker-compose.1panel.yml" ]] && \
|
||||
args+=(-f "$d/docker/docker-compose.1panel.yml")
|
||||
q=$(cd "$d" && docker compose "${args[@]}" \
|
||||
--env-file "$d/docker/.env.prod" --env-file "$prof" ps -q nexus 2>/dev/null || true)
|
||||
[[ -n "$q" ]] && echo "docker $d" && exit 0
|
||||
fi
|
||||
fi
|
||||
if command -v supervisorctl >/dev/null 2>&1 && supervisorctl status nexus >/dev/null 2>&1; then
|
||||
echo "supervisor $d"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
echo "unknown"
|
||||
REMOTE
|
||||
)
|
||||
|
||||
RUNTIME="${REMOTE_RUNTIME%% *}"
|
||||
DEPLOY_PATH="${REMOTE_RUNTIME#* }"
|
||||
[[ "$DEPLOY_PATH" == "$REMOTE_RUNTIME" ]] && DEPLOY_PATH="/www/wwwroot/api.synaglobal.vip"
|
||||
|
||||
echo "Remote runtime: ${RUNTIME} @ ${DEPLOY_PATH}"
|
||||
|
||||
echo "▶ Building frontend (local verify)..."
|
||||
cd frontend && npx vite build && cd ..
|
||||
echo "✓ Build complete"
|
||||
|
||||
# 2. Tar the build output (index.html + assets/)
|
||||
echo "▶ Packaging..."
|
||||
tar czf /tmp/nexus-frontend.tar.gz -C web/app index.html assets/
|
||||
echo "✓ Package ready ($(du -h /tmp/nexus-frontend.tar.gz | cut -f1))"
|
||||
if [[ "$RUNTIME" == "docker" ]]; then
|
||||
echo "▶ Docker 生产:重建镜像以包含新前端(非 tar 热更新)..."
|
||||
ssh_cmd "cd ${DEPLOY_PATH} && sudo env NEXUS_ROOT=${DEPLOY_PATH} bash ${DEPLOY_PATH}/deploy/nexus-1panel.sh upgrade"
|
||||
else
|
||||
echo "▶ Packaging..."
|
||||
tar czf /tmp/nexus-frontend.tar.gz -C web/app index.html assets/
|
||||
echo "✓ Package ready ($(du -h /tmp/nexus-frontend.tar.gz | cut -f1))"
|
||||
|
||||
# 3. Upload to server
|
||||
echo "▶ Uploading to server..."
|
||||
scp /tmp/nexus-frontend.tar.gz nexus:/tmp/nexus-frontend.tar.gz
|
||||
echo "✓ Upload complete"
|
||||
echo "▶ Uploading to server..."
|
||||
scp "${SSH_OPTS[@]}" /tmp/nexus-frontend.tar.gz "${NEXUS_SSH_HOST}:/tmp/nexus-frontend.tar.gz"
|
||||
echo "✓ Upload complete"
|
||||
|
||||
# 4. Extract on server + prune orphaned legacy chunks
|
||||
echo "▶ Deploying on server..."
|
||||
ssh nexus "cd /www/wwwroot/api.synaglobal.vip/web/app && tar xzf /tmp/nexus-frontend.tar.gz && rm /tmp/nexus-frontend.tar.gz"
|
||||
echo "▶ Pruning orphaned assets (dry-run first)..."
|
||||
if ! ssh nexus "python3 /www/wwwroot/api.synaglobal.vip/deploy/prune_frontend_assets.py --dry-run"; then
|
||||
echo "✗ Prune dry-run failed — aborting (no files deleted)"
|
||||
exit 1
|
||||
echo "▶ Deploying on server..."
|
||||
ssh_cmd "cd ${DEPLOY_PATH}/web/app && tar xzf /tmp/nexus-frontend.tar.gz && rm /tmp/nexus-frontend.tar.gz"
|
||||
echo "▶ Pruning orphaned assets..."
|
||||
if ! ssh_cmd "python3 ${DEPLOY_PATH}/deploy/prune_frontend_assets.py --dry-run"; then
|
||||
echo "✗ Prune dry-run failed"
|
||||
exit 1
|
||||
fi
|
||||
ssh_cmd "python3 ${DEPLOY_PATH}/deploy/prune_frontend_assets.py"
|
||||
echo "✓ Extracted and pruned"
|
||||
|
||||
echo "▶ Restarting backend..."
|
||||
ssh_cmd "supervisorctl restart nexus"
|
||||
echo "✓ Restarted"
|
||||
fi
|
||||
if ! ssh nexus "python3 /www/wwwroot/api.synaglobal.vip/deploy/prune_frontend_assets.py"; then
|
||||
echo "✗ Prune failed — check index.html references before retry"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Extracted and pruned"
|
||||
|
||||
# 5. Restart backend
|
||||
echo "▶ Restarting backend..."
|
||||
ssh nexus "supervisorctl restart nexus"
|
||||
echo "✓ Restarted"
|
||||
|
||||
# 6. Health check
|
||||
sleep 3
|
||||
HEALTH=$(ssh nexus "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8600/health" 2>/dev/null || echo "000")
|
||||
SPA=$(ssh nexus "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8600/app/" 2>/dev/null || echo "000")
|
||||
HEALTH=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8600/health" 2>/dev/null || echo "000")
|
||||
SPA=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8600/app/" 2>/dev/null || echo "000")
|
||||
|
||||
echo ""
|
||||
echo "═══ Verification ═══"
|
||||
echo " /health → $HEALTH"
|
||||
echo " /app/ → $SPA"
|
||||
|
||||
if [ "$HEALTH" = "200" ] && [ "$SPA" = "200" ]; then
|
||||
if [[ "$HEALTH" == "200" || "$HEALTH" == "ok" ]] && [[ "$SPA" == "200" ]]; then
|
||||
echo "✓ Deploy successful!"
|
||||
else
|
||||
echo "✗ Verification failed — check server logs"
|
||||
|
||||
+32
-15
@@ -1,28 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run ON production server (宝塔终端 / ssh root@47.254.123.106)
|
||||
# Pulls latest main from Gitea and restarts Nexus. Frontend tar must be uploaded separately
|
||||
# if you did not build on this machine.
|
||||
# Run ON production server — 自动识别 Docker / Supervisor
|
||||
#
|
||||
# Usage (on server):
|
||||
# cd /www/wwwroot/api.synaglobal.vip
|
||||
# bash deploy/deploy-on-server.sh
|
||||
# cd /opt/nexus && sudo bash deploy/deploy-on-server.sh
|
||||
# cd /www/wwwroot/api.synaglobal.vip && bash deploy/deploy-on-server.sh
|
||||
#
|
||||
# Optional env:
|
||||
# NEXUS_GITEA_TOKEN — if git pull needs auth
|
||||
# SKIP_FRONTEND=1 — backend only
|
||||
# SKIP_FRONTEND=1 — Supervisor 模式跳过热更新前端 tar
|
||||
# NEXUS_DEPLOY_PATH — 覆盖自动检测路径
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DEPLOY_PATH="${NEXUS_DEPLOY_PATH:-/www/wwwroot/api.synaglobal.vip}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=./detect_deploy_runtime.sh
|
||||
source "${SCRIPT_DIR}/detect_deploy_runtime.sh"
|
||||
|
||||
DEPLOY_PATH="$(resolve_nexus_root "${NEXUS_DEPLOY_PATH:-}" 2>/dev/null || true)"
|
||||
if [[ -z "$DEPLOY_PATH" ]]; then
|
||||
echo "ERROR: cannot resolve Nexus root" >&2
|
||||
exit 1
|
||||
fi
|
||||
cd "${DEPLOY_PATH}"
|
||||
|
||||
echo "═══ Nexus server-side deploy ═══"
|
||||
echo "Path: ${DEPLOY_PATH}"
|
||||
RUNTIME="$(detect_nexus_runtime "$DEPLOY_PATH")"
|
||||
PORT="$(nexus_publish_port_for "$DEPLOY_PATH")"
|
||||
|
||||
if [ -n "${NEXUS_GITEA_TOKEN:-}" ]; then
|
||||
echo "═══ Nexus server-side deploy ═══"
|
||||
echo "Path: ${DEPLOY_PATH}"
|
||||
echo "Runtime: ${RUNTIME}"
|
||||
|
||||
if [[ -n "${NEXUS_GITEA_TOKEN:-}" ]]; then
|
||||
git remote set-url origin "http://admin:${NEXUS_GITEA_TOKEN}@66.154.115.8:3000/admin/Nexus.git"
|
||||
fi
|
||||
|
||||
if [[ "$RUNTIME" == "docker" ]]; then
|
||||
echo "▶ Docker upgrade (pull + rebuild)..."
|
||||
exec env NEXUS_ROOT="$DEPLOY_PATH" bash "$DEPLOY_PATH/deploy/nexus-1panel.sh" upgrade
|
||||
fi
|
||||
|
||||
echo "▶ git fetch + reset --hard origin/main..."
|
||||
git fetch --all
|
||||
git reset --hard origin/main
|
||||
@@ -31,11 +47,11 @@ echo " HEAD: $(git log -1 --oneline)"
|
||||
echo "▶ supervisorctl restart nexus..."
|
||||
supervisorctl restart nexus
|
||||
|
||||
if [ -f /tmp/nexus-frontend.tar.gz ] && [ "${SKIP_FRONTEND:-0}" != "1" ]; then
|
||||
if [[ -f /tmp/nexus-frontend.tar.gz && "${SKIP_FRONTEND:-0}" != "1" ]]; then
|
||||
echo "▶ extract /tmp/nexus-frontend.tar.gz..."
|
||||
tar xzf /tmp/nexus-frontend.tar.gz -C web/app
|
||||
rm -f /tmp/nexus-frontend.tar.gz
|
||||
if [ -f deploy/prune_frontend_assets.py ]; then
|
||||
if [[ -f deploy/prune_frontend_assets.py ]]; then
|
||||
python3 deploy/prune_frontend_assets.py --dry-run
|
||||
python3 deploy/prune_frontend_assets.py
|
||||
fi
|
||||
@@ -43,13 +59,14 @@ if [ -f /tmp/nexus-frontend.tar.gz ] && [ "${SKIP_FRONTEND:-0}" != "1" ]; then
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
HEALTH=$(curl -s http://127.0.0.1:8600/health || true)
|
||||
SPA=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8600/app/ || echo "000")
|
||||
HEALTH=$(curl -s "http://127.0.0.1:${PORT}/health" || true)
|
||||
SPA=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/app/" || echo "000")
|
||||
echo " /health → ${HEALTH}"
|
||||
echo " /app/ → ${SPA}"
|
||||
|
||||
if [ "${HEALTH}" = "ok" ] && [ "${SPA}" = "200" ]; then
|
||||
if [[ "${HEALTH}" == "ok" && "${SPA}" == "200" ]]; then
|
||||
echo "✓ Server deploy OK"
|
||||
suggest_ops_cron "$DEPLOY_PATH"
|
||||
else
|
||||
echo "✗ Verify failed — check: supervisorctl status nexus" >&2
|
||||
exit 1
|
||||
|
||||
+74
-22
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Full production deploy: push (optional) + backend pull + frontend tar + health
|
||||
# Full production deploy: push (optional) + backend upgrade + frontend + health
|
||||
#
|
||||
# 自动识别远程 Docker(/opt/nexus)或 Supervisor(/www/wwwroot/...)运行时。
|
||||
#
|
||||
# Prerequisites on YOUR machine:
|
||||
# - SSH: ~/.ssh/config Host nexus OR export NEXUS_SSH="root@47.254.123.106"
|
||||
# - SSH: ~/.ssh/config Host nexus OR export NEXUS_SSH="azureuser@20.24.218.235"
|
||||
# - Key: export NEXUS_SSH_KEY=~/.ssh/id_rsa_nexus
|
||||
# - Gitea push access for origin main
|
||||
#
|
||||
# Usage:
|
||||
# cd "$NEXUS_ROOT"
|
||||
# git add ... && git commit ... && git push origin main # if not pushed yet
|
||||
# bash deploy/pre_deploy_check.sh
|
||||
# bash deploy/deploy-production.sh
|
||||
|
||||
@@ -19,17 +19,14 @@ cd "${ROOT}"
|
||||
|
||||
NEXUS_SSH_HOST="${NEXUS_SSH:-nexus}"
|
||||
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=15)
|
||||
if [ -n "${NEXUS_SSH_KEY:-}" ]; then
|
||||
if [[ -n "${NEXUS_SSH_KEY:-}" ]]; then
|
||||
SSH_OPTS+=(-i "${NEXUS_SSH_KEY}")
|
||||
fi
|
||||
ssh_cmd() { ssh "${SSH_OPTS[@]}" "${NEXUS_SSH_HOST}" "$@"; }
|
||||
scp_cmd() { scp "${SSH_OPTS[@]}" "$@"; }
|
||||
|
||||
DEPLOY_PATH="${NEXUS_DEPLOY_PATH:-/www/wwwroot/api.synaglobal.vip}"
|
||||
|
||||
echo "═══ Nexus production deploy ═══"
|
||||
echo "SSH target: ${NEXUS_SSH_HOST}"
|
||||
echo "Deploy path: ${DEPLOY_PATH}"
|
||||
|
||||
if ! ssh_cmd "echo SSH OK" >/dev/null 2>&1; then
|
||||
echo "ERROR: Cannot SSH to ${NEXUS_SSH_HOST}" >&2
|
||||
@@ -37,27 +34,82 @@ if ! ssh_cmd "echo SSH OK" >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "▶ Backend: git pull + restart..."
|
||||
ssh_cmd "cd ${DEPLOY_PATH} && git fetch --all && git reset --hard origin/main && supervisorctl restart nexus"
|
||||
REMOTE_DETECT=$(ssh_cmd 'bash -s' <<'REMOTE'
|
||||
set -euo pipefail
|
||||
for d in /opt/nexus /www/wwwroot/api.synaglobal.vip; do
|
||||
[[ -d "$d/server" ]] || continue
|
||||
echo "ROOT=$d"
|
||||
if [[ -f "$d/docker/.env.prod" ]] && docker compose version >/dev/null 2>&1; then
|
||||
prof="$d/docker/.host-profile"
|
||||
[[ -f "$prof" ]] || prof="$d/docker/profiles/2c8g.env"
|
||||
if [[ -f "$prof" ]]; then
|
||||
args=(-f "$d/docker/docker-compose.prod.yml")
|
||||
docker network inspect 1panel-network >/dev/null 2>&1 && \
|
||||
[[ -f "$d/docker/docker-compose.1panel.yml" ]] && \
|
||||
args+=(-f "$d/docker/docker-compose.1panel.yml")
|
||||
q=$(cd "$d" && docker compose "${args[@]}" \
|
||||
--env-file "$d/docker/.env.prod" --env-file "$prof" ps -q nexus 2>/dev/null || true)
|
||||
[[ -n "$q" ]] && echo RUNTIME=docker && exit 0
|
||||
fi
|
||||
fi
|
||||
if command -v supervisorctl >/dev/null 2>&1 && supervisorctl status nexus >/dev/null 2>&1; then
|
||||
echo RUNTIME=supervisor
|
||||
exit 0
|
||||
fi
|
||||
echo RUNTIME=unknown
|
||||
exit 0
|
||||
done
|
||||
echo RUNTIME=none
|
||||
REMOTE
|
||||
)
|
||||
|
||||
echo "▶ Frontend: build + upload..."
|
||||
cd frontend && npx vite build && cd ..
|
||||
tar czf /tmp/nexus-frontend.tar.gz -C web/app index.html assets/
|
||||
scp_cmd /tmp/nexus-frontend.tar.gz "${NEXUS_SSH_HOST}:/tmp/nexus-frontend.tar.gz"
|
||||
ssh_cmd "cd ${DEPLOY_PATH}/web/app && tar xzf /tmp/nexus-frontend.tar.gz && rm /tmp/nexus-frontend.tar.gz"
|
||||
if ssh_cmd "test -f ${DEPLOY_PATH}/deploy/prune_frontend_assets.py"; then
|
||||
ssh_cmd "python3 ${DEPLOY_PATH}/deploy/prune_frontend_assets.py --dry-run"
|
||||
ssh_cmd "python3 ${DEPLOY_PATH}/deploy/prune_frontend_assets.py"
|
||||
DEPLOY_PATH="${NEXUS_DEPLOY_PATH:-$(echo "$REMOTE_DETECT" | sed -n 's/^ROOT=//p' | head -1)}"
|
||||
RUNTIME="$(echo "$REMOTE_DETECT" | sed -n 's/^RUNTIME=//p' | head -1)"
|
||||
|
||||
if [[ -z "$DEPLOY_PATH" ]]; then
|
||||
DEPLOY_PATH="${NEXUS_DEPLOY_PATH:-/opt/nexus}"
|
||||
fi
|
||||
if [[ -z "$RUNTIME" || "$RUNTIME" == "none" ]]; then
|
||||
RUNTIME="unknown"
|
||||
fi
|
||||
|
||||
echo "Deploy path: ${DEPLOY_PATH}"
|
||||
echo "Runtime: ${RUNTIME}"
|
||||
|
||||
if [[ "$RUNTIME" == "docker" ]]; then
|
||||
echo "▶ Docker: git pull + 镜像重建(前端在镜像内 vite build)..."
|
||||
ssh_cmd "cd ${DEPLOY_PATH} && sudo git fetch --all && sudo git reset --hard origin/main && \
|
||||
sudo env NEXUS_ROOT=${DEPLOY_PATH} bash ${DEPLOY_PATH}/deploy/nexus-1panel.sh upgrade"
|
||||
elif [[ "$RUNTIME" == "supervisor" ]]; then
|
||||
echo "▶ Supervisor: git pull + restart..."
|
||||
ssh_cmd "cd ${DEPLOY_PATH} && git fetch --all && git reset --hard origin/main && supervisorctl restart nexus"
|
||||
|
||||
echo "▶ Frontend: build + upload..."
|
||||
cd frontend && npx vite build && cd ..
|
||||
tar czf /tmp/nexus-frontend.tar.gz -C web/app index.html assets/
|
||||
scp_cmd /tmp/nexus-frontend.tar.gz "${NEXUS_SSH_HOST}:/tmp/nexus-frontend.tar.gz"
|
||||
ssh_cmd "cd ${DEPLOY_PATH}/web/app && tar xzf /tmp/nexus-frontend.tar.gz && rm /tmp/nexus-frontend.tar.gz"
|
||||
if ssh_cmd "test -f ${DEPLOY_PATH}/deploy/prune_frontend_assets.py"; then
|
||||
ssh_cmd "python3 ${DEPLOY_PATH}/deploy/prune_frontend_assets.py --dry-run"
|
||||
ssh_cmd "python3 ${DEPLOY_PATH}/deploy/prune_frontend_assets.py"
|
||||
fi
|
||||
else
|
||||
echo "ERROR: 无法识别远程运行时(非 Docker 也非 Supervisor)" >&2
|
||||
echo " 请手动: ssh ${NEXUS_SSH_HOST} 'sudo nx update'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
HEALTH=$(ssh_cmd "curl -s http://127.0.0.1:8600/health" 2>/dev/null || true)
|
||||
SPA=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8600/app/" 2>/dev/null || echo "000")
|
||||
PORT="$(ssh_cmd "grep -E '^NEXUS_PUBLISH_PORT=' ${DEPLOY_PATH}/docker/.env.prod 2>/dev/null | cut -d= -f2 | tr -d '\"' || echo 8600")"
|
||||
HEALTH=$(ssh_cmd "curl -s http://127.0.0.1:${PORT}/health" 2>/dev/null || true)
|
||||
SPA=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${PORT}/app/" 2>/dev/null || echo "000")
|
||||
echo " /health → ${HEALTH}"
|
||||
echo " /app/ → ${SPA}"
|
||||
if [ "${HEALTH}" = "ok" ] && [ "${SPA}" = "200" ]; then
|
||||
if [[ "${HEALTH}" == "ok" && "${SPA}" == "200" ]]; then
|
||||
echo "✓ Deploy successful"
|
||||
ssh_cmd "command -v crontab >/dev/null && crontab -l 2>/dev/null | grep -q nexus-health-monitor" 2>/dev/null || \
|
||||
echo " 提示: 远程未安装巡检 cron,可在服务器执行 sudo nx cron"
|
||||
else
|
||||
echo "✗ Verification failed — check supervisor logs on server" >&2
|
||||
echo "✗ Verification failed — check logs on server" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nexus — 部署运行时检测(Docker 1Panel / Supervisor 裸机)
|
||||
#
|
||||
# source deploy/detect_deploy_runtime.sh
|
||||
# ROOT="$(resolve_nexus_root)"
|
||||
# RUNTIME="$(detect_nexus_runtime "$ROOT")" # docker | supervisor | unknown
|
||||
set -euo pipefail
|
||||
|
||||
_COMPOSE_FILE="docker/docker-compose.prod.yml"
|
||||
_COMPOSE_1PANEL="docker/docker-compose.1panel.yml"
|
||||
_ENV_PROD="docker/.env.prod"
|
||||
|
||||
resolve_nexus_root() {
|
||||
local hint="${1:-}"
|
||||
if [[ -n "$hint" && -d "$hint/server" ]]; then
|
||||
echo "$hint"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${NEXUS_ROOT:-}" && -d "${NEXUS_ROOT}/server" ]]; then
|
||||
echo "$NEXUS_ROOT"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${NEXUS_DEPLOY_PATH:-}" && -d "${NEXUS_DEPLOY_PATH}/server" ]]; then
|
||||
echo "$NEXUS_DEPLOY_PATH"
|
||||
return 0
|
||||
fi
|
||||
local d
|
||||
for d in /opt/nexus /www/wwwroot/api.synaglobal.vip; do
|
||||
if [[ -d "$d/server" ]]; then
|
||||
echo "$d"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
for d in /www/wwwroot/*/; do
|
||||
[[ -d "${d}server" ]] || continue
|
||||
echo "${d%/}"
|
||||
return 0
|
||||
done
|
||||
if command -v supervisorctl >/dev/null 2>&1; then
|
||||
local cwd
|
||||
cwd="$(supervisorctl status nexus 2>/dev/null | grep -oP 'cwd=\K[^ ,]+' || true)"
|
||||
if [[ -n "$cwd" && -d "$cwd/server" ]]; then
|
||||
echo "$cwd"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
_has_1panel_network() {
|
||||
docker network inspect 1panel-network >/dev/null 2>&1
|
||||
}
|
||||
|
||||
detect_nexus_runtime() {
|
||||
local root="$1"
|
||||
if [[ -f "$root/$_ENV_PROD" ]] && command -v docker >/dev/null 2>&1 \
|
||||
&& docker compose version >/dev/null 2>&1; then
|
||||
local -a args=(-f "$root/$_COMPOSE_FILE")
|
||||
if _has_1panel_network && [[ -f "$root/$_COMPOSE_1PANEL" ]]; then
|
||||
args+=(-f "$root/$_COMPOSE_1PANEL")
|
||||
fi
|
||||
local prof q
|
||||
prof="$root/docker/.host-profile"
|
||||
[[ -f "$prof" ]] || prof="$root/docker/profiles/2c8g.env"
|
||||
if [[ -f "$prof" ]]; then
|
||||
q="$(cd "$root" && docker compose "${args[@]}" \
|
||||
--env-file "$root/$_ENV_PROD" --env-file "$prof" \
|
||||
ps -q nexus 2>/dev/null || true)"
|
||||
if [[ -n "$q" ]]; then
|
||||
echo docker
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if command -v supervisorctl >/dev/null 2>&1 \
|
||||
&& supervisorctl status nexus >/dev/null 2>&1; then
|
||||
echo supervisor
|
||||
return 0
|
||||
fi
|
||||
echo unknown
|
||||
}
|
||||
|
||||
nexus_publish_port_for() {
|
||||
local root="$1"
|
||||
local port
|
||||
port="$(grep -E '^NEXUS_PUBLISH_PORT=' "$root/$_ENV_PROD" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r" ' || true)"
|
||||
if [[ -z "$port" && -f "$root/.env" ]]; then
|
||||
port="$(grep -E '^NEXUS_PORT=' "$root/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r" ' || true)"
|
||||
fi
|
||||
echo "${port:-8600}"
|
||||
}
|
||||
|
||||
nexus_restart_service() {
|
||||
local root="$1"
|
||||
local runtime
|
||||
runtime="$(detect_nexus_runtime "$root")"
|
||||
case "$runtime" in
|
||||
docker)
|
||||
local cname
|
||||
cname="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true)"
|
||||
if [[ -n "$cname" ]]; then
|
||||
docker restart "$cname"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
;;
|
||||
supervisor)
|
||||
supervisorctl restart nexus
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
nexus_has_ops_cron() {
|
||||
command -v crontab >/dev/null 2>&1 \
|
||||
&& crontab -l 2>/dev/null | grep -q 'nexus-health-monitor'
|
||||
}
|
||||
|
||||
suggest_ops_cron() {
|
||||
local root="$1"
|
||||
if nexus_has_ops_cron; then
|
||||
return 0
|
||||
fi
|
||||
echo ""
|
||||
echo "[WARN] 未检测到 health_monitor / db_backup cron"
|
||||
echo " 建议: sudo bash ${root}/deploy/install_ops_cron.sh"
|
||||
echo " 或: sudo nx cron"
|
||||
}
|
||||
+125
-49
@@ -1,70 +1,146 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Nexus — External Health Monitor (Layer 3 of 3-tier guardian)
|
||||
# Runs via crontab every minute.
|
||||
# Does NOT depend on Python — even if Python completely crashes, this script works.
|
||||
# Runs via crontab every minute. Works on Docker (1Panel) and Supervisor bare-metal.
|
||||
#
|
||||
# Guardian layers:
|
||||
# Layer 1: Supervisor — auto-restart crashed Python process
|
||||
# Layer 2: Python self_monitor — internal Redis/MySQL/WS checks (30s)
|
||||
# Layer 3: Shell health_monitor.sh — external HTTP health check (1min cron)
|
||||
#
|
||||
# Flow:
|
||||
# 1. HTTP GET http://127.0.0.1:8600/health
|
||||
# 2. If fails >= 3 consecutive times → restart via supervisorctl + Telegram alert
|
||||
# 3. If restart succeeds → Telegram recovery notification
|
||||
# 4. If restart fails → Telegram "needs human intervention" alert
|
||||
#
|
||||
# *** Configure DEPLOY_DIR and NEXUS_SERVICE before deploying ***
|
||||
# Layer 1: Docker restart policy / Supervisor autorestart
|
||||
# Layer 2: Python self_monitor (30s)
|
||||
# Layer 3: This script — HTTP /health, 3 failures → restart + optional Telegram
|
||||
set -euo pipefail
|
||||
|
||||
DEPLOY_DIR="${NEXUS_DEPLOY_DIR:-/opt/nexus}"
|
||||
NEXUS_SERVICE="${NEXUS_SERVICE:-nexus}"
|
||||
FAIL_FILE="/tmp/nexus_health_fail_count"
|
||||
LAST_RESTART_FILE="/tmp/nexus_health_last_restart"
|
||||
MAX_FAIL=3
|
||||
HEALTH_URL="http://127.0.0.1:8600/health"
|
||||
RESTART_COOLDOWN_SEC="${NEXUS_HEALTH_RESTART_COOLDOWN:-300}"
|
||||
ENV_PROD="${DEPLOY_DIR}/docker/.env.prod"
|
||||
|
||||
# ── Read Telegram config from .env file ──
|
||||
DOTENV="$DEPLOY_DIR/.env"
|
||||
resolve_publish_port() {
|
||||
local port
|
||||
port="$(grep -E '^NEXUS_PUBLISH_PORT=' "$ENV_PROD" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r" ' || true)"
|
||||
echo "${port:-8600}"
|
||||
}
|
||||
|
||||
BOT_TOKEN=""
|
||||
CHAT_ID=""
|
||||
PUBLISH_PORT="$(resolve_publish_port)"
|
||||
HEALTH_URL="http://127.0.0.1:${PUBLISH_PORT}/health"
|
||||
|
||||
if [ -f "$DOTENV" ]; then
|
||||
BOT_TOKEN=$(grep '^NEXUS_TELEGRAM_BOT_TOKEN=' "$DOTENV" 2>/dev/null | cut -d'=' -f2- || echo "")
|
||||
CHAT_ID=$(grep '^NEXUS_TELEGRAM_CHAT_ID=' "$DOTENV" 2>/dev/null | cut -d'=' -f2- || echo "")
|
||||
fi
|
||||
resolve_nexus_container() {
|
||||
docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true
|
||||
}
|
||||
|
||||
read_env_var() {
|
||||
local key="$1" file="$2"
|
||||
grep -E "^${key}=" "$file" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r"' || true
|
||||
}
|
||||
|
||||
load_telegram_config() {
|
||||
local cname="$1" dotenv
|
||||
BOT_TOKEN=""
|
||||
CHAT_ID=""
|
||||
for dotenv in \
|
||||
"${DEPLOY_DIR}/.env" \
|
||||
"$ENV_PROD" \
|
||||
; do
|
||||
[[ -f "$dotenv" ]] || continue
|
||||
BOT_TOKEN="$(read_env_var NEXUS_TELEGRAM_BOT_TOKEN "$dotenv")"
|
||||
CHAT_ID="$(read_env_var NEXUS_TELEGRAM_CHAT_ID "$dotenv")"
|
||||
[[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]] && return 0
|
||||
done
|
||||
if [[ -n "$cname" ]]; then
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
NEXUS_TELEGRAM_BOT_TOKEN=*) BOT_TOKEN="${line#NEXUS_TELEGRAM_BOT_TOKEN=}" ;;
|
||||
NEXUS_TELEGRAM_CHAT_ID=*) CHAT_ID="${line#NEXUS_TELEGRAM_CHAT_ID=}" ;;
|
||||
esac
|
||||
done < <(docker exec "$cname" grep -E '^NEXUS_TELEGRAM_' /app/.env 2>/dev/null || true)
|
||||
fi
|
||||
}
|
||||
|
||||
send_telegram() {
|
||||
[ -z "$BOT_TOKEN" ] || [ -z "$CHAT_ID" ] && return 1
|
||||
[[ -n "${BOT_TOKEN:-}" && -n "${CHAT_ID:-}" ]] || return 1
|
||||
curl -sf --max-time 10 "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
||||
-d chat_id="$CHAT_ID" \
|
||||
-d text="$1" \
|
||||
-d parse_mode=HTML \
|
||||
-d "chat_id=${CHAT_ID}" \
|
||||
-d "text=$1" \
|
||||
-d "parse_mode=HTML" \
|
||||
> /dev/null 2>&1
|
||||
}
|
||||
|
||||
# ── Health Check ──
|
||||
if ! curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
# Python is NOT responding
|
||||
FAIL=$(cat "$FAIL_FILE" 2>/dev/null || echo "0")
|
||||
FAIL=$((FAIL + 1))
|
||||
echo "$FAIL" > "$FAIL_FILE"
|
||||
bump_fail_count() {
|
||||
local fail=0
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
(
|
||||
flock -x 9
|
||||
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
|
||||
fail=$((fail + 1))
|
||||
echo "$fail" > "$FAIL_FILE"
|
||||
) 9>"${FAIL_FILE}.lock"
|
||||
else
|
||||
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
|
||||
fail=$((fail + 1))
|
||||
echo "$fail" > "$FAIL_FILE"
|
||||
fi
|
||||
echo "$fail"
|
||||
}
|
||||
|
||||
if [ "$FAIL" -ge "$MAX_FAIL" ]; then
|
||||
# Consecutive failures >= threshold → attempt restart
|
||||
send_telegram "🔴 <b>Nexus后端连续${MAX_FAIL}次无响应</b>\n正在通过Supervisor自动重启..."
|
||||
supervisorctl restart "$NEXUS_SERVICE" > /dev/null 2>&1
|
||||
sleep 5
|
||||
reset_fail_count() {
|
||||
echo "0" > "$FAIL_FILE"
|
||||
}
|
||||
|
||||
# Verify restart
|
||||
if curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
send_telegram "🟢 <b>Nexus后端已恢复</b>\nSupervisor重启成功"
|
||||
echo "0" > "$FAIL_FILE"
|
||||
else
|
||||
send_telegram "🔴 <b>Nexus重启后仍未恢复!请人工介入</b>\nSupervisor重启失败,需要手动检查"
|
||||
# Don't reset fail count — keep retrying each cron cycle
|
||||
fi
|
||||
restart_cooldown_active() {
|
||||
local now last elapsed
|
||||
[[ -f "$LAST_RESTART_FILE" ]] || return 1
|
||||
now="$(date +%s)"
|
||||
last="$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0)"
|
||||
elapsed=$((now - last))
|
||||
[[ "$elapsed" -lt "$RESTART_COOLDOWN_SEC" ]]
|
||||
}
|
||||
|
||||
mark_restart_attempt() {
|
||||
date +%s > "$LAST_RESTART_FILE"
|
||||
}
|
||||
|
||||
restart_nexus() {
|
||||
local cname="$1" via=""
|
||||
if [[ -n "$cname" ]]; then
|
||||
docker restart "$cname" >/dev/null 2>&1 && via="Docker 容器 ${cname}"
|
||||
elif command -v supervisorctl >/dev/null 2>&1 && supervisorctl status "$NEXUS_SERVICE" >/dev/null 2>&1; then
|
||||
supervisorctl restart "$NEXUS_SERVICE" >/dev/null 2>&1 && via="Supervisor ${NEXUS_SERVICE}"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
echo "$via"
|
||||
}
|
||||
|
||||
NEXUS_CONTAINER="$(resolve_nexus_container)"
|
||||
load_telegram_config "$NEXUS_CONTAINER"
|
||||
|
||||
if curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
reset_fail_count
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FAIL="$(bump_fail_count)"
|
||||
if [[ "$FAIL" -lt "$MAX_FAIL" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if restart_cooldown_active; then
|
||||
send_telegram "🔴 <b>Nexus /health 连续失败</b>\n冷却期内跳过自动重启(${RESTART_COOLDOWN_SEC}s)\n请检查: docker logs ${NEXUS_CONTAINER:-nexus}" || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
send_telegram "🔴 <b>Nexus 连续 ${MAX_FAIL} 次无响应</b>\n正在自动重启…" || true
|
||||
mark_restart_attempt
|
||||
|
||||
if VIA="$(restart_nexus "$NEXUS_CONTAINER")"; then
|
||||
sleep 5
|
||||
if curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
send_telegram "🟢 <b>Nexus 已恢复</b>\n${VIA} 重启成功" || true
|
||||
reset_fail_count
|
||||
else
|
||||
send_telegram "🔴 <b>Nexus 重启后仍未恢复</b>\n${VIA} — 需人工介入" || true
|
||||
fi
|
||||
else
|
||||
# Python is responding normally
|
||||
echo "0" > "$FAIL_FILE"
|
||||
send_telegram "🔴 <b>Nexus 无法自动重启</b>\n未找到 Docker 容器或 Supervisor 服务" || true
|
||||
fi
|
||||
|
||||
@@ -281,11 +281,23 @@ phase_post() {
|
||||
echo ""
|
||||
}
|
||||
|
||||
warn_if_already_installed() {
|
||||
local env_prod="${NEXUS_ROOT}/docker/.env.prod" cname
|
||||
[[ -f "$env_prod" ]] || return 0
|
||||
cname="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true)"
|
||||
if [[ -n "$cname" ]] && docker exec "$cname" test -f /var/lib/nexus/.install_locked 2>/dev/null; then
|
||||
warn "检测到已安装并锁定的 Nexus(将清除 nexus-state 卷内配置并重建)"
|
||||
read -r -p "确认继续全新安装? [y/N]: " ok
|
||||
[[ "$ok" == "y" || "$ok" == "Y" ]] || exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
banner
|
||||
if [[ "$SKIP_CLONE" == true && -d "${NEXUS_ROOT}/deploy" ]]; then
|
||||
install_root_commands
|
||||
fi
|
||||
warn_if_already_installed
|
||||
phase_preflight
|
||||
phase_1panel
|
||||
phase_packages
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nexus — 安装/更新 host crontab:health_monitor + db_backup
|
||||
#
|
||||
# sudo bash deploy/install_ops_cron.sh
|
||||
# NEXUS_ROOT=/opt/nexus sudo bash deploy/install_ops_cron.sh
|
||||
set -euo pipefail
|
||||
|
||||
NEXUS_ROOT="${NEXUS_ROOT:-/opt/nexus}"
|
||||
DEPLOY_DIR="${NEXUS_ROOT}/deploy"
|
||||
HEALTH_MARKER="nexus-health-monitor"
|
||||
BACKUP_MARKER="nexus-mysql-backup"
|
||||
HEALTH_LOG="${NEXUS_HEALTH_LOG:-/var/log/nexus_health.log}"
|
||||
BACKUP_LOG="${NEXUS_BACKUP_LOG:-/var/log/nexus_backup.log}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
error "请使用 root 运行: sudo bash $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_crontab() {
|
||||
if command -v crontab >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
warn "未检测到 crontab,尝试安装 cron 包…"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq cron
|
||||
systemctl enable --now cron 2>/dev/null || systemctl enable --now crond 2>/dev/null || true
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y cronie
|
||||
systemctl enable --now crond 2>/dev/null || true
|
||||
fi
|
||||
if ! command -v crontab >/dev/null 2>&1; then
|
||||
error "无法安装 crontab(请手动: apt install cron)"
|
||||
exit 1
|
||||
fi
|
||||
info "cron 已安装"
|
||||
}
|
||||
|
||||
ensure_crontab
|
||||
|
||||
for f in \
|
||||
"${DEPLOY_DIR}/health_monitor.sh" \
|
||||
"${DEPLOY_DIR}/db_backup.sh" \
|
||||
"${DEPLOY_DIR}/mysql_dump_to_file.sh" \
|
||||
; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
error "缺少脚本: $f"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$f" 2>/dev/null || true
|
||||
done
|
||||
|
||||
touch "$HEALTH_LOG" "$BACKUP_LOG" 2>/dev/null || true
|
||||
chmod 644 "$HEALTH_LOG" "$BACKUP_LOG" 2>/dev/null || true
|
||||
|
||||
HEALTH_LINE="* * * * * NEXUS_DEPLOY_DIR=${NEXUS_ROOT} ${DEPLOY_DIR}/health_monitor.sh >> ${HEALTH_LOG} 2>&1 # ${HEALTH_MARKER}"
|
||||
BACKUP_LINE="0 3 * * * NEXUS_ROOT=${NEXUS_ROOT} ${DEPLOY_DIR}/db_backup.sh >> ${BACKUP_LOG} 2>&1 # ${BACKUP_MARKER}"
|
||||
|
||||
existing="$(crontab -l 2>/dev/null || true)"
|
||||
filtered="$(printf '%s\n' "$existing" | grep -v "$HEALTH_MARKER" | grep -v "$BACKUP_MARKER" | grep -v 'deploy/health_monitor.sh' | grep -v 'deploy/db_backup.sh' || true)"
|
||||
{
|
||||
printf '%s\n' "$filtered" | sed '/^[[:space:]]*$/d'
|
||||
echo "$HEALTH_LINE"
|
||||
echo "$BACKUP_LINE"
|
||||
} | crontab -
|
||||
|
||||
info "已写入 crontab:"
|
||||
crontab -l | grep -E "$HEALTH_MARKER|$BACKUP_MARKER" || true
|
||||
info "健康巡检日志: $HEALTH_LOG"
|
||||
info "MySQL 备份日志: $BACKUP_LOG"
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nexus — MySQL dump(Docker 1Panel / 宿主机 mysqldump 通用)
|
||||
#
|
||||
# bash deploy/mysql_dump_to_file.sh --output /path/dump.sql [--root /opt/nexus]
|
||||
# bash deploy/mysql_dump_to_file.sh --output /path/dump.sql --db-url 'mysql+aiomysql://...'
|
||||
#
|
||||
# 策略(按序):
|
||||
# 1. DB host 为运行中容器名 → docker exec 该容器内 mysqldump(连 127.0.0.1)
|
||||
# 2. 宿主机有 mysqldump 且 host 可解析 → 本机 mysqldump
|
||||
# 3. 1panel-network 存在 → docker run --rm mysql:8 客户端
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
NEXUS_ROOT="${NEXUS_ROOT:-/opt/nexus}"
|
||||
OUTPUT=""
|
||||
DB_URL=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: mysql_dump_to_file.sh --output FILE.sql [--root /opt/nexus] [--db-url URL]
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--output|-o) OUTPUT="$2"; shift 2 ;;
|
||||
--root) NEXUS_ROOT="$2"; shift 2 ;;
|
||||
--db-url) DB_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "未知参数: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$OUTPUT" ]]; then
|
||||
echo "缺少 --output" >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
resolve_db_url() {
|
||||
if [[ -n "$DB_URL" ]]; then
|
||||
echo "$DB_URL"
|
||||
return 0
|
||||
fi
|
||||
local cname url
|
||||
cname="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true)"
|
||||
if [[ -n "$cname" ]]; then
|
||||
url="$(docker exec "$cname" grep -E '^NEXUS_DATABASE_URL=' /app/.env 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" || true)"
|
||||
[[ -n "$url" ]] && echo "$url" && return 0
|
||||
fi
|
||||
for f in \
|
||||
"${NEXUS_ROOT}/docker/.env.prod" \
|
||||
"${NEXUS_ROOT}/.env" \
|
||||
; do
|
||||
[[ -f "$f" ]] || continue
|
||||
url="$(grep -E '^NEXUS_DATABASE_URL=' "$f" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" || true)"
|
||||
[[ -n "$url" ]] && echo "$url" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
DB_URL="$(resolve_db_url || true)"
|
||||
if [[ -z "$DB_URL" ]]; then
|
||||
echo "未找到 NEXUS_DATABASE_URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$DB_URL" "$OUTPUT" <<'PY'
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
|
||||
def docker_names() -> set[str]:
|
||||
r = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Names}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return {ln.strip() for ln in (r.stdout or "").splitlines() if ln.strip()}
|
||||
|
||||
|
||||
def network_exists(name: str) -> bool:
|
||||
return (
|
||||
subprocess.run(
|
||||
["docker", "network", "inspect", name],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def write_dump(cmd: list[str], env: dict[str, str], out: str) -> int:
|
||||
r = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
sys.stderr.write(r.stderr or r.stdout or "mysqldump failed\n")
|
||||
return r.returncode
|
||||
with open(out, "w", encoding="utf-8") as fh:
|
||||
fh.write(r.stdout)
|
||||
return 0
|
||||
|
||||
|
||||
def dump_flags(db: str) -> list[str]:
|
||||
return [
|
||||
"--single-transaction",
|
||||
"--routines",
|
||||
"--triggers",
|
||||
"--set-gtid-purged=OFF",
|
||||
db,
|
||||
]
|
||||
|
||||
|
||||
url = sys.argv[1].replace("mysql+aiomysql://", "mysql://", 1)
|
||||
url = url.replace("mysql+asyncmy://", "mysql://", 1)
|
||||
out = sys.argv[2]
|
||||
p = urllib.parse.urlparse(url)
|
||||
user = urllib.parse.unquote(p.username or "")
|
||||
password = urllib.parse.unquote(p.password or "")
|
||||
host = p.hostname or "127.0.0.1"
|
||||
if host == "host.docker.internal":
|
||||
host = "127.0.0.1"
|
||||
port = str(p.port or 3306)
|
||||
db = (p.path or "/nexus").lstrip("/").split("?")[0]
|
||||
env = {**__import__("os").environ, "MYSQL_PWD": password}
|
||||
names = docker_names()
|
||||
|
||||
# 1) MySQL 跑在 Docker 容器内(1Panel 常见:URL host = 容器名)
|
||||
if host in names:
|
||||
cmd = [
|
||||
"docker",
|
||||
"exec",
|
||||
"-e",
|
||||
f"MYSQL_PWD={password}",
|
||||
host,
|
||||
"mysqldump",
|
||||
"-h",
|
||||
"127.0.0.1",
|
||||
"-P",
|
||||
port,
|
||||
"-u",
|
||||
user,
|
||||
*dump_flags(db),
|
||||
]
|
||||
rc = write_dump(cmd, env, out)
|
||||
if rc == 0:
|
||||
sys.exit(0)
|
||||
sys.stderr.write(f"docker exec {host} mysqldump 失败,尝试其它方式…\n")
|
||||
|
||||
# 2) 宿主机 mysqldump
|
||||
if shutil.which("mysqldump"):
|
||||
cmd = [
|
||||
"mysqldump",
|
||||
"-h",
|
||||
host,
|
||||
"-P",
|
||||
port,
|
||||
"-u",
|
||||
user,
|
||||
*dump_flags(db),
|
||||
]
|
||||
rc = write_dump(cmd, env, out)
|
||||
if rc == 0:
|
||||
sys.exit(0)
|
||||
|
||||
# 3) 临时 mysql 客户端容器(1panel-network)
|
||||
for net in ("1panel-network", "bridge"):
|
||||
if not network_exists(net):
|
||||
continue
|
||||
cmd = [
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
net,
|
||||
"-e",
|
||||
f"MYSQL_PWD={password}",
|
||||
"mysql:8.0",
|
||||
"mysqldump",
|
||||
"-h",
|
||||
host,
|
||||
"-P",
|
||||
port,
|
||||
"-u",
|
||||
user,
|
||||
*dump_flags(db),
|
||||
]
|
||||
rc = write_dump(cmd, env, out)
|
||||
if rc == 0:
|
||||
sys.exit(0)
|
||||
|
||||
sys.stderr.write("所有 mysqldump 策略均失败(无宿主机客户端且 Docker 回退不可用)\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
@@ -1,7 +1,10 @@
|
||||
# 可选 — 仅私人 Gitea 仓库需要(公共仓库匿名 clone,新机无需此文件)
|
||||
# 复制: cp deploy/nexus-1panel.secrets.sh.example deploy/nexus-1panel.secrets.sh
|
||||
# 用于 git push(scripts/git-push.sh);clone/pull 仍可匿名
|
||||
|
||||
# NEXUS_GITEA_TOKEN='你的Gitea_Access_Token'
|
||||
# NEXUS_GITEA_HOST='66.154.115.8:3000'
|
||||
# NEXUS_GITEA_REPO='admin/Nexus.git'
|
||||
# admin 账号密码或 Access Token:
|
||||
# NEXUS_GITEA_TOKEN='你的密码或令牌'
|
||||
|
||||
# 新机安装勿填下面三项(install-nexus-fresh / --fresh 会自动按机生成)
|
||||
# NEXUS_SECRET_KEY=''
|
||||
|
||||
+377
-62
@@ -54,6 +54,7 @@ FRESH_INSTALL=false
|
||||
REUSE_SECRETS=false
|
||||
FROM_ENV="${NEXUS_FROM_ENV}"
|
||||
NO_BACKUP=false
|
||||
REQUIRE_BACKUP=false
|
||||
PRUNE_IMAGES=false
|
||||
NO_CACHE=false
|
||||
CHECK_ONLY=false
|
||||
@@ -83,7 +84,7 @@ Nexus 1Panel 一键脚本
|
||||
--domain NAME 覆盖域名
|
||||
|
||||
升级选项:
|
||||
--no-backup --prune --no-cache --dry-run --check(仅查 Git,不重建)
|
||||
--no-backup --require-backup --prune --no-cache --dry-run --check(仅查 Git,不重建)
|
||||
|
||||
Git: 公共仓库匿名 clone(无需令牌);私人仓库见 init-token
|
||||
EOF
|
||||
@@ -200,6 +201,252 @@ upsert_env_var() {
|
||||
fi
|
||||
}
|
||||
|
||||
migrate_redis_url_host() {
|
||||
local old_url="$1" new_host="$2"
|
||||
python3 - "$old_url" "$new_host" <<'PY'
|
||||
import sys
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
old_url, new_host = sys.argv[1], sys.argv[2]
|
||||
u = urlparse(old_url)
|
||||
if u.scheme != "redis":
|
||||
sys.exit(0)
|
||||
password = unquote(u.password or "")
|
||||
if not password and u.username:
|
||||
password = unquote(u.username)
|
||||
if password and new_host:
|
||||
print(f"redis://:{quote(password, safe='')}@{new_host}:6379/0")
|
||||
PY
|
||||
}
|
||||
|
||||
# Prefer wizard-written redis://:pass@host from nexus-state or .env.prod; avoid clobbering on nx update.
|
||||
resolve_nexus_redis_url() {
|
||||
local redis_host="$1"
|
||||
local env_file="$2"
|
||||
local vol_url="" existing="" migrated="" host_lc="${redis_host,,}"
|
||||
|
||||
local state_vol
|
||||
state_vol="$(docker volume ls -q --filter name=nexus-state 2>/dev/null | head -1 || true)"
|
||||
if [[ -n "$state_vol" ]]; then
|
||||
vol_url="$(docker run --rm -v "${state_vol}:/data" alpine sh -c \
|
||||
"grep '^NEXUS_REDIS_URL=' /data/.env 2>/dev/null | cut -d= -f2- | tr -d '\"'" 2>/dev/null || true)"
|
||||
if [[ -n "$vol_url" ]]; then
|
||||
if [[ "${vol_url,,}" == *"${host_lc}"* ]]; then
|
||||
echo "$vol_url"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$vol_url" == *"@"* ]]; then
|
||||
migrated="$(migrate_redis_url_host "$vol_url" "$redis_host" 2>/dev/null || true)"
|
||||
if [[ -n "$migrated" ]]; then
|
||||
echo "$migrated"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [[ -f "$env_file" ]]; then
|
||||
existing="$(grep '^NEXUS_REDIS_URL=' "$env_file" 2>/dev/null | cut -d= -f2- | tr -d '"' || true)"
|
||||
if [[ -n "$existing" && "$existing" == *"@"* ]]; then
|
||||
if [[ "${existing,,}" == *"${host_lc}"* ]]; then
|
||||
echo "$existing"
|
||||
return 0
|
||||
fi
|
||||
migrated="$(migrate_redis_url_host "$existing" "$redis_host" 2>/dev/null || true)"
|
||||
if [[ -n "$migrated" ]]; then
|
||||
echo "$migrated"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "redis://${redis_host}:6379/0"
|
||||
}
|
||||
|
||||
nexus_container_name() {
|
||||
docker ps --format '{{.Names}}' | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true
|
||||
}
|
||||
|
||||
nexus_publish_port() {
|
||||
local root="$1"
|
||||
local env_file="$root/$ENV_PROD"
|
||||
local port
|
||||
port="$(grep -E '^NEXUS_PUBLISH_PORT=' "$env_file" 2>/dev/null | cut -d= -f2- | tr -d '"' || true)"
|
||||
echo "${port:-8600}"
|
||||
}
|
||||
|
||||
nexus_container_has_install_lock() {
|
||||
local cname="$1"
|
||||
docker exec "$cname" test -f /app/.install_locked 2>/dev/null \
|
||||
|| docker exec "$cname" test -f /var/lib/nexus/.install_locked 2>/dev/null
|
||||
}
|
||||
|
||||
restore_install_lock_in_container() {
|
||||
local cname="$1"
|
||||
[[ -n "$cname" ]] || return 0
|
||||
docker exec "$cname" sh -c '
|
||||
if [ ! -f /app/.install_locked ] && [ -f /var/lib/nexus/.install_locked ]; then
|
||||
cp /var/lib/nexus/.install_locked /app/.install_locked
|
||||
chmod 600 /app/.install_locked
|
||||
fi
|
||||
' 2>/dev/null || true
|
||||
}
|
||||
|
||||
nexus_on_1panel_network() {
|
||||
local cname="$1"
|
||||
[[ -n "$cname" ]] || return 1
|
||||
docker inspect "$cname" --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null \
|
||||
| grep -qw '1panel-network'
|
||||
}
|
||||
|
||||
verify_nexus_1panel_network() {
|
||||
local root="$1"
|
||||
if ! has_1panel_network; then
|
||||
return 0
|
||||
fi
|
||||
local cname
|
||||
cname="$(nexus_container_name)"
|
||||
[[ -n "$cname" ]] || return 0
|
||||
restore_install_lock_in_container "$cname"
|
||||
if nexus_on_1panel_network "$cname"; then
|
||||
info "已验证 Nexus 接入 1panel-network"
|
||||
return 0
|
||||
fi
|
||||
error "Nexus 容器 ${cname} 未接入 1panel-network,无法解析 1Panel MySQL/Redis 主机名"
|
||||
warn "正在叠加 docker-compose.1panel.yml 重新创建容器..."
|
||||
compose_cmd "$root" up -d --no-build --remove-orphans
|
||||
sleep 2
|
||||
cname="$(nexus_container_name)"
|
||||
restore_install_lock_in_container "$cname"
|
||||
if nexus_on_1panel_network "$cname"; then
|
||||
info "已修复:Nexus 已接入 1panel-network"
|
||||
return 0
|
||||
fi
|
||||
error "修复失败:Nexus 仍未接入 1panel-network"
|
||||
return 1
|
||||
}
|
||||
|
||||
set_install_complete() {
|
||||
local root="$1"
|
||||
local env_file="$root/$ENV_PROD"
|
||||
local cname
|
||||
[[ -f "$env_file" ]] || return 0
|
||||
cname="$(nexus_container_name)"
|
||||
[[ -n "$cname" ]] || return 0
|
||||
restore_install_lock_in_container "$cname"
|
||||
if nexus_container_has_install_lock "$cname"; then
|
||||
archive_install_wizard_in_container "$cname"
|
||||
upsert_env_var "$env_file" "NEXUS_INSTALL_WIZARD_PENDING" "0"
|
||||
info "安装已完成:NEXUS_INSTALL_WIZARD_PENDING=0"
|
||||
fi
|
||||
}
|
||||
|
||||
sync_env_prod_to_volume() {
|
||||
local root="$1"
|
||||
local env_file="$root/$ENV_PROD"
|
||||
local state_vol cname
|
||||
state_vol="$(docker volume ls -q --filter name=nexus-state 2>/dev/null | head -1 || true)"
|
||||
[[ -n "$state_vol" && -f "$env_file" ]] || return 0
|
||||
cname="$(nexus_container_name)"
|
||||
[[ -n "$cname" ]] || return 0
|
||||
restore_install_lock_in_container "$cname"
|
||||
nexus_container_has_install_lock "$cname" || return 0
|
||||
python3 - "$env_file" "$state_vol" <<'PY'
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
env_file = Path(sys.argv[1])
|
||||
vol = sys.argv[2]
|
||||
keys = (
|
||||
"NEXUS_SECRET_KEY",
|
||||
"NEXUS_API_KEY",
|
||||
"NEXUS_ENCRYPTION_KEY",
|
||||
"NEXUS_REDIS_URL",
|
||||
"NEXUS_DATABASE_URL",
|
||||
)
|
||||
prod = {}
|
||||
for line in env_file.read_text(encoding="utf-8").splitlines():
|
||||
m = re.match(r"^([A-Z0-9_]+)=(.*)$", line.strip())
|
||||
if m and m.group(1) in keys and m.group(2).strip():
|
||||
prod[m.group(1)] = m.group(2).strip().strip('"')
|
||||
if not prod:
|
||||
sys.exit(0)
|
||||
cur = subprocess.run(
|
||||
["docker", "run", "--rm", "-v", f"{vol}:/data", "alpine", "cat", "/data/.env"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
text = cur.stdout if cur.returncode == 0 else ""
|
||||
if not text.strip():
|
||||
sys.exit(0)
|
||||
lines = text.splitlines()
|
||||
out = []
|
||||
seen = set()
|
||||
for line in lines:
|
||||
key = line.split("=", 1)[0].strip() if "=" in line else ""
|
||||
if key in prod:
|
||||
out.append(f'{key}={prod[key]}')
|
||||
seen.add(key)
|
||||
else:
|
||||
out.append(line)
|
||||
for key, val in prod.items():
|
||||
if key not in seen:
|
||||
out.append(f'{key}={val}')
|
||||
new_text = "\n".join(out).rstrip() + "\n"
|
||||
subprocess.run(
|
||||
["docker", "run", "--rm", "-i", "-v", f"{vol}:/data", "alpine", "sh", "-c", "cat > /data/.env && chmod 600 /data/.env"],
|
||||
input=new_text,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
print("synced")
|
||||
PY
|
||||
info "已将 docker/.env.prod 密钥/URL 同步至 nexus-state 卷"
|
||||
}
|
||||
|
||||
tag_rollback_image() {
|
||||
local root="$1" old_id
|
||||
old_id="$(docker ps --filter "name=nexus-prod-nexus" --format '{{.Image}}' 2>/dev/null | head -1 || true)"
|
||||
if [[ -z "$old_id" ]]; then
|
||||
old_id="$(compose_cmd "$root" images -q nexus 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
if [[ -n "$old_id" ]]; then
|
||||
docker tag "$old_id" "nexus-prod-nexus:rollback" 2>/dev/null || true
|
||||
info "已标记回滚镜像 nexus-prod-nexus:rollback"
|
||||
fi
|
||||
}
|
||||
|
||||
gate_new_image_import() {
|
||||
local root="$1" img
|
||||
img="$(compose_cmd "$root" images -q nexus 2>/dev/null | head -1 || true)"
|
||||
if [[ -z "$img" ]]; then
|
||||
error "镜像门控失败:未找到 nexus 镜像"
|
||||
return 1
|
||||
fi
|
||||
if ! docker run --rm --entrypoint python3 "$img" -c "import server.main" >/dev/null 2>&1; then
|
||||
error "镜像门控失败:import server.main"
|
||||
return 1
|
||||
fi
|
||||
info "镜像门控通过 (import server.main)"
|
||||
return 0
|
||||
}
|
||||
|
||||
rollback_compose_upgrade() {
|
||||
local root="$1"
|
||||
if docker image inspect nexus-prod-nexus:rollback >/dev/null 2>&1; then
|
||||
warn "健康检查失败,回滚至上一镜像..."
|
||||
docker tag nexus-prod-nexus:rollback nexus-prod-nexus:latest 2>/dev/null || true
|
||||
compose_cmd "$root" up -d --no-build --remove-orphans
|
||||
if ! verify_nexus_1panel_network "$root"; then
|
||||
error "回滚后仍未接入 1panel-network"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
warn "无回滚镜像 nexus-prod-nexus:rollback,跳过自动回滚"
|
||||
return 1
|
||||
}
|
||||
|
||||
write_1panel_hosts_volume() {
|
||||
local mysql_host="${1:-}" redis_host="${2:-}"
|
||||
[[ -n "$mysql_host" || -n "$redis_host" ]] || return 0
|
||||
@@ -257,8 +504,14 @@ sync_1panel_service_hosts() {
|
||||
NEXUS_1PANEL_REDIS_HOST=*)
|
||||
redis_host="${line#NEXUS_1PANEL_REDIS_HOST=}"
|
||||
upsert_env_var "$env_file" NEXUS_1PANEL_REDIS_HOST "$redis_host"
|
||||
upsert_env_var "$env_file" "NEXUS_REDIS_URL" "redis://${redis_host}:6379/0"
|
||||
info "1Panel Redis 容器: $redis_host(NEXUS_REDIS_URL 已同步)"
|
||||
local redis_url
|
||||
redis_url="$(resolve_nexus_redis_url "$redis_host" "$env_file")"
|
||||
upsert_env_var "$env_file" "NEXUS_REDIS_URL" "$redis_url"
|
||||
if [[ "$redis_url" == *"@"* ]]; then
|
||||
info "1Panel Redis 容器: $redis_host(NEXUS_REDIS_URL 已保留向导密码)"
|
||||
else
|
||||
info "1Panel Redis 容器: $redis_host(NEXUS_REDIS_URL 占位,向导步骤 3 填密码后写入卷 .env)"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done <<<"$detect_out"
|
||||
@@ -634,31 +887,83 @@ purge_legacy_bundled_services() {
|
||||
done
|
||||
}
|
||||
|
||||
reset_nexus_state_volume_for_fresh() {
|
||||
local vol
|
||||
vol="$(docker volume ls -q --filter name=nexus-state 2>/dev/null | head -1 || true)"
|
||||
[[ -n "$vol" ]] || return 0
|
||||
warn "全新安装:清除 nexus-state 卷内旧 .env / .install_locked"
|
||||
docker run --rm -v "${vol}:/data" alpine:3.19 \
|
||||
sh -c 'rm -f /data/.env /data/.install_locked' 2>/dev/null || true
|
||||
}
|
||||
|
||||
restore_install_wizard_html_in_container() {
|
||||
local cname="$1"
|
||||
[[ -n "$cname" ]] || return 0
|
||||
docker exec "$cname" sh -c '
|
||||
rm -f /app/.install_locked
|
||||
if [ -f /app/web/app/install.html.bak ] && [ ! -f /app/web/app/install.html ]; then
|
||||
mv /app/web/app/install.html.bak /app/web/app/install.html
|
||||
fi
|
||||
' 2>/dev/null || true
|
||||
}
|
||||
|
||||
compose_up() {
|
||||
local root="$1"
|
||||
local root="$1" cname
|
||||
save_host_profile "$root"
|
||||
purge_legacy_bundled_services "$root"
|
||||
sync_1panel_service_hosts "$root"
|
||||
if [[ "$FRESH_INSTALL" == true ]]; then
|
||||
reset_nexus_state_volume_for_fresh
|
||||
fi
|
||||
if has_1panel_network; then
|
||||
info "已接入 1panel-network(MySQL/Redis 使用 1Panel 容器名互联)"
|
||||
fi
|
||||
info "档位: ${NEXUS_PROFILE}(仅 Nexus 容器;MySQL/Redis 请自行安装)"
|
||||
info "构建并启动(首次约 10~20 分钟)..."
|
||||
compose_cmd "$root" up -d --build --remove-orphans
|
||||
verify_nexus_1panel_network "$root" || return 1
|
||||
if [[ "$FRESH_INSTALL" == true ]]; then
|
||||
cname="$(nexus_container_name)"
|
||||
restore_install_wizard_html_in_container "$cname"
|
||||
fi
|
||||
}
|
||||
|
||||
archive_install_wizard_in_container() {
|
||||
local cname="$1"
|
||||
[[ -n "$cname" ]] || return 0
|
||||
docker exec "$cname" sh -c '
|
||||
if [ -f /app/web/app/install.html.bak ]; then
|
||||
rm -f /app/web/app/install.html
|
||||
elif [ -f /app/web/app/install.html ]; then
|
||||
mv /app/web/app/install.html /app/web/app/install.html.bak
|
||||
fi
|
||||
' 2>/dev/null || true
|
||||
}
|
||||
|
||||
verify_install_wizard() {
|
||||
local root="$1"
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:8600/app/install.html" 2>/dev/null || echo "000")
|
||||
local port="${2:-$(nexus_publish_port "$root")}"
|
||||
local code cname
|
||||
cname="$(nexus_container_name)"
|
||||
if [[ -n "$cname" ]] && nexus_container_has_install_lock "$cname"; then
|
||||
archive_install_wizard_in_container "$cname"
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/install.html" 2>/dev/null || echo "000")
|
||||
if [[ "$code" == "404" ]]; then
|
||||
info " /app/install.html 已归档 → HTTP 404"
|
||||
return 0
|
||||
fi
|
||||
warn " 已锁定但 /app/install.html → HTTP $code"
|
||||
return 1
|
||||
fi
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/install.html" 2>/dev/null || echo "000")
|
||||
if [[ "$code" == "200" ]]; then
|
||||
info " /app/install.html → HTTP 200"
|
||||
return 0
|
||||
fi
|
||||
warn " /app/install.html → HTTP $code(尝试同步静态文件…)"
|
||||
if [[ -x "$root/deploy/sync-install-wizard-to-container.sh" ]]; then
|
||||
NEXUS_ROOT="$root" bash "$root/deploy/sync-install-wizard-to-container.sh" || true
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:8600/app/install.html" 2>/dev/null || echo "000")
|
||||
NEXUS_PUBLISH_PORT="$port" NEXUS_ROOT="$root" bash "$root/deploy/sync-install-wizard-to-container.sh" || true
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/install.html" 2>/dev/null || echo "000")
|
||||
if [[ "$code" == "200" ]]; then
|
||||
info " /app/install.html → HTTP 200(已同步)"
|
||||
return 0
|
||||
@@ -670,13 +975,14 @@ verify_install_wizard() {
|
||||
|
||||
verify_health() {
|
||||
local root="$1" title="${2:-安装}"
|
||||
local i health spa
|
||||
info "等待服务就绪..."
|
||||
local i health spa port
|
||||
port="$(nexus_publish_port "$root")"
|
||||
info "等待服务就绪 (端口 ${port})..."
|
||||
for i in $(seq 1 60); do
|
||||
health=$(curl -sf "http://127.0.0.1:8600/health" 2>/dev/null || true)
|
||||
health=$(curl -sf "http://127.0.0.1:${port}/health" 2>/dev/null || true)
|
||||
if [[ "$health" == "ok" ]]; then
|
||||
spa=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:8600/app/" 2>/dev/null || echo "000")
|
||||
verify_install_wizard "$root" || true
|
||||
spa=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/" 2>/dev/null || echo "000")
|
||||
verify_install_wizard "$root" "$port" || true
|
||||
echo ""
|
||||
info "═══ ${title}完成 ═══"
|
||||
info " 档位: ${NEXUS_PROFILE}"
|
||||
@@ -687,7 +993,7 @@ verify_health() {
|
||||
info " 版本: $(git -C "$root" log -1 --oneline)"
|
||||
fi
|
||||
echo ""
|
||||
info "1Panel → 网站 → 反代 http://127.0.0.1:8600 域名: ${NEXUS_DOMAIN}"
|
||||
info "1Panel → 网站 → 反代 http://127.0.0.1:${port} 域名: ${NEXUS_DOMAIN}"
|
||||
info "配置示例: $root/deploy/1panel/openresty-nexus.conf.example"
|
||||
if [[ "$FRESH_INSTALL" == true ]] || ! grep -qE '^NEXUS_SECRET_KEY=.+$' "$root/$ENV_PROD" 2>/dev/null; then
|
||||
info "浏览器: https://${NEXUS_DOMAIN}/app/install.html"
|
||||
@@ -705,7 +1011,7 @@ verify_health() {
|
||||
error "健康检查超时"
|
||||
check_ports_post "$root" || true
|
||||
compose_cmd "$root" logs --tail=80 nexus || true
|
||||
exit 1
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_repo() {
|
||||
@@ -745,58 +1051,29 @@ show_pending() {
|
||||
}
|
||||
|
||||
backup_mysql() {
|
||||
local root="$1" ts file db_url cname
|
||||
local root="$1" ts file dump_sh
|
||||
if [[ "$NO_BACKUP" == true ]]; then
|
||||
warn "已跳过备份 (--no-backup)"
|
||||
return 0
|
||||
fi
|
||||
if ! command -v mysqldump >/dev/null 2>&1; then
|
||||
warn "未安装 mysqldump,跳过 MySQL 备份"
|
||||
return 0
|
||||
fi
|
||||
cname="$(docker ps --format '{{.Names}}' | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true)"
|
||||
db_url=""
|
||||
if [[ -n "$cname" ]]; then
|
||||
db_url="$(docker exec "$cname" grep -E '^NEXUS_DATABASE_URL=' /app/.env 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" || true)"
|
||||
fi
|
||||
if [[ -z "$db_url" ]]; then
|
||||
warn "未找到 NEXUS_DATABASE_URL(外置 MySQL 需先完成安装向导),跳过备份"
|
||||
dump_sh="${root}/deploy/mysql_dump_to_file.sh"
|
||||
if [[ ! -f "$dump_sh" ]]; then
|
||||
warn "缺少 $dump_sh,跳过 MySQL 备份"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$NEXUS_BACKUP_DIR"
|
||||
ts="$(date +%Y%m%d-%H%M%S)"
|
||||
file="${NEXUS_BACKUP_DIR}/nexus-before-upgrade-${ts}.sql"
|
||||
step "备份外置 MySQL → $file"
|
||||
if ! python3 - "$db_url" "$file" <<'PY'
|
||||
import sys
|
||||
import urllib.parse
|
||||
from subprocess import run
|
||||
|
||||
url = sys.argv[1].replace("mysql+aiomysql://", "mysql://", 1)
|
||||
out = sys.argv[2]
|
||||
p = urllib.parse.urlparse(url)
|
||||
user = urllib.parse.unquote(p.username or "")
|
||||
password = urllib.parse.unquote(p.password or "")
|
||||
host = p.hostname or "127.0.0.1"
|
||||
if host == "host.docker.internal":
|
||||
host = "127.0.0.1"
|
||||
port = str(p.port or 3306)
|
||||
db = (p.path or "/nexus").lstrip("/").split("?")[0]
|
||||
env = {**__import__("os").environ, "MYSQL_PWD": password}
|
||||
cmd = [
|
||||
"mysqldump", "-h", host, "-P", port, "-u", user,
|
||||
"--single-transaction", "--routines", "--triggers", "--set-gtid-purged=OFF", db,
|
||||
]
|
||||
r = run(cmd, env=env, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
sys.stderr.write(r.stderr or r.stdout or "mysqldump failed\n")
|
||||
sys.exit(r.returncode)
|
||||
open(out, "w", encoding="utf-8").write(r.stdout)
|
||||
PY
|
||||
then
|
||||
error "mysqldump 失败,已中止"
|
||||
step "备份 MySQL → $file"
|
||||
if ! NEXUS_ROOT="$root" bash "$dump_sh" --output "$file" --root "$root"; then
|
||||
if [[ "$REQUIRE_BACKUP" == true ]]; then
|
||||
error "mysqldump 失败,已中止(--require-backup)"
|
||||
rm -f "$file"
|
||||
exit 1
|
||||
fi
|
||||
warn "mysqldump 失败,继续升级(加 --require-backup 可在备份失败时中止)"
|
||||
rm -f "$file"
|
||||
exit 1
|
||||
return 0
|
||||
fi
|
||||
gzip -f "$file"
|
||||
info "备份: ${file}.gz"
|
||||
@@ -841,7 +1118,7 @@ cmd_install() {
|
||||
NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/install-nx-cli.sh" 2>/dev/null || true
|
||||
write_env_prod "$NEXUS_ROOT"
|
||||
compose_up "$NEXUS_ROOT"
|
||||
verify_health "$NEXUS_ROOT" "安装"
|
||||
verify_health "$NEXUS_ROOT" "安装" || exit 1
|
||||
}
|
||||
|
||||
cmd_upgrade() {
|
||||
@@ -882,23 +1159,60 @@ cmd_upgrade() {
|
||||
fi
|
||||
|
||||
apply_pool_to_env_prod "$NEXUS_ROOT" "$(profile_env_file "$NEXUS_ROOT")"
|
||||
upsert_env_var "$NEXUS_ROOT/$ENV_PROD" "NEXUS_HOST_ROOT" "$NEXUS_ROOT"
|
||||
save_host_profile "$NEXUS_ROOT"
|
||||
purge_legacy_bundled_services "$NEXUS_ROOT"
|
||||
sync_1panel_service_hosts "$NEXUS_ROOT"
|
||||
sync_env_prod_to_volume "$NEXUS_ROOT"
|
||||
tag_rollback_image "$NEXUS_ROOT"
|
||||
if [[ "$NO_CACHE" == true ]]; then
|
||||
step "无缓存重建 Nexus 镜像..."
|
||||
compose_cmd "$NEXUS_ROOT" build --no-cache nexus
|
||||
else
|
||||
step "重建 Nexus 镜像..."
|
||||
compose_cmd "$NEXUS_ROOT" build nexus
|
||||
fi
|
||||
if ! gate_new_image_import "$NEXUS_ROOT"; then
|
||||
exit 1
|
||||
fi
|
||||
step "重建容器(--remove-orphans 清理旧 mysql/redis)..."
|
||||
compose_cmd "$NEXUS_ROOT" up -d --build --remove-orphans
|
||||
if [[ -x "$NEXUS_ROOT/deploy/sync-install-wizard-to-container.sh" ]]; then
|
||||
NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/sync-install-wizard-to-container.sh" || true
|
||||
compose_cmd "$NEXUS_ROOT" up -d --no-build --remove-orphans
|
||||
if ! verify_nexus_1panel_network "$NEXUS_ROOT"; then
|
||||
rollback_compose_upgrade "$NEXUS_ROOT" || true
|
||||
exit 1
|
||||
fi
|
||||
local port wiz_code i health
|
||||
port="$(nexus_publish_port "$NEXUS_ROOT")"
|
||||
for i in $(seq 1 30); do
|
||||
health="$(curl -sf "http://127.0.0.1:${port}/health" 2>/dev/null || true)"
|
||||
[[ "$health" == "ok" ]] && break
|
||||
sleep 2
|
||||
done
|
||||
cname="$(nexus_container_name)"
|
||||
if [[ -n "$cname" ]] && nexus_container_has_install_lock "$cname"; then
|
||||
archive_install_wizard_in_container "$cname"
|
||||
info "安装已锁定,跳过 install.html 热同步"
|
||||
else
|
||||
wiz_code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/install.html" 2>/dev/null || echo "000")"
|
||||
if [[ "$wiz_code" != "200" && -x "$NEXUS_ROOT/deploy/sync-install-wizard-to-container.sh" ]]; then
|
||||
NEXUS_PUBLISH_PORT="$port" NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/sync-install-wizard-to-container.sh"
|
||||
fi
|
||||
fi
|
||||
set_install_complete "$NEXUS_ROOT"
|
||||
if [[ "$PRUNE_IMAGES" == true ]]; then
|
||||
docker image prune -f >/dev/null 2>&1 || true
|
||||
fi
|
||||
NEXUS_ROOT="$NEXUS_ROOT" bash "$NEXUS_ROOT/deploy/install-nx-cli.sh" 2>/dev/null || true
|
||||
verify_health "$NEXUS_ROOT" "升级"
|
||||
if ! verify_health "$NEXUS_ROOT" "升级"; then
|
||||
rollback_compose_upgrade "$NEXUS_ROOT" || true
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=./detect_deploy_runtime.sh
|
||||
if [[ -f "$NEXUS_ROOT/deploy/detect_deploy_runtime.sh" ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$NEXUS_ROOT/deploy/detect_deploy_runtime.sh"
|
||||
suggest_ops_cron "$NEXUS_ROOT"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_check() {
|
||||
@@ -943,6 +1257,7 @@ parse_common_upgrade() {
|
||||
--root) NEXUS_ROOT="$2"; shift 2 ;;
|
||||
--branch) GIT_BRANCH="$2"; shift 2 ;;
|
||||
--no-backup) NO_BACKUP=true; shift ;;
|
||||
--require-backup) REQUIRE_BACKUP=true; shift ;;
|
||||
--prune|--prune-images) PRUNE_IMAGES=true; shift ;;
|
||||
--no-cache|--rebuild) NO_CACHE=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
|
||||
@@ -34,7 +34,11 @@ detect_runtime() {
|
||||
local prof q
|
||||
prof="$(profile_env_file "$NEXUS_ROOT")"
|
||||
if [[ -f "$prof" && -f "$NEXUS_ROOT/$ENV_PROD" ]]; then
|
||||
q="$(cd "$NEXUS_ROOT" && docker compose -f "$COMPOSE_FILE" \
|
||||
local -a det_args=(-f "$COMPOSE_FILE")
|
||||
if has_1panel_network && [[ -f "$NEXUS_ROOT/$COMPOSE_1PANEL" ]]; then
|
||||
det_args+=(-f "$COMPOSE_1PANEL")
|
||||
fi
|
||||
q="$(cd "$NEXUS_ROOT" && docker compose "${det_args[@]}" \
|
||||
--env-file "$ENV_PROD" --env-file "$prof" ps -q nexus 2>/dev/null || true)"
|
||||
if [[ -n "$q" ]]; then
|
||||
NX_RUNTIME="docker"
|
||||
@@ -174,6 +178,7 @@ ops_stop_stack() {
|
||||
|
||||
ops_start_stack() {
|
||||
compose_cmd "$NEXUS_ROOT" up -d --remove-orphans
|
||||
verify_nexus_1panel_network "$NEXUS_ROOT" || return 1
|
||||
verify_health "$NEXUS_ROOT" "启动" || true
|
||||
}
|
||||
|
||||
@@ -196,8 +201,18 @@ ops_status() {
|
||||
port="$(nexus_publish_port "$NEXUS_ROOT")"
|
||||
h=$(curl -sf "http://127.0.0.1:${port}/health" 2>/dev/null || echo "fail")
|
||||
info "/health → ${h}"
|
||||
local cname
|
||||
cname="$(nexus_container_name 2>/dev/null || true)"
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/install.html" 2>/dev/null || echo "000")
|
||||
info "/app/install.html → HTTP ${code}"
|
||||
if [[ -n "$cname" ]] && nexus_container_has_install_lock "$cname"; then
|
||||
if [[ "$code" == "404" ]]; then
|
||||
info "/app/install.html → 已归档 (HTTP 404)"
|
||||
else
|
||||
warn "/app/install.html → 应已归档但 HTTP ${code}"
|
||||
fi
|
||||
else
|
||||
info "/app/install.html → HTTP ${code}"
|
||||
fi
|
||||
}
|
||||
|
||||
ops_backup_now() {
|
||||
@@ -205,6 +220,10 @@ ops_backup_now() {
|
||||
backup_mysql "$NEXUS_ROOT"
|
||||
}
|
||||
|
||||
ops_install_cron() {
|
||||
bash "${NX_DIR}/install_ops_cron.sh"
|
||||
}
|
||||
|
||||
ops_update() {
|
||||
local args=()
|
||||
PRUNE_IMAGES=false
|
||||
@@ -250,6 +269,7 @@ menu_main() {
|
||||
[7] 启动 / 停止 Compose 栈
|
||||
[8] 重建镜像并启动(--build nexus)
|
||||
[9] 备份 MySQL
|
||||
[e] 安装巡检/备份 cron(health_monitor + db_backup)
|
||||
|
||||
── 其它 ──
|
||||
[a] 检查 Git 是否有更新
|
||||
@@ -264,6 +284,11 @@ EOF
|
||||
1) menu_install ;;
|
||||
2)
|
||||
require_root
|
||||
if is_stack_installed; then
|
||||
warn "将执行全新安装(清除 nexus-state 卷内旧配置并重建容器)"
|
||||
read -r -p "确认继续? [y/N]: " fresh_ok
|
||||
[[ "$fresh_ok" == "y" || "$fresh_ok" == "Y" ]] || continue
|
||||
fi
|
||||
exec bash "${NX_DIR}/install-nexus-fresh.sh" --profile "${NEXUS_PROFILE:-2c8g}"
|
||||
;;
|
||||
3)
|
||||
@@ -309,6 +334,7 @@ EOF
|
||||
compose_cmd "$NEXUS_ROOT" build --no-cache nexus
|
||||
fi
|
||||
compose_cmd "$NEXUS_ROOT" up -d --build --remove-orphans nexus
|
||||
verify_nexus_1panel_network "$NEXUS_ROOT" || true
|
||||
verify_health "$NEXUS_ROOT" "重建" || true
|
||||
pause_enter
|
||||
;;
|
||||
@@ -317,6 +343,11 @@ EOF
|
||||
ops_backup_now
|
||||
pause_enter
|
||||
;;
|
||||
e|E)
|
||||
require_root
|
||||
ops_install_cron
|
||||
pause_enter
|
||||
;;
|
||||
a|A)
|
||||
require_root
|
||||
cmd_check
|
||||
@@ -368,7 +399,8 @@ nx_main() {
|
||||
install-auto|auto)
|
||||
require_root
|
||||
if is_stack_installed; then
|
||||
run_install "${NEXUS_PROFILE:-2c8g}" skip-clone
|
||||
warn "已安装,install-auto 将执行升级而非重复安装"
|
||||
exec bash "${NX_DIR}/update.sh"
|
||||
else
|
||||
run_install "${NEXUS_PROFILE:-2c8g}" ""
|
||||
fi
|
||||
@@ -377,6 +409,10 @@ nx_main() {
|
||||
require_root
|
||||
is_stack_installed && ops_status || check_ports_preflight "$NEXUS_ROOT"
|
||||
;;
|
||||
cron)
|
||||
require_root
|
||||
ops_install_cron
|
||||
;;
|
||||
-h|--help)
|
||||
cat <<EOF
|
||||
用法: nx [子命令]
|
||||
@@ -386,6 +422,7 @@ nx_main() {
|
||||
install-fresh 全新安装(/app/install.html)
|
||||
install-auto 非交互安装(默认 2c8g)
|
||||
health 健康检查
|
||||
cron 安装 health_monitor + db_backup crontab
|
||||
|
||||
god / ops 已合并进主菜单(兼容别名)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
NEXUS_ROOT="${NEXUS_ROOT:-/opt/nexus}"
|
||||
NEXUS_PUBLISH_PORT="${NEXUS_PUBLISH_PORT:-8600}"
|
||||
CONTAINER="${NEXUS_CONTAINER:-}"
|
||||
|
||||
info() { echo -e "\033[0;32m[INFO]\033[0m $*"; }
|
||||
@@ -33,13 +34,33 @@ sync_file() {
|
||||
docker cp "$src" "${CONTAINER}:${dest}"
|
||||
}
|
||||
|
||||
wizard_archived_in_container() {
|
||||
docker exec "$CONTAINER" test -f /app/.install_locked 2>/dev/null \
|
||||
|| docker exec "$CONTAINER" test -f /var/lib/nexus/.install_locked 2>/dev/null \
|
||||
|| docker exec "$CONTAINER" test -f /app/web/app/install.html.bak 2>/dev/null
|
||||
}
|
||||
|
||||
main() {
|
||||
local app="$NEXUS_ROOT/web/app"
|
||||
local wizard_archived=false
|
||||
if [[ ! -f "$app/install.html" ]]; then
|
||||
error "仓库中无 $app/install.html,请先 git pull"
|
||||
exit 1
|
||||
fi
|
||||
resolve_container
|
||||
if wizard_archived_in_container; then
|
||||
wizard_archived=true
|
||||
info "安装已锁定或向导已归档,跳过静态/Python 热同步"
|
||||
local code
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${NEXUS_PUBLISH_PORT}/app/install.html" 2>/dev/null || echo 000)"
|
||||
if [[ "$code" == "404" ]]; then
|
||||
info "/app/install.html 已归档 → HTTP 404"
|
||||
exit 0
|
||||
fi
|
||||
warn "/app/install.html 应已归档但返回 HTTP $code"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "同步安装向导静态文件..."
|
||||
sync_file "$app/install.html" /app/web/app/install.html
|
||||
sync_file "$app/vendor/tailwindcss-browser.js" /app/web/app/vendor/tailwindcss-browser.js
|
||||
@@ -47,14 +68,14 @@ main() {
|
||||
sync_file "$app/vendor/theme.css" /app/web/app/vendor/theme.css
|
||||
sync_file "$app/vendor/alpinejs.min.js" /app/web/app/vendor/alpinejs.min.js
|
||||
|
||||
local install_py="$NEXUS_ROOT/server/api/install.py"
|
||||
local install_py="$NEXUS_ROOT/server/api/install.py" code
|
||||
if [[ -f "$install_py" ]]; then
|
||||
info "同步 install.py(Docker Redis env-check 修复)..."
|
||||
sync_file "$install_py" /app/server/api/install.py
|
||||
info "重启 Nexus 容器以加载 Python 变更..."
|
||||
docker restart "$CONTAINER" >/dev/null
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:8600/health >/dev/null 2>&1; then
|
||||
if curl -sf "http://127.0.0.1:${NEXUS_PUBLISH_PORT}/health" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
@@ -63,12 +84,11 @@ main() {
|
||||
warn "未找到 $install_py,跳过 Python 热同步"
|
||||
fi
|
||||
|
||||
local code
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8600/app/install.html 2>/dev/null || echo 000)"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${NEXUS_PUBLISH_PORT}/app/install.html" 2>/dev/null || echo 000)"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
info "/app/install.html → HTTP 200"
|
||||
else
|
||||
warn "/app/install.html → HTTP $code(请检查 Nexus 是否在 8600 监听)"
|
||||
warn "/app/install.html → HTTP $code(请检查 Nexus 是否在 ${NEXUS_PUBLISH_PORT} 监听)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -83,7 +83,12 @@ if [[ -f "${NEXUS_ROOT}/${COMPOSE_FILE}" ]] && grep -q '^ mysql:' "${NEXUS_ROOT
|
||||
removed=$((removed + 1))
|
||||
elif [[ -f "${NEXUS_ROOT}/${COMPOSE_FILE}" && -f "${NEXUS_ROOT}/docker/.env.prod" ]]; then
|
||||
info "Compose --remove-orphans(从 nexus-prod 项目移除 mysql 孤儿容器)..."
|
||||
(cd "$NEXUS_ROOT" && docker compose -f "$COMPOSE_FILE" "${local_env[@]}" up -d --remove-orphans 2>/dev/null || true)
|
||||
compose_files=(-f "${NEXUS_ROOT}/${COMPOSE_FILE}")
|
||||
if docker network inspect 1panel-network >/dev/null 2>&1 \
|
||||
&& [[ -f "${NEXUS_ROOT}/docker/docker-compose.1panel.yml" ]]; then
|
||||
compose_files+=(-f "${NEXUS_ROOT}/docker/docker-compose.1panel.yml")
|
||||
fi
|
||||
(cd "$NEXUS_ROOT" && docker compose "${compose_files[@]}" "${local_env[@]}" up -d --remove-orphans 2>/dev/null || true)
|
||||
while IFS= read -r c; do
|
||||
[[ -z "$c" ]] && continue
|
||||
stop_rm_container "$c"
|
||||
|
||||
+19
-2
@@ -5,11 +5,28 @@
|
||||
# bash deploy/update.sh
|
||||
# bash deploy/update.sh --no-cache # 强制无缓存重建(entrypoint/Dockerfile 变更后)
|
||||
#
|
||||
# 远程一条命令:
|
||||
# 远程一条命令(推荐在仓库目录执行,管道模式会回退 /opt/nexus):
|
||||
# cd /opt/nexus && bash deploy/update.sh
|
||||
# curl -fsSL "http://66.154.115.8:3000/admin/Nexus/raw/branch/main/deploy/update.sh" | bash
|
||||
#
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
||||
|
||||
_resolve_nexus_root() {
|
||||
local src="${BASH_SOURCE[0]:-}"
|
||||
if [[ -n "$src" && "$src" != /dev/fd/* && "$src" != "-" && -f "$src" ]]; then
|
||||
cd "$(dirname "$(readlink -f "$src")")/.." && pwd
|
||||
return
|
||||
fi
|
||||
if [[ -d /opt/nexus ]]; then
|
||||
echo "警告: 通过管道执行无法解析脚本路径,已回退 /opt/nexus;推荐: cd /opt/nexus && bash deploy/update.sh" >&2
|
||||
echo /opt/nexus
|
||||
return
|
||||
fi
|
||||
echo "错误: 无法定位 Nexus 根目录;请 cd /opt/nexus && bash deploy/update.sh" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ROOT="$(_resolve_nexus_root)"
|
||||
|
||||
NEXUS_ROOT="$ROOT" bash "$ROOT/deploy/install-nx-cli.sh" 2>/dev/null || true
|
||||
exec bash "$ROOT/deploy/nexus-1panel.sh" upgrade "$@"
|
||||
|
||||
+49
-55
@@ -1,16 +1,17 @@
|
||||
#!/bin/bash
|
||||
# ================================================================
|
||||
# Nexus 6.0 — Upgrade Script
|
||||
#!/usr/bin/env bash
|
||||
# Nexus 6.0 — Upgrade Script(自动识别 Docker / Supervisor)
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash upgrade.sh # auto-detect deploy dir
|
||||
# sudo bash upgrade.sh --deploy-dir /opt/nexus # specify deploy dir
|
||||
# sudo bash deploy/upgrade.sh
|
||||
# sudo bash deploy/upgrade.sh --deploy-dir /opt/nexus
|
||||
#
|
||||
# Safe: git pull + pip install + restart. Database is NOT touched.
|
||||
# ================================================================
|
||||
|
||||
# Docker 1Panel:委托 deploy/nexus-1panel.sh upgrade(拉代码 + 备份 + 重建镜像)
|
||||
# Supervisor 裸机:git pull + venv pip + supervisorctl restart
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=./detect_deploy_runtime.sh
|
||||
source "${SCRIPT_DIR}/detect_deploy_runtime.sh"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
@@ -21,98 +22,90 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
|
||||
DEPLOY_DIR=""
|
||||
EXTRA_ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
case "$1" in
|
||||
--deploy-dir) DEPLOY_DIR="$2"; shift 2 ;;
|
||||
--no-backup|--require-backup|--no-cache|--prune|--check|--dry-run)
|
||||
EXTRA_ARGS+=("$1"); shift ;;
|
||||
-h|--help)
|
||||
echo "Usage: sudo bash upgrade.sh [--deploy-dir PATH]"
|
||||
exit 0 ;;
|
||||
*) shift ;;
|
||||
cat <<'EOF'
|
||||
用法: sudo bash deploy/upgrade.sh [--deploy-dir PATH] [nexus-1panel 升级选项]
|
||||
|
||||
Docker 环境自动走 nexus-1panel.sh upgrade;Supervisor 环境走 venv + supervisorctl。
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*) EXTRA_ARGS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Auto-detect deploy directory
|
||||
if [ -z "$DEPLOY_DIR" ]; then
|
||||
# Check common locations
|
||||
for d in /opt/nexus /www/wwwroot/*/; do
|
||||
if [ -d "$d/server" ] && [ -d "$d/web" ]; then
|
||||
DEPLOY_DIR="$d"
|
||||
break
|
||||
fi
|
||||
done
|
||||
# Try from Supervisor config
|
||||
if [ -z "$DEPLOY_DIR" ]; then
|
||||
DEPLOY_DIR=$(supervisorctl status nexus 2>/dev/null | grep -oP 'cwd=\K[^ ,]+' || true)
|
||||
fi
|
||||
if [[ -z "$DEPLOY_DIR" ]]; then
|
||||
DEPLOY_DIR="$(resolve_nexus_root 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$DEPLOY_DIR" ] || [ ! -d "$DEPLOY_DIR/server" ]; then
|
||||
error "Cannot find Nexus installation. Use --deploy-dir to specify."
|
||||
if [[ -z "$DEPLOY_DIR" || ! -d "$DEPLOY_DIR/server" ]]; then
|
||||
error "找不到 Nexus 安装目录,请使用 --deploy-dir 指定"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RUNTIME="$(detect_nexus_runtime "$DEPLOY_DIR")"
|
||||
|
||||
if [[ "$RUNTIME" == "docker" ]]; then
|
||||
info "检测到 Docker Compose 运行时,委托 nexus-1panel.sh upgrade"
|
||||
exec env NEXUS_ROOT="$DEPLOY_DIR" bash "$DEPLOY_DIR/deploy/nexus-1panel.sh" upgrade "${EXTRA_ARGS[@]}"
|
||||
fi
|
||||
|
||||
VENV_DIR="$DEPLOY_DIR/venv"
|
||||
ENV_FILE="$DEPLOY_DIR/.env"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Nexus 6.0 Upgrade"
|
||||
echo " Nexus 6.0 Upgrade (Supervisor)"
|
||||
echo "=========================================="
|
||||
echo " Deploy: $DEPLOY_DIR"
|
||||
echo ""
|
||||
|
||||
# 1. Pull latest code
|
||||
info "Step 1/4: Updating source code..."
|
||||
if [ -d "$DEPLOY_DIR/.git" ]; then
|
||||
if [[ -d "$DEPLOY_DIR/.git" ]]; then
|
||||
cd "$DEPLOY_DIR"
|
||||
OLD_REV=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||
OLD_REV="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
git pull --ff-only 2>/dev/null || {
|
||||
error "git pull failed. You may have local changes."
|
||||
error "Resolve conflicts manually: cd $DEPLOY_DIR && git status"
|
||||
error "git pull failed. Resolve conflicts: cd $DEPLOY_DIR && git status"
|
||||
exit 1
|
||||
}
|
||||
NEW_REV=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||
if [ "$OLD_REV" = "$NEW_REV" ]; then
|
||||
NEW_REV="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
if [[ "$OLD_REV" == "$NEW_REV" ]]; then
|
||||
info " Already up to date (no new commits)"
|
||||
else
|
||||
info " Updated: ${OLD_REV:0:8}..${NEW_REV:0:8}"
|
||||
fi
|
||||
else
|
||||
warn " Not a git repo. Skip git pull (update files manually)"
|
||||
warn " Not a git repo. Skip git pull"
|
||||
fi
|
||||
|
||||
# 2. Update Python dependencies
|
||||
info "Step 2/4: Updating Python dependencies..."
|
||||
if [ -d "$VENV_DIR/bin" ]; then
|
||||
if [[ -d "$VENV_DIR/bin" ]]; then
|
||||
"$VENV_DIR/bin/pip" install -q --upgrade pip
|
||||
"$VENV_DIR/bin/pip" install -q -r "$DEPLOY_DIR/requirements.txt" 2>/dev/null || {
|
||||
warn " pip install had some warnings, but continuing..."
|
||||
warn " pip install had warnings, continuing..."
|
||||
}
|
||||
else
|
||||
error " venv not found at $VENV_DIR. Run install.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Restart application
|
||||
info "Step 3/4: Restarting Nexus..."
|
||||
supervisorctl restart nexus >/dev/null 2>&1 || {
|
||||
warn " supervisorctl restart failed. Try manually: supervisorctl restart nexus"
|
||||
warn " supervisorctl restart failed. Try: supervisorctl restart nexus"
|
||||
}
|
||||
|
||||
# 4. Health check
|
||||
info "Step 4/4: Verifying health..."
|
||||
|
||||
# Read port from .env or default
|
||||
PORT=8600
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
PORT_FROM_ENV=$(grep '^NEXUS_PORT=' "$ENV_FILE" 2>/dev/null | cut -d'=' -f2 || true)
|
||||
[ -n "$PORT_FROM_ENV" ] && PORT="$PORT_FROM_ENV"
|
||||
fi
|
||||
|
||||
PORT="$(nexus_publish_port_for "$DEPLOY_DIR")"
|
||||
HEALTH_OK=false
|
||||
for i in $(seq 1 15); do
|
||||
if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||
for _ in $(seq 1 15); do
|
||||
if curl -sf "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
|
||||
HEALTH_OK=true
|
||||
break
|
||||
fi
|
||||
@@ -120,11 +113,12 @@ for i in $(seq 1 15); do
|
||||
done
|
||||
|
||||
echo ""
|
||||
if [ "$HEALTH_OK" = true ]; then
|
||||
if [[ "$HEALTH_OK" == true ]]; then
|
||||
echo -e " ${GREEN}Upgrade complete! Nexus is healthy.${NC}"
|
||||
suggest_ops_cron "$DEPLOY_DIR"
|
||||
else
|
||||
echo -e " ${YELLOW}Health check pending. Verify manually:${NC}"
|
||||
echo " curl http://127.0.0.1:$PORT/health"
|
||||
echo " curl http://127.0.0.1:${PORT}/health"
|
||||
echo " supervisorctl status nexus"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
@@ -15,10 +15,13 @@ NC='\033[0m'
|
||||
|
||||
pass() { echo -e "${GREEN}[PASS]${NC} $*"; }
|
||||
fail() { echo -e "${RED}[FAIL]${NC} $*"; FAILURES=$((FAILURES + 1)); }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; WARNS=$((WARNS + 1)); }
|
||||
info() { echo -e " $*"; }
|
||||
|
||||
FAILURES=0
|
||||
WARNS=0
|
||||
INSTALL_WIZARD_ARCHIVED=false
|
||||
PUBLISH_PORT=8600
|
||||
|
||||
require_root() {
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
@@ -67,6 +70,9 @@ check_env_prod() {
|
||||
return 1
|
||||
fi
|
||||
pass "docker/.env.prod 存在"
|
||||
PUBLISH_PORT="$(grep -E '^NEXUS_PUBLISH_PORT=' "$ENV_PROD" 2>/dev/null | cut -d= -f2- | tr -d '\r" ' || true)"
|
||||
PUBLISH_PORT="${PUBLISH_PORT:-8600}"
|
||||
pass "NEXUS_PUBLISH_PORT=$PUBLISH_PORT"
|
||||
if grep -qE '^NEXUS_INSTALL_WIZARD_PENDING=1' "$ENV_PROD"; then
|
||||
pass "NEXUS_INSTALL_WIZARD_PENDING=1(安装模式)"
|
||||
else
|
||||
@@ -176,8 +182,11 @@ check_install_mode() {
|
||||
else
|
||||
pass "/app/.env 不存在(安装模式 OK)"
|
||||
fi
|
||||
if docker exec "$NEXUS_CONTAINER" test -f /app/.install_locked 2>/dev/null; then
|
||||
fail "已锁定安装向导 — 应访问 /app/ 登录"
|
||||
if docker exec "$NEXUS_CONTAINER" test -f /app/.install_locked 2>/dev/null \
|
||||
|| docker exec "$NEXUS_CONTAINER" test -f /var/lib/nexus/.install_locked 2>/dev/null \
|
||||
|| docker exec "$NEXUS_CONTAINER" test -f /app/web/app/install.html.bak 2>/dev/null; then
|
||||
pass "安装向导已锁定/已归档 — 应访问 /app/ 登录"
|
||||
INSTALL_WIZARD_ARCHIVED=true
|
||||
fi
|
||||
local hosts_json
|
||||
hosts_json="$(docker exec "$NEXUS_CONTAINER" cat /app/web/data/1panel-hosts.json 2>/dev/null || true)"
|
||||
@@ -188,9 +197,56 @@ check_install_mode() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_ops_cron() {
|
||||
if [[ "${INSTALL_WIZARD_ARCHIVED:-false}" != true ]]; then
|
||||
return 0
|
||||
fi
|
||||
if ! command -v crontab >/dev/null 2>&1; then
|
||||
warn "未安装 cron/crontab — Layer 3 宿主机巡检不可用"
|
||||
info "修复: sudo bash $NEXUS_ROOT/deploy/install_ops_cron.sh"
|
||||
return 0
|
||||
fi
|
||||
local cron
|
||||
cron="$(crontab -l 2>/dev/null || true)"
|
||||
if echo "$cron" | grep -q 'nexus-health-monitor\|deploy/health_monitor.sh'; then
|
||||
pass "Crontab 含 health_monitor"
|
||||
else
|
||||
warn "Crontab 未配置 health_monitor(Layer 3 巡检)"
|
||||
info "修复: sudo nx cron"
|
||||
fi
|
||||
if echo "$cron" | grep -q 'nexus-mysql-backup\|deploy/db_backup.sh'; then
|
||||
pass "Crontab 含 db_backup"
|
||||
else
|
||||
warn "Crontab 未配置 db_backup(每日 MySQL 备份)"
|
||||
info "修复: sudo nx cron"
|
||||
fi
|
||||
}
|
||||
|
||||
check_mysql_backup() {
|
||||
if [[ "${INSTALL_WIZARD_ARCHIVED:-false}" != true ]]; then
|
||||
return 0
|
||||
fi
|
||||
local dump_sh="${NEXUS_ROOT}/deploy/mysql_dump_to_file.sh"
|
||||
if [[ -f "$dump_sh" ]]; then
|
||||
pass "mysql_dump_to_file.sh 存在"
|
||||
else
|
||||
warn "缺少 mysql_dump_to_file.sh — 升级前无法备份 MySQL"
|
||||
info "修复: cd $NEXUS_ROOT && git pull && nx update"
|
||||
return 0
|
||||
fi
|
||||
if [[ -d /var/backups/nexus ]] && ls /var/backups/nexus/nexus_*.sql.gz >/dev/null 2>&1; then
|
||||
local latest
|
||||
latest="$(ls -t /var/backups/nexus/nexus_*.sql.gz 2>/dev/null | head -1)"
|
||||
pass "已有 MySQL 备份: $(basename "$latest")"
|
||||
else
|
||||
warn "尚无 MySQL 备份文件(/var/backups/nexus)"
|
||||
info "可手动: sudo bash $NEXUS_ROOT/deploy/db_backup.sh"
|
||||
fi
|
||||
}
|
||||
|
||||
check_http_local() {
|
||||
local health code
|
||||
health="$(curl -sf http://127.0.0.1:8600/health 2>/dev/null || true)"
|
||||
health="$(curl -sf "http://127.0.0.1:${PUBLISH_PORT}/health" 2>/dev/null || true)"
|
||||
if [[ "$health" == "ok" ]]; then
|
||||
pass "/health → ok"
|
||||
else
|
||||
@@ -198,7 +254,16 @@ check_http_local() {
|
||||
info "修复: docker logs --tail=80 $NEXUS_CONTAINER"
|
||||
return 1
|
||||
fi
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8600/app/install.html 2>/dev/null || echo 000)"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PUBLISH_PORT}/app/install.html" 2>/dev/null || echo 000)"
|
||||
if [[ "${INSTALL_WIZARD_ARCHIVED:-false}" == true ]]; then
|
||||
if [[ "$code" == "404" ]]; then
|
||||
pass "/app/install.html 已归档 → HTTP 404"
|
||||
else
|
||||
fail "/app/install.html 应已归档但 HTTP $code"
|
||||
info "修复: nx update(会执行 archive_install_wizard_in_container)"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if [[ "$code" == "200" ]]; then
|
||||
pass "/app/install.html → HTTP 200"
|
||||
else
|
||||
@@ -208,8 +273,12 @@ check_http_local() {
|
||||
}
|
||||
|
||||
check_install_api() {
|
||||
if [[ "${INSTALL_WIZARD_ARCHIVED:-false}" == true ]]; then
|
||||
pass "安装 API 已关闭(向导已锁定,/api/install/* 返回 403 为预期)"
|
||||
return 0
|
||||
fi
|
||||
local status env_json
|
||||
status="$(curl -sf http://127.0.0.1:8600/api/install/status 2>/dev/null || true)"
|
||||
status="$(curl -sf "http://127.0.0.1:${PUBLISH_PORT}/api/install/status" 2>/dev/null || true)"
|
||||
if [[ -n "$status" ]]; then
|
||||
pass "/api/install/status → $status"
|
||||
if echo "$status" | grep -q '"installed":true'; then
|
||||
@@ -218,7 +287,7 @@ check_install_api() {
|
||||
else
|
||||
fail "/api/install/status 无响应"
|
||||
fi
|
||||
env_json="$(curl -sf http://127.0.0.1:8600/api/install/env-check 2>/dev/null || true)"
|
||||
env_json="$(curl -sf "http://127.0.0.1:${PUBLISH_PORT}/api/install/env-check" 2>/dev/null || true)"
|
||||
if [[ -z "$env_json" ]]; then
|
||||
fail "/api/install/env-check 无响应"
|
||||
return 1
|
||||
@@ -260,12 +329,26 @@ check_openresty_hint() {
|
||||
}
|
||||
|
||||
print_next_steps() {
|
||||
local domain
|
||||
domain="$(grep -E '^NEXUS_API_BASE_URL=' "$ENV_PROD" 2>/dev/null | sed 's|.*https\?://||;s|/.*||' || echo '你的域名')"
|
||||
echo ""
|
||||
if [[ "${INSTALL_WIZARD_ARCHIVED:-false}" == true ]]; then
|
||||
echo "=========================================="
|
||||
echo " 已安装 — 日常运维"
|
||||
echo "=========================================="
|
||||
echo " 登录: https://${domain}/app/"
|
||||
echo " 更新: cd $NEXUS_ROOT && nx update"
|
||||
echo " 巡检 cron: sudo nx cron"
|
||||
echo " 手动备份: sudo bash $NEXUS_ROOT/deploy/db_backup.sh"
|
||||
echo " 验收: bash $NEXUS_ROOT/deploy/verify-1panel-install-wizard.sh"
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
echo "=========================================="
|
||||
echo " 安装向导后续步骤"
|
||||
echo "=========================================="
|
||||
echo "1. 1Panel 数据库 → 建库 nexus、用户 nexus(仅授权 nexus 库)"
|
||||
echo "2. 浏览器打开: https://$(grep -E '^NEXUS_API_BASE_URL=' "$ENV_PROD" 2>/dev/null | sed 's|.*https\?://||;s|/.*||' || echo '你的域名')/app/install.html"
|
||||
echo "2. 浏览器打开: https://${domain}/app/install.html"
|
||||
echo "3. 步骤 2:MySQL/Redis TCP 应显示 ✓(容器名:端口)"
|
||||
echo "4. 步骤 3:确认 db_host/redis_host 为 1Panel-mysql/redis-xxxx,填 MySQL 密码"
|
||||
echo "5. 步骤 4:连接检测全绿 → 创建 admin"
|
||||
@@ -291,6 +374,8 @@ main() {
|
||||
check_install_mode || true
|
||||
check_http_local || true
|
||||
check_install_api || true
|
||||
check_ops_cron || true
|
||||
check_mysql_backup || true
|
||||
check_openresty_hint || true
|
||||
print_next_steps
|
||||
|
||||
@@ -300,7 +385,14 @@ main() {
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
pass "验收通过 — 可打开安装向导完成步骤 1–5"
|
||||
if [[ "${INSTALL_WIZARD_ARCHIVED:-false}" == true ]]; then
|
||||
pass "验收通过 — 安装已完成,请访问 /app/ 登录"
|
||||
if [[ "$WARNS" -gt 0 ]]; then
|
||||
warn "有 ${WARNS} 项 [WARN](cron/备份等建议项,不阻断登录)"
|
||||
fi
|
||||
else
|
||||
pass "验收通过 — 可打开安装向导完成步骤 1–5"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -22,8 +22,12 @@ NEXUS_DB_MAX_OVERFLOW=20
|
||||
# NEXUS_1PANEL_DB_HOST=1Panel-mysql-xxxx
|
||||
# NEXUS_1PANEL_REDIS_HOST=1Panel-redis-xxxx
|
||||
|
||||
# 外置 Redis(非 1Panel 时用 host.docker.internal)
|
||||
# 宿主机 Compose 插值/ nx update 同步用;安装向导完成后以卷内 /app/.env 为准(不经 Compose 注入)
|
||||
# 1Panel 有密码时: redis://:你的密码@1Panel-redis-xxxx:6379/0
|
||||
NEXUS_REDIS_URL=redis://host.docker.internal:6379/0
|
||||
|
||||
# 宿主机 git 仓库路径(容器内安装向导用于提示 nx cron / nx update)
|
||||
NEXUS_HOST_ROOT=/opt/nexus
|
||||
|
||||
# Optional
|
||||
# NEXUS_PUBLISH_PORT=8600
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ docker compose build --no-cache nexus
|
||||
| 本地开发 `docker-compose.yml` | nexus + mysql + redis | 一键本地联调 |
|
||||
| 生产 `docker-compose.prod.yml` | **仅 nexus** | MySQL/Redis 宿主机或 1Panel 自建 |
|
||||
|
||||
生产 1Panel:`nx update` 自动写入 `NEXUS_REDIS_URL=redis://1Panel-redis-xxxx:6379/0`;MySQL 由安装向导写入 `NEXUS_DATABASE_URL`。
|
||||
生产 1Panel:`docker/.env.prod` 可含 `NEXUS_REDIS_URL`(供 `nx update` 探测同步),**不经 Compose 注入容器**;运行时 Redis/MySQL URL 以安装向导写入的 `/app/.env`(`nexus-state` 卷)为准。MySQL `NEXUS_DATABASE_URL` 仅由向导步骤 3 写入。
|
||||
|
||||
## 生产注意
|
||||
|
||||
|
||||
@@ -5,9 +5,6 @@ services:
|
||||
nexus:
|
||||
networks:
|
||||
- 1panel-network
|
||||
environment:
|
||||
NEXUS_1PANEL_DB_HOST: ${NEXUS_1PANEL_DB_HOST:-}
|
||||
NEXUS_1PANEL_REDIS_HOST: ${NEXUS_1PANEL_REDIS_HOST:-}
|
||||
|
||||
networks:
|
||||
1panel-network:
|
||||
|
||||
@@ -26,8 +26,8 @@ services:
|
||||
NEXUS_HOST: "0.0.0.0"
|
||||
NEXUS_PORT: "8600"
|
||||
NEXUS_DEPLOY_PATH: /app
|
||||
# 1Panel: nx update 写入 redis://1Panel-redis-xxxx:6379/0;非 1Panel 回退 host.docker.internal
|
||||
NEXUS_REDIS_URL: ${NEXUS_REDIS_URL:-redis://${NEXUS_1PANEL_REDIS_HOST:-host.docker.internal}:6379/0}
|
||||
NEXUS_HOST_ROOT: ${NEXUS_HOST_ROOT:-/opt/nexus}
|
||||
# Redis URL 仅来自 /app/.env(安装向导 + entrypoint export_app_env),勿在此注入以免覆盖密码
|
||||
NEXUS_1PANEL_DB_HOST: ${NEXUS_1PANEL_DB_HOST:-}
|
||||
NEXUS_1PANEL_REDIS_HOST: ${NEXUS_1PANEL_REDIS_HOST:-}
|
||||
NEXUS_CORS_ORIGINS: ${NEXUS_CORS_ORIGINS:?set NEXUS_CORS_ORIGINS}
|
||||
@@ -44,10 +44,10 @@ services:
|
||||
mem_limit: ${NEXUS_MEM_LIMIT:-2g}
|
||||
cpus: ${NEXUS_CPUS:-1.5}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://127.0.0.1:8600/health"]
|
||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8600/health | grep -q '^ok' || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
networks:
|
||||
- nexus-internal
|
||||
|
||||
+82
-12
@@ -24,7 +24,8 @@ wait_tcp_optional() {
|
||||
host="$1"
|
||||
port="$2"
|
||||
tries="${3:-15}"
|
||||
echo "entrypoint: waiting for external Redis ${host}:${port} (max ${tries} tries)..."
|
||||
label="${4:-service}"
|
||||
echo "entrypoint: waiting for ${label} ${host}:${port} (max ${tries} tries)..."
|
||||
n=0
|
||||
while [ "$n" -lt "$tries" ]; do
|
||||
if python3 -c "
|
||||
@@ -34,22 +35,79 @@ s.settimeout(2)
|
||||
s.connect(('${host}', ${port}))
|
||||
s.close()
|
||||
" 2>/dev/null; then
|
||||
echo "entrypoint: Redis ready at ${host}:${port}"
|
||||
echo "entrypoint: ${label} ready at ${host}:${port}"
|
||||
return 0
|
||||
fi
|
||||
n=$((n + 1))
|
||||
sleep 2
|
||||
done
|
||||
echo "entrypoint: warn — Redis not reachable at ${host}:${port}; install on host or set NEXUS_REDIS_URL"
|
||||
echo "entrypoint: warn — ${label} not reachable at ${host}:${port}; install on host or set URL in .env"
|
||||
return 0
|
||||
}
|
||||
|
||||
restore_persisted_env() {
|
||||
if [ -f "${PERSIST_ENV}" ]; then
|
||||
cp "${PERSIST_ENV}" /app/.env
|
||||
chmod 600 /app/.env
|
||||
echo "entrypoint: restored .env from ${PERSIST_ENV}"
|
||||
restore_persisted_install_lock() {
|
||||
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" = "1" ]; then
|
||||
return 0
|
||||
fi
|
||||
PERSIST_LOCK="${PERSIST_DIR}/.install_locked"
|
||||
if [ -f "${PERSIST_LOCK}" ] && [ ! -f /app/.install_locked ]; then
|
||||
cp "${PERSIST_LOCK}" /app/.install_locked
|
||||
chmod 600 /app/.install_locked
|
||||
echo "entrypoint: restored .install_locked from ${PERSIST_LOCK}"
|
||||
fi
|
||||
}
|
||||
|
||||
archive_install_wizard_if_locked() {
|
||||
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" = "1" ]; then
|
||||
rm -f /app/.install_locked "${PERSIST_DIR}/.install_locked" 2>/dev/null || true
|
||||
if [ -f /app/web/app/install.html.bak ] && [ ! -f /app/web/app/install.html ]; then
|
||||
mv /app/web/app/install.html.bak /app/web/app/install.html
|
||||
echo "entrypoint: restored install.html from .bak (wizard pending)"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if [ ! -f /app/.install_locked ] && [ ! -f "${PERSIST_DIR}/.install_locked" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -f /app/web/app/install.html.bak ]; then
|
||||
if [ -f /app/web/app/install.html ]; then
|
||||
rm -f /app/web/app/install.html
|
||||
echo "entrypoint: removed install.html (wizard archived as install.html.bak)"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if [ -f /app/web/app/install.html ]; then
|
||||
mv /app/web/app/install.html /app/web/app/install.html.bak
|
||||
echo "entrypoint: archived install.html → install.html.bak"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_persisted_env() {
|
||||
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" = "1" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ ! -f "${PERSIST_ENV}" ]; then
|
||||
return 0
|
||||
fi
|
||||
if ! grep -q '^NEXUS_DATABASE_URL=' "${PERSIST_ENV}" 2>/dev/null; then
|
||||
echo "entrypoint: warn — ${PERSIST_ENV} missing NEXUS_DATABASE_URL; skip restore (complete install wizard)"
|
||||
return 0
|
||||
fi
|
||||
cp "${PERSIST_ENV}" /app/.env
|
||||
chmod 600 /app/.env
|
||||
echo "entrypoint: restored .env from ${PERSIST_ENV}"
|
||||
}
|
||||
|
||||
# Compose may inject placeholder NEXUS_REDIS_URL (no password). Wizard .env wins.
|
||||
export_app_env() {
|
||||
if [ ! -f /app/.env ]; then
|
||||
return 0
|
||||
fi
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. /app/.env
|
||||
set +a
|
||||
echo "entrypoint: exported NEXUS_* from /app/.env (overrides Compose placeholders)"
|
||||
}
|
||||
|
||||
write_env_from_environment() {
|
||||
@@ -66,6 +124,10 @@ write_env_from_environment() {
|
||||
echo "entrypoint: install wizard pending — skip writing /app/.env"
|
||||
return 0
|
||||
fi
|
||||
if [ -z "${NEXUS_DATABASE_URL:-}" ]; then
|
||||
echo "entrypoint: skip writing /app/.env — NEXUS_DATABASE_URL not set (use /app/install.html)"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "${PERSIST_DIR}"
|
||||
{
|
||||
echo "# Generated by docker/entrypoint.sh — do not commit"
|
||||
@@ -76,30 +138,38 @@ write_env_from_environment() {
|
||||
echo "entrypoint: wrote .env from container environment"
|
||||
}
|
||||
|
||||
persist_env_on_exit() {
|
||||
persist_state_on_exit() {
|
||||
if [ -f /app/.env ]; then
|
||||
mkdir -p "${PERSIST_DIR}"
|
||||
cp /app/.env "${PERSIST_ENV}"
|
||||
chmod 600 "${PERSIST_ENV}" 2>/dev/null || true
|
||||
fi
|
||||
if [ -f /app/.install_locked ]; then
|
||||
mkdir -p "${PERSIST_DIR}"
|
||||
cp /app/.install_locked "${PERSIST_DIR}/.install_locked"
|
||||
chmod 600 "${PERSIST_DIR}/.install_locked" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap persist_env_on_exit EXIT INT TERM
|
||||
trap persist_state_on_exit EXIT INT TERM
|
||||
|
||||
# MySQL / Redis 均由用户自行安装在宿主机;安装向导阶段不阻塞启动
|
||||
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" != "1" ] && [ "${NEXUS_SKIP_MYSQL_WAIT:-0}" != "1" ]; then
|
||||
MYSQL_HOST="${NEXUS_1PANEL_DB_HOST:-${MYSQL_HOST:-host.docker.internal}}"
|
||||
MYSQL_PORT="${MYSQL_PORT:-3306}"
|
||||
wait_tcp_optional "${MYSQL_HOST}" "${MYSQL_PORT}" 30
|
||||
wait_tcp_optional "${MYSQL_HOST}" "${MYSQL_PORT}" 30 "MySQL"
|
||||
fi
|
||||
|
||||
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" != "1" ] && [ "${NEXUS_SKIP_REDIS_WAIT:-0}" != "1" ]; then
|
||||
REDIS_HOST="${NEXUS_1PANEL_REDIS_HOST:-${REDIS_HOST:-host.docker.internal}}"
|
||||
REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
wait_tcp_optional "${REDIS_HOST}" "${REDIS_PORT}" 15
|
||||
wait_tcp_optional "${REDIS_HOST}" "${REDIS_PORT}" 15 "Redis"
|
||||
fi
|
||||
|
||||
restore_persisted_install_lock
|
||||
archive_install_wizard_if_locked
|
||||
restore_persisted_env
|
||||
write_env_from_environment
|
||||
export_app_env
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# 2026-06-06 — 1Panel 配置 / 安装向导 / nx update 根治
|
||||
|
||||
## 摘要
|
||||
|
||||
根治安装向导 `/state` 敏感信息泄露、`init-db` db_port NameError、`nx update` 无门控与回滚、配置多层不一致等问题。
|
||||
|
||||
## 动机
|
||||
|
||||
生产 1Panel 审计发现:升级可覆盖 Redis 密码 URL、向导状态 API 返回明文 token、MySQL 连接失败时抛 NameError、备份失败会阻断或半残升级。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/install.py` — 白名单 `/state`、`db_port` 修复、修复后清空 `redis_pass`
|
||||
- `web/app/install.html` — `sessionStorage` token、无 token 提示
|
||||
- `server/config.py` — 连接池占位注释
|
||||
- `docker/docker-compose.prod.yml`、`docker-compose.1panel.yml`、`docker/README.md`、`docker/.env.prod.example`
|
||||
- `deploy/nexus-1panel.sh` — 门控、回滚、PENDING 回写、卷双写、Redis 迁移、备份容错
|
||||
- `deploy/update.sh` — 管道执行 ROOT 回退
|
||||
- `deploy/sync-install-wizard-to-container.sh` — 非静默、可配置端口
|
||||
- `tests/test_security_unit.py` — 脱敏与 redis_pass 清理回归
|
||||
- `docs/design/specs/2026-06-06-1panel-config-hardening-design.md`
|
||||
- `docs/project/nexus-1panel-operations-knowledge.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无需 DB 迁移
|
||||
- 需 `nx update` 或重建镜像使部署脚本与后端生效
|
||||
- 已锁定环境升级后 `NEXUS_INSTALL_WIZARD_PENDING` 应变为 `0`
|
||||
|
||||
## 验证方式
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_security_unit.py -q
|
||||
.venv/bin/python -c "import server.main"
|
||||
ruff check server/ --select F
|
||||
# 生产
|
||||
sudo nx update
|
||||
bash deploy/verify-1panel-install-wizard.sh
|
||||
curl -s http://127.0.0.1:8600/api/install/state | jq .
|
||||
```
|
||||
|
||||
## 运维注意
|
||||
|
||||
- 密钥/Redis URL 轮换:改 `docker/.env.prod` 后 `nx update` 会 `sync_env_prod_to_volume` 双写卷内 `.env`
|
||||
- 升级加 `--require-backup` 可在 mysqldump 失败时中止
|
||||
- 推荐 `cd /opt/nexus && bash deploy/update.sh`,避免 `curl | bash` 路径解析失败
|
||||
@@ -0,0 +1,43 @@
|
||||
# 2026-06-06 — 凭据轮询登录与连接失败列表
|
||||
|
||||
## 摘要
|
||||
|
||||
密码/SSH 密钥预设增加用户名;快速添加服务器仅需 IP,自动轮询全部凭据尝试 SSH 登录;失败进入 `pending_servers` 列表并可重试。
|
||||
|
||||
## 动机
|
||||
|
||||
批量添加 2000+ 子机时,每台需尝试多组账号密码;手动填用户名密码效率低,需凭据预设驱动自动探测。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/domain/models/__init__.py` — `username` 字段、`PendingServer` 模型
|
||||
- `server/infrastructure/database/migrations.py` — 幂等 schema 迁移
|
||||
- `server/infrastructure/ssh/asyncssh_pool.py` — `try_ssh_login`
|
||||
- `server/application/services/credential_poller.py` — 轮询服务(新)
|
||||
- `server/infrastructure/database/pending_server_repo.py` — 失败队列 repo(新)
|
||||
- `server/api/servers.py` — `add-by-ip`、`/pending`、retry、delete
|
||||
- `server/api/schemas.py`、`server/api/settings.py` — 预设 username
|
||||
- `frontend/src/pages/CredentialsPage.vue` — 凭据 UI
|
||||
- `frontend/src/pages/ServersPage.vue` — 快速添加 + 失败列表
|
||||
- `tests/test_credential_poller.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 启动时 `run_schema_migrations()` 自动 ALTER/CREATE
|
||||
- 需重启 API;前端需 `vite build` 部署
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_credential_poller.py -q
|
||||
cd frontend && npx vite build
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/servers/add-by-ip` | 仅 IP,轮询凭据 |
|
||||
| GET | `/api/servers/pending` | 失败列表 |
|
||||
| POST | `/api/servers/pending/{id}/retry` | 重试轮询 |
|
||||
| DELETE | `/api/servers/pending/{id}` | 删除 pending |
|
||||
@@ -0,0 +1,27 @@
|
||||
# 2026-06-06 — Docker .env 持久化与 entrypoint 覆盖修复
|
||||
|
||||
## 摘要
|
||||
|
||||
修复 1Panel 安装向导完成后 `nx update`/容器重启导致 `nexus-state` 卷内 `.env` 缺少 `NEXUS_DATABASE_URL`、应用连 `127.0.0.1` 崩溃的问题。
|
||||
|
||||
## 动机
|
||||
|
||||
- `init-db` 仅写 `/app/.env`,卷同步依赖容器 EXIT trap,重建容器时 `restore_persisted_env` 可能恢复 entrypoint 生成的简版 env。
|
||||
- `write_env_from_environment` 在 `NEXUS_INSTALL_WIZARD_PENDING=0` 时用 `env | grep NEXUS_` 写卷,Compose 无 `NEXUS_DATABASE_URL`。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/install.py` — `_sync_env_to_persist_volume`;`init-db` / Redis 修补 / lock 后立即同步卷
|
||||
- `docker/entrypoint.sh` — 无 `NEXUS_DATABASE_URL` 的卷不 restore;无 `NEXUS_DATABASE_URL` 环境变量时不生成 `.env`;`export_app_env` 在启动前 source `/app/.env`,使向导写入的 `DATABASE_URL` / `REDIS_URL` 覆盖 Compose 占位符
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
已损坏的卷内 `.env` 需手工补全 `NEXUS_DATABASE_URL` 与 Redis URL 后 `docker start`;新装/重装向导自动同步。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_security_unit.py -q
|
||||
# 向导 init-db 后:
|
||||
docker exec nexus-prod-nexus-1 grep NEXUS_DATABASE_URL /var/lib/nexus/.env
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
# 2026-06-06 — Gitea 推送脚本与本地 secrets
|
||||
|
||||
## 摘要
|
||||
|
||||
新增 `scripts/git-push.sh`,从 `deploy/nexus-1panel.secrets.sh` 读取 admin 凭据推送;更新 `.cursorrules` / `linux-dev-paths.md`。
|
||||
|
||||
## 动机
|
||||
|
||||
公共仓库可匿名 pull,push 需写权限;凭据仅存本机 gitignore 文件,不入仓库。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `scripts/git-push.sh`
|
||||
- `deploy/nexus-1panel.secrets.sh.example`
|
||||
- `.cursorrules`、`docs/project/linux-dev-paths.md`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash scripts/git-push.sh main # 需已配置 deploy/nexus-1panel.secrets.sh
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
# 安装完成后归档 install.html
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 动机 | 已安装环境仍可访问 `/app/install.html` 并展示「系统已安装」,应物理移除向导页 |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
- `install.py`:`_finalize_install_lock()` 将 `web/app/install.html` 重命名为 `install.html.bak`。
|
||||
- `entrypoint.sh`:检测到安装锁时启动阶段归档(防止镜像重建恢复向导页)。
|
||||
- `sync-install-wizard-to-container.sh`:已锁定/已归档时不再覆盖 `install.html`,验收改为期望 HTTP 404。
|
||||
- `nexus-1panel.sh`:升级与健康检查在已安装时期望 `install.html` → HTTP 404。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/install.py`
|
||||
- `docker/entrypoint.sh`
|
||||
- `deploy/sync-install-wizard-to-container.sh`
|
||||
- `deploy/nexus-1panel.sh`
|
||||
- `tests/test_security_unit.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 需重建 Nexus 镜像(entrypoint)并重启容器。
|
||||
- 已安装实例:`nx update` 后 `set_install_complete` / entrypoint 自动归档;或手动 `mv install.html install.html.bak`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_security_unit.py -k archive_install -q
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://api.synaglobal.vip/app/install.html # 404
|
||||
docker exec nexus-prod-nexus-1 test -f /app/web/app/install.html.bak
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
# 安装锁持久化 + 1panel-network 升级校验
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 动机 | 生产 `nx update` 重建容器后丢失 `.install_locked`;容器未接入 `1panel-network` 导致 MySQL/Redis DNS 失败与 502 |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **安装锁写入 nexus-state 卷**:`install.py` 锁定后同步至 `/var/lib/nexus/.install_locked`;`entrypoint.sh` 启动时恢复至 `/app/.install_locked`,退出时双写。
|
||||
2. **升级后 1panel-network 校验**:`deploy/nexus-1panel.sh` 新增 `verify_nexus_1panel_network`,`compose up` 后自动检测;未接入则叠加 `docker-compose.1panel.yml` 重建,失败则回滚。
|
||||
3. **entrypoint 日志**:MySQL/Redis 等待日志区分服务名(不再误标为 Redis)。
|
||||
4. **部署脚本**:`set_install_complete` / `sync_env_prod_to_volume` 识别卷内锁文件并恢复至 `/app`。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/install.py`
|
||||
- `docker/entrypoint.sh`
|
||||
- `deploy/nexus-1panel.sh`
|
||||
- `tests/test_security_unit.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 需重建 Nexus 镜像(entrypoint 变更)并 `compose up`。
|
||||
- 无 DB 迁移。
|
||||
|
||||
## 后续修补(nx 上帝菜单巡检)
|
||||
|
||||
- `uninstall-mysql-compose.sh`:`compose up` 叠加 `docker-compose.1panel.yml`,避免升级 purge 阶段摘掉 `1panel-network`。
|
||||
- `deploy/nx`:`ops_start_stack` / 菜单 `[8]` 重建后调用 `verify_nexus_1panel_network`;`install-auto` 已安装时改走 `update.sh`。
|
||||
- `cmd_upgrade`:同步 install.html 前等待 `/health`,减少误触发 `sync-install-wizard` 重启。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash -n deploy/nx && bash -n deploy/nexus-1panel.sh && bash -n deploy/uninstall-mysql-compose.sh
|
||||
pytest tests/test_security_unit.py -k "sync_lock or install_reject" -q
|
||||
# 升级后容器应同时在 1panel-network 与 nexus-internal
|
||||
docker inspect nexus-prod-nexus-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
|
||||
# 锁定后卷内应有锁文件
|
||||
docker exec nexus-prod-nexus-1 test -f /var/lib/nexus/.install_locked
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
# 2026-06-06 — 安装向导:1Panel Docker 进程守护
|
||||
|
||||
## 摘要
|
||||
|
||||
1Panel + Docker 部署路径下,安装向导步骤 5 展示 Docker/Compose 守护清单(替代 Supervisor/venv/Nginx 裸机清单);`init-db` 调用 `_configure_docker_guardian` 检测容器 health 与 1Panel 网络。
|
||||
|
||||
## 动机
|
||||
|
||||
- 1Panel 生产使用 `docker-compose.prod.yml`,进程守护由 **restart + healthcheck** 负责,容器内写 Supervisor 配置无意义且易误导。
|
||||
- 步骤 5 原清单面向宝塔/裸机,与 1Panel 运维路径不符。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/install.py` — `_configure_docker_guardian`、`_configure_guardian` 分支、`docker_mode` 响应字段
|
||||
- `web/app/install.html` — 步骤 1 准备项、步骤 5 1Panel 清单(OpenResty/nx update)
|
||||
- `docs/project/nexus-1panel-operations-knowledge.md` — §3.5 守护说明
|
||||
- `tests/test_security_unit.py` — Docker 守护分支测试
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无 schema 变更。部署后 `bash deploy/sync-install-wizard-to-container.sh` 或 `nx update`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_security_unit.py -k configure_guardian
|
||||
```
|
||||
|
||||
浏览器完成向导步骤 5,确认显示「1Panel 后续配置」而非 Supervisor 清单。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 2026-06-06 — 安装向导 Redis 持久化与 nx update 防覆盖
|
||||
|
||||
## 摘要
|
||||
|
||||
修复 1Panel Docker 场景下安装向导写入带密码 Redis URL 后,`nx update` 与 Compose 环境变量覆盖导致步骤 4 检测失败的问题。
|
||||
|
||||
## 动机
|
||||
|
||||
- `nexus-1panel.sh` 同步 `NEXUS_REDIS_URL=redis://容器:6379/0`(无密码)覆盖 `docker/.env.prod`
|
||||
- Compose 注入 `NEXUS_REDIS_URL` 优先级高于 `/app/.env`,步骤 4 无法从无密码 URL 自愈
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `docker/docker-compose.prod.yml` — 移除 `NEXUS_REDIS_URL` 环境变量注入
|
||||
- `deploy/nexus-1panel.sh` — `resolve_nexus_redis_url` 优先保留卷/已有带密码 URL
|
||||
- `server/api/install.py` — `init-db` 暂存 `redis_pass`;`connection-check` 用 `_redis_repair_request` 修复无密码 URL
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_security_unit.py -q -k redis
|
||||
bash deploy/verify-1panel-install-wizard.sh
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# 2026-06-06 — nx update `root: unbound variable` 修复
|
||||
|
||||
## 摘要
|
||||
|
||||
修复 `deploy/nexus-1panel.sh` 在 `set -u` 下 `sync_env_prod_to_volume` 等函数因同行 `local` 引用 `$root` 导致升级中断。
|
||||
|
||||
## 动机
|
||||
|
||||
生产 `sudo nx update` 在写入 `1panel-hosts.json` 后报错:`line 286: root: unbound variable`,后续镜像构建/健康检查未执行。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `deploy/nexus-1panel.sh` — `nexus_publish_port`、`set_install_complete`、`sync_env_prod_to_volume` 拆分 `local` 声明
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无;拉代码后重新 `nx update` 即可
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash -n deploy/nexus-1panel.sh
|
||||
sudo nx update
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# nx 上帝菜单巡检第二轮修补
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 动机 | 继续巡检 deploy/nx 链路:全新安装与持久化卷冲突、验收脚本误判、回滚网络 |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **全新安装 vs nexus-state 卷**:`FRESH_INSTALL` 时清除卷内旧 `.env`/`.install_locked`;`NEXUS_INSTALL_WIZARD_PENDING=1` 时 entrypoint 不恢复旧配置、从 `.bak` 恢复 `install.html`。
|
||||
2. **回滚**:`rollback_compose_upgrade` 在仍未接入 `1panel-network` 时返回失败。
|
||||
3. **验收脚本**:`verify-1panel-install-wizard.sh` 已安装时期望 `install.html` → 404,并跳过 install API 检查(403 为预期)。
|
||||
4. **nx 菜单**:`ops_status` 区分已归档;菜单 `[2]` 全新安装对已安装栈二次确认。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `docker/entrypoint.sh`
|
||||
- `deploy/nexus-1panel.sh`
|
||||
- `deploy/nx`
|
||||
- `deploy/verify-1panel-install-wizard.sh`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash -n deploy/nx && bash -n deploy/nexus-1panel.sh && bash -n docker/entrypoint.sh
|
||||
bash deploy/verify-1panel-install-wizard.sh # 已安装应 PASS install.html 404
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
# nx 上帝菜单巡检第三轮 — health_monitor Docker 适配
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 动机 | `health_monitor.sh` 仅 supervisorctl,Docker 生产无效;验收/热同步仍有误报路径 |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **health_monitor.sh**:识别 `nexus-prod-nexus` 容器 → `docker restart`;从 `docker/.env.prod` 读端口;从容器 `/app/.env` 读 Telegram;失败计数 `flock`;重启冷却 300s。
|
||||
2. **install.py**:守护配置更新 `DEPLOY_DIR`(修复原 `INSTALL_DIR` 正则不匹配)。
|
||||
3. **sync-install-wizard**:已归档时跳过全部热同步(避免无意义 restart)。
|
||||
4. **verify-1panel**:已安装时 `print_next_steps` 输出运维指引。
|
||||
5. **install-nexus-fresh**:已锁定安装时二次确认。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `deploy/health_monitor.sh`
|
||||
- `server/api/install.py`
|
||||
- `deploy/sync-install-wizard-to-container.sh`
|
||||
- `deploy/verify-1panel-install-wizard.sh`
|
||||
- `deploy/install-nexus-fresh.sh`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash -n deploy/health_monitor.sh
|
||||
NEXUS_DEPLOY_DIR=/opt/nexus bash deploy/health_monitor.sh # 健康时应 exit 0
|
||||
bash deploy/verify-1panel-install-wizard.sh # 已安装全 PASS
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
# nx 上帝菜单巡检第四轮 — Docker MySQL 备份 + 运维 cron
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 动机 | 升级时 `backup_mysql` 因宿主机无 `mysqldump` 且 URL host 为 1Panel 容器名而静默跳过;`db_backup.sh` 仍读 Supervisor 时代 `.env` |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **mysql_dump_to_file.sh**(新):统一 mysqldump 策略 — `docker exec` MySQL 容器 → 宿主机客户端 → `docker run` on `1panel-network`。
|
||||
2. **backup_mysql**:改调共享脚本,不再要求宿主机预装 `mysqldump`。
|
||||
3. **db_backup.sh**:Docker 生产路径,从 Nexus 容器/volume 解析 `NEXUS_DATABASE_URL`,默认 `/var/backups/nexus`。
|
||||
4. **install_ops_cron.sh**(新):幂等写入 `health_monitor`(每分钟)与 `db_backup`(每日 03:00)crontab。
|
||||
5. **nx**:菜单 `[e]` / 子命令 `nx cron` 安装上述 cron。
|
||||
6. **install_ops_cron**:宿主机无 `crontab` 时自动 `apt install cron` 并启用服务。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `deploy/mysql_dump_to_file.sh`
|
||||
- `deploy/install_ops_cron.sh`
|
||||
- `deploy/db_backup.sh`
|
||||
- `deploy/nexus-1panel.sh`
|
||||
- `deploy/nx`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无需 DB 迁移;仅需 `git pull` 更新 host 脚本。
|
||||
- 可选:`sudo nx cron` 或菜单 `[e]` 安装 crontab。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash -n deploy/mysql_dump_to_file.sh deploy/install_ops_cron.sh deploy/db_backup.sh
|
||||
sudo bash deploy/mysql_dump_to_file.sh --output /tmp/nexus-test.sql --root /opt/nexus
|
||||
ls -lh /var/backups/nexus/
|
||||
sudo nx cron && crontab -l | grep nexus
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# nx 上帝菜单巡检第五轮 — 部署脚本 Docker/Supervisor 分流
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 动机 | `upgrade.sh`、`deploy-production.sh` 等仍硬编码 Supervisor + `/www/wwwroot`,Docker 1Panel 生产(`/opt/nexus`)无法正确升级 |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **detect_deploy_runtime.sh**(新):共享 `resolve_nexus_root` / `detect_nexus_runtime` / `suggest_ops_cron`。
|
||||
2. **upgrade.sh**:检测到 Docker Compose 栈时 `exec nexus-1panel.sh upgrade`;Supervisor 保留 venv 路径。
|
||||
3. **deploy-production.sh**:SSH 远程探测运行时;Docker 走镜像重建,Supervisor 走 tar 前端 + supervisorctl。
|
||||
4. **deploy-on-server.sh** / **deploy-frontend.sh**:同上自动分流。
|
||||
5. **nexus-1panel.sh upgrade**:成功后若未安装 cron 则提示 `nx cron`。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `deploy/detect_deploy_runtime.sh`
|
||||
- `deploy/upgrade.sh`
|
||||
- `deploy/deploy-production.sh`
|
||||
- `deploy/deploy-on-server.sh`
|
||||
- `deploy/deploy-frontend.sh`
|
||||
- `deploy/nexus-1panel.sh`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 仅 host 脚本更新,`git pull` 即可;无需 DB 迁移。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bash -n deploy/detect_deploy_runtime.sh deploy/upgrade.sh deploy/deploy-production.sh
|
||||
# Docker 生产:
|
||||
sudo bash deploy/upgrade.sh --deploy-dir /opt/nexus --check
|
||||
sudo bash deploy/deploy-on-server.sh --help 2>/dev/null || true
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# nx 上帝菜单巡检第六轮 — 守护配置对齐 + 验收 cron/备份
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 动机 | 安装向导 Docker 守护文案仍指向容器内 `/app`;裸机 cron 格式过时;验收脚本未检查 Layer 3 cron 与 MySQL 备份 |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
1. **install.py**:`_host_deploy_root` + `_append_ops_cron_results`;Docker 守护提示 `install_ops_cron` / `nx cron`;裸机 cron 与第四轮脚本同格式(含 db_backup)。
|
||||
2. **docker-compose.prod.yml**:注入 `NEXUS_HOST_ROOT`(默认 `/opt/nexus`)。
|
||||
3. **nexus-1panel.sh upgrade**:同步 `NEXUS_HOST_ROOT` 到 `.env.prod`。
|
||||
4. **verify-1panel-install-wizard.sh**:已安装时检查 crontab、备份脚本与备份文件;HTTP 探测改用 `NEXUS_PUBLISH_PORT`。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/install.py`
|
||||
- `docker/docker-compose.prod.yml`
|
||||
- `docker/.env.prod.example`
|
||||
- `deploy/nexus-1panel.sh`
|
||||
- `deploy/verify-1panel-install-wizard.sh`
|
||||
- `tests/test_security_unit.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- `git pull` 后 `nx update` 使容器获得 `NEXUS_HOST_ROOT` 环境变量(可选)。
|
||||
- 已安装主机若缺 cron:`sudo nx cron`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_security_unit.py::test_configure_guardian_docker_mode_skips_supervisor -q
|
||||
bash -n deploy/verify-1panel-install-wizard.sh
|
||||
sudo bash deploy/verify-1panel-install-wizard.sh
|
||||
```
|
||||
@@ -289,7 +289,17 @@ docker compose -f docker/docker-compose.prod.yml exec mysql \
|
||||
|
||||
## 6. 日常运维
|
||||
|
||||
### 6.1 升级(Gitea 有新 commit)
|
||||
### 6.1 一键升级(推荐)
|
||||
|
||||
```bash
|
||||
# 可选:echo 'NEXUS_GITEA_TOKEN=...' > /root/.nexus-deploy.env && chmod 600 /root/.nexus-deploy.env
|
||||
bash /opt/nexus/deploy/upgrade-1panel-docker.sh --check # 仅查是否有更新
|
||||
bash /opt/nexus/deploy/upgrade-1panel-docker.sh # 备份 DB + pull + 重建 + /health
|
||||
```
|
||||
|
||||
脚本:`deploy/upgrade-1panel-docker.sh`(`--no-backup` / `--prune` / `--dry-run`)。
|
||||
|
||||
### 6.2 手工升级
|
||||
|
||||
```bash
|
||||
cd /opt/nexus
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# 1Panel 配置 / 安装向导 / nx update 根治设计
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 状态 | 已实施 |
|
||||
| 动机 | 三路审计发现安装向导安全泄露、配置不一致、`nx update` 无门控与回滚 |
|
||||
|
||||
## 背景与目标
|
||||
|
||||
生产 1Panel+Docker 链路曾出现:`nx update` 覆盖 Redis URL、`GET /state` 泄露 `install_token`/`redis_pass`、`init-db` MySQL 2003 分支 NameError、升级无镜像自检与健康回滚。
|
||||
|
||||
**目标**:在不改向导步骤数、不引入新依赖前提下,根治上述问题并补齐运维文档。
|
||||
|
||||
## 方案对比
|
||||
|
||||
| 维度 | 最小修补 | 根治(选定) |
|
||||
|------|----------|--------------|
|
||||
| `/state` | 仅删两个字段 | 白名单 `_public_install_state` + `has_token` |
|
||||
| `nx update` | 手工回滚 | 镜像 `import` 门控 + tag 回滚 + 健康失败自动恢复 |
|
||||
| 配置一致性 | 文档说明 | PENDING 回写、卷双写、Compose 去重、healthcheck 对齐 |
|
||||
| 备份失败 | 中止升级 | 默认 WARN 继续;`--require-backup` 可强制中止 |
|
||||
|
||||
## 选定方案
|
||||
|
||||
### A. 安装向导
|
||||
|
||||
- `init-db`:`db_port = int(req.db_port)`,修复 `_mysql_connect_error_detail` NameError。
|
||||
- `GET /state`:仅返回 `_INSTALL_STATE_PUBLIC_KEYS`;有 token 时设 `has_token: true`。
|
||||
- 前端:`sessionStorage` 暂存 token;无 token 时步骤 4 友好提示。
|
||||
- `connection-check` 修复 Redis 后 `_clear_state_redis_pass()`。
|
||||
|
||||
### B. 配置一致性
|
||||
|
||||
- `set_install_complete()`:检测到 `.install_locked` 时将 `NEXUS_INSTALL_WIZARD_PENDING=0` 写入 `docker/.env.prod`。
|
||||
- `sync_env_prod_to_volume()`:已锁定且 `docker/.env.prod` 密钥/URL 变更时同步至 `nexus-state` 卷。
|
||||
- `docker-compose.1panel.yml` 移除重复 `NEXUS_1PANEL_*` environment。
|
||||
- `docker-compose.prod.yml` healthcheck 与 `Dockerfile.prod` 对齐。
|
||||
- 文档注明 `NEXUS_REDIS_URL` 不经 Compose 注入。
|
||||
|
||||
### C. nx update 门控与回滚
|
||||
|
||||
1. 升级前 `tag_rollback_image` → `nexus-prod-nexus:rollback`
|
||||
2. `compose build` → `gate_new_image_import`(`python -c "import server.main"`)
|
||||
3. `compose up -d --no-build`
|
||||
4. `verify_health` 失败 → `rollback_compose_upgrade` + 退出非 0
|
||||
5. `backup_mysql` 失败默认 WARN;`--require-backup` 时中止
|
||||
6. `resolve_nexus_redis_url`:容器改名时迁移 `redis://:pass@` 至新 host
|
||||
7. `sync-install-wizard-to-container.sh` 仅在 install.html 非 200 时执行,失败可见
|
||||
8. `update.sh` 管道执行时 ROOT 回退 `/opt/nexus`
|
||||
|
||||
## 接口 / 数据模型
|
||||
|
||||
- `GET /api/install/state` 响应不再含 `install_token`、`redis_pass`;可选 `has_token: boolean`。
|
||||
- 无 DB 迁移;无新环境变量(沿用 `NEXUS_PUBLISH_PORT`、`NEXUS_INSTALL_WIZARD_PENDING`)。
|
||||
|
||||
## 安全与性能约束
|
||||
|
||||
- 白名单脱敏为强制;`sessionStorage` 仅当前标签页,锁定后 state 文件删除。
|
||||
- 镜像门控增加约数秒构建后延迟;回滚依赖升级前 tag 成功。
|
||||
- 卷双写仅在已锁定且 prod 密钥/URL 非空时执行。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `pytest tests/test_security_unit.py` 通过
|
||||
- [ ] `ruff check server/ --select F` 无错误
|
||||
- [ ] `GET /api/install/state` 无敏感字段
|
||||
- [ ] `nx update` 门控失败不切换容器;健康失败自动回滚
|
||||
- [ ] 已锁定主机 `NEXUS_INSTALL_WIZARD_PENDING=0`
|
||||
- [ ] Redis 容器改名后 URL 保留密码
|
||||
@@ -0,0 +1,32 @@
|
||||
# 凭据轮询登录与连接失败列表
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 状态 | 已实施 |
|
||||
|
||||
## 背景
|
||||
|
||||
运维批量添加子机时,每台机器需尝试多组账号密码/密钥。原密码预设仅含密码、用户名在服务器表单单独填写,添加时不做 SSH 探测。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 密码/SSH 密钥预设增加 `username` 字段。
|
||||
2. `POST /api/servers/add-by-ip`:仅 IP,顺序轮询全部预设,`try_ssh_login` 验证。
|
||||
3. 成功:创建 `servers` 行,`connectivity=ok`,记录命中预设。
|
||||
4. 失败:写入 `pending_servers`,前端弹窗 + 失败列表展示,支持重试/删除。
|
||||
|
||||
## 数据模型
|
||||
|
||||
- `password_presets.username`、`ssh_key_presets.username`(默认 root)
|
||||
- `pending_servers`:domain、port、attempts、last_error、last_attempt_at 等
|
||||
|
||||
## 安全
|
||||
|
||||
- 轮询明文仅在内存;落库 Fernet 加密;失败摘要不含完整密码/私钥。
|
||||
|
||||
## 验收
|
||||
|
||||
- 凭据页可维护用户名
|
||||
- 快速添加(IP) 轮询成功入 servers 列表
|
||||
- 全失败入 pending 列表,重试重新轮询全部预设
|
||||
@@ -8,7 +8,13 @@
|
||||
| 变量 | 示例 |
|
||||
|------|------|
|
||||
| `NEXUS_ROOT` | `~/Nexus` 或 `/home/you/Nexus` |
|
||||
| Git 克隆 | `git clone http://66.154.115.8:3000/admin/Nexus.git "$NEXUS_ROOT"` |
|
||||
| Git 克隆 | `git clone http://66.154.115.8:3000/admin/Nexus.git "$NEXUS_ROOT"`(匿名,无需令牌) |
|
||||
|
||||
### Git push
|
||||
|
||||
- `git clone` / `git pull`:公共仓库可匿名。
|
||||
- `git push`:`bash scripts/git-push.sh`(读取 `deploy/nexus-1panel.secrets.sh` 中的 `NEXUS_GITEA_TOKEN`,即 admin 密码;**该文件不入 git**)。
|
||||
- 应用密钥仍在 `.env`,与 Gitea 无关。
|
||||
|
||||
## SSH / 部署
|
||||
|
||||
|
||||
@@ -109,9 +109,9 @@ entrypoint EXIT trap
|
||||
| 3 | 初始化 | `POST /api/install/init-db` | 建表、三写、发 `install_token`;**.env 已存在则 403** |
|
||||
| 4 | 管理员 | `GET /api/install/connection-check` | 读 `.env` 验 MySQL+Redis |
|
||||
| 4 | 创建 admin | `POST /api/install/create-admin` | 需 `install_token` |
|
||||
| 5 | 完成 | `POST /api/install/lock`(兜底) | 写 `.install_locked` |
|
||||
| 5 | 完成 | `POST /api/install/lock`(兜底) | 写 `.install_locked`;**Docker 模式展示 1Panel 守护清单** |
|
||||
|
||||
辅助:`GET /status`、`GET /state`(断点续装)。
|
||||
辅助:`GET /status`、`GET /state`(断点续装;**白名单脱敏**,不含 `install_token`/`redis_pass`,有 token 时仅 `has_token: true`)。
|
||||
|
||||
实现文件:`server/api/install.py`、`web/app/install.html`。
|
||||
|
||||
@@ -125,7 +125,17 @@ entrypoint EXIT trap
|
||||
| 用户名 | `nexus` | **无**(1Panel Redis 无账号;向导仅填密码) |
|
||||
| 密码 | 1Panel「数据库」里建的 nexus 密码 | 1Panel Redis **应用参数**中的口令 |
|
||||
|
||||
### 3.3 Docker 预填来源
|
||||
### 3.3 进程守护(1Panel Docker)
|
||||
|
||||
| 层级 | 机制 | 说明 |
|
||||
|------|------|------|
|
||||
| Layer 1 | Compose `restart: unless-stopped` + `healthcheck` | 容器崩溃自动拉起 |
|
||||
| Layer 2 | Python `self_monitor`(30s) | 向导完成后正常模式自动运行 |
|
||||
| Layer 3 | 宿主机 OpenResty + 可选 `health_monitor.sh` | 反代 `127.0.0.1:8600`;见 `deploy/1panel/openresty-nexus.conf.example` |
|
||||
|
||||
`init-db` 在 `NEXUS_DOCKER_WRITE_ENV=1` 时调用 `_configure_docker_guardian`(**不**在容器内写 Supervisor)。
|
||||
|
||||
### 3.4 Docker 预填来源
|
||||
|
||||
优先级(`_onepanel_service_host`):
|
||||
|
||||
@@ -135,7 +145,7 @@ entrypoint EXIT trap
|
||||
|
||||
`env-check` 在 `NEXUS_DOCKER_WRITE_ENV=1` 时返回 `docker_defaults`(主机/端口/库名,**不含** Redis 用户名)。前端会覆盖 stale 的 `127.0.0.1` / `host.docker.internal`,并隐藏 Redis 用户名字段。
|
||||
|
||||
### 3.4 连接 URL 格式(必读)
|
||||
### 3.5 连接 URL 格式(必读)
|
||||
|
||||
**MySQL**(写入 `NEXUS_DATABASE_URL`):
|
||||
|
||||
@@ -176,14 +186,16 @@ cd /opt/nexus && bash deploy/install-nexus-fresh.sh --skip-clone
|
||||
|
||||
完成后:`https://<域名>/app/install.html`
|
||||
|
||||
### 4.3 `nx update` 自动 sync(b25d079+)
|
||||
### 4.3 `nx update` 自动 sync(2026-06-06 根治版)
|
||||
|
||||
`deploy/nexus-1panel.sh` 在 install/upgrade 时:
|
||||
|
||||
1. `detect-1panel-services.sh` 探测容器名
|
||||
2. 写入 `docker/.env.prod`:`NEXUS_1PANEL_*_HOST`、`NEXUS_REDIS_URL=redis://容器:6379/0`(**无密码**,密码由向导填)
|
||||
2. 写入 `docker/.env.prod`:`NEXUS_1PANEL_*_HOST`;`NEXUS_REDIS_URL` 优先保留卷/向导中的 `redis://:pass@host`(**不经 Compose 注入容器**)
|
||||
3. `write_1panel_hosts_volume` → `1panel-hosts.json`
|
||||
4. 若存在 `1panel-network`,叠加 `docker-compose.1panel.yml`
|
||||
4. 若存在 `1panel-network`,叠加 `docker-compose.1panel.yml`(仅 networks,无重复 environment)
|
||||
5. **已锁定**:`sync_env_prod_to_volume` 将 `docker/.env.prod` 三密钥 + `NEXUS_REDIS_URL`/`NEXUS_DATABASE_URL` 双写至 `nexus-state` 卷
|
||||
6. **已锁定**:`set_install_complete` 将 `NEXUS_INSTALL_WIZARD_PENDING=0` 回写 `docker/.env.prod`
|
||||
|
||||
### 4.4 OpenResty
|
||||
|
||||
@@ -212,15 +224,39 @@ cd /opt/nexus && bash deploy/install-nexus-fresh.sh --skip-clone
|
||||
| `nx update` | `deploy/update.sh` → `nexus-1panel.sh upgrade` |
|
||||
| `nexus-update` | 同上 |
|
||||
|
||||
**upgrade 顺序**:`git fetch` → 可选 MySQL 备份 → `git reset --hard origin/main` → `sync_1panel_service_hosts` → `compose up -d --build` → `sync-install-wizard-to-container.sh` → `/health` 验收。
|
||||
**upgrade 顺序**(2026-06-06+):
|
||||
|
||||
```
|
||||
git fetch → MySQL 备份(失败默认 WARN)→ git reset --hard
|
||||
→ sync_1panel_service_hosts → sync_env_prod_to_volume
|
||||
→ tag 当前镜像为 nexus-prod-nexus:rollback
|
||||
→ compose build → 镜像门控 (import server.main)
|
||||
→ compose up -d --no-build
|
||||
→(仅 install.html 非 200 时)sync-install-wizard-to-container.sh
|
||||
→ set_install_complete → verify_health(端口 NEXUS_PUBLISH_PORT)
|
||||
→ 健康失败:自动 tag rollback + up -d 回滚
|
||||
```
|
||||
|
||||
| 参数 | 含义 |
|
||||
|------|------|
|
||||
| `--check` | 仅查 Git |
|
||||
| `--no-cache` | 无缓存重建镜像 |
|
||||
| `--no-backup` | 跳过 mysqldump |
|
||||
| `--require-backup` | mysqldump 失败则中止升级 |
|
||||
| `--prune` | 清理悬空镜像 |
|
||||
|
||||
**推荐更新方式**:`cd /opt/nexus && bash deploy/update.sh`(`curl \| bash` 会回退 `/opt/nexus` 并打印警告)。
|
||||
|
||||
### 5.1 密钥 / Redis URL 轮换(双写)
|
||||
|
||||
已锁定生产环境运行时以 **卷内** `/var/lib/nexus/.env` 为准。轮换步骤:
|
||||
|
||||
1. 修改宿主机 `docker/.env.prod` 中 `NEXUS_SECRET_KEY` / `NEXUS_API_KEY` / `NEXUS_ENCRYPTION_KEY` 或 `NEXUS_REDIS_URL`
|
||||
2. 执行 `sudo nx update` — 脚本 `sync_env_prod_to_volume` 会同步至卷
|
||||
3. 验收:`docker exec <nexus> redis-cli -u "$URL" ping` → `PONG`;`/health` → `ok`
|
||||
|
||||
Redis 容器改名时,`resolve_nexus_redis_url` 会尝试将旧 URL 中的密码迁移到新 host。
|
||||
|
||||
---
|
||||
|
||||
## 6. 脚本速查
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# 1Panel 安装向导验收报告(待服务器执行)
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-06 |
|
||||
| 目标 | 完成 `https://api.synaglobal.vip/app/install.html` 五步安装 |
|
||||
| 代码基线 | `main` @ `b25d079`(1Panel 容器名 sync) |
|
||||
| DNS | `api.synaglobal.vip` → `20.24.218.235`(新 VPS) |
|
||||
|
||||
## Agent 侧探测(本机)
|
||||
|
||||
| 检查 | 结果 | 说明 |
|
||||
|------|------|------|
|
||||
| SSH `Host nexus` | 失败 | 本机无 `~/.ssh/id_rsa_nexus`,无法代登服务器 |
|
||||
| `https://api.synaglobal.vip/health` | 连接重置 | 外网 HTTPS 未通或 OpenResty 未反代 |
|
||||
| `20.24.218.235:8600` | 不可达 | 预期:仅 `127.0.0.1:8600` 监听 |
|
||||
|
||||
**结论**:安装向导必须在 **1Panel 终端** 本机验证;外网访问依赖 OpenResty 反代配置。
|
||||
|
||||
## 服务器执行清单(复制到 1Panel 终端)
|
||||
|
||||
### 1. 更新到 b25d079+ 并 sync 1Panel 主机名
|
||||
|
||||
```bash
|
||||
cd /opt/nexus
|
||||
git fetch && git log -1 --oneline
|
||||
nx update --no-cache
|
||||
```
|
||||
|
||||
### 2. 一键验收
|
||||
|
||||
```bash
|
||||
bash deploy/verify-1panel-install-wizard.sh
|
||||
```
|
||||
|
||||
关键 `[PASS]`:
|
||||
|
||||
- 已包含 b25d079
|
||||
- `NEXUS_1PANEL_DB_HOST=1Panel-mysql-*`
|
||||
- `NEXUS_1PANEL_REDIS_HOST=1Panel-redis-*`
|
||||
- Nexus 已接入 `1panel-network`
|
||||
- 容器内 TCP → MySQL/Redis
|
||||
- `/app/.env` 不存在
|
||||
- 步骤 3 预填 `db_host=1Panel-mysql-*`(非 `host.docker.internal`)
|
||||
|
||||
### 3. 配置 OpenResty(外网访问)
|
||||
|
||||
1. 1Panel → 应用商店 → **OpenResty**
|
||||
2. 网站 → 域名 `api.synaglobal.vip` → 反代 `http://127.0.0.1:8600`
|
||||
3. HTTPS → Let's Encrypt
|
||||
4. 参考 `deploy/1panel/openresty-nexus.conf.example`(`/ws/` 超时 ≥ 3600s)
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
curl -sk https://api.synaglobal.vip/health # → ok
|
||||
```
|
||||
|
||||
### 4. 1Panel 数据库准备
|
||||
|
||||
- 库名:`nexus`
|
||||
- 用户:`nexus`(仅授权 `nexus` 库)
|
||||
- 记下密码(步骤 3 填写)
|
||||
|
||||
### 5. 浏览器完成安装向导
|
||||
|
||||
打开:`https://api.synaglobal.vip/app/install.html`
|
||||
|
||||
| 步骤 | 要点 |
|
||||
|------|------|
|
||||
| 2 环境检测 | MySQL/Redis 显示 `✓ 已安装(1Panel-mysql-xxxx:3306)` |
|
||||
| 3 数据库 | `db_host` / `redis_host` 为容器名;填 MySQL 密码;Redis 有密码则填 `redis_pass` |
|
||||
| 4 连接 | MySQL + Redis 全绿 |
|
||||
| 4 管理员 | 创建 admin 账号 |
|
||||
| 5 锁定 | 完成安装 |
|
||||
|
||||
### 6. 安装后 L5 抽测
|
||||
|
||||
```bash
|
||||
curl -sk https://api.synaglobal.vip/health
|
||||
curl -sk -o /dev/null -w '%{http_code}\n' https://api.synaglobal.vip/app/
|
||||
# 未登录 API → 401
|
||||
curl -sk -o /dev/null -w '%{http_code}\n' https://api.synaglobal.vip/api/servers/
|
||||
```
|
||||
|
||||
## 常见故障
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| 步骤 3 仍是 `host.docker.internal` | `nx update --no-cache`;确认 `git log -1` ≥ b25d079 |
|
||||
| MySQL `Can't connect on host.docker.internal` | `bash deploy/detect-1panel-services.sh` → `nx update` |
|
||||
| `/app/install.html` 404 | `bash deploy/sync-install-wizard-to-container.sh` |
|
||||
| `/app/.env` 已存在但向导未完成 | 删 `nexus-state` 卷内 `.env`,设 `NEXUS_INSTALL_WIZARD_PENDING=1`,`nx update` |
|
||||
| 外网 HTTPS 失败、本机 8600 正常 | 配置 OpenResty 反代 + SSL |
|
||||
|
||||
## DoD(待填)
|
||||
|
||||
- [ ] `verify-1panel-install-wizard.sh` 全 PASS
|
||||
- [ ] `https://api.synaglobal.vip/health` → `ok`
|
||||
- [ ] 安装向导步骤 5 锁定完成
|
||||
- [ ] `/app/` 可登录
|
||||
@@ -65,7 +65,7 @@
|
||||
<v-select
|
||||
v-model="form.preset_id"
|
||||
:items="passwordPresets"
|
||||
item-title="name"
|
||||
item-title="display"
|
||||
item-value="id"
|
||||
label="密码预设(可选)"
|
||||
variant="outlined"
|
||||
@@ -91,7 +91,7 @@
|
||||
<v-select
|
||||
v-model="form.ssh_key_preset_id"
|
||||
:items="sshKeyPresets"
|
||||
item-title="name"
|
||||
item-title="display"
|
||||
item-value="id"
|
||||
label="SSH 密钥预设(可选)"
|
||||
variant="outlined"
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface ServerFormModel {
|
||||
export interface PresetOption {
|
||||
id: number
|
||||
name: string
|
||||
username?: string
|
||||
display?: string
|
||||
}
|
||||
|
||||
function emptyForm(): ServerFormModel {
|
||||
@@ -117,8 +119,14 @@ export function useServerFormDialog(onSaved: () => void) {
|
||||
}
|
||||
categoryOptions.value = [...names].sort((a, b) => a.localeCompare(b, 'zh'))
|
||||
platforms.value = (catRes.platforms || []).map((p) => ({ id: p.id, name: p.name }))
|
||||
passwordPresets.value = pwRes.items || []
|
||||
sshKeyPresets.value = skRes.items || []
|
||||
passwordPresets.value = (pwRes.items || []).map((p) => ({
|
||||
...p,
|
||||
display: `${p.name} (${p.username || 'root'})`,
|
||||
}))
|
||||
sshKeyPresets.value = (skRes.items || []).map((p) => ({
|
||||
...p,
|
||||
display: `${p.name} (${p.username || 'root'})`,
|
||||
}))
|
||||
nodes.value = Array.isArray(nodesRes) ? nodesRes : []
|
||||
} catch {
|
||||
snackbar('加载表单选项失败', 'error')
|
||||
|
||||
@@ -14,10 +14,9 @@
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- Password Presets Tab:仅名称+密码模板,不含 SSH 用户名(见 docs/design/plans/2026-05-16-password-presets.md) -->
|
||||
<template v-if="credTab === 'passwords'">
|
||||
<v-card-text class="text-body-2 text-medium-emphasis pt-0 pb-2">
|
||||
保存常用 SSH 密码模板;在「服务器」里选择预设后由服务端解密填入。SSH 用户名在每台服务器上单独配置。
|
||||
保存常用 SSH 账号+密码模板;快速添加服务器时将按顺序轮询所有预设尝试登录。
|
||||
</v-card-text>
|
||||
<v-data-table
|
||||
:items="passwords"
|
||||
@@ -108,6 +107,15 @@
|
||||
placeholder="如:生产环境 root 密码"
|
||||
:rules="[required('预设名称')]"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="pwForm.username"
|
||||
label="SSH 用户名"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
placeholder="root"
|
||||
:rules="[required('SSH 用户名')]"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="pwForm.password"
|
||||
label="SSH 密码"
|
||||
@@ -132,6 +140,7 @@
|
||||
<v-card-title>{{ editingId ? '编辑 SSH 密钥' : '新建 SSH 密钥' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="skForm.name" label="名称" variant="outlined" density="compact" class="mb-2" />
|
||||
<v-text-field v-model="skForm.username" label="SSH 用户名" variant="outlined" density="compact" class="mb-2" placeholder="root" />
|
||||
<v-textarea v-model="skForm.public_key" label="公钥" variant="outlined" density="compact" rows="3" class="mb-2" />
|
||||
<v-textarea v-model="skForm.private_key" label="私钥" variant="outlined" density="compact" rows="5" />
|
||||
</v-card-text>
|
||||
@@ -194,7 +203,7 @@ function formatPresetTime(iso: string | undefined): string {
|
||||
}
|
||||
|
||||
// ── Credential types ──
|
||||
interface PasswordItem { id: number; name: string; created_at?: string }
|
||||
interface PasswordItem { id: number; name: string; username?: string; created_at?: string }
|
||||
interface SshKeyItem { id: number; name: string; username?: string; public_key?: string; private_key_set?: boolean; description?: string }
|
||||
interface DbCredItem { id: number; name: string; db_type?: string; host: string; port: number; username: string; password_set?: boolean; database?: string }
|
||||
|
||||
@@ -211,9 +220,10 @@ const itemsPerPage = ref(20)
|
||||
// Password presets
|
||||
const passwords = ref<PasswordItem[]>([])
|
||||
const showPasswordDialog = ref(false)
|
||||
const pwForm = ref({ name: '', password: '' })
|
||||
const pwForm = ref({ name: '', username: 'root', password: '' })
|
||||
const passwordHeaders = [
|
||||
{ title: '预设名称', key: 'name' },
|
||||
{ title: '用户名', key: 'username', width: 120 },
|
||||
{ title: '创建时间', key: 'created_at', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
|
||||
]
|
||||
@@ -221,9 +231,10 @@ const passwordHeaders = [
|
||||
// SSH keys
|
||||
const sshKeys = ref<SshKeyItem[]>([])
|
||||
const showSshKeyDialog = ref(false)
|
||||
const skForm = ref({ name: '', public_key: '', private_key: '' })
|
||||
const skForm = ref({ name: '', username: 'root', public_key: '', private_key: '' })
|
||||
const sshKeyHeaders = [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '用户名', key: 'username', width: 120 },
|
||||
{ title: '私钥', key: 'private_key_set', width: 120 },
|
||||
{ title: '操作', key: 'actions', width: 150, align: 'end' as const },
|
||||
]
|
||||
@@ -303,7 +314,7 @@ watch(credTab, () => { page.value = 1; loadCurrentTab() })
|
||||
// ── Password CRUD ──
|
||||
function editPassword(item: PasswordItem) {
|
||||
editingId.value = item.id
|
||||
pwForm.value = { name: item.name, password: '' }
|
||||
pwForm.value = { name: item.name, username: item.username || 'root', password: '' }
|
||||
showPasswordDialog.value = true
|
||||
}
|
||||
|
||||
@@ -319,12 +330,13 @@ async function savePassword() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const username = pwForm.value.username.trim() || 'root'
|
||||
if (editingId.value) {
|
||||
const body: { name: string; encrypted_pw?: string } = { name }
|
||||
const body: { name: string; username: string; encrypted_pw?: string } = { name, username }
|
||||
if (password) body.encrypted_pw = password
|
||||
await http.put(`/presets/${editingId.value}`, body)
|
||||
} else {
|
||||
await http.post('/presets/', { name, encrypted_pw: password })
|
||||
await http.post('/presets/', { name, username, encrypted_pw: password })
|
||||
}
|
||||
showPasswordDialog.value = false
|
||||
loadPasswords()
|
||||
@@ -337,7 +349,7 @@ async function savePassword() {
|
||||
// ── SSH Key CRUD ──
|
||||
function editSshKey(item: SshKeyItem) {
|
||||
editingId.value = item.id
|
||||
skForm.value = { name: item.name, public_key: item.public_key || '', private_key: '' }
|
||||
skForm.value = { name: item.name, username: item.username || 'root', public_key: item.public_key || '', private_key: '' }
|
||||
showSshKeyDialog.value = true
|
||||
}
|
||||
|
||||
@@ -371,8 +383,8 @@ async function saveDbCred() {
|
||||
// ── Delete ──
|
||||
function openAdd() {
|
||||
editingId.value = null
|
||||
if (credTab.value === 'passwords') { pwForm.value = { name: '', password: '' }; showPasswordDialog.value = true }
|
||||
else if (credTab.value === 'sshkeys') { skForm.value = { name: '', public_key: '', private_key: '' }; showSshKeyDialog.value = true }
|
||||
if (credTab.value === 'passwords') { pwForm.value = { name: '', username: 'root', password: '' }; showPasswordDialog.value = true }
|
||||
else if (credTab.value === 'sshkeys') { skForm.value = { name: '', username: 'root', public_key: '', private_key: '' }; showSshKeyDialog.value = true }
|
||||
else { dbForm.value = { name: '', host: '', port: 3306, username: '', password: '', database: '' }; showDbCredDialog.value = true }
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<v-btn color="primary" variant="flat" class="ml-3" prepend-icon="mdi-plus" size="small" @click="openAddServer">
|
||||
添加
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-flash" size="small" @click="openQuickAdd">
|
||||
快速添加(IP)
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-download" size="small" @click="exportCSV">
|
||||
导出CSV
|
||||
</v-btn>
|
||||
@@ -103,6 +106,49 @@
|
||||
</v-data-table-server>
|
||||
</v-card>
|
||||
|
||||
<!-- Pending / failed SSH connections -->
|
||||
<v-card elevation="0" rounded="lg" class="my-5" border>
|
||||
<v-card-title class="d-flex align-center">
|
||||
连接失败列表
|
||||
<v-chip v-if="pendingServers.length" size="small" class="ml-2" color="error" variant="tonal" label>
|
||||
{{ pendingServers.length }}
|
||||
</v-chip>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" size="small" prepend-icon="mdi-refresh" :loading="pendingLoading" @click="loadPendingServers">
|
||||
刷新
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:items="pendingServers"
|
||||
:headers="pendingHeaders"
|
||||
:loading="pendingLoading"
|
||||
hover
|
||||
density="comfortable"
|
||||
:items-per-page="10"
|
||||
>
|
||||
<template #item.address="{ item }">
|
||||
<span class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</span>
|
||||
</template>
|
||||
<template #item.last_attempt_at="{ item }">
|
||||
<span class="text-caption text-medium-emphasis">{{ formatRelativeTime(item.last_attempt_at) }}</span>
|
||||
</template>
|
||||
<template #item.last_error="{ item }">
|
||||
<span class="text-caption text-error text-truncate d-inline-block" style="max-width: 320px" :title="item.last_error">
|
||||
{{ item.last_error || '—' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn variant="text" size="x-small" color="primary" density="compact" :loading="retryingId === item.id" @click="retryPending(item)">
|
||||
重试
|
||||
</v-btn>
|
||||
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDeletePending(item)">删除</v-btn>
|
||||
</template>
|
||||
<template #no-data>
|
||||
<div class="text-center text-medium-emphasis py-6">暂无连接失败记录</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Server Detail Panel -->
|
||||
<v-expand-transition>
|
||||
<v-card v-if="detailServer" class="mt-4" elevation="0" border rounded="lg">
|
||||
@@ -188,6 +234,84 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Quick add by IP -->
|
||||
<v-dialog v-model="showQuickAdd" max-width="480" persistent>
|
||||
<v-card border>
|
||||
<v-card-title>快速添加服务器(仅 IP)</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">
|
||||
将按顺序轮询凭据管理中的全部密码预设与 SSH 密钥预设尝试登录,首个成功即加入服务器列表。
|
||||
</p>
|
||||
<v-text-field
|
||||
v-model="quickAddForm.domain"
|
||||
label="IP 或域名"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
placeholder="192.168.1.10"
|
||||
:rules="[v => !!v?.trim() || '必填']"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="quickAddForm.port"
|
||||
label="SSH 端口"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
type="number"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="quickAddForm.name"
|
||||
label="显示名称(可选)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="留空则使用 IP"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="quickAddLoading" @click="showQuickAdd = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="quickAddLoading" @click="submitQuickAdd">开始轮询</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Poll failure detail -->
|
||||
<v-dialog v-model="showPollFailure" max-width="560">
|
||||
<v-card border>
|
||||
<v-card-title class="text-error">SSH 连接失败</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">已尝试全部凭据预设,均未登录成功。该服务器已加入「连接失败列表」,可稍后重试。</p>
|
||||
<v-list v-if="pollFailureErrors.length" density="compact" class="bg-grey-lighten-4 rounded">
|
||||
<v-list-item v-for="(err, i) in pollFailureErrors" :key="i">
|
||||
<v-list-item-title class="text-body-2">
|
||||
[{{ err.preset_type === 'password' ? '密码' : '密钥' }}] {{ err.preset_name }} ({{ err.username }})
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-error">{{ err.error }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<p v-else class="text-medium-emphasis">{{ pollFailureMessage }}</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="flat" @click="showPollFailure = false">知道了</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete pending confirm -->
|
||||
<v-dialog v-model="showDeletePending" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>确认删除</v-card-title>
|
||||
<v-card-text>从失败列表移除 <strong>{{ deletingPending?.domain }}</strong>?</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showDeletePending = false">取消</v-btn>
|
||||
<v-btn color="error" variant="flat" :loading="deletingPendingLoading" @click="doDeletePending">删除</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Import CSV Dialog -->
|
||||
<v-dialog v-model="showImport" max-width="500">
|
||||
<v-card border>
|
||||
@@ -247,6 +371,153 @@ const showDelete = ref(false)
|
||||
const deleting = ref(false)
|
||||
const deletingServer = ref<ServerApiItem | null>(null)
|
||||
|
||||
// ── Quick add by IP + pending failure list ──
|
||||
interface PollErrorItem {
|
||||
preset_type: string
|
||||
preset_name: string
|
||||
username: string
|
||||
error: string
|
||||
}
|
||||
|
||||
interface PendingServerItem {
|
||||
id: number
|
||||
name: string
|
||||
domain: string
|
||||
port: number
|
||||
attempts: number
|
||||
last_error: string
|
||||
last_attempt_at: string | null
|
||||
}
|
||||
|
||||
interface AddByIpResponse {
|
||||
success: boolean
|
||||
server?: ServerApiItem
|
||||
pending_id?: number
|
||||
errors?: PollErrorItem[]
|
||||
message?: string
|
||||
matched_preset?: string
|
||||
matched_username?: string
|
||||
}
|
||||
|
||||
const showQuickAdd = ref(false)
|
||||
const quickAddLoading = ref(false)
|
||||
const quickAddForm = ref({ domain: '', port: 22, name: '' })
|
||||
const pendingServers = ref<PendingServerItem[]>([])
|
||||
const pendingLoading = ref(false)
|
||||
const retryingId = ref<number | null>(null)
|
||||
const showPollFailure = ref(false)
|
||||
const pollFailureErrors = ref<PollErrorItem[]>([])
|
||||
const pollFailureMessage = ref('')
|
||||
const showDeletePending = ref(false)
|
||||
const deletingPending = ref<PendingServerItem | null>(null)
|
||||
const deletingPendingLoading = ref(false)
|
||||
|
||||
const pendingHeaders = [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '地址', key: 'address' },
|
||||
{ title: '尝试次数', key: 'attempts', width: 100 },
|
||||
{ title: '最后错误', key: 'last_error' },
|
||||
{ title: '最后尝试', key: 'last_attempt_at', width: 160 },
|
||||
{ title: '操作', key: 'actions', width: 140, align: 'end' as const },
|
||||
]
|
||||
|
||||
function openQuickAdd() {
|
||||
quickAddForm.value = { domain: '', port: 22, name: '' }
|
||||
showQuickAdd.value = true
|
||||
}
|
||||
|
||||
function showFailureDialog(errors: PollErrorItem[], message?: string) {
|
||||
pollFailureErrors.value = errors
|
||||
pollFailureMessage.value = message || ''
|
||||
showPollFailure.value = true
|
||||
}
|
||||
|
||||
async function loadPendingServers() {
|
||||
pendingLoading.value = true
|
||||
try {
|
||||
const res = await http.get<{ items: PendingServerItem[]; total: number }>('/servers/pending')
|
||||
pendingServers.value = res.items || []
|
||||
} catch (e: unknown) {
|
||||
pendingServers.value = []
|
||||
snackbar(formatApiError(e, '加载失败列表失败'), 'error')
|
||||
} finally {
|
||||
pendingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitQuickAdd() {
|
||||
const domain = quickAddForm.value.domain.trim()
|
||||
if (!domain) {
|
||||
snackbar('请填写 IP 或域名', 'error')
|
||||
return
|
||||
}
|
||||
quickAddLoading.value = true
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
domain,
|
||||
port: quickAddForm.value.port || 22,
|
||||
}
|
||||
const name = quickAddForm.value.name.trim()
|
||||
if (name) body.name = name
|
||||
|
||||
const res = await http.post<AddByIpResponse>('/servers/add-by-ip', body)
|
||||
if (res.success && res.server) {
|
||||
showQuickAdd.value = false
|
||||
snackbar(`添加成功:${res.matched_preset} (${res.matched_username})`)
|
||||
loadServers()
|
||||
loadStats()
|
||||
} else {
|
||||
showQuickAdd.value = false
|
||||
showFailureDialog(res.errors || [], res.message)
|
||||
loadPendingServers()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '添加失败'), 'error')
|
||||
} finally {
|
||||
quickAddLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retryPending(item: PendingServerItem) {
|
||||
retryingId.value = item.id
|
||||
try {
|
||||
const res = await http.post<AddByIpResponse>(`/servers/pending/${item.id}/retry`, {})
|
||||
if (res.success && res.server) {
|
||||
snackbar(`重试成功:${res.matched_preset} (${res.matched_username})`)
|
||||
loadServers()
|
||||
loadStats()
|
||||
loadPendingServers()
|
||||
} else {
|
||||
showFailureDialog(res.errors || [], res.message)
|
||||
loadPendingServers()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '重试失败'), 'error')
|
||||
} finally {
|
||||
retryingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeletePending(item: PendingServerItem) {
|
||||
deletingPending.value = item
|
||||
showDeletePending.value = true
|
||||
}
|
||||
|
||||
async function doDeletePending() {
|
||||
if (!deletingPending.value) return
|
||||
deletingPendingLoading.value = true
|
||||
try {
|
||||
await http.delete(`/servers/pending/${deletingPending.value.id}`)
|
||||
showDeletePending.value = false
|
||||
snackbar('已从失败列表移除')
|
||||
loadPendingServers()
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '删除失败'), 'error')
|
||||
} finally {
|
||||
deletingPendingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
visible: serverFormVisible,
|
||||
form: serverForm,
|
||||
@@ -473,5 +744,6 @@ async function doDelete() {
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadServers()
|
||||
loadPendingServers()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Push to Gitea using deploy/nexus-1panel.secrets.sh (not committed).
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SECRETS="${ROOT}/deploy/nexus-1panel.secrets.sh"
|
||||
if [[ -f "$SECRETS" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$SECRETS"
|
||||
fi
|
||||
HOST="${NEXUS_GITEA_HOST:-66.154.115.8:3000}"
|
||||
REPO="${NEXUS_GITEA_REPO:-admin/Nexus.git}"
|
||||
TOKEN="${NEXUS_GITEA_TOKEN:?缺少 NEXUS_GITEA_TOKEN,请配置 $SECRETS}"
|
||||
BRANCH="${1:-main}"
|
||||
GIT_TERMINAL_PROMPT=0 git -C "$ROOT" push "http://admin:${TOKEN}@${HOST}/${REPO}" "$BRANCH"
|
||||
+266
-26
@@ -31,6 +31,8 @@ router = APIRouter(prefix="/api/install", tags=["install"])
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
ENV_FILE = ROOT_DIR / ".env"
|
||||
INSTALL_LOCK = ROOT_DIR / ".install_locked"
|
||||
INSTALL_HTML = ROOT_DIR / "web" / "app" / "install.html"
|
||||
INSTALL_HTML_BAK = ROOT_DIR / "web" / "app" / "install.html.bak"
|
||||
CONFIG_DIR = ROOT_DIR / "web" / "data"
|
||||
CONFIG_JSON = CONFIG_DIR / "config.json"
|
||||
ONEPANEL_HOSTS_JSON = CONFIG_DIR / "1panel-hosts.json"
|
||||
@@ -408,6 +410,27 @@ def _init_db_request_from_redis_url(redis_url: str) -> InitDbRequest | None:
|
||||
)
|
||||
|
||||
|
||||
def _redis_repair_request(redis_url: str) -> InitDbRequest | None:
|
||||
"""Build InitDbRequest for connection-check repair (URL + install state password)."""
|
||||
req = _init_db_request_from_redis_url(redis_url)
|
||||
if not req:
|
||||
return None
|
||||
if (req.redis_pass or "").strip():
|
||||
return req
|
||||
if not STATE_FILE.is_file():
|
||||
return req
|
||||
import json
|
||||
|
||||
try:
|
||||
state = json.loads(read_utf8_text(STATE_FILE))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return req
|
||||
saved_pass = (state.get("redis_pass") or "").strip()
|
||||
if saved_pass:
|
||||
return req.model_copy(update={"redis_pass": saved_pass})
|
||||
return req
|
||||
|
||||
|
||||
def _mask_redis_url(redis_url: str) -> str:
|
||||
parsed = urlparse(redis_url)
|
||||
if not parsed.password:
|
||||
@@ -443,6 +466,7 @@ def _patch_env_redis_url(new_url: str) -> None:
|
||||
out.append(new_line)
|
||||
write_utf8_lf(ENV_FILE, "\n".join(out) + "\n")
|
||||
logger.info("Patched NEXUS_REDIS_URL in %s", ENV_FILE)
|
||||
_sync_env_to_persist_volume()
|
||||
|
||||
|
||||
def _resolve_redis_url(req: InitDbRequest) -> tuple[bool, str, str | None]:
|
||||
@@ -553,6 +577,52 @@ def _write_env(req: InitDbRequest, secret_key: str, api_key: str, encryption_key
|
||||
|
||||
write_utf8_lf(ENV_FILE, "\n".join(lines) + "\n")
|
||||
logger.info(f".env written to {ENV_FILE}")
|
||||
_sync_env_to_persist_volume()
|
||||
|
||||
|
||||
def _persist_dir() -> Path:
|
||||
raw = os.environ.get("NEXUS_PERSIST_DIR", "/var/lib/nexus").strip() or "/var/lib/nexus"
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def _persist_env_path() -> Path:
|
||||
return _persist_dir() / ".env"
|
||||
|
||||
|
||||
def _persist_lock_path() -> Path:
|
||||
return _persist_dir() / ".install_locked"
|
||||
|
||||
|
||||
def _sync_env_to_persist_volume() -> None:
|
||||
"""Copy /app/.env to nexus-state volume immediately (Docker install wizard)."""
|
||||
if not _is_docker_install_mode() or not ENV_FILE.is_file():
|
||||
return
|
||||
dest = _persist_env_path()
|
||||
try:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
import shutil
|
||||
|
||||
shutil.copy2(ENV_FILE, dest)
|
||||
dest.chmod(0o600)
|
||||
logger.info("Synced .env to persist volume %s", dest)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to sync .env to persist volume: %s", e)
|
||||
|
||||
|
||||
def _sync_lock_to_persist_volume() -> None:
|
||||
"""Copy /app/.install_locked to nexus-state volume (survives container recreate)."""
|
||||
if not INSTALL_LOCK.is_file():
|
||||
return
|
||||
dest = _persist_lock_path()
|
||||
try:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
import shutil
|
||||
|
||||
shutil.copy2(INSTALL_LOCK, dest)
|
||||
dest.chmod(0o600)
|
||||
logger.info("Synced install lock to persist volume %s", dest)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to sync install lock to persist volume: %s", e)
|
||||
|
||||
|
||||
def _write_config_json(req: InitDbRequest, api_key: str, site_url: str) -> None:
|
||||
@@ -586,8 +656,118 @@ def _detect_bt_panel() -> bool:
|
||||
return Path("/www/server/panel/class/common.py").exists()
|
||||
|
||||
|
||||
def _host_deploy_root(install_dir: str) -> str:
|
||||
"""宿主机仓库路径(容器内 /app 与宿主机 /opt/nexus 不同)。"""
|
||||
host = os.environ.get("NEXUS_HOST_ROOT", "").strip()
|
||||
if host:
|
||||
return host
|
||||
if install_dir != "/app" and Path(install_dir).is_dir():
|
||||
return install_dir
|
||||
return "/opt/nexus"
|
||||
|
||||
|
||||
def _append_ops_cron_results(results: list[str], install_dir: str) -> None:
|
||||
"""Crontab 条目与 deploy/install_ops_cron.sh 保持一致。"""
|
||||
health_marker = "nexus-health-monitor"
|
||||
backup_marker = "nexus-mysql-backup"
|
||||
health_line = (
|
||||
f"* * * * * NEXUS_DEPLOY_DIR={install_dir} "
|
||||
f"{install_dir}/deploy/health_monitor.sh "
|
||||
f">> /var/log/nexus_health.log 2>&1 # {health_marker}"
|
||||
)
|
||||
backup_line = (
|
||||
f"0 3 * * * NEXUS_ROOT={install_dir} "
|
||||
f"{install_dir}/deploy/db_backup.sh "
|
||||
f">> /var/log/nexus_backup.log 2>&1 # {backup_marker}"
|
||||
)
|
||||
try:
|
||||
current = subprocess.run(
|
||||
["crontab", "-l"], capture_output=True, text=True, timeout=5
|
||||
).stdout or ""
|
||||
added: list[str] = []
|
||||
if health_marker not in current and "health_monitor.sh" not in current:
|
||||
added.append(health_line)
|
||||
if backup_marker not in current and "db_backup.sh" not in current:
|
||||
added.append(backup_line)
|
||||
if added:
|
||||
new_cron = current.rstrip() + "\n" + "\n".join(added) + "\n"
|
||||
proc = subprocess.run(
|
||||
["crontab", "-"], input=new_cron, capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
results.append("✓ Crontab 已配置(health_monitor + db_backup)")
|
||||
else:
|
||||
results.append("✗ Crontab 配置失败 — 请手动: bash deploy/install_ops_cron.sh")
|
||||
else:
|
||||
results.append("✓ Crontab 已存在 health_monitor / db_backup 条目")
|
||||
except Exception:
|
||||
results.append(
|
||||
f"⚠ 宿主机 cron 需在安装完成后执行: "
|
||||
f"sudo bash {install_dir}/deploy/install_ops_cron.sh"
|
||||
)
|
||||
|
||||
|
||||
def _configure_docker_guardian(install_dir: str, api_port: str) -> list[str]:
|
||||
"""1Panel + Docker: Compose restart/healthcheck + in-app self_monitor (no host Supervisor)."""
|
||||
results: list[str] = []
|
||||
host_root = _host_deploy_root(install_dir)
|
||||
|
||||
if Path("/.dockerenv").is_file():
|
||||
results.append("✓ Layer 1: Docker 容器运行中(Compose restart: unless-stopped)")
|
||||
else:
|
||||
results.append("⚠ Layer 1: 未检测到 /.dockerenv — 请确认使用 docker-compose.prod.yml 部署")
|
||||
|
||||
results.append("✓ Layer 2: Python self_monitor 随正常模式启动(每 30s 自检 Redis/MySQL/WebSocket)")
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
with urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{api_port}/health", timeout=3.0
|
||||
) as resp:
|
||||
body = resp.read().decode().strip()
|
||||
if body == "ok":
|
||||
results.append(f"✓ 容器内 /health 正常(127.0.0.1:{api_port})")
|
||||
else:
|
||||
results.append(f"⚠ /health 响应异常: {body[:80]}")
|
||||
except Exception as e:
|
||||
results.append(f"⚠ 容器内 /health 暂不可达: {e}")
|
||||
|
||||
db_host = _onepanel_service_host("DB")
|
||||
redis_host = _onepanel_service_host("REDIS")
|
||||
if db_host and redis_host:
|
||||
results.append(f"✓ 1panel-network 容器名: MySQL={db_host}, Redis={redis_host}")
|
||||
elif db_host or redis_host:
|
||||
results.append(
|
||||
f"⚠ 1Panel 主机名不完整(DB={db_host or '未设置'}, Redis={redis_host or '未设置'})"
|
||||
)
|
||||
else:
|
||||
results.append("ℹ 未配置 NEXUS_1PANEL_*_HOST — 宿主机执行 nx update 可自动探测")
|
||||
|
||||
site_url = os.environ.get("NEXUS_API_BASE_URL", "").strip()
|
||||
publish_port = os.environ.get("NEXUS_PUBLISH_PORT", api_port).strip() or api_port
|
||||
if site_url:
|
||||
results.append(
|
||||
f"ℹ Layer 3(宿主机): 1Panel OpenResty 反代 {site_url} → 127.0.0.1:{publish_port}"
|
||||
)
|
||||
else:
|
||||
results.append(
|
||||
f"ℹ Layer 3(宿主机): 配置 OpenResty 反代 → 127.0.0.1:{publish_port}(见 deploy/1panel/openresty-nexus.conf.example)"
|
||||
)
|
||||
|
||||
results.append(
|
||||
f"ℹ Layer 3(宿主机 cron): sudo bash {host_root}/deploy/install_ops_cron.sh"
|
||||
)
|
||||
results.append("ℹ 或: sudo nx cron(health_monitor 每分钟 + MySQL 备份每日 03:00)")
|
||||
results.append(f"ℹ 日常升级: 宿主机 cd {host_root} && nx update")
|
||||
return results
|
||||
|
||||
|
||||
def _configure_guardian(install_dir: str, api_port: str) -> list[str]:
|
||||
"""Best-effort: configure Supervisor, crontab, health_monitor.sh"""
|
||||
"""Best-effort process guardian: Docker/1Panel vs bare-metal Supervisor stack."""
|
||||
if _is_docker_install_mode():
|
||||
return _configure_docker_guardian(install_dir, api_port)
|
||||
|
||||
results: list[str] = []
|
||||
bt_panel = _detect_bt_panel()
|
||||
|
||||
@@ -635,44 +815,27 @@ def _configure_guardian(install_dir: str, api_port: str) -> list[str]:
|
||||
except OSError:
|
||||
results.append("✗ Supervisor 配置写入失败(需 root 权限)")
|
||||
|
||||
# 3. Update health_monitor.sh INSTALL_DIR
|
||||
# 3. health_monitor 默认路径(cron 行内 NEXUS_DEPLOY_DIR 优先,此处仅兼容旧部署)
|
||||
health_sh = Path(install_dir) / "deploy" / "health_monitor.sh"
|
||||
if health_sh.exists():
|
||||
try:
|
||||
content = read_utf8_text(health_sh)
|
||||
import re
|
||||
content = re.sub(
|
||||
r'INSTALL_DIR="[^"]*"',
|
||||
f'INSTALL_DIR="{install_dir}"',
|
||||
r'DEPLOY_DIR="\$\{NEXUS_DEPLOY_DIR:-[^"]*\}"',
|
||||
f'DEPLOY_DIR="${{NEXUS_DEPLOY_DIR:-{install_dir}}}"',
|
||||
content,
|
||||
)
|
||||
write_utf8_lf(health_sh, content)
|
||||
health_sh.chmod(0o755)
|
||||
results.append("✓ health_monitor.sh INSTALL_DIR 已更新")
|
||||
results.append("✓ health_monitor.sh DEPLOY_DIR 默认值已更新")
|
||||
except OSError:
|
||||
results.append("✗ health_monitor.sh 更新失败")
|
||||
else:
|
||||
results.append("⚠ health_monitor.sh 未找到,跳过")
|
||||
|
||||
# 4. Crontab
|
||||
cron_entry = f"* * * * * {install_dir}/deploy/health_monitor.sh"
|
||||
try:
|
||||
current = subprocess.run(
|
||||
["crontab", "-l"], capture_output=True, text=True, timeout=5
|
||||
).stdout or ""
|
||||
if "health_monitor.sh" not in current:
|
||||
new_cron = current.rstrip() + "\n" + cron_entry + "\n"
|
||||
proc = subprocess.run(
|
||||
["crontab", "-"], input=new_cron, capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
results.append("✓ Crontab 已配置(每分钟健康检查)")
|
||||
else:
|
||||
results.append("✗ Crontab 配置失败 — 请手动添加")
|
||||
else:
|
||||
results.append("✓ Crontab 已存在 health_monitor 条目")
|
||||
except Exception:
|
||||
results.append("⚠ Crontab 配置跳过(权限不足或 cron 不可用)")
|
||||
# 4. Crontab(与 install_ops_cron.sh 同格式)
|
||||
_append_ops_cron_results(results, install_dir)
|
||||
|
||||
# 5. Reload Supervisor
|
||||
try:
|
||||
@@ -685,12 +848,80 @@ def _configure_guardian(install_dir: str, api_port: str) -> list[str]:
|
||||
return results
|
||||
|
||||
|
||||
_INSTALL_STATE_PUBLIC_KEYS = frozenset({
|
||||
"step",
|
||||
"system_name",
|
||||
"db_host",
|
||||
"db_port",
|
||||
"db_name",
|
||||
"db_user",
|
||||
"redis_host",
|
||||
"redis_port",
|
||||
"redis_db",
|
||||
"site_url",
|
||||
"api_port",
|
||||
"pool_size",
|
||||
"max_overflow",
|
||||
"install_dir",
|
||||
"docker_mode",
|
||||
"guardian_results",
|
||||
"installed",
|
||||
"locked",
|
||||
"has_token",
|
||||
})
|
||||
|
||||
|
||||
def _public_install_state(state: dict) -> dict:
|
||||
"""Return install state safe for GET /state (no install_token or redis_pass)."""
|
||||
out = {k: state[k] for k in _INSTALL_STATE_PUBLIC_KEYS if k in state}
|
||||
if state.get("install_token"):
|
||||
out["has_token"] = True
|
||||
return out
|
||||
|
||||
|
||||
def _clear_state_redis_pass() -> None:
|
||||
"""Remove redis_pass from install state after connection-check repair wrote .env."""
|
||||
if not STATE_FILE.is_file():
|
||||
return
|
||||
import json
|
||||
|
||||
try:
|
||||
state = json.loads(read_utf8_text(STATE_FILE))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return
|
||||
if not state.get("redis_pass"):
|
||||
return
|
||||
state["redis_pass"] = ""
|
||||
write_utf8_lf(STATE_FILE, json.dumps(state, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def _archive_install_wizard_html() -> None:
|
||||
"""Rename install.html → install.html.bak so the wizard is not served after lock."""
|
||||
if INSTALL_HTML_BAK.is_file():
|
||||
if INSTALL_HTML.is_file():
|
||||
try:
|
||||
INSTALL_HTML.unlink()
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove install.html after archive: %s", e)
|
||||
return
|
||||
if not INSTALL_HTML.is_file():
|
||||
return
|
||||
try:
|
||||
INSTALL_HTML.rename(INSTALL_HTML_BAK)
|
||||
logger.info("Archived install wizard: %s → %s", INSTALL_HTML, INSTALL_HTML_BAK)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to archive install.html: %s", e)
|
||||
|
||||
|
||||
def _finalize_install_lock() -> None:
|
||||
"""Write install lock marker and remove wizard state file."""
|
||||
_sync_env_to_persist_volume()
|
||||
write_utf8_lf(
|
||||
INSTALL_LOCK,
|
||||
f"Nexus installation locked at {datetime.now(timezone.utc).isoformat()}\n",
|
||||
)
|
||||
_sync_lock_to_persist_volume()
|
||||
_archive_install_wizard_html()
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
STATE_FILE.unlink()
|
||||
@@ -879,13 +1110,14 @@ async def connection_check():
|
||||
resolved_display: str | None = None
|
||||
|
||||
if not redis_ok and redis_url:
|
||||
req_from_env = _init_db_request_from_redis_url(redis_url)
|
||||
req_from_env = _redis_repair_request(redis_url)
|
||||
if req_from_env:
|
||||
ok, msg, resolved = _resolve_redis_url(_sanitize_redis_request(req_from_env))
|
||||
if ok and resolved:
|
||||
if resolved != redis_url and not _is_locked():
|
||||
_patch_env_redis_url(resolved)
|
||||
redis_repaired = True
|
||||
_clear_state_redis_pass()
|
||||
redis_ok, redis_msg = True, msg
|
||||
resolved_display = _mask_redis_url(resolved)
|
||||
|
||||
@@ -909,6 +1141,7 @@ async def init_db(req: InitDbRequest):
|
||||
|
||||
db_url = _make_db_url(req)
|
||||
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
|
||||
db_port = int(req.db_port)
|
||||
|
||||
checks, cred_ok, redis_url = await _credential_checks(req)
|
||||
if not cred_ok:
|
||||
@@ -1046,7 +1279,13 @@ async def init_db(req: InitDbRequest):
|
||||
"install_dir": install_dir,
|
||||
"site_url": site_url,
|
||||
"api_port": req.api_port,
|
||||
"redis_host": req.redis_host,
|
||||
"redis_port": req.redis_port,
|
||||
"redis_db": req.redis_db,
|
||||
# Step 4 connection-check repair only; removed when install locks
|
||||
"redis_pass": req.redis_pass,
|
||||
"guardian_results": guardian_results,
|
||||
"docker_mode": _is_docker_install_mode(),
|
||||
"step": 4,
|
||||
}
|
||||
write_utf8_lf(STATE_FILE, json.dumps(state, ensure_ascii=False) + "\n")
|
||||
@@ -1059,6 +1298,7 @@ async def init_db(req: InitDbRequest):
|
||||
"tables_created": 14,
|
||||
"guardian_results": guardian_results,
|
||||
"bt_panel": _detect_bt_panel(),
|
||||
"docker_mode": _is_docker_install_mode(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1212,6 +1452,6 @@ async def get_install_state():
|
||||
state = json.loads(read_utf8_text(STATE_FILE))
|
||||
state["installed"] = _is_installed()
|
||||
state["locked"] = _is_locked()
|
||||
return state
|
||||
return _public_install_state(state)
|
||||
except Exception:
|
||||
return {"step": 1, "installed": _is_installed(), "locked": _is_locked()}
|
||||
|
||||
@@ -589,11 +589,13 @@ class ScheduleUpdate(BaseModel):
|
||||
|
||||
class PresetCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
username: str = Field("root", min_length=1, max_length=100)
|
||||
encrypted_pw: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class PresetUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
username: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
encrypted_pw: Optional[str] = Field(None, min_length=1, description="留空则不修改密码")
|
||||
|
||||
|
||||
@@ -601,16 +603,34 @@ class PresetUpdate(BaseModel):
|
||||
|
||||
class SshKeyPresetCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
username: str = Field("root", min_length=1, max_length=100)
|
||||
private_key: str = Field(..., min_length=1)
|
||||
public_key: Optional[str] = None
|
||||
|
||||
|
||||
class SshKeyPresetUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
username: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
private_key: Optional[str] = Field(None, min_length=1, description="留空则不修改私钥")
|
||||
public_key: Optional[str] = None
|
||||
|
||||
|
||||
class AddByIpRequest(BaseModel):
|
||||
domain: str = Field(..., min_length=1, max_length=255, description="IP 或域名")
|
||||
port: int = Field(22, ge=1, le=65535)
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
agent_port: int = Field(8601, ge=1, le=65535)
|
||||
target_path: Optional[str] = Field("", description="推送目标路径,可后续编辑")
|
||||
category: Optional[str] = None
|
||||
platform_id: Optional[int] = None
|
||||
node_id: Optional[int] = None
|
||||
|
||||
@field_validator("target_path", mode="before")
|
||||
@classmethod
|
||||
def _normalize_target_path_add(cls, v: object) -> object:
|
||||
return coerce_optional_remote_abs_path(v if v is None else str(v))
|
||||
|
||||
|
||||
# ── Platform / Node ──
|
||||
|
||||
class PlatformCreate(BaseModel):
|
||||
|
||||
+300
-1
@@ -18,10 +18,11 @@ from server.api.auth_jwt import get_current_admin
|
||||
from server.api.schemas import (
|
||||
ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest,
|
||||
ServerImportResult, BatchAgentAction, BatchAgentResult, BatchAgentResultItem,
|
||||
AddByIpRequest,
|
||||
)
|
||||
from server.application.services.server_service import ServerService
|
||||
from server.application.services.sync_service import SyncService
|
||||
from server.domain.models import Server, Admin, AuditLog
|
||||
from server.domain.models import Server, Admin, AuditLog, PendingServer
|
||||
from server.config import settings
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
@@ -910,6 +911,296 @@ async def batch_uninstall_agent(
|
||||
return BatchAgentResult(results=results)
|
||||
|
||||
|
||||
# ── Credential polling: add-by-ip + pending failure queue ──
|
||||
|
||||
|
||||
def _pending_to_dict(row: PendingServer) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"domain": row.domain,
|
||||
"port": row.port,
|
||||
"agent_port": row.agent_port,
|
||||
"target_path": row.target_path or "",
|
||||
"category": row.category,
|
||||
"platform_id": row.platform_id,
|
||||
"node_id": row.node_id,
|
||||
"attempts": row.attempts or 0,
|
||||
"last_error": row.last_error or "",
|
||||
"last_attempt_at": str(row.last_attempt_at) if row.last_attempt_at else None,
|
||||
"created_at": str(row.created_at) if row.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def _unique_server_name(db: AsyncSession, base: str) -> str:
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
candidate = base.strip()[:100] or "server"
|
||||
result = await db.execute(sa_select(Server.name).where(Server.name == candidate))
|
||||
if not result.scalar_one_or_none():
|
||||
return candidate
|
||||
for i in range(2, 1000):
|
||||
name = f"{candidate[:90]}-{i}"
|
||||
result = await db.execute(sa_select(Server.name).where(Server.name == name))
|
||||
if not result.scalar_one_or_none():
|
||||
return name
|
||||
raise HTTPException(500, "无法生成唯一服务器名称")
|
||||
|
||||
|
||||
async def _server_exists_for_domain(db: AsyncSession, domain: str) -> bool:
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
result = await db.execute(sa_select(Server.id).where(Server.domain == domain).limit(1))
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _create_server_from_poll(
|
||||
db: AsyncSession,
|
||||
service: ServerService,
|
||||
*,
|
||||
poll_match,
|
||||
name: str,
|
||||
domain: str,
|
||||
port: int,
|
||||
agent_port: int,
|
||||
target_path: str | None,
|
||||
category: str | None,
|
||||
platform_id: int | None,
|
||||
node_id: int | None,
|
||||
admin: Admin,
|
||||
request: Request,
|
||||
) -> Server:
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
|
||||
unique_name = await _unique_server_name(db, name)
|
||||
data: dict = {
|
||||
"name": unique_name,
|
||||
"domain": domain,
|
||||
"port": port,
|
||||
"username": poll_match.username,
|
||||
"auth_method": poll_match.auth_method,
|
||||
"agent_port": agent_port,
|
||||
"target_path": target_path or "",
|
||||
"category": category,
|
||||
"platform_id": platform_id,
|
||||
"node_id": node_id,
|
||||
"connectivity": "ok",
|
||||
"last_checked_at": datetime.now(timezone.utc),
|
||||
"agent_api_key": f"nxs-{secrets.token_urlsafe(32)}",
|
||||
}
|
||||
if poll_match.auth_method == "password":
|
||||
data["preset_id"] = poll_match.preset_id
|
||||
data["password"] = encrypt_value(poll_match.password)
|
||||
else:
|
||||
data["ssh_key_preset_id"] = poll_match.ssh_key_preset_id
|
||||
data["ssh_key_private"] = encrypt_value(poll_match.ssh_key_private)
|
||||
data["ssh_key_public"] = poll_match.ssh_key_public or ""
|
||||
data["ssh_key_configured"] = True
|
||||
|
||||
server = Server(**data)
|
||||
created = await service.create_server(server)
|
||||
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="create_server",
|
||||
target_type="server",
|
||||
target_id=created.id,
|
||||
detail=(
|
||||
f"凭据轮询添加 名称={created.name} 地址={created.domain}:{created.port} "
|
||||
f"预设={poll_match.preset_name} 用户={poll_match.username}"
|
||||
),
|
||||
ip_address=ip_address,
|
||||
))
|
||||
return created
|
||||
|
||||
|
||||
@router.get("/pending", response_model=dict)
|
||||
async def list_pending_servers(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List servers that failed SSH credential polling."""
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
|
||||
repo = PendingServerRepositoryImpl(db)
|
||||
rows = await repo.get_all()
|
||||
items = [_pending_to_dict(r) for r in rows]
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.post("/add-by-ip", response_model=dict)
|
||||
async def add_server_by_ip(
|
||||
request: Request,
|
||||
payload: AddByIpRequest,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add server by IP only — poll all credential presets until SSH login succeeds."""
|
||||
from server.application.services.credential_poller import (
|
||||
format_poll_errors,
|
||||
poll_credentials,
|
||||
)
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
|
||||
domain = payload.domain.strip()
|
||||
if await _server_exists_for_domain(db, domain):
|
||||
raise HTTPException(409, f"服务器 {domain} 已存在于列表中")
|
||||
|
||||
display_name = (payload.name or domain).strip()
|
||||
poll = await poll_credentials(db, domain, payload.port)
|
||||
|
||||
if poll.success and poll.match:
|
||||
created = await _create_server_from_poll(
|
||||
db,
|
||||
service,
|
||||
poll_match=poll.match,
|
||||
name=display_name,
|
||||
domain=domain,
|
||||
port=payload.port,
|
||||
agent_port=payload.agent_port,
|
||||
target_path=payload.target_path,
|
||||
category=payload.category,
|
||||
platform_id=payload.platform_id,
|
||||
node_id=payload.node_id,
|
||||
admin=admin,
|
||||
request=request,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"server": _server_to_dict(created),
|
||||
"matched_preset": poll.match.preset_name,
|
||||
"matched_username": poll.match.username,
|
||||
}
|
||||
|
||||
err_text = format_poll_errors(poll.errors)
|
||||
pending_repo = PendingServerRepositoryImpl(db)
|
||||
pending = await pending_repo.record_failure(
|
||||
name=display_name,
|
||||
domain=domain,
|
||||
port=payload.port,
|
||||
agent_port=payload.agent_port,
|
||||
target_path=payload.target_path,
|
||||
category=payload.category,
|
||||
platform_id=payload.platform_id,
|
||||
node_id=payload.node_id,
|
||||
last_error=err_text,
|
||||
)
|
||||
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="add_server_by_ip_failed",
|
||||
target_type="pending_server",
|
||||
target_id=pending.id,
|
||||
detail=f"凭据轮询失败 地址={domain}:{payload.port}",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"pending_id": pending.id,
|
||||
"errors": [{"preset_type": e.preset_type, "preset_name": e.preset_name, "username": e.username, "error": e.error} for e in poll.errors],
|
||||
"message": err_text,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pending/{pending_id}/retry", response_model=dict)
|
||||
async def retry_pending_server(
|
||||
pending_id: int,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Re-poll all credential presets for a pending server."""
|
||||
from server.application.services.credential_poller import (
|
||||
format_poll_errors,
|
||||
poll_credentials,
|
||||
)
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
|
||||
pending_repo = PendingServerRepositoryImpl(db)
|
||||
pending = await pending_repo.get_by_id(pending_id)
|
||||
if not pending:
|
||||
raise HTTPException(404, "待连接服务器不存在")
|
||||
|
||||
if await _server_exists_for_domain(db, pending.domain):
|
||||
await pending_repo.delete(pending_id)
|
||||
raise HTTPException(409, f"服务器 {pending.domain} 已存在于列表中,已从失败列表移除")
|
||||
|
||||
poll = await poll_credentials(db, pending.domain, pending.port or 22)
|
||||
|
||||
if poll.success and poll.match:
|
||||
created = await _create_server_from_poll(
|
||||
db,
|
||||
service,
|
||||
poll_match=poll.match,
|
||||
name=pending.name,
|
||||
domain=pending.domain,
|
||||
port=pending.port or 22,
|
||||
agent_port=pending.agent_port or 8601,
|
||||
target_path=pending.target_path,
|
||||
category=pending.category,
|
||||
platform_id=pending.platform_id,
|
||||
node_id=pending.node_id,
|
||||
admin=admin,
|
||||
request=request,
|
||||
)
|
||||
await pending_repo.delete(pending_id)
|
||||
return {
|
||||
"success": True,
|
||||
"server": _server_to_dict(created),
|
||||
"matched_preset": poll.match.preset_name,
|
||||
"matched_username": poll.match.username,
|
||||
}
|
||||
|
||||
err_text = format_poll_errors(poll.errors)
|
||||
pending.attempts = (pending.attempts or 0) + 1
|
||||
pending.last_error = err_text
|
||||
from datetime import datetime, timezone
|
||||
|
||||
pending.last_attempt_at = datetime.now(timezone.utc)
|
||||
await pending_repo.update(pending)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"pending_id": pending.id,
|
||||
"errors": [{"preset_type": e.preset_type, "preset_name": e.preset_name, "username": e.username, "error": e.error} for e in poll.errors],
|
||||
"message": err_text,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/pending/{pending_id}", status_code=204)
|
||||
async def delete_pending_server(
|
||||
pending_id: int,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
|
||||
repo = PendingServerRepositoryImpl(db)
|
||||
if not await repo.delete(pending_id):
|
||||
raise HTTPException(404, "待连接服务器不存在")
|
||||
|
||||
ip_address = request.client.host if request.client else ""
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="delete_pending_server",
|
||||
target_type="pending_server",
|
||||
target_id=pending_id,
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=dict)
|
||||
async def get_server(
|
||||
id: int,
|
||||
@@ -1080,6 +1371,8 @@ async def create_server(
|
||||
preset = await preset_repo.get_by_id(data["preset_id"])
|
||||
if preset:
|
||||
data["password"] = decrypt_value(preset.encrypted_pw)
|
||||
if preset.username and (not data.get("username") or data.get("username") == "root"):
|
||||
data["username"] = preset.username
|
||||
# If preset not found, fall through — user may provide password manually
|
||||
|
||||
# If ssh_key_preset_id is provided, resolve the preset private key (server-side, no re-auth needed)
|
||||
@@ -1090,6 +1383,8 @@ async def create_server(
|
||||
data["ssh_key_private"] = decrypt_value(ssh_key_preset.encrypted_private_key)
|
||||
if ssh_key_preset.public_key and not data.get("ssh_key_public"):
|
||||
data["ssh_key_public"] = ssh_key_preset.public_key
|
||||
if ssh_key_preset.username and (not data.get("username") or data.get("username") == "root"):
|
||||
data["username"] = ssh_key_preset.username
|
||||
# If preset not found, fall through — user may provide key manually
|
||||
|
||||
# Encrypt sensitive fields before storing
|
||||
@@ -1148,6 +1443,8 @@ async def update_server(
|
||||
preset = await preset_repo.get_by_id(update_data["preset_id"])
|
||||
if preset:
|
||||
update_data["password"] = decrypt_value(preset.encrypted_pw)
|
||||
if preset.username:
|
||||
update_data["username"] = preset.username
|
||||
|
||||
# If ssh_key_preset_id is provided, resolve the preset private key
|
||||
if "ssh_key_preset_id" in update_data and update_data["ssh_key_preset_id"] is not None and not update_data.get("ssh_key_private"):
|
||||
@@ -1157,6 +1454,8 @@ async def update_server(
|
||||
update_data["ssh_key_private"] = decrypt_value(ssh_key_preset.encrypted_private_key)
|
||||
if ssh_key_preset.public_key:
|
||||
update_data["ssh_key_public"] = ssh_key_preset.public_key
|
||||
if ssh_key_preset.username:
|
||||
update_data["username"] = ssh_key_preset.username
|
||||
|
||||
# Explicit allowlist to prevent mass-assignment to sensitive columns
|
||||
# agent_api_key is excluded — must be set via dedicated /agent-key endpoint
|
||||
|
||||
+29
-4
@@ -491,7 +491,10 @@ async def list_presets(admin: Admin = Depends(get_current_admin), db: AsyncSessi
|
||||
"""List all password presets"""
|
||||
repo = PasswordPresetRepositoryImpl(db)
|
||||
presets = await repo.get_all()
|
||||
return [{"id": p.id, "name": p.name, "created_at": str(p.created_at)} for p in presets]
|
||||
return [
|
||||
{"id": p.id, "name": p.name, "username": p.username or "root", "created_at": str(p.created_at)}
|
||||
for p in presets
|
||||
]
|
||||
|
||||
|
||||
@preset_router.post("/", response_model=dict, status_code=201)
|
||||
@@ -505,7 +508,11 @@ async def create_preset(
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
repo = PasswordPresetRepositoryImpl(db)
|
||||
encrypted = encrypt_value(payload.encrypted_pw)
|
||||
preset = PasswordPreset(name=payload.name, encrypted_pw=encrypted)
|
||||
preset = PasswordPreset(
|
||||
name=payload.name,
|
||||
username=(payload.username or "root").strip() or "root",
|
||||
encrypted_pw=encrypted,
|
||||
)
|
||||
created = await repo.create(preset)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
@@ -541,6 +548,8 @@ async def update_preset(
|
||||
new_pw = updates.pop("encrypted_pw", None)
|
||||
if updates.get("name"):
|
||||
preset.name = updates["name"]
|
||||
if updates.get("username"):
|
||||
preset.username = updates["username"].strip() or "root"
|
||||
if new_pw:
|
||||
preset.encrypted_pw = encrypt_value(new_pw)
|
||||
|
||||
@@ -625,7 +634,16 @@ async def list_ssh_key_presets(admin: Admin = Depends(get_current_admin), db: As
|
||||
"""List all SSH key presets"""
|
||||
repo = SshKeyPresetRepositoryImpl(db)
|
||||
presets = await repo.get_all()
|
||||
return [{"id": p.id, "name": p.name, "public_key": p.public_key or "", "created_at": str(p.created_at)} for p in presets]
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"username": p.username or "root",
|
||||
"public_key": p.public_key or "",
|
||||
"created_at": str(p.created_at),
|
||||
}
|
||||
for p in presets
|
||||
]
|
||||
|
||||
|
||||
@ssh_key_preset_router.post("/", response_model=dict, status_code=201)
|
||||
@@ -639,7 +657,12 @@ async def create_ssh_key_preset(
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
repo = SshKeyPresetRepositoryImpl(db)
|
||||
encrypted_private = encrypt_value(payload.private_key)
|
||||
preset = SshKeyPreset(name=payload.name, encrypted_private_key=encrypted_private, public_key=payload.public_key)
|
||||
preset = SshKeyPreset(
|
||||
name=payload.name,
|
||||
username=(payload.username or "root").strip() or "root",
|
||||
encrypted_private_key=encrypted_private,
|
||||
public_key=payload.public_key,
|
||||
)
|
||||
created = await repo.create(preset)
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
@@ -675,6 +698,8 @@ async def update_ssh_key_preset(
|
||||
new_private_key = updates.pop("private_key", None)
|
||||
if updates.get("name"):
|
||||
preset.name = updates["name"]
|
||||
if updates.get("username"):
|
||||
preset.username = updates["username"].strip() or "root"
|
||||
if new_private_key:
|
||||
preset.encrypted_private_key = encrypt_value(new_private_key)
|
||||
if "public_key" in updates:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Poll password/SSH-key presets against a host until one SSH login succeeds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
|
||||
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
|
||||
from server.infrastructure.ssh.asyncssh_pool import try_ssh_login
|
||||
|
||||
logger = logging.getLogger("nexus.credential_poller")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CredentialAttemptError:
|
||||
preset_type: str
|
||||
preset_name: str
|
||||
username: str
|
||||
error: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollMatch:
|
||||
auth_method: str
|
||||
username: str
|
||||
preset_id: int | None = None
|
||||
ssh_key_preset_id: int | None = None
|
||||
password: str | None = None
|
||||
ssh_key_private: str | None = None
|
||||
ssh_key_public: str | None = None
|
||||
preset_name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollResult:
|
||||
success: bool
|
||||
match: PollMatch | None = None
|
||||
errors: list[CredentialAttemptError] = field(default_factory=list)
|
||||
|
||||
|
||||
def format_poll_errors(errors: list[CredentialAttemptError]) -> str:
|
||||
if not errors:
|
||||
return "无可用凭据预设或全部登录失败"
|
||||
lines = [f"[{e.preset_type}] {e.preset_name} ({e.username}): {e.error}" for e in errors]
|
||||
return "\n".join(lines)[:4000]
|
||||
|
||||
|
||||
async def poll_credentials(
|
||||
db: AsyncSession,
|
||||
host: str,
|
||||
port: int = 22,
|
||||
*,
|
||||
timeout: float = 8.0,
|
||||
) -> PollResult:
|
||||
"""Try each password preset then each SSH key preset; first success wins."""
|
||||
pw_repo = PasswordPresetRepositoryImpl(db)
|
||||
key_repo = SshKeyPresetRepositoryImpl(db)
|
||||
password_presets = await pw_repo.get_all()
|
||||
ssh_key_presets = await key_repo.get_all()
|
||||
|
||||
errors: list[CredentialAttemptError] = []
|
||||
|
||||
if not password_presets and not ssh_key_presets:
|
||||
return PollResult(success=False, errors=[
|
||||
CredentialAttemptError("system", "—", "—", "请先在凭据管理中添加密码或 SSH 密钥预设"),
|
||||
])
|
||||
|
||||
for preset in password_presets:
|
||||
username = (preset.username or "root").strip() or "root"
|
||||
try:
|
||||
password = decrypt_value(preset.encrypted_pw)
|
||||
except ValueError as e:
|
||||
err = CredentialAttemptError("password", preset.name, username, f"解密失败: {e}")
|
||||
errors.append(err)
|
||||
logger.warning("Password preset %s decrypt failed: %s", preset.id, e)
|
||||
continue
|
||||
|
||||
ok, msg = await try_ssh_login(
|
||||
host, port, username, password=password, timeout=timeout,
|
||||
)
|
||||
if ok:
|
||||
return PollResult(
|
||||
success=True,
|
||||
match=PollMatch(
|
||||
auth_method="password",
|
||||
username=username,
|
||||
preset_id=preset.id,
|
||||
password=password,
|
||||
preset_name=preset.name,
|
||||
),
|
||||
)
|
||||
errors.append(CredentialAttemptError("password", preset.name, username, msg or "认证失败"))
|
||||
|
||||
for preset in ssh_key_presets:
|
||||
username = (preset.username or "root").strip() or "root"
|
||||
try:
|
||||
private_key = decrypt_value(preset.encrypted_private_key)
|
||||
except ValueError as e:
|
||||
err = CredentialAttemptError("ssh_key", preset.name, username, f"解密失败: {e}")
|
||||
errors.append(err)
|
||||
logger.warning("SSH key preset %s decrypt failed: %s", preset.id, e)
|
||||
continue
|
||||
|
||||
ok, msg = await try_ssh_login(
|
||||
host, port, username, private_key=private_key, timeout=timeout,
|
||||
)
|
||||
if ok:
|
||||
return PollResult(
|
||||
success=True,
|
||||
match=PollMatch(
|
||||
auth_method="key",
|
||||
username=username,
|
||||
ssh_key_preset_id=preset.id,
|
||||
ssh_key_private=private_key,
|
||||
ssh_key_public=preset.public_key or "",
|
||||
preset_name=preset.name,
|
||||
),
|
||||
)
|
||||
errors.append(CredentialAttemptError("ssh_key", preset.name, username, msg or "认证失败"))
|
||||
|
||||
return PollResult(success=False, errors=errors)
|
||||
+3
-3
@@ -39,10 +39,10 @@ class Settings(BaseSettings):
|
||||
|
||||
# Database (immutable — must be set in .env)
|
||||
DATABASE_URL: str = "mysql+aiomysql://root:password@127.0.0.1:3306/nexus"
|
||||
# Pool params: install wizard auto-calculates from MySQL max_connections
|
||||
# Pool params: install wizard auto-calculates from MySQL max_connections (authoritative).
|
||||
# Formula: pool_size = max(20, maxConn * 0.4), overflow = max(20, maxConn * 0.3)
|
||||
# MySQL max_connections=400 → pool=160, overflow=120, max=280
|
||||
# These defaults match install wizard; override via .env or MySQL settings table
|
||||
# Defaults below are pre-install placeholders only; production uses wizard .env or
|
||||
# docker/profiles/*.env via Compose — not these class defaults after install.
|
||||
DB_POOL_SIZE: Optional[int] = 160
|
||||
DB_MAX_OVERFLOW: Optional[int] = 120
|
||||
|
||||
|
||||
@@ -197,6 +197,7 @@ class PasswordPreset(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="预设名称")
|
||||
username = Column(String(100), default="root", comment="SSH用户名")
|
||||
encrypted_pw = Column(String(500), nullable=False, comment="加密后的密码")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
@@ -207,11 +208,35 @@ class SshKeyPreset(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="预设名称")
|
||||
username = Column(String(100), default="root", comment="SSH用户名")
|
||||
encrypted_private_key = Column(Text, nullable=False, comment="加密后的私钥内容(Fernet)")
|
||||
public_key = Column(Text, nullable=True, comment="公钥内容(不加密)")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
|
||||
class PendingServer(Base):
|
||||
"""Servers awaiting successful SSH credential polling before promotion to servers table."""
|
||||
__tablename__ = "pending_servers"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="显示名称")
|
||||
domain = Column(String(255), nullable=False, comment="IP或域名")
|
||||
port = Column(Integer, default=22, comment="SSH端口")
|
||||
agent_port = Column(Integer, default=8601, comment="Agent端口")
|
||||
target_path = Column(String(500), nullable=True, comment="推送目标路径")
|
||||
category = Column(String(100), nullable=True, comment="分类")
|
||||
platform_id = Column(Integer, nullable=True, comment="平台ID")
|
||||
node_id = Column(Integer, nullable=True, comment="节点ID")
|
||||
attempts = Column(Integer, default=0, comment="轮询尝试次数")
|
||||
last_error = Column(Text, nullable=True, comment="最后一次失败摘要")
|
||||
last_attempt_at = Column(DateTime, nullable=True, comment="最后尝试时间")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_pending_servers_domain", "domain"),
|
||||
)
|
||||
|
||||
|
||||
class PushSchedule(Base):
|
||||
"""Scheduled task — cron-triggered file push OR script execution."""
|
||||
__tablename__ = "push_schedules"
|
||||
|
||||
@@ -157,6 +157,25 @@ async def run_schema_migrations():
|
||||
# Legacy single-token refresh columns (replaced by Redis refresh_tokens:*)
|
||||
"ALTER TABLE admins DROP COLUMN jwt_refresh_token",
|
||||
"ALTER TABLE admins DROP COLUMN jwt_token_expires",
|
||||
# Credential polling — preset username + pending servers queue
|
||||
"ALTER TABLE password_presets ADD COLUMN username VARCHAR(100) DEFAULT 'root' COMMENT 'SSH用户名'",
|
||||
"ALTER TABLE ssh_key_presets ADD COLUMN username VARCHAR(100) DEFAULT 'root' COMMENT 'SSH用户名'",
|
||||
"""CREATE TABLE IF NOT EXISTS `pending_servers` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(100) NOT NULL COMMENT '显示名称',
|
||||
`domain` VARCHAR(255) NOT NULL COMMENT 'IP或域名',
|
||||
`port` INT DEFAULT 22 COMMENT 'SSH端口',
|
||||
`agent_port` INT DEFAULT 8601 COMMENT 'Agent端口',
|
||||
`target_path` VARCHAR(500) NULL COMMENT '推送目标路径',
|
||||
`category` VARCHAR(100) NULL COMMENT '分类',
|
||||
`platform_id` INT NULL COMMENT '平台ID',
|
||||
`node_id` INT NULL COMMENT '节点ID',
|
||||
`attempts` INT DEFAULT 0 COMMENT '轮询尝试次数',
|
||||
`last_error` TEXT NULL COMMENT '最后一次失败摘要',
|
||||
`last_attempt_at` DATETIME NULL COMMENT '最后尝试时间',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX `idx_pending_servers_domain` (`domain`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
]
|
||||
async with AsyncSessionLocal() as session:
|
||||
for sql in migrations:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Nexus — PendingServer Repository (SSH poll failures queue)"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import PendingServer
|
||||
|
||||
|
||||
class PendingServerRepositoryImpl:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_by_id(self, id: int) -> Optional[PendingServer]:
|
||||
result = await self.session.execute(select(PendingServer).where(PendingServer.id == id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all(self) -> List[PendingServer]:
|
||||
result = await self.session.execute(
|
||||
select(PendingServer).order_by(PendingServer.last_attempt_at.desc(), PendingServer.id.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, pending: PendingServer) -> PendingServer:
|
||||
self.session.add(pending)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(pending)
|
||||
return pending
|
||||
|
||||
async def update(self, pending: PendingServer) -> PendingServer:
|
||||
await self.session.commit()
|
||||
await self.session.refresh(pending)
|
||||
return pending
|
||||
|
||||
async def delete(self, id: int) -> bool:
|
||||
row = await self.get_by_id(id)
|
||||
if not row:
|
||||
return False
|
||||
await self.session.delete(row)
|
||||
await self.session.commit()
|
||||
return True
|
||||
|
||||
async def record_failure(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
domain: str,
|
||||
port: int,
|
||||
agent_port: int,
|
||||
target_path: str | None,
|
||||
category: str | None,
|
||||
platform_id: int | None,
|
||||
node_id: int | None,
|
||||
last_error: str,
|
||||
existing_id: int | None = None,
|
||||
) -> PendingServer:
|
||||
now = datetime.now(timezone.utc)
|
||||
if existing_id:
|
||||
row = await self.get_by_id(existing_id)
|
||||
if row:
|
||||
row.attempts = (row.attempts or 0) + 1
|
||||
row.last_error = last_error[:4000]
|
||||
row.last_attempt_at = now
|
||||
return await self.update(row)
|
||||
|
||||
pending = PendingServer(
|
||||
name=name,
|
||||
domain=domain,
|
||||
port=port,
|
||||
agent_port=agent_port,
|
||||
target_path=target_path,
|
||||
category=category,
|
||||
platform_id=platform_id,
|
||||
node_id=node_id,
|
||||
attempts=1,
|
||||
last_error=last_error[:4000],
|
||||
last_attempt_at=now,
|
||||
)
|
||||
return await self.create(pending)
|
||||
@@ -25,6 +25,51 @@ from server.infrastructure.database.crypto import decrypt_value
|
||||
logger = logging.getLogger("nexus.asyncssh_pool")
|
||||
|
||||
|
||||
async def try_ssh_login(
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
*,
|
||||
password: str | None = None,
|
||||
private_key: str | None = None,
|
||||
timeout: float = 8.0,
|
||||
) -> tuple[bool, str]:
|
||||
"""One-shot SSH login probe (not pooled). Returns (ok, error_message)."""
|
||||
from server.config import settings
|
||||
|
||||
if not password and not private_key:
|
||||
return False, "未提供密码或私钥"
|
||||
|
||||
connect_kwargs: dict = {
|
||||
"host": host,
|
||||
"port": port or 22,
|
||||
"username": username or "root",
|
||||
"connect_timeout": timeout,
|
||||
}
|
||||
if settings.SSH_STRICT_HOST_CHECKING.lower() in ("false", "0", "no"):
|
||||
connect_kwargs["known_hosts"] = None
|
||||
|
||||
if password:
|
||||
connect_kwargs["password"] = password
|
||||
elif private_key:
|
||||
connect_kwargs["client_keys"] = [asyncssh.import_private_key(private_key)]
|
||||
|
||||
conn: asyncssh.SSHClientConnection | None = None
|
||||
try:
|
||||
conn = await asyncssh.connect(**connect_kwargs)
|
||||
return True, ""
|
||||
except asyncssh.Error as e:
|
||||
return False, str(e)[:200]
|
||||
except Exception as e:
|
||||
return False, str(e)[:200]
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — best-effort cleanup
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PooledConnection:
|
||||
"""A tracked asyncssh connection with reference counting"""
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Tests for SSH credential polling and add-by-ip pending queue."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from server.application.services.credential_poller import (
|
||||
format_poll_errors,
|
||||
poll_credentials,
|
||||
CredentialAttemptError,
|
||||
)
|
||||
from server.domain.models import Base, PasswordPreset, PendingServer, SshKeyPreset
|
||||
from server.infrastructure.database.crypto import encrypt_value
|
||||
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
|
||||
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
|
||||
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession)
|
||||
session = session_factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_credentials_first_password_success(db_session):
|
||||
pw_repo = PasswordPresetRepositoryImpl(db_session)
|
||||
await pw_repo.create(PasswordPreset(
|
||||
name="prod-root",
|
||||
username="root",
|
||||
encrypted_pw=encrypt_value("secret1"),
|
||||
))
|
||||
await pw_repo.create(PasswordPreset(
|
||||
name="prod-admin",
|
||||
username="admin",
|
||||
encrypted_pw=encrypt_value("secret2"),
|
||||
))
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_try(host, port, username, *, password=None, private_key=None, timeout=8.0):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if username == "root" and password == "secret1":
|
||||
return True, ""
|
||||
return False, "auth failed"
|
||||
|
||||
with patch("server.application.services.credential_poller.try_ssh_login", side_effect=fake_try):
|
||||
result = await poll_credentials(db_session, "10.0.0.1", 22)
|
||||
|
||||
assert result.success is True
|
||||
assert result.match is not None
|
||||
assert result.match.auth_method == "password"
|
||||
assert result.match.username == "root"
|
||||
assert result.match.preset_name == "prod-root"
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_credentials_tries_ssh_key_after_passwords_fail(db_session):
|
||||
pw_repo = PasswordPresetRepositoryImpl(db_session)
|
||||
await pw_repo.create(PasswordPreset(
|
||||
name="bad",
|
||||
username="root",
|
||||
encrypted_pw=encrypt_value("wrong"),
|
||||
))
|
||||
key_repo = SshKeyPresetRepositoryImpl(db_session)
|
||||
await key_repo.create(SshKeyPreset(
|
||||
name="key1",
|
||||
username="deploy",
|
||||
encrypted_private_key=encrypt_value("-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----"),
|
||||
public_key="ssh-rsa AAAA",
|
||||
))
|
||||
|
||||
async def fake_try(host, port, username, *, password=None, private_key=None, timeout=8.0):
|
||||
if private_key and username == "deploy":
|
||||
return True, ""
|
||||
return False, "auth failed"
|
||||
|
||||
with patch("server.application.services.credential_poller.try_ssh_login", side_effect=fake_try):
|
||||
result = await poll_credentials(db_session, "10.0.0.2", 22)
|
||||
|
||||
assert result.success is True
|
||||
assert result.match is not None
|
||||
assert result.match.auth_method == "key"
|
||||
assert result.match.ssh_key_preset_id is not None
|
||||
assert result.match.username == "deploy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_credentials_all_fail(db_session):
|
||||
pw_repo = PasswordPresetRepositoryImpl(db_session)
|
||||
await pw_repo.create(PasswordPreset(
|
||||
name="p1",
|
||||
username="root",
|
||||
encrypted_pw=encrypt_value("x"),
|
||||
))
|
||||
|
||||
with patch(
|
||||
"server.application.services.credential_poller.try_ssh_login",
|
||||
new=AsyncMock(return_value=(False, "Permission denied")),
|
||||
):
|
||||
result = await poll_credentials(db_session, "10.0.0.3", 22)
|
||||
|
||||
assert result.success is False
|
||||
assert result.match is None
|
||||
assert len(result.errors) == 1
|
||||
assert "p1" in format_poll_errors(result.errors)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_credentials_no_presets(db_session):
|
||||
result = await poll_credentials(db_session, "10.0.0.4", 22)
|
||||
assert result.success is False
|
||||
assert any("凭据" in e.error for e in result.errors)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_server_repo_record_failure(db_session):
|
||||
repo = PendingServerRepositoryImpl(db_session)
|
||||
row = await repo.record_failure(
|
||||
name="srv",
|
||||
domain="192.168.1.5",
|
||||
port=22,
|
||||
agent_port=8601,
|
||||
target_path="",
|
||||
category=None,
|
||||
platform_id=None,
|
||||
node_id=None,
|
||||
last_error="all failed",
|
||||
)
|
||||
assert row.id
|
||||
assert row.attempts == 1
|
||||
|
||||
updated = await repo.record_failure(
|
||||
name="srv",
|
||||
domain="192.168.1.5",
|
||||
port=22,
|
||||
agent_port=8601,
|
||||
target_path="",
|
||||
category=None,
|
||||
platform_id=None,
|
||||
node_id=None,
|
||||
last_error="still failed",
|
||||
existing_id=row.id,
|
||||
)
|
||||
assert updated.attempts == 2
|
||||
assert "still failed" in (updated.last_error or "")
|
||||
|
||||
|
||||
def test_format_poll_errors_truncates():
|
||||
errors = [
|
||||
CredentialAttemptError("password", "a", "root", "e1"),
|
||||
CredentialAttemptError("ssh_key", "b", "admin", "e2"),
|
||||
]
|
||||
text = format_poll_errors(errors)
|
||||
assert "a" in text and "b" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_by_ip_endpoint_success(monkeypatch):
|
||||
"""API layer: mocked poll → server created response shape."""
|
||||
from server.api import servers as servers_api
|
||||
from server.domain.models import Server
|
||||
|
||||
mock_server = Server(
|
||||
id=1,
|
||||
name="10.1.1.1",
|
||||
domain="10.1.1.1",
|
||||
port=22,
|
||||
username="root",
|
||||
auth_method="password",
|
||||
connectivity="ok",
|
||||
)
|
||||
|
||||
async def fake_poll(*_a, **_k):
|
||||
return servers_api.__dict__.get("_PollResult") # won't work
|
||||
|
||||
match = type("M", (), {
|
||||
"auth_method": "password",
|
||||
"username": "root",
|
||||
"preset_id": 1,
|
||||
"ssh_key_preset_id": None,
|
||||
"password": "pass",
|
||||
"ssh_key_private": None,
|
||||
"ssh_key_public": "",
|
||||
"preset_name": "ok-pw",
|
||||
})()
|
||||
poll_result = type("R", (), {"success": True, "match": match, "errors": []})()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"server.application.services.credential_poller.poll_credentials",
|
||||
AsyncMock(return_value=poll_result),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
servers_api,
|
||||
"_server_exists_for_domain",
|
||||
AsyncMock(return_value=False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
servers_api,
|
||||
"_create_server_from_poll",
|
||||
AsyncMock(return_value=mock_server),
|
||||
)
|
||||
monkeypatch.setattr(servers_api, "_server_to_dict", lambda s: {"id": s.id, "domain": s.domain})
|
||||
|
||||
db = MagicMock()
|
||||
admin = MagicMock(username="admin")
|
||||
request = MagicMock()
|
||||
request.client.host = "127.0.0.1"
|
||||
service = MagicMock()
|
||||
|
||||
from server.api.schemas import AddByIpRequest
|
||||
|
||||
out = await servers_api.add_server_by_ip(
|
||||
request,
|
||||
AddByIpRequest(domain="10.1.1.1", port=22),
|
||||
admin,
|
||||
service,
|
||||
db,
|
||||
)
|
||||
assert out["success"] is True
|
||||
assert out["server"]["domain"] == "10.1.1.1"
|
||||
@@ -274,6 +274,48 @@ def test_redis_url_candidates_never_tries_root_in_docker_mode(monkeypatch):
|
||||
assert not any("root:" in u for u in urls)
|
||||
|
||||
|
||||
def test_configure_guardian_docker_mode_skips_supervisor(monkeypatch):
|
||||
from server.api import install as install_api
|
||||
from server.api.install import _configure_docker_guardian, _configure_guardian
|
||||
|
||||
monkeypatch.setenv("NEXUS_DOCKER_WRITE_ENV", "1")
|
||||
monkeypatch.setenv("NEXUS_API_BASE_URL", "https://api.example.com")
|
||||
monkeypatch.setattr(
|
||||
install_api.Path,
|
||||
"is_file",
|
||||
lambda self: str(self) == "/.dockerenv",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("NEXUS_HOST_ROOT", "/opt/nexus")
|
||||
docker_results = _configure_docker_guardian("/app", "8600")
|
||||
docker_text = "\n".join(docker_results)
|
||||
assert "Supervisor" not in docker_text
|
||||
assert "self_monitor" in docker_text
|
||||
assert "OpenResty" in docker_text or "反代" in docker_text
|
||||
assert "install_ops_cron" in docker_text or "nx cron" in docker_text
|
||||
assert "/opt/nexus" in docker_text
|
||||
|
||||
bare_results = _configure_guardian("/opt/nexus", "8600")
|
||||
assert bare_results == docker_results
|
||||
|
||||
|
||||
def test_redis_repair_request_uses_install_state_password(tmp_path, monkeypatch):
|
||||
from server.api import install as install_api
|
||||
from server.api.install import _redis_repair_request
|
||||
|
||||
state_file = tmp_path / ".install_state.json"
|
||||
state_file.write_text(
|
||||
'{"redis_pass":"secret-from-state","step":4}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(install_api, "STATE_FILE", state_file)
|
||||
|
||||
req = _redis_repair_request("redis://1Panel-redis-x:6379/0")
|
||||
assert req is not None
|
||||
assert req.redis_pass == "secret-from-state"
|
||||
assert req.redis_host == "1panel-redis-x"
|
||||
|
||||
|
||||
def test_init_db_request_from_redis_url_legacy_and_colon_pass():
|
||||
from server.api.install import _init_db_request_from_redis_url
|
||||
|
||||
@@ -364,6 +406,39 @@ def test_script_aggregate_empty_server_ids_no_pending():
|
||||
assert _aggregate_execution_status([], {}) == "failed"
|
||||
|
||||
|
||||
def test_archive_install_wizard_html(monkeypatch, tmp_path):
|
||||
from server.api import install as install_api
|
||||
|
||||
app_dir = tmp_path / "web" / "app"
|
||||
app_dir.mkdir(parents=True)
|
||||
html = app_dir / "install.html"
|
||||
html.write_text("<html>wizard</html>", encoding="utf-8")
|
||||
monkeypatch.setattr(install_api, "INSTALL_HTML", html)
|
||||
monkeypatch.setattr(install_api, "INSTALL_HTML_BAK", app_dir / "install.html.bak")
|
||||
|
||||
install_api._archive_install_wizard_html()
|
||||
|
||||
assert not html.is_file()
|
||||
assert (app_dir / "install.html.bak").read_text(encoding="utf-8") == "<html>wizard</html>"
|
||||
|
||||
|
||||
def test_sync_lock_to_persist_volume(monkeypatch, tmp_path):
|
||||
from server.api import install as install_api
|
||||
|
||||
persist_dir = tmp_path / "state"
|
||||
persist_dir.mkdir()
|
||||
lock_file = tmp_path / ".install_locked"
|
||||
lock_file.write_text("locked at test\n", encoding="utf-8")
|
||||
monkeypatch.setattr(install_api, "INSTALL_LOCK", lock_file)
|
||||
monkeypatch.setenv("NEXUS_PERSIST_DIR", str(persist_dir))
|
||||
|
||||
install_api._sync_lock_to_persist_volume()
|
||||
|
||||
dest = persist_dir / ".install_locked"
|
||||
assert dest.is_file()
|
||||
assert dest.read_text(encoding="utf-8") == "locked at test\n"
|
||||
|
||||
|
||||
def test_install_reject_when_locked(monkeypatch, tmp_path):
|
||||
from fastapi import HTTPException
|
||||
from server.api import install as install_api
|
||||
@@ -397,6 +472,99 @@ def test_install_verify_token_rejects_missing(monkeypatch, tmp_path):
|
||||
install_api._verify_install_token("abc123")
|
||||
|
||||
|
||||
def test_public_install_state_redacts_secrets():
|
||||
from server.api.install import _public_install_state
|
||||
|
||||
state = {
|
||||
"step": 4,
|
||||
"db_host": "1Panel-mysql-x",
|
||||
"install_token": "secret-token-abc",
|
||||
"redis_pass": "redis-secret",
|
||||
"db_port": "3306",
|
||||
}
|
||||
public = _public_install_state(state)
|
||||
assert "install_token" not in public
|
||||
assert "redis_pass" not in public
|
||||
assert public.get("has_token") is True
|
||||
assert public["db_host"] == "1Panel-mysql-x"
|
||||
|
||||
|
||||
def test_public_install_state_no_has_token_when_missing():
|
||||
from server.api.install import _public_install_state
|
||||
|
||||
public = _public_install_state({"step": 2, "db_host": "h"})
|
||||
assert "has_token" not in public
|
||||
|
||||
|
||||
def test_clear_state_redis_pass(tmp_path, monkeypatch):
|
||||
from server.api import install as install_api
|
||||
from server.api.install import _clear_state_redis_pass
|
||||
|
||||
state_file = tmp_path / ".install_state.json"
|
||||
state_file.write_text(
|
||||
'{"redis_pass":"secret","step":4,"install_token":"tok"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(install_api, "STATE_FILE", state_file)
|
||||
_clear_state_redis_pass()
|
||||
import json
|
||||
|
||||
data = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert data.get("redis_pass") == ""
|
||||
assert data.get("install_token") == "tok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_check_clears_redis_pass_after_repair(monkeypatch, tmp_path):
|
||||
from server.api import install as install_api
|
||||
from server.api.install import connection_check
|
||||
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
'NEXUS_DATABASE_URL="mysql+aiomysql://n:pass@h:3306/n"\n'
|
||||
'NEXUS_REDIS_URL="redis://root:secret@1Panel-redis-x:6379/0"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
state_file = tmp_path / ".install_state.json"
|
||||
state_file.write_text(
|
||||
'{"redis_pass":"secret","step":4,"install_token":"tok"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(install_api, "ENV_FILE", env_file)
|
||||
monkeypatch.setattr(install_api, "STATE_FILE", state_file)
|
||||
monkeypatch.setattr(install_api, "INSTALL_LOCK", tmp_path / ".install_locked")
|
||||
|
||||
async def _mysql_ok(_url):
|
||||
return True, "✓ MySQL"
|
||||
|
||||
def fake_redis(url: str) -> tuple[bool, str]:
|
||||
if url.startswith("redis://:secret@"):
|
||||
return True, "✓ Redis"
|
||||
return False, "auth fail"
|
||||
|
||||
monkeypatch.setattr(install_api, "_test_mysql_url", _mysql_ok)
|
||||
monkeypatch.setattr(install_api, "_test_redis_url", fake_redis)
|
||||
monkeypatch.setenv("NEXUS_DOCKER_WRITE_ENV", "1")
|
||||
|
||||
out = await connection_check()
|
||||
assert out["all_pass"] is True
|
||||
import json
|
||||
|
||||
data = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert data.get("redis_pass") == ""
|
||||
|
||||
|
||||
def test_init_db_mysql_error_uses_db_port_not_nameerror(monkeypatch, tmp_path):
|
||||
"""Regression: init-db except branch must use int db_port, not undefined name."""
|
||||
import inspect
|
||||
|
||||
from server.api import install as install_api
|
||||
|
||||
source = inspect.getsource(install_api.init_db)
|
||||
assert "db_port = int(req.db_port)" in source
|
||||
assert "_mysql_connect_error_detail(req.db_host, db_port)" in source
|
||||
|
||||
|
||||
def test_resolve_nexus_push_source_path_rejects_system_prefix(tmp_path, monkeypatch):
|
||||
from server.utils.posix_paths import PosixPathError, resolve_nexus_push_source_path
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,A as t,Ar as n,Br as r,Cr as i,F as a,N as o,Rt as s,X as c,Xn as l,_r as u,ar as d,er as f,hr as p,ir as m,k as h,nr as g,or as _,pr as v,w as y}from"./index-o0-2k-vI.js";import{o as b,t as x}from"./VContainer-BbTGUkA2.js";import{t as S}from"./VChip-B_7LyIUT.js";import{t as C}from"./VSelect-RidH-jin.js";import{t as w}from"./VDataTableServer-CYEpP3ht.js";import{n as T,t as E}from"./VRow-D9pCZkmz.js";import{t as D}from"./VSpacer-DE-o2Lgp.js";var O={class:`font-weight-medium`},k={class:`text-body-2`},A={class:`text-caption text-medium-emphasis`},j=_({__name:`AlertsPage`,setup(_){let j=b(),M=n(!1),N=n([]),P=n(0),F=n(1),I=n(null),L=n(null),R=n([{label:`今日告警`,value:`0`,color:`blue`,icon:`mdi-alert`},{label:`活跃告警`,value:`0`,color:`orange`,icon:`mdi-bell-ring`},{label:`已恢复`,value:`0`,color:`green`,icon:`mdi-check-circle`},{label:`Top 服务器`,value:`—`,color:`red`,icon:`mdi-server`}]),z=[{label:`CPU`,value:`cpu`},{label:`内存`,value:`memory`},{label:`磁盘`,value:`disk`},{label:`连接`,value:`connection`}],B=[{label:`活跃`,value:`active`},{label:`已恢复`,value:`recovered`}],V=[{title:`类型`,key:`alert_type`,width:100},{title:`服务器`,key:`server_name`},{title:`详情`,key:`value`},{title:`状态`,key:`is_recovery`,width:100},{title:`时间`,key:`created_at`,width:160}];async function H(){try{let e=await s.get(`/alert-history/stats`);R.value[0].value=String(e.today||0),R.value[1].value=String(e.active||0),R.value[2].value=String(e.recovered||0),R.value[3].value=e.top_server||`—`}catch{j(`加载统计失败`,`error`)}}async function U(){M.value=!0;try{let e=await s.get(`/alert-history/`,{page:F.value,per_page:20,type:I.value||void 0,status:L.value||void 0});N.value=e.items||[],P.value=e.total||0}catch{N.value=[]}finally{M.value=!1}}function W(e){return e===`cpu`||e===`CPU`?`error`:e===`memory`||e===`内存`?`warning`:e===`disk`||e===`磁盘`?`info`:`primary`}function G(e){return e===`cpu`||e===`CPU`?`mdi-chip`:e===`memory`||e===`内存`?`mdi-memory`:e===`disk`||e===`磁盘`?`mdi-harddisk`:`mdi-lan-disconnect`}return v(()=>{H(),U()}),(n,s)=>(p(),f(x,{fluid:``,class:`pa-6`},{default:i(()=>[d(E,{class:`mb-4`},{default:i(()=>[(p(!0),g(l,null,u(R.value,e=>(p(),f(T,{cols:`12`,sm:`6`,lg:`3`,key:e.label},{default:i(()=>[d(y,{elevation:`0`,lines:`two`,rounded:`lg`,border:``},{default:i(()=>[d(h,null,{append:i(()=>[d(c,{color:e.color,size:`30`},{default:i(()=>[m(r(e.icon),1)]),_:2},1032,[`color`])]),default:i(()=>[d(t,{class:`text-body-small`},{default:i(()=>[m(r(e.label),1)]),_:2},1024),d(t,null,{default:i(()=>[m(r(e.value),1)]),_:2},1024)]),_:2},1024)]),_:2},1024)]),_:2},1024))),128))]),_:1}),d(o,{elevation:`0`,border:``,rounded:`lg`},{default:i(()=>[d(a,{class:`d-flex align-center`},{default:i(()=>[s[5]||=m(` 告警历史 `,-1),d(D),d(C,{modelValue:I.value,"onUpdate:modelValue":[s[0]||=e=>I.value=e,s[1]||=e=>{F.value=1,U()}],items:z,"item-title":`label`,"item-value":`value`,label:`类型`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`140px`},clearable:``},null,8,[`modelValue`]),d(C,{modelValue:L.value,"onUpdate:modelValue":[s[2]||=e=>L.value=e,s[3]||=e=>{F.value=1,U()}],items:B,"item-title":`label`,"item-value":`value`,label:`状态`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`140px`},class:`ml-3`,clearable:``},null,8,[`modelValue`])]),_:1}),d(w,{items:N.value,headers:V,"items-length":P.value,loading:M.value,page:F.value,"items-per-page":20,hover:``,density:`comfortable`,"onUpdate:page":s[4]||=e=>{F.value=e,U()}},{"item.alert_type":i(({item:e})=>[d(S,{color:W(e.alert_type),size:`small`,variant:`tonal`,label:``},{prepend:i(()=>[d(c,{size:`12`},{default:i(()=>[m(r(G(e.alert_type)),1)]),_:2},1024)]),default:i(()=>[m(` `+r(e.alert_type),1)]),_:2},1032,[`color`])]),"item.server_name":i(({item:t})=>[e(`span`,O,r(t.server_name),1)]),"item.value":i(({item:t})=>[e(`span`,k,r(t.value),1)]),"item.is_recovery":i(({item:e})=>[e.is_recovery?(p(),f(S,{key:0,color:`success`,size:`small`,variant:`tonal`,label:``},{default:i(()=>[...s[6]||=[m(`已恢复`,-1)]]),_:1})):(p(),f(S,{key:1,color:`error`,size:`small`,variant:`tonal`,label:``},{default:i(()=>[...s[7]||=[m(`告警中`,-1)]]),_:1}))]),"item.created_at":i(({item:t})=>[e(`span`,A,r(t.created_at),1)]),"no-data":i(()=>[...s[8]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])]),_:1})]),_:1}))}});export{j as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,A as t,Ar as n,Br as r,Cr as i,F as a,N as o,Rt as s,X as c,Xn as l,_r as u,ar as d,er as f,hr as p,ir as m,k as h,nr as g,or as _,pr as v,w as y}from"./index-C_dP75wD.js";import{o as b,t as x}from"./VContainer-DGQjsCTw.js";import{t as S}from"./VChip-oig-b64O.js";import{t as C}from"./VSelect-V_uWG8_q.js";import{t as w}from"./VDataTableServer-BMEL-4WM.js";import{n as T,t as E}from"./VRow-MZyk7N4E.js";import{t as D}from"./VSpacer-Cl6Zcy_X.js";var O={class:`font-weight-medium`},k={class:`text-body-2`},A={class:`text-caption text-medium-emphasis`},j=_({__name:`AlertsPage`,setup(_){let j=b(),M=n(!1),N=n([]),P=n(0),F=n(1),I=n(null),L=n(null),R=n([{label:`今日告警`,value:`0`,color:`blue`,icon:`mdi-alert`},{label:`活跃告警`,value:`0`,color:`orange`,icon:`mdi-bell-ring`},{label:`已恢复`,value:`0`,color:`green`,icon:`mdi-check-circle`},{label:`Top 服务器`,value:`—`,color:`red`,icon:`mdi-server`}]),z=[{label:`CPU`,value:`cpu`},{label:`内存`,value:`memory`},{label:`磁盘`,value:`disk`},{label:`连接`,value:`connection`}],B=[{label:`活跃`,value:`active`},{label:`已恢复`,value:`recovered`}],V=[{title:`类型`,key:`alert_type`,width:100},{title:`服务器`,key:`server_name`},{title:`详情`,key:`value`},{title:`状态`,key:`is_recovery`,width:100},{title:`时间`,key:`created_at`,width:160}];async function H(){try{let e=await s.get(`/alert-history/stats`);R.value[0].value=String(e.today||0),R.value[1].value=String(e.active||0),R.value[2].value=String(e.recovered||0),R.value[3].value=e.top_server||`—`}catch{j(`加载统计失败`,`error`)}}async function U(){M.value=!0;try{let e=await s.get(`/alert-history/`,{page:F.value,per_page:20,type:I.value||void 0,status:L.value||void 0});N.value=e.items||[],P.value=e.total||0}catch{N.value=[]}finally{M.value=!1}}function W(e){return e===`cpu`||e===`CPU`?`error`:e===`memory`||e===`内存`?`warning`:e===`disk`||e===`磁盘`?`info`:`primary`}function G(e){return e===`cpu`||e===`CPU`?`mdi-chip`:e===`memory`||e===`内存`?`mdi-memory`:e===`disk`||e===`磁盘`?`mdi-harddisk`:`mdi-lan-disconnect`}return v(()=>{H(),U()}),(n,s)=>(p(),f(x,{fluid:``,class:`pa-6`},{default:i(()=>[d(E,{class:`mb-4`},{default:i(()=>[(p(!0),g(l,null,u(R.value,e=>(p(),f(T,{cols:`12`,sm:`6`,lg:`3`,key:e.label},{default:i(()=>[d(y,{elevation:`0`,lines:`two`,rounded:`lg`,border:``},{default:i(()=>[d(h,null,{append:i(()=>[d(c,{color:e.color,size:`30`},{default:i(()=>[m(r(e.icon),1)]),_:2},1032,[`color`])]),default:i(()=>[d(t,{class:`text-body-small`},{default:i(()=>[m(r(e.label),1)]),_:2},1024),d(t,null,{default:i(()=>[m(r(e.value),1)]),_:2},1024)]),_:2},1024)]),_:2},1024)]),_:2},1024))),128))]),_:1}),d(o,{elevation:`0`,border:``,rounded:`lg`},{default:i(()=>[d(a,{class:`d-flex align-center`},{default:i(()=>[s[5]||=m(` 告警历史 `,-1),d(D),d(C,{modelValue:I.value,"onUpdate:modelValue":[s[0]||=e=>I.value=e,s[1]||=e=>{F.value=1,U()}],items:z,"item-title":`label`,"item-value":`value`,label:`类型`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`140px`},clearable:``},null,8,[`modelValue`]),d(C,{modelValue:L.value,"onUpdate:modelValue":[s[2]||=e=>L.value=e,s[3]||=e=>{F.value=1,U()}],items:B,"item-title":`label`,"item-value":`value`,label:`状态`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`140px`},class:`ml-3`,clearable:``},null,8,[`modelValue`])]),_:1}),d(w,{items:N.value,headers:V,"items-length":P.value,loading:M.value,page:F.value,"items-per-page":20,hover:``,density:`comfortable`,"onUpdate:page":s[4]||=e=>{F.value=e,U()}},{"item.alert_type":i(({item:e})=>[d(S,{color:W(e.alert_type),size:`small`,variant:`tonal`,label:``},{prepend:i(()=>[d(c,{size:`12`},{default:i(()=>[m(r(G(e.alert_type)),1)]),_:2},1024)]),default:i(()=>[m(` `+r(e.alert_type),1)]),_:2},1032,[`color`])]),"item.server_name":i(({item:t})=>[e(`span`,O,r(t.server_name),1)]),"item.value":i(({item:t})=>[e(`span`,k,r(t.value),1)]),"item.is_recovery":i(({item:e})=>[e.is_recovery?(p(),f(S,{key:0,color:`success`,size:`small`,variant:`tonal`,label:``},{default:i(()=>[...s[6]||=[m(`已恢复`,-1)]]),_:1})):(p(),f(S,{key:1,color:`error`,size:`small`,variant:`tonal`,label:``},{default:i(()=>[...s[7]||=[m(`告警中`,-1)]]),_:1}))]),"item.created_at":i(({item:t})=>[e(`span`,A,r(t.created_at),1)]),"no-data":i(()=>[...s[8]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])]),_:1})]),_:1}))}});export{j as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,F as i,Ir as a,N as o,Rt as s,ar as c,er as l,hr as u,ir as d,or as f,pr as p,t as m}from"./index-C_dP75wD.js";import{o as h,t as g}from"./VContainer-DGQjsCTw.js";import{t as _}from"./VChip-oig-b64O.js";import{t as v}from"./VSelect-V_uWG8_q.js";import{t as y}from"./VDataTableServer-BMEL-4WM.js";import{t as b}from"./VSpacer-Cl6Zcy_X.js";import{a as x,n as S,r as C,t as w}from"./auditNormalize-BHJPJFP9.js";var T={class:`font-weight-medium`},E={class:`text-body-2`},D={class:`text-body-2 text-medium-emphasis text-truncate`,style:{"max-width":`300px`,display:`inline-block`}},O={class:`text-caption text-medium-emphasis`},k={class:`text-caption text-medium-emphasis`},A=f({__name:`AuditPage`,setup(f){let A=h(),j=t(!1),M=t([]),N=t(0),P=t(1),F=t(null),I=t(``),L=t(``),R=S,z=[{title:`操作`,key:`action`,width:100},{title:`用户`,key:`admin_username`,width:100},{title:`目标`,key:`target`,width:140},{title:`详情`,key:`detail`},{title:`IP`,key:`ip_address`,width:120},{title:`时间`,key:`created_at`,width:160}];async function B(){j.value=!0;try{let e=(P.value-1)*50,t=await s.get(`/audit/`,{limit:50,offset:e,action:F.value||void 0,admin_username:I.value||void 0,date_from:L.value||void 0});M.value=w(t.items||[]),N.value=t.total||0}catch{M.value=[],A(`加载审计日志失败`,`error`)}finally{j.value=!1}}function V(e){return e.startsWith(`create`)||e===`login`?`success`:e.startsWith(`update`)?`info`:e.startsWith(`delete`)||e===`logout`?`error`:e.includes(`execute`)||e.includes(`push`)||e.includes(`retry`)?`warning`:`primary`}return p(()=>B()),(t,s)=>(u(),l(g,{fluid:``,class:`pa-6`},{default:r(()=>[c(o,{elevation:`0`,border:``,rounded:`lg`},{default:r(()=>[c(i,{class:`d-flex align-center`},{default:r(()=>[s[7]||=d(` 审计日志 `,-1),c(b),c(v,{modelValue:F.value,"onUpdate:modelValue":[s[0]||=e=>F.value=e,s[1]||=e=>{P.value=1,B()}],items:a(R),"item-title":`label`,"item-value":`value`,label:`操作类型`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`200px`},clearable:``},null,8,[`modelValue`,`items`]),c(m,{modelValue:I.value,"onUpdate:modelValue":[s[2]||=e=>I.value=e,s[3]||=e=>{P.value=1,B()}],"prepend-inner-icon":`mdi-account`,label:`用户`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`140px`},class:`ml-3`,clearable:``},null,8,[`modelValue`]),c(m,{modelValue:L.value,"onUpdate:modelValue":[s[4]||=e=>L.value=e,s[5]||=e=>{P.value=1,B()}],"prepend-inner-icon":`mdi-calendar`,label:`日期`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`160px`},class:`ml-3`,type:`date`,clearable:``},null,8,[`modelValue`])]),_:1}),c(y,{items:M.value,headers:z,"items-length":N.value,loading:j.value,page:P.value,"items-per-page":50,hover:``,density:`compact`,"onUpdate:page":s[6]||=e=>{P.value=e,B()}},{"item.action":r(({item:e})=>[c(_,{color:V(e.action),size:`small`,variant:`tonal`,label:``},{default:r(()=>[d(n(a(C)(e.action)),1)]),_:2},1032,[`color`])]),"item.admin_username":r(({item:t})=>[e(`span`,T,n(t.admin_username),1)]),"item.target":r(({item:t})=>[e(`span`,E,n(a(x)(t.resource_type))+n(t.resource_id?` #${t.resource_id}`:``),1)]),"item.detail":r(({item:t})=>[e(`span`,D,n(t.detail||`—`),1)]),"item.ip_address":r(({item:t})=>[e(`span`,O,n(t.ip_address||`—`),1)]),"item.created_at":r(({item:t})=>[e(`span`,k,n(t.created_at),1)]),"no-data":r(()=>[...s[8]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])]),_:1})]),_:1}))}});export{A as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,F as i,Ir as a,N as o,Rt as s,ar as c,er as l,hr as u,ir as d,or as f,pr as p,t as m}from"./index-o0-2k-vI.js";import{o as h,t as g}from"./VContainer-BbTGUkA2.js";import{t as _}from"./VChip-B_7LyIUT.js";import{t as v}from"./VSelect-RidH-jin.js";import{t as y}from"./VDataTableServer-CYEpP3ht.js";import{t as b}from"./VSpacer-DE-o2Lgp.js";import{a as x,n as S,r as C,t as w}from"./auditNormalize-BHJPJFP9.js";var T={class:`font-weight-medium`},E={class:`text-body-2`},D={class:`text-body-2 text-medium-emphasis text-truncate`,style:{"max-width":`300px`,display:`inline-block`}},O={class:`text-caption text-medium-emphasis`},k={class:`text-caption text-medium-emphasis`},A=f({__name:`AuditPage`,setup(f){let A=h(),j=t(!1),M=t([]),N=t(0),P=t(1),F=t(null),I=t(``),L=t(``),R=S,z=[{title:`操作`,key:`action`,width:100},{title:`用户`,key:`admin_username`,width:100},{title:`目标`,key:`target`,width:140},{title:`详情`,key:`detail`},{title:`IP`,key:`ip_address`,width:120},{title:`时间`,key:`created_at`,width:160}];async function B(){j.value=!0;try{let e=(P.value-1)*50,t=await s.get(`/audit/`,{limit:50,offset:e,action:F.value||void 0,admin_username:I.value||void 0,date_from:L.value||void 0});M.value=w(t.items||[]),N.value=t.total||0}catch{M.value=[],A(`加载审计日志失败`,`error`)}finally{j.value=!1}}function V(e){return e.startsWith(`create`)||e===`login`?`success`:e.startsWith(`update`)?`info`:e.startsWith(`delete`)||e===`logout`?`error`:e.includes(`execute`)||e.includes(`push`)||e.includes(`retry`)?`warning`:`primary`}return p(()=>B()),(t,s)=>(u(),l(g,{fluid:``,class:`pa-6`},{default:r(()=>[c(o,{elevation:`0`,border:``,rounded:`lg`},{default:r(()=>[c(i,{class:`d-flex align-center`},{default:r(()=>[s[7]||=d(` 审计日志 `,-1),c(b),c(v,{modelValue:F.value,"onUpdate:modelValue":[s[0]||=e=>F.value=e,s[1]||=e=>{P.value=1,B()}],items:a(R),"item-title":`label`,"item-value":`value`,label:`操作类型`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`200px`},clearable:``},null,8,[`modelValue`,`items`]),c(m,{modelValue:I.value,"onUpdate:modelValue":[s[2]||=e=>I.value=e,s[3]||=e=>{P.value=1,B()}],"prepend-inner-icon":`mdi-account`,label:`用户`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`140px`},class:`ml-3`,clearable:``},null,8,[`modelValue`]),c(m,{modelValue:L.value,"onUpdate:modelValue":[s[4]||=e=>L.value=e,s[5]||=e=>{P.value=1,B()}],"prepend-inner-icon":`mdi-calendar`,label:`日期`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`160px`},class:`ml-3`,type:`date`,clearable:``},null,8,[`modelValue`])]),_:1}),c(y,{items:M.value,headers:z,"items-length":N.value,loading:j.value,page:P.value,"items-per-page":50,hover:``,density:`compact`,"onUpdate:page":s[6]||=e=>{P.value=e,B()}},{"item.action":r(({item:e})=>[c(_,{color:V(e.action),size:`small`,variant:`tonal`,label:``},{default:r(()=>[d(n(a(C)(e.action)),1)]),_:2},1032,[`color`])]),"item.admin_username":r(({item:t})=>[e(`span`,T,n(t.admin_username),1)]),"item.target":r(({item:t})=>[e(`span`,E,n(a(x)(t.resource_type))+n(t.resource_id?` #${t.resource_id}`:``),1)]),"item.detail":r(({item:t})=>[e(`span`,D,n(t.detail||`—`),1)]),"item.ip_address":r(({item:t})=>[e(`span`,O,n(t.ip_address||`—`),1)]),"item.created_at":r(({item:t})=>[e(`span`,k,n(t.created_at),1)]),"no-data":r(()=>[...s[8]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])]),_:1})]),_:1}))}});export{A as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,F as i,Ir as a,M as o,N as s,Rt as c,ar as l,er as u,hr as d,ir as f,or as p,pr as m}from"./index-o0-2k-vI.js";import{o as h,t as g}from"./VContainer-BbTGUkA2.js";import{t as _}from"./VChip-B_7LyIUT.js";import{t as v}from"./VSelect-RidH-jin.js";import{t as y}from"./VDataTableServer-CYEpP3ht.js";import{t as b}from"./VSpacer-DE-o2Lgp.js";import{t as x}from"./apiError-nzQ5DKV3.js";import{t as S}from"./useServerList-CY5z3iKc.js";var C={class:`text-body-2`},w={class:`text-medium-emphasis`},T={class:`text-medium-emphasis`},E={class:`text-caption text-medium-emphasis`},D={class:`font-weight-medium`},O={class:`text-caption text-medium-emphasis`},k=p({__name:`CommandsPage`,setup(p){let k=h(),{servers:A,loadServers:j}=S(),M=t(!1),N=t(`commands`),P=t(1),F=t(0),I=t(30),L=t(null),R=t([]),z=t([]),B=[{title:`命令`,key:`command`},{title:`服务器`,key:`server_name`},{title:`用户`,key:`admin_username`,width:100},{title:`时间`,key:`created_at`,width:160}],V=[{title:`服务器`,key:`server_name`},{title:`状态`,key:`status`,width:80},{title:`来源`,key:`remote_addr`,width:120},{title:`开始时间`,key:`started_at`,width:160}];async function H(){M.value=!0;try{if(N.value===`commands`){let e=await c.getList(`/assets/command-logs`,{server_id:L.value||void 0,page:P.value,per_page:I.value});R.value=e.items,F.value=e.total}else{let e=await c.getList(`/assets/ssh-sessions`,{server_id:L.value||void 0,page:P.value,per_page:I.value});z.value=e.items,F.value=e.total}}catch(e){R.value=[],z.value=[],k(x(e,`加载命令日志失败`),`error`)}finally{M.value=!1}}return m(()=>{j(),H()}),(t,c)=>(d(),u(g,{fluid:``,class:`pa-6`},{default:r(()=>[l(s,{elevation:`0`,border:``,rounded:`lg`},{default:r(()=>[l(i,{class:`d-flex align-center`},{default:r(()=>[c[4]||=f(` 命令日志 `,-1),l(b),l(v,{modelValue:N.value,"onUpdate:modelValue":[c[0]||=e=>N.value=e,H],items:[{title:`命令视图`,value:`commands`},{title:`会话视图`,value:`sessions`}],"item-title":`title`,"item-value":`value`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`160px`}},null,8,[`modelValue`]),l(v,{modelValue:L.value,"onUpdate:modelValue":[c[1]||=e=>L.value=e,H],items:a(A),"item-title":`name`,"item-value":`id`,label:`服务器`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`180px`},class:`ml-3`,clearable:``},null,8,[`modelValue`,`items`])]),_:1}),l(o),N.value===`commands`?(d(),u(y,{key:0,items:R.value,headers:B,"items-length":F.value,loading:M.value,page:P.value,"items-per-page":30,hover:``,density:`compact`,"onUpdate:page":c[2]||=e=>{P.value=e,H()}},{"item.command":r(({item:t})=>[e(`code`,C,n(t.command),1)]),"item.server_name":r(({item:t})=>[e(`span`,w,n(t.server_name||`—`),1)]),"item.admin_username":r(({item:t})=>[e(`span`,T,n(t.admin_username||`—`),1)]),"item.created_at":r(({item:t})=>[e(`span`,E,n(t.created_at),1)]),"no-data":r(()=>[...c[5]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])):(d(),u(y,{key:1,items:z.value,headers:V,"items-length":F.value,loading:M.value,page:P.value,"items-per-page":30,hover:``,density:`compact`,"onUpdate:page":c[3]||=e=>{P.value=e,H()}},{"item.server_name":r(({item:t})=>[e(`span`,D,n(t.server_name||`—`),1)]),"item.status":r(({item:e})=>[l(_,{color:e.status===`closed`?`grey`:`success`,size:`small`,variant:`tonal`,label:``},{default:r(()=>[f(n(e.status),1)]),_:2},1032,[`color`])]),"item.started_at":r(({item:t})=>[e(`span`,O,n(t.started_at),1)]),"no-data":r(()=>[...c[6]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`]))]),_:1})]),_:1}))}});export{k as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,F as i,Ir as a,M as o,N as s,Rt as c,ar as l,er as u,hr as d,ir as f,or as p,pr as m}from"./index-C_dP75wD.js";import{o as h,t as g}from"./VContainer-DGQjsCTw.js";import{t as _}from"./VChip-oig-b64O.js";import{t as v}from"./VSelect-V_uWG8_q.js";import{t as y}from"./VDataTableServer-BMEL-4WM.js";import{t as b}from"./VSpacer-Cl6Zcy_X.js";import{t as x}from"./apiError-BgZlS-MZ.js";import{t as S}from"./useServerList-DS-TV_aM.js";var C={class:`text-body-2`},w={class:`text-medium-emphasis`},T={class:`text-medium-emphasis`},E={class:`text-caption text-medium-emphasis`},D={class:`font-weight-medium`},O={class:`text-caption text-medium-emphasis`},k=p({__name:`CommandsPage`,setup(p){let k=h(),{servers:A,loadServers:j}=S(),M=t(!1),N=t(`commands`),P=t(1),F=t(0),I=t(30),L=t(null),R=t([]),z=t([]),B=[{title:`命令`,key:`command`},{title:`服务器`,key:`server_name`},{title:`用户`,key:`admin_username`,width:100},{title:`时间`,key:`created_at`,width:160}],V=[{title:`服务器`,key:`server_name`},{title:`状态`,key:`status`,width:80},{title:`来源`,key:`remote_addr`,width:120},{title:`开始时间`,key:`started_at`,width:160}];async function H(){M.value=!0;try{if(N.value===`commands`){let e=await c.getList(`/assets/command-logs`,{server_id:L.value||void 0,page:P.value,per_page:I.value});R.value=e.items,F.value=e.total}else{let e=await c.getList(`/assets/ssh-sessions`,{server_id:L.value||void 0,page:P.value,per_page:I.value});z.value=e.items,F.value=e.total}}catch(e){R.value=[],z.value=[],k(x(e,`加载命令日志失败`),`error`)}finally{M.value=!1}}return m(()=>{j(),H()}),(t,c)=>(d(),u(g,{fluid:``,class:`pa-6`},{default:r(()=>[l(s,{elevation:`0`,border:``,rounded:`lg`},{default:r(()=>[l(i,{class:`d-flex align-center`},{default:r(()=>[c[4]||=f(` 命令日志 `,-1),l(b),l(v,{modelValue:N.value,"onUpdate:modelValue":[c[0]||=e=>N.value=e,H],items:[{title:`命令视图`,value:`commands`},{title:`会话视图`,value:`sessions`}],"item-title":`title`,"item-value":`value`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`160px`}},null,8,[`modelValue`]),l(v,{modelValue:L.value,"onUpdate:modelValue":[c[1]||=e=>L.value=e,H],items:a(A),"item-title":`name`,"item-value":`id`,label:`服务器`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`180px`},class:`ml-3`,clearable:``},null,8,[`modelValue`,`items`])]),_:1}),l(o),N.value===`commands`?(d(),u(y,{key:0,items:R.value,headers:B,"items-length":F.value,loading:M.value,page:P.value,"items-per-page":30,hover:``,density:`compact`,"onUpdate:page":c[2]||=e=>{P.value=e,H()}},{"item.command":r(({item:t})=>[e(`code`,C,n(t.command),1)]),"item.server_name":r(({item:t})=>[e(`span`,w,n(t.server_name||`—`),1)]),"item.admin_username":r(({item:t})=>[e(`span`,T,n(t.admin_username||`—`),1)]),"item.created_at":r(({item:t})=>[e(`span`,E,n(t.created_at),1)]),"no-data":r(()=>[...c[5]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])):(d(),u(y,{key:1,items:z.value,headers:V,"items-length":F.value,loading:M.value,page:P.value,"items-per-page":30,hover:``,density:`compact`,"onUpdate:page":c[3]||=e=>{P.value=e,H()}},{"item.server_name":r(({item:t})=>[e(`span`,D,n(t.server_name||`—`),1)]),"item.status":r(({item:e})=>[l(_,{color:e.status===`closed`?`grey`:`success`,size:`small`,variant:`tonal`,label:``},{default:r(()=>[f(n(e.status),1)]),_:2},1032,[`color`])]),"item.started_at":r(({item:t})=>[e(`span`,O,n(t.started_at),1)]),"no-data":r(()=>[...c[6]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`]))]),_:1})]),_:1}))}});export{k as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.dashboard-root[data-v-1a05ebac]{min-width:0;max-width:100%}.dashboard-root[data-v-1a05ebac] .v-table{overflow-x:auto}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,Ft as i,Ht as a,It as o,N as s,Pt as c,Qn as l,R as u,Rr as d,Rt as f,Vt as p,Yn as m,ar as h,c as g,cn as _,dn as v,er as y,fn as b,hr as x,ir as S,l as C,mr as w,nn as T,nr as E,or as D,pr as O,t as k,tr as A,v as j,xr as M,zr as N}from"./index-C_dP75wD.js";import{t as P}from"./VChip-oig-b64O.js";import{t as F}from"./_plugin-vue_export-helper-BDNMzG2s.js";import{t as I}from"./VAlert-CNuOH9h_.js";var L=b({...v(),...C()},`VForm`),R=_()({name:`VForm`,props:L(),emits:{"update:modelValue":e=>!0,submit:e=>!0},setup(n,{slots:r,emit:i}){let a=g(n),o=t();function s(e){e.preventDefault(),a.reset()}function c(e){let t=e,n=a.validate();t.then=n.then.bind(n),t.catch=n.catch.bind(n),t.finally=n.finally.bind(n),i(`submit`,t),t.defaultPrevented||n.then(({valid:e})=>{e&&o.value?.submit()}),t.preventDefault()}return T(()=>e(`form`,{ref:o,class:d([`v-form`,n.class]),style:N(n.style),novalidate:!0,onReset:s,onSubmit:c},[r.default?.({get errors(){return a.errors.value},get isDisabled(){return a.isDisabled.value},get isReadonly(){return a.isReadonly.value},get isValidating(){return a.isValidating.value},get isValid(){return a.isValid.value},get items(){return a.items.value},validate:a.validate,reset:a.reset,resetValidation:a.resetValidation})])),j(a,o)}}),z={class:`login-page`},B={class:`login-center`},V={key:2,class:`text-center mt-4`},H=F(D({__name:`LoginPage`,setup(d){let g=p(),_=a(),v=c(),b=t(``),C=t(``),T=t(``),D=t(!1),j=t(!1),F=t(``),L=t(!1),H=t(0),U=t(null),W=t([]),G=t(0),K=null,q=l(()=>W.value.length===0?``:W.value[G.value]);async function J(){try{let e=await f.get(`/settings/bing-wallpapers`);e?.urls?.length&&(W.value=e.urls,G.value=Math.floor(Math.random()*e.urls.length))}catch{}}function Y(){K&&clearInterval(K),!(W.value.length<=1)&&(K=setInterval(()=>{G.value=(G.value+1)%W.value.length},36e5))}O(async()=>{await J(),Y()}),w(()=>{K&&clearInterval(K),Z&&clearInterval(Z)});let X=t(Date.now()),Z=null,Q=l(()=>{if(!U.value)return 0;let e=new Date(U.value).getTime()-X.value;return Math.max(0,Math.ceil(e/6e4))});M(U,e=>{Z&&=(clearInterval(Z),null),e&&(Z=setInterval(()=>{X.value=Date.now()},3e4))});async function $(){if(!b.value||!C.value){F.value=`请输入用户名和密码`;return}j.value=!0,F.value=``;try{await v.login(b.value,C.value,L.value?T.value:void 0);let e=g.query.redirect||`/`,t=e.startsWith(`/`)&&!e.startsWith(`//`)?e:`/`;_.push(t)}catch(e){e instanceof o?(L.value=!0,F.value=e.message):e instanceof i?e.status===429?(U.value=new Date(Date.now()+900*1e3).toISOString(),F.value=`登录尝试过多,账户已锁定 15 分钟`):(H.value++,F.value=e.message):F.value=`网络错误,请重试`}finally{j.value=!1}}return(t,i)=>(x(),E(`div`,z,[e(`div`,{class:`wallpaper-bg`,style:N({backgroundImage:`url(${q.value})`})},null,4),i[6]||=e(`div`,{class:`wallpaper-overlay`},null,-1),e(`div`,B,[h(s,{elevation:`0`,rounded:`xl`,class:`login-card pa-10`,border:``},{default:r(()=>[F.value?(x(),y(I,{key:0,type:`error`,density:`compact`,class:`mb-4`,closable:``,"onClick:close":i[0]||=e=>F.value=``,rounded:`lg`,variant:`tonal`},{default:r(()=>[S(n(F.value),1)]),_:1})):A(``,!0),U.value?(x(),y(I,{key:1,type:`warning`,density:`compact`,class:`mb-4`,rounded:`lg`,variant:`tonal`},{default:r(()=>[S(` 账户已锁定,请 `+n(Q.value)+` 分钟后重试 `,1)]),_:1})):A(``,!0),h(R,{onSubmit:m($,[`prevent`])},{default:r(()=>[h(k,{modelValue:b.value,"onUpdate:modelValue":i[1]||=e=>b.value=e,label:`用户名`,variant:`outlined`,density:`comfortable`,"prepend-inner-icon":`mdi-account-outline`,disabled:j.value,class:`mb-4`,autocomplete:`username`,rounded:`lg`,"hide-details":`auto`},null,8,[`modelValue`,`disabled`]),h(k,{modelValue:C.value,"onUpdate:modelValue":i[2]||=e=>C.value=e,label:`密码`,variant:`outlined`,density:`comfortable`,"prepend-inner-icon":`mdi-lock-outline`,"append-inner-icon":D.value?`mdi-eye`:`mdi-eye-off`,type:D.value?`text`:`password`,"onClick:appendInner":i[3]||=e=>D.value=!D.value,disabled:j.value,class:`mb-4`,autocomplete:`current-password`,rounded:`lg`,"hide-details":`auto`},null,8,[`modelValue`,`append-inner-icon`,`type`,`disabled`]),L.value?(x(),y(k,{key:0,modelValue:T.value,"onUpdate:modelValue":i[4]||=e=>T.value=e,label:`TOTP 验证码`,variant:`outlined`,density:`comfortable`,"prepend-inner-icon":`mdi-shield-key-outline`,disabled:j.value,class:`mb-4`,autocomplete:`one-time-code`,rounded:`lg`,"hide-details":`auto`},null,8,[`modelValue`,`disabled`])):A(``,!0),h(u,{type:`submit`,color:`primary`,variant:`flat`,block:``,size:`large`,loading:j.value,disabled:!!U.value,rounded:`lg`,class:`mt-2`,height:`48`},{default:r(()=>[...i[5]||=[S(` 登录 `,-1)]]),_:1},8,[`loading`,`disabled`])]),_:1}),H.value>0&&!U.value?(x(),E(`div`,V,[h(P,{size:`small`,variant:`tonal`,color:`warning`,label:``},{default:r(()=>[S(` 已失败 `+n(H.value)+` 次 `,1)]),_:1})])):A(``,!0)]),_:1})])]))}}),[[`__scopeId`,`data-v-c0c6156f`]]);export{H as default};
|
||||
@@ -0,0 +1 @@
|
||||
.login-page[data-v-c0c6156f]{width:100%;min-height:100vh;position:relative;overflow:hidden}.wallpaper-bg[data-v-c0c6156f]{z-index:0;background-position:50%;background-repeat:no-repeat;background-size:cover;transition:background-image 1.5s ease-in-out;position:fixed;inset:0}.wallpaper-overlay[data-v-c0c6156f]{z-index:1;background:linear-gradient(135deg,#00000073 0%,#0003 100%);position:fixed;inset:0}.login-center[data-v-c0c6156f]{z-index:2;justify-content:center;align-items:center;min-height:100vh;padding:24px;display:flex;position:relative}.login-card[data-v-c0c6156f]{-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);width:100%;max-width:420px;background:rgba(var(--v-theme-surface), .92)!important}
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,Ft as i,Ht as a,It as o,N as s,Pt as c,Qn as l,R as u,Rr as d,Rt as f,Vt as p,Yn as m,ar as h,c as g,cn as _,dn as v,er as y,fn as b,hr as x,ir as S,l as C,mr as w,nn as T,nr as E,or as D,pr as O,t as k,tr as A,v as j,xr as M,zr as N}from"./index-o0-2k-vI.js";import{t as P}from"./VChip-B_7LyIUT.js";import{t as F}from"./_plugin-vue_export-helper-BDNMzG2s.js";import{t as I}from"./VAlert-CJEBMMcd.js";var L=b({...v(),...C()},`VForm`),R=_()({name:`VForm`,props:L(),emits:{"update:modelValue":e=>!0,submit:e=>!0},setup(n,{slots:r,emit:i}){let a=g(n),o=t();function s(e){e.preventDefault(),a.reset()}function c(e){let t=e,n=a.validate();t.then=n.then.bind(n),t.catch=n.catch.bind(n),t.finally=n.finally.bind(n),i(`submit`,t),t.defaultPrevented||n.then(({valid:e})=>{e&&o.value?.submit()}),t.preventDefault()}return T(()=>e(`form`,{ref:o,class:d([`v-form`,n.class]),style:N(n.style),novalidate:!0,onReset:s,onSubmit:c},[r.default?.({get errors(){return a.errors.value},get isDisabled(){return a.isDisabled.value},get isReadonly(){return a.isReadonly.value},get isValidating(){return a.isValidating.value},get isValid(){return a.isValid.value},get items(){return a.items.value},validate:a.validate,reset:a.reset,resetValidation:a.resetValidation})])),j(a,o)}}),z={class:`login-page`},B={class:`login-center`},V={key:2,class:`text-center mt-4`},H=F(D({__name:`LoginPage`,setup(d){let g=p(),_=a(),v=c(),b=t(``),C=t(``),T=t(``),D=t(!1),j=t(!1),F=t(``),L=t(!1),H=t(0),U=t(null),W=t([]),G=t(0),K=null,q=l(()=>W.value.length===0?``:W.value[G.value]);async function J(){try{let e=await f.get(`/settings/bing-wallpapers`);e?.urls?.length&&(W.value=e.urls,G.value=Math.floor(Math.random()*e.urls.length))}catch{}}function Y(){K&&clearInterval(K),!(W.value.length<=1)&&(K=setInterval(()=>{G.value=(G.value+1)%W.value.length},36e5))}O(async()=>{await J(),Y()}),w(()=>{K&&clearInterval(K),Z&&clearInterval(Z)});let X=t(Date.now()),Z=null,Q=l(()=>{if(!U.value)return 0;let e=new Date(U.value).getTime()-X.value;return Math.max(0,Math.ceil(e/6e4))});M(U,e=>{Z&&=(clearInterval(Z),null),e&&(Z=setInterval(()=>{X.value=Date.now()},3e4))});async function $(){if(!b.value||!C.value){F.value=`请输入用户名和密码`;return}j.value=!0,F.value=``;try{await v.login(b.value,C.value,L.value?T.value:void 0);let e=g.query.redirect||`/`,t=e.startsWith(`/`)&&!e.startsWith(`//`)?e:`/`;_.push(t)}catch(e){e instanceof o?(L.value=!0,F.value=e.message):e instanceof i?e.status===429?(U.value=new Date(Date.now()+900*1e3).toISOString(),F.value=`登录尝试过多,账户已锁定 15 分钟`):(H.value++,F.value=e.message):F.value=`网络错误,请重试`}finally{j.value=!1}}return(t,i)=>(x(),E(`div`,z,[e(`div`,{class:`wallpaper-bg`,style:N({backgroundImage:`url(${q.value})`})},null,4),i[6]||=e(`div`,{class:`wallpaper-overlay`},null,-1),e(`div`,B,[h(s,{elevation:`0`,rounded:`xl`,class:`login-card pa-10`,border:``},{default:r(()=>[F.value?(x(),y(I,{key:0,type:`error`,density:`compact`,class:`mb-4`,closable:``,"onClick:close":i[0]||=e=>F.value=``,rounded:`lg`,variant:`tonal`},{default:r(()=>[S(n(F.value),1)]),_:1})):A(``,!0),U.value?(x(),y(I,{key:1,type:`warning`,density:`compact`,class:`mb-4`,rounded:`lg`,variant:`tonal`},{default:r(()=>[S(` 账户已锁定,请 `+n(Q.value)+` 分钟后重试 `,1)]),_:1})):A(``,!0),h(R,{onSubmit:m($,[`prevent`])},{default:r(()=>[h(k,{modelValue:b.value,"onUpdate:modelValue":i[1]||=e=>b.value=e,label:`用户名`,variant:`outlined`,density:`comfortable`,"prepend-inner-icon":`mdi-account-outline`,disabled:j.value,class:`mb-4`,autocomplete:`username`,rounded:`lg`,"hide-details":`auto`},null,8,[`modelValue`,`disabled`]),h(k,{modelValue:C.value,"onUpdate:modelValue":i[2]||=e=>C.value=e,label:`密码`,variant:`outlined`,density:`comfortable`,"prepend-inner-icon":`mdi-lock-outline`,"append-inner-icon":D.value?`mdi-eye`:`mdi-eye-off`,type:D.value?`text`:`password`,"onClick:appendInner":i[3]||=e=>D.value=!D.value,disabled:j.value,class:`mb-4`,autocomplete:`current-password`,rounded:`lg`,"hide-details":`auto`},null,8,[`modelValue`,`append-inner-icon`,`type`,`disabled`]),L.value?(x(),y(k,{key:0,modelValue:T.value,"onUpdate:modelValue":i[4]||=e=>T.value=e,label:`TOTP 验证码`,variant:`outlined`,density:`comfortable`,"prepend-inner-icon":`mdi-shield-key-outline`,disabled:j.value,class:`mb-4`,autocomplete:`one-time-code`,rounded:`lg`,"hide-details":`auto`},null,8,[`modelValue`,`disabled`])):A(``,!0),h(u,{type:`submit`,color:`primary`,variant:`flat`,block:``,size:`large`,loading:j.value,disabled:!!U.value,rounded:`lg`,class:`mt-2`,height:`48`},{default:r(()=>[...i[5]||=[S(` 登录 `,-1)]]),_:1},8,[`loading`,`disabled`])]),_:1}),H.value>0&&!U.value?(x(),E(`div`,V,[h(P,{size:`small`,variant:`tonal`,color:`warning`,label:``},{default:r(()=>[S(` 已失败 `+n(H.value)+` 次 `,1)]),_:1})])):A(``,!0)]),_:1})])]))}}),[[`__scopeId`,`data-v-c0c6156f`]]);export{H as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,F as i,I as a,N as o,P as s,R as c,Rt as l,ar as u,er as d,hr as f,ir as p,or as m,pr as h,tr as g}from"./index-C_dP75wD.js";import{o as _,t as v}from"./VContainer-DGQjsCTw.js";import{t as y}from"./VChip-oig-b64O.js";import{t as b}from"./VSelect-V_uWG8_q.js";import{t as x}from"./VDataTableServer-BMEL-4WM.js";import{t as S}from"./VSpacer-Cl6Zcy_X.js";import{t as C}from"./VDialog-CnSngTCg.js";var w={class:`font-weight-medium text-truncate d-inline-block`,style:{"max-width":`240px`}},T={class:`text-medium-emphasis`},E={class:`text-medium-emphasis`},D={class:`text-caption text-medium-emphasis`},O={class:`text-caption text-medium-emphasis`},k=m({__name:`RetriesPage`,setup(m){let k=_(),A=t(!1),j=t([]),M=t(0),N=t(1),P=t(null),F=t(!1),I=t(null),L=[{label:`待重试`,value:`pending`},{label:`重试中`,value:`retrying`},{label:`成功`,value:`success`},{label:`失败`,value:`failed`}],R=[{title:`状态`,key:`status`,width:100},{title:`任务`,key:`operation`},{title:`服务器`,key:`server_name`},{title:`次数`,key:`retry_count`,width:100},{title:`下次重试`,key:`next_retry_at`,width:140},{title:`创建时间`,key:`created_at`,width:160},{title:``,key:`actions`,width:120,align:`end`,sortable:!1}];async function z(){A.value=!0;try{let e=(await l.getList(`/retries/`,{limit:200})).items||[];P.value&&(e=e.filter(e=>e.status===P.value)),M.value=e.length;let t=(N.value-1)*20;j.value=e.slice(t,t+20)}catch(e){j.value=[],M.value=0,k(e instanceof Error?e.message:`加载重试队列失败`,`error`)}finally{A.value=!1}}async function B(e){try{await l.post(`/retries/${e.id}/retry`),z(),k(`重试已触发`)}catch(e){k(e.message||`操作失败`,`error`)}}function V(e){I.value=e.id,F.value=!0}async function H(){if(I.value)try{await l.delete(`/retries/${I.value}`),F.value=!1,z(),k(`已删除`)}catch(e){k(e.message||`删除失败`,`error`)}}function U(e){return e===`success`?`success`:e===`failed`?`error`:e===`retrying`?`warning`:`info`}function W(e){return e===`pending`?`待重试`:e===`retrying`?`重试中`:e===`success`?`成功`:e===`failed`?`失败`:e}function G(e){return e.source_path&&e.target_path?`${e.source_path} → ${e.target_path}`:e.source_path||e.target_path||`文件推送`}return h(()=>z()),(t,l)=>(f(),d(v,{fluid:``,class:`pa-6`},{default:r(()=>[u(o,{elevation:`0`,border:``,rounded:`lg`},{default:r(()=>[u(i,{class:`d-flex align-center text-h6`},{default:r(()=>[l[5]||=p(` 重试队列 `,-1),u(S),u(b,{modelValue:P.value,"onUpdate:modelValue":[l[0]||=e=>P.value=e,l[1]||=e=>{N.value=1,z()}],items:L,"item-title":`label`,"item-value":`value`,label:`状态筛选`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`160px`},clearable:``},null,8,[`modelValue`])]),_:1}),u(x,{items:j.value,headers:R,"items-length":M.value,loading:A.value,page:N.value,"items-per-page":20,hover:``,density:`comfortable`,"onUpdate:page":l[2]||=e=>{N.value=e,z()}},{"item.status":r(({item:e})=>[u(y,{color:U(e.status),size:`x-small`,variant:`tonal`,label:``,border:`sm`},{default:r(()=>[p(n(W(e.status)),1)]),_:2},1032,[`color`])]),"item.operation":r(({item:t})=>[e(`span`,w,n(G(t)),1)]),"item.server_name":r(({item:t})=>[e(`span`,T,n(t.server_name||`—`),1)]),"item.retry_count":r(({item:t})=>[e(`span`,E,n(t.retry_count||0)+` / `+n(t.max_retries||3),1)]),"item.next_retry_at":r(({item:t})=>[e(`span`,D,n(t.next_retry_at||`—`),1)]),"item.created_at":r(({item:t})=>[e(`span`,O,n(t.created_at),1)]),"item.actions":r(({item:e})=>[e.status===`pending`||e.status===`failed`?(f(),d(c,{key:0,variant:`text`,size:`x-small`,color:`primary`,density:`compact`,onClick:t=>B(e)},{default:r(()=>[...l[6]||=[p(` 重试 `,-1)]]),_:1},8,[`onClick`])):g(``,!0),u(c,{variant:`text`,size:`x-small`,color:`error`,density:`compact`,onClick:t=>V(e)},{default:r(()=>[...l[7]||=[p(` 删除 `,-1)]]),_:1},8,[`onClick`])]),"no-data":r(()=>[...l[8]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])]),_:1}),u(C,{modelValue:F.value,"onUpdate:modelValue":l[4]||=e=>F.value=e,"max-width":`400`},{default:r(()=>[u(o,{border:``},{default:r(()=>[u(i,null,{default:r(()=>[...l[9]||=[p(`确认删除`,-1)]]),_:1}),u(s,null,{default:r(()=>[...l[10]||=[p(`确定要删除此重试任务吗?`,-1)]]),_:1}),u(a,null,{default:r(()=>[u(S),u(c,{variant:`text`,onClick:l[3]||=e=>F.value=!1},{default:r(()=>[...l[11]||=[p(`取消`,-1)]]),_:1}),u(c,{color:`error`,variant:`flat`,onClick:H},{default:r(()=>[...l[12]||=[p(`删除`,-1)]]),_:1})]),_:1})]),_:1})]),_:1},8,[`modelValue`])]),_:1}))}});export{k as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,F as i,I as a,N as o,P as s,R as c,Rt as l,ar as u,er as d,hr as f,ir as p,or as m,pr as h,tr as g}from"./index-o0-2k-vI.js";import{o as _,t as v}from"./VContainer-BbTGUkA2.js";import{t as y}from"./VChip-B_7LyIUT.js";import{t as b}from"./VSelect-RidH-jin.js";import{t as x}from"./VDataTableServer-CYEpP3ht.js";import{t as S}from"./VSpacer-DE-o2Lgp.js";import{t as C}from"./VDialog-nLwlI-ms.js";var w={class:`font-weight-medium text-truncate d-inline-block`,style:{"max-width":`240px`}},T={class:`text-medium-emphasis`},E={class:`text-medium-emphasis`},D={class:`text-caption text-medium-emphasis`},O={class:`text-caption text-medium-emphasis`},k=m({__name:`RetriesPage`,setup(m){let k=_(),A=t(!1),j=t([]),M=t(0),N=t(1),P=t(null),F=t(!1),I=t(null),L=[{label:`待重试`,value:`pending`},{label:`重试中`,value:`retrying`},{label:`成功`,value:`success`},{label:`失败`,value:`failed`}],R=[{title:`状态`,key:`status`,width:100},{title:`任务`,key:`operation`},{title:`服务器`,key:`server_name`},{title:`次数`,key:`retry_count`,width:100},{title:`下次重试`,key:`next_retry_at`,width:140},{title:`创建时间`,key:`created_at`,width:160},{title:``,key:`actions`,width:120,align:`end`,sortable:!1}];async function z(){A.value=!0;try{let e=(await l.getList(`/retries/`,{limit:200})).items||[];P.value&&(e=e.filter(e=>e.status===P.value)),M.value=e.length;let t=(N.value-1)*20;j.value=e.slice(t,t+20)}catch(e){j.value=[],M.value=0,k(e instanceof Error?e.message:`加载重试队列失败`,`error`)}finally{A.value=!1}}async function B(e){try{await l.post(`/retries/${e.id}/retry`),z(),k(`重试已触发`)}catch(e){k(e.message||`操作失败`,`error`)}}function V(e){I.value=e.id,F.value=!0}async function H(){if(I.value)try{await l.delete(`/retries/${I.value}`),F.value=!1,z(),k(`已删除`)}catch(e){k(e.message||`删除失败`,`error`)}}function U(e){return e===`success`?`success`:e===`failed`?`error`:e===`retrying`?`warning`:`info`}function W(e){return e===`pending`?`待重试`:e===`retrying`?`重试中`:e===`success`?`成功`:e===`failed`?`失败`:e}function G(e){return e.source_path&&e.target_path?`${e.source_path} → ${e.target_path}`:e.source_path||e.target_path||`文件推送`}return h(()=>z()),(t,l)=>(f(),d(v,{fluid:``,class:`pa-6`},{default:r(()=>[u(o,{elevation:`0`,border:``,rounded:`lg`},{default:r(()=>[u(i,{class:`d-flex align-center text-h6`},{default:r(()=>[l[5]||=p(` 重试队列 `,-1),u(S),u(b,{modelValue:P.value,"onUpdate:modelValue":[l[0]||=e=>P.value=e,l[1]||=e=>{N.value=1,z()}],items:L,"item-title":`label`,"item-value":`value`,label:`状态筛选`,variant:`outlined`,density:`compact`,"hide-details":``,style:{"max-width":`160px`},clearable:``},null,8,[`modelValue`])]),_:1}),u(x,{items:j.value,headers:R,"items-length":M.value,loading:A.value,page:N.value,"items-per-page":20,hover:``,density:`comfortable`,"onUpdate:page":l[2]||=e=>{N.value=e,z()}},{"item.status":r(({item:e})=>[u(y,{color:U(e.status),size:`x-small`,variant:`tonal`,label:``,border:`sm`},{default:r(()=>[p(n(W(e.status)),1)]),_:2},1032,[`color`])]),"item.operation":r(({item:t})=>[e(`span`,w,n(G(t)),1)]),"item.server_name":r(({item:t})=>[e(`span`,T,n(t.server_name||`—`),1)]),"item.retry_count":r(({item:t})=>[e(`span`,E,n(t.retry_count||0)+` / `+n(t.max_retries||3),1)]),"item.next_retry_at":r(({item:t})=>[e(`span`,D,n(t.next_retry_at||`—`),1)]),"item.created_at":r(({item:t})=>[e(`span`,O,n(t.created_at),1)]),"item.actions":r(({item:e})=>[e.status===`pending`||e.status===`failed`?(f(),d(c,{key:0,variant:`text`,size:`x-small`,color:`primary`,density:`compact`,onClick:t=>B(e)},{default:r(()=>[...l[6]||=[p(` 重试 `,-1)]]),_:1},8,[`onClick`])):g(``,!0),u(c,{variant:`text`,size:`x-small`,color:`error`,density:`compact`,onClick:t=>V(e)},{default:r(()=>[...l[7]||=[p(` 删除 `,-1)]]),_:1},8,[`onClick`])]),"no-data":r(()=>[...l[8]||=[e(`div`,{class:`text-center text-medium-emphasis py-6`},`暂无数据`,-1)]]),_:1},8,[`items`,`items-length`,`loading`,`page`])]),_:1}),u(C,{modelValue:F.value,"onUpdate:modelValue":l[4]||=e=>F.value=e,"max-width":`400`},{default:r(()=>[u(o,{border:``},{default:r(()=>[u(i,null,{default:r(()=>[...l[9]||=[p(`确认删除`,-1)]]),_:1}),u(s,null,{default:r(()=>[...l[10]||=[p(`确定要删除此重试任务吗?`,-1)]]),_:1}),u(a,null,{default:r(()=>[u(S),u(c,{variant:`text`,onClick:l[3]||=e=>F.value=!1},{default:r(()=>[...l[11]||=[p(`取消`,-1)]]),_:1}),u(c,{color:`error`,variant:`flat`,onClick:H},{default:r(()=>[...l[12]||=[p(`删除`,-1)]]),_:1})]),_:1})]),_:1})]),_:1},8,[`modelValue`])]),_:1}))}});export{k as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,L as i,N as a,P as o,Rr as s,Rt as c,X as l,Xn as u,_r as d,ar as f,er as p,hr as m,mr as h,nr as g,or as _}from"./index-C_dP75wD.js";import{n as v,t as y}from"./VRow-MZyk7N4E.js";import{t as b}from"./_plugin-vue_export-helper-BDNMzG2s.js";function x(){let e=t([]),n=t(!1),r=t(1),i=t(20),a=t(0),o=t(``);async function s(){n.value=!0;try{let t=await c.get(`/servers/`,{page:r.value,per_page:i.value,search:o.value||void 0});e.value=t.items||[],a.value=t.total||0}catch(t){throw e.value=[],a.value=0,t}finally{n.value=!1}}let l;h(()=>clearTimeout(l));function u(){clearTimeout(l),l=setTimeout(()=>{r.value=1,s()},300)}function d(e){r.value=e,s()}function f(e){i.value=e,r.value=1,s()}return{servers:e,loading:n,page:r,itemsPerPage:i,total:a,search:o,loadServers:s,onSearch:u,onPageChange:d,onItemsPerPageChange:f}}function S(e){switch(e){case`online`:return`success`;case`offline`:return`error`;default:return`warning`}}function C(e){switch(e){case`online`:return`在线`;case`offline`:return`离线`;default:return`未知`}}function w(e){if(!e)return`—`;let t=new Date(e);if(isNaN(t.getTime()))return`—`;let n=Date.now()-t.getTime();return n<6e4?`刚刚`:n<36e5?`${Math.floor(n/6e4)} 分钟前`:n<864e5?`${Math.floor(n/36e5)} 小时前`:`${Math.floor(n/864e5)} 天前`}var T={class:`text-caption text-medium-emphasis mb-1`,style:{"font-size":`14px`,"letter-spacing":`0.5px`,"text-transform":`uppercase`}},E=b(_({__name:`StatCardsRow`,props:{items:{}},setup(t){return(c,h)=>(m(),p(y,null,{default:r(()=>[(m(!0),g(u,null,d(t.items,(t,c)=>(m(),p(v,{key:c,cols:`6`,sm:`3`},{default:r(()=>[f(a,{elevation:`0`,rounded:`xl`,border:``,class:`stat-card`},{default:r(()=>[f(o,{class:`pa-4 d-flex align-center justify-space-between`},{default:r(()=>[e(`div`,null,[e(`div`,T,n(t.subtitle),1),e(`div`,{class:s([`font-weight-bold`,`text-`+t.color]),style:{"font-size":`36px`,"line-height":`1.1`}},n(t.title),3)]),f(i,{color:t.color,size:`48`,rounded:`lg`,class:`stat-icon`},{default:r(()=>[f(l,{icon:t.icon,size:`24`,color:`white`},null,8,[`icon`])]),_:2},1032,[`color`])]),_:2},1024)]),_:2},1024)]),_:2},1024))),128))]),_:1}))}}),[[`__scopeId`,`data-v-68b984da`]]);export{x as a,C as i,w as n,S as r,E as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,Ar as t,Br as n,Cr as r,L as i,N as a,P as o,Rr as s,Rt as c,X as l,Xn as u,_r as d,ar as f,er as p,hr as m,mr as h,nr as g,or as _}from"./index-o0-2k-vI.js";import{n as v,t as y}from"./VRow-D9pCZkmz.js";import{t as b}from"./_plugin-vue_export-helper-BDNMzG2s.js";function x(){let e=t([]),n=t(!1),r=t(1),i=t(20),a=t(0),o=t(``);async function s(){n.value=!0;try{let t=await c.get(`/servers/`,{page:r.value,per_page:i.value,search:o.value||void 0});e.value=t.items||[],a.value=t.total||0}catch(t){throw e.value=[],a.value=0,t}finally{n.value=!1}}let l;h(()=>clearTimeout(l));function u(){clearTimeout(l),l=setTimeout(()=>{r.value=1,s()},300)}function d(e){r.value=e,s()}function f(e){i.value=e,r.value=1,s()}return{servers:e,loading:n,page:r,itemsPerPage:i,total:a,search:o,loadServers:s,onSearch:u,onPageChange:d,onItemsPerPageChange:f}}function S(e){switch(e){case`online`:return`success`;case`offline`:return`error`;default:return`warning`}}function C(e){switch(e){case`online`:return`在线`;case`offline`:return`离线`;default:return`未知`}}function w(e){if(!e)return`—`;let t=new Date(e);if(isNaN(t.getTime()))return`—`;let n=Date.now()-t.getTime();return n<6e4?`刚刚`:n<36e5?`${Math.floor(n/6e4)} 分钟前`:n<864e5?`${Math.floor(n/36e5)} 小时前`:`${Math.floor(n/864e5)} 天前`}var T={class:`text-caption text-medium-emphasis mb-1`,style:{"font-size":`14px`,"letter-spacing":`0.5px`,"text-transform":`uppercase`}},E=b(_({__name:`StatCardsRow`,props:{items:{}},setup(t){return(c,h)=>(m(),p(y,null,{default:r(()=>[(m(!0),g(u,null,d(t.items,(t,c)=>(m(),p(v,{key:c,cols:`6`,sm:`3`},{default:r(()=>[f(a,{elevation:`0`,rounded:`xl`,border:``,class:`stat-card`},{default:r(()=>[f(o,{class:`pa-4 d-flex align-center justify-space-between`},{default:r(()=>[e(`div`,null,[e(`div`,T,n(t.subtitle),1),e(`div`,{class:s([`font-weight-bold`,`text-`+t.color]),style:{"font-size":`36px`,"line-height":`1.1`}},n(t.title),3)]),f(i,{color:t.color,size:`48`,rounded:`lg`,class:`stat-icon`},{default:r(()=>[f(l,{icon:t.icon,size:`24`,color:`white`},null,8,[`icon`])]),_:2},1032,[`color`])]),_:2},1024)]),_:2},1024)]),_:2},1024))),128))]),_:1}))}}),[[`__scopeId`,`data-v-68b984da`]]);export{x as a,C as i,w as n,S as r,E as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{$n as e,$t as t,Ct as n,Gt as r,Nr as i,Qn as a,R as o,Rr as s,St as c,Tt as l,U as u,W as d,Wt as f,X as p,Zt as m,ar as h,at as g,bt as _,cn as v,ct as y,dn as b,dt as x,fn as S,ft as C,it as w,jt as T,lr as E,on as D,ot as O,pt as k,st as A,tn as j,ut as M,wt as N,yt as P,zr as F}from"./index-o0-2k-vI.js";var I=D(`v-alert-title`),L=S({iconSize:[Number,String],iconSizes:{type:Array,default:()=>[[`x-small`,10],[`small`,16],[`default`,24],[`large`,28],[`x-large`,32]]}},`iconSize`);function R(e,t){return{iconSize:a(()=>{let n=new Map(e.iconSizes),r=e.iconSize??t()??`default`;return n.has(r)?n.get(r):r})}}var z=[`success`,`info`,`warning`,`error`],B=S({border:{type:[Boolean,String],validator:e=>typeof e==`boolean`||[`top`,`end`,`bottom`,`start`].includes(e)},borderColor:String,closable:Boolean,closeIcon:{type:j,default:`$close`},closeLabel:{type:String,default:`$vuetify.close`},icon:{type:[Boolean,String,Function,Object],default:null},modelValue:{type:Boolean,default:!0},prominent:Boolean,title:String,text:String,type:{type:String,validator:e=>z.includes(e)},...b(),...A(),...n(),...C(),...L(),...M(),...u(),...P(),...T(),...f(),...g({variant:`flat`})},`VAlert`),V=v()({name:`VAlert`,props:B(),emits:{"click:close":e=>!0,"update:modelValue":e=>!0},setup(n,{emit:a,slots:u}){let f=t(n,`modelValue`),g=i(()=>{if(n.icon!==!1)return n.type?n.icon??`$${n.type}`:n.icon}),{iconSize:v}=R(n,()=>n.prominent?44:void 0),{themeClasses:b}=r(n),{colorClasses:S,colorStyles:C,variantClasses:T}=O(()=>({color:n.color??n.type,variant:n.variant})),{densityClasses:D}=y(n),{dimensionStyles:A}=N(n),{elevationClasses:j}=k(n),{locationStyles:M}=x(n),{positionClasses:P}=d(n),{roundedClasses:L}=_(n),{textColorClasses:z,textColorStyles:B}=c(()=>n.borderColor),{t:V}=m(),H=i(()=>({"aria-label":V(n.closeLabel),onClick(e){f.value=!1,a(`click:close`,e)}}));return()=>{let t=!!(u.prepend||g.value),r=!!(u.title||n.title),i=!!(u.close||n.closable),a={density:n.density,icon:g.value,size:n.iconSize||n.prominent?v.value:void 0};return f.value&&h(n.tag,{class:s([`v-alert`,n.border&&{"v-alert--border":!!n.border,[`v-alert--border-${n.border===!0?`start`:n.border}`]:!0},{"v-alert--prominent":n.prominent},b.value,S.value,D.value,j.value,P.value,L.value,T.value,n.class]),style:F([C.value,A.value,M.value,n.style]),role:`alert`},{default:()=>[w(!1,`v-alert`),n.border&&e(`div`,{key:`border`,class:s([`v-alert__border`,z.value]),style:F(B.value)},null),t&&e(`div`,{key:`prepend`,class:`v-alert__prepend`},[u.prepend?h(l,{key:`prepend-defaults`,disabled:!g.value,defaults:{VIcon:{...a}}},u.prepend):h(p,E({key:`prepend-icon`},a),null)]),e(`div`,{class:`v-alert__content`},[r&&h(I,{key:`title`},{default:()=>[u.title?.()??n.title]}),u.text?.()??n.text,u.default?.()]),u.append&&e(`div`,{key:`append`,class:`v-alert__append`},[u.append()]),i&&e(`div`,{key:`close`,class:`v-alert__close`},[u.close?h(l,{key:`close-defaults`,defaults:{VBtn:{icon:n.closeIcon,size:`x-small`,variant:`text`}}},{default:()=>[u.close?.({props:H.value})]}):h(o,E({key:`close-btn`,icon:n.closeIcon,size:`x-small`,variant:`text`},H.value),null)])]})}}});export{V as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{$n as e,$t as t,Ct as n,Gt as r,Nr as i,Qn as a,R as o,Rr as s,St as c,Tt as l,U as u,W as d,Wt as f,X as p,Zt as m,ar as h,at as g,bt as _,cn as v,ct as y,dn as b,dt as x,fn as S,ft as C,it as w,jt as T,lr as E,on as D,ot as O,pt as k,st as A,tn as j,ut as M,wt as N,yt as P,zr as F}from"./index-C_dP75wD.js";var I=D(`v-alert-title`),L=S({iconSize:[Number,String],iconSizes:{type:Array,default:()=>[[`x-small`,10],[`small`,16],[`default`,24],[`large`,28],[`x-large`,32]]}},`iconSize`);function R(e,t){return{iconSize:a(()=>{let n=new Map(e.iconSizes),r=e.iconSize??t()??`default`;return n.has(r)?n.get(r):r})}}var z=[`success`,`info`,`warning`,`error`],B=S({border:{type:[Boolean,String],validator:e=>typeof e==`boolean`||[`top`,`end`,`bottom`,`start`].includes(e)},borderColor:String,closable:Boolean,closeIcon:{type:j,default:`$close`},closeLabel:{type:String,default:`$vuetify.close`},icon:{type:[Boolean,String,Function,Object],default:null},modelValue:{type:Boolean,default:!0},prominent:Boolean,title:String,text:String,type:{type:String,validator:e=>z.includes(e)},...b(),...A(),...n(),...C(),...L(),...M(),...u(),...P(),...T(),...f(),...g({variant:`flat`})},`VAlert`),V=v()({name:`VAlert`,props:B(),emits:{"click:close":e=>!0,"update:modelValue":e=>!0},setup(n,{emit:a,slots:u}){let f=t(n,`modelValue`),g=i(()=>{if(n.icon!==!1)return n.type?n.icon??`$${n.type}`:n.icon}),{iconSize:v}=R(n,()=>n.prominent?44:void 0),{themeClasses:b}=r(n),{colorClasses:S,colorStyles:C,variantClasses:T}=O(()=>({color:n.color??n.type,variant:n.variant})),{densityClasses:D}=y(n),{dimensionStyles:A}=N(n),{elevationClasses:j}=k(n),{locationStyles:M}=x(n),{positionClasses:P}=d(n),{roundedClasses:L}=_(n),{textColorClasses:z,textColorStyles:B}=c(()=>n.borderColor),{t:V}=m(),H=i(()=>({"aria-label":V(n.closeLabel),onClick(e){f.value=!1,a(`click:close`,e)}}));return()=>{let t=!!(u.prepend||g.value),r=!!(u.title||n.title),i=!!(u.close||n.closable),a={density:n.density,icon:g.value,size:n.iconSize||n.prominent?v.value:void 0};return f.value&&h(n.tag,{class:s([`v-alert`,n.border&&{"v-alert--border":!!n.border,[`v-alert--border-${n.border===!0?`start`:n.border}`]:!0},{"v-alert--prominent":n.prominent},b.value,S.value,D.value,j.value,P.value,L.value,T.value,n.class]),style:F([C.value,A.value,M.value,n.style]),role:`alert`},{default:()=>[w(!1,`v-alert`),n.border&&e(`div`,{key:`border`,class:s([`v-alert__border`,z.value]),style:F(B.value)},null),t&&e(`div`,{key:`prepend`,class:`v-alert__prepend`},[u.prepend?h(l,{key:`prepend-defaults`,disabled:!g.value,defaults:{VIcon:{...a}}},u.prepend):h(p,E({key:`prepend-icon`},a),null)]),e(`div`,{class:`v-alert__content`},[r&&h(I,{key:`title`},{default:()=>[u.title?.()??n.title]}),u.text?.()??n.text,u.default?.()]),u.append&&e(`div`,{key:`append`,class:`v-alert__append`},[u.append()]),i&&e(`div`,{key:`close`,class:`v-alert__close`},[u.close?h(l,{key:`close-defaults`,defaults:{VBtn:{icon:n.closeIcon,size:`x-small`,variant:`text`}}},{default:()=>[u.close?.({props:H.value})]}):h(o,E({key:`close-btn`,icon:n.closeIcon,size:`x-small`,variant:`text`},H.value),null)])]})}}});export{V as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{$t as e,Ar as t,Rn as n,ar as r,br as i,cn as a,fn as o,lr as s,nn as c,o as l,p as u,s as d,v as f,wn as p}from"./index-o0-2k-vI.js";import{f as m,p as h}from"./VSelect-RidH-jin.js";var g=o({...n(d(),[`direction`]),...n(h(),[`inline`])},`VCheckbox`),_=a()({name:`VCheckbox`,inheritAttrs:!1,props:g(),emits:{"update:modelValue":e=>!0,"update:focused":e=>!0},setup(n,{attrs:a,slots:o}){let d=e(n,`modelValue`),{isFocused:h,focus:g,blur:_}=u(n),v=t(),y=i();return c(()=>{let[e,t]=p(a),i=l.filterProps(n),c=m.filterProps(n);return r(l,s({ref:v,class:[`v-checkbox`,n.class]},e,i,{modelValue:d.value,"onUpdate:modelValue":e=>d.value=e,id:n.id||`checkbox-${y}`,focused:h.value,style:n.style}),{...o,default:({id:e,messagesId:n,isDisabled:i,isReadonly:a,isValid:l})=>r(m,s(c,{id:e.value,"aria-describedby":n.value,disabled:i.value,readonly:a.value},t,{error:l.value===!1,modelValue:d.value,"onUpdate:modelValue":e=>d.value=e,onFocus:g,onBlur:_}),o)})}),f({},v)}});export{_ as t};
|
||||
@@ -0,0 +1 @@
|
||||
import{$t as e,Ar as t,Rn as n,ar as r,br as i,cn as a,fn as o,lr as s,nn as c,o as l,p as u,s as d,v as f,wn as p}from"./index-C_dP75wD.js";import{f as m,p as h}from"./VSelect-V_uWG8_q.js";var g=o({...n(d(),[`direction`]),...n(h(),[`inline`])},`VCheckbox`),_=a()({name:`VCheckbox`,inheritAttrs:!1,props:g(),emits:{"update:modelValue":e=>!0,"update:focused":e=>!0},setup(n,{attrs:a,slots:o}){let d=e(n,`modelValue`),{isFocused:h,focus:g,blur:_}=u(n),v=t(),y=i();return c(()=>{let[e,t]=p(a),i=l.filterProps(n),c=m.filterProps(n);return r(l,s({ref:v,class:[`v-checkbox`,n.class]},e,i,{modelValue:d.value,"onUpdate:modelValue":e=>d.value=e,id:n.id||`checkbox-${y}`,focused:h.value,style:n.style}),{...o,default:({id:e,messagesId:n,isDisabled:i,isReadonly:a,isValid:l})=>r(m,s(c,{id:e.value,"aria-describedby":n.value,disabled:i.value,readonly:a.value},t,{error:l.value===!1,modelValue:d.value,"onUpdate:modelValue":e=>d.value=e,onFocus:g,onBlur:_}),o)})}),f({},v)}});export{_ as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user