20 Commits

Author SHA1 Message Date
Nexus Agent c9e08799f9 fix(install): 守护配置对齐宿主机 cron 与验收检查
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
Docker 向导提示 nx cron/宿主机路径;裸机 cron 含 db_backup;验收脚本检查 cron 与 MySQL 备份能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 23:11:02 +08:00
Nexus Agent 89865ec690 fix(deploy): 部署脚本自动识别 Docker 与 Supervisor 运行时
upgrade/deploy-production 等分流到 nexus-1panel upgrade;升级完成后提示安装 nx cron。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 23:07:47 +08:00
Nexus Agent ba69bc3355 fix(deploy): git pull 后备份脚本不依赖可执行位
db_backup/backup_mysql 改为检测文件存在并用 bash 调用;install_ops_cron 一并 chmod 备份脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:39:10 +08:00
Nexus Agent 49f159a8ce fix(deploy): install_ops_cron 自动安装 cron 包
生产 Azure 主机默认无 crontab,安装脚本现会 apt install cron 并启用服务。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:38:43 +08:00
Nexus Agent 4e1347ba45 fix(deploy): Docker 感知 MySQL 备份与 nx cron 安装
升级/定时备份通过 docker exec 1Panel MySQL 容器 dump,不再依赖宿主机 mysqldump;nx 菜单新增巡检与备份 crontab 安装。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:37:49 +08:00
Nexus Deploy 61b8d7d419 fix(deploy): health_monitor Docker 适配与巡检第三轮修补
Docker 生产用 docker restart;读 .env.prod 端口与容器 Telegram;已归档跳过全量热同步。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 22:25:27 +08:00
Nexus Deploy 954bdbd972 fix(deploy): 验收脚本已安装时输出正确通过文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:54:03 +08:00
Nexus Deploy 314bece418 fix(deploy): 验收脚本已安装时跳过 install API 检查
锁定后 /api/install/* 返回 403 属预期,不应判 FAIL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:53:43 +08:00
Nexus Deploy 687e059a86 fix(deploy): 上帝菜单巡检第二轮 — 全新安装卷冲突与验收修正
FRESH 清除 nexus-state 旧锁;PENDING=1 时 entrypoint 不恢复旧 env;验收脚本已安装期望 404。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:53:03 +08:00
Nexus Deploy baacdb0252 fix(deploy): 已归档安装向导时 sync 脚本验收期望 404
避免 nx update 因 install.html 已移除而误报失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:31:06 +08:00
Nexus Deploy 58bdf820dc fix(install): 安装锁定后将 install.html 归档为 .bak
防止已安装环境继续提供安装向导静态页;entrypoint 与升级脚本避免容器重建后恢复。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:30:15 +08:00
Nexus Deploy dd66d99f2d fix(deploy): nx 上帝菜单巡检修补 1panel-network 与升级竞态
purge 阶段 compose 叠加 1panel overlay;菜单启动/重建与回滚后校验网络;install-auto 已安装改走 update。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:27:01 +08:00
Nexus Deploy 107b089b16 fix(1panel): 安装锁持久化与升级后 1panel-network 校验
防止 nx update 重建容器后丢失安装锁、未接入 1panel-network 导致 MySQL/Redis 不可达。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 20:52:13 +08:00
Nexus Deploy 1f5daebeed fix(deploy): nx update 在 set -u 下 local root 同行展开报错
拆分 nexus_publish_port/set_install_complete/sync_env_prod_to_volume 的 local 声明,
避免 env_file="$root/..." 在 root 赋值前展开导致 unbound variable。
2026-06-06 20:38:11 +08:00
Nexus Deploy 4ba45abf38 feat(servers): 凭据轮询登录与连接失败列表
- 密码/SSH密钥预设增加用户名;快速添加(IP)轮询全部预设尝试 SSH 登录
- 成功入 servers 列表;失败写入 pending_servers 并支持重试/删除
- 新增 credential_poller、try_ssh_login 与 add-by-ip/pending API
2026-06-06 20:08:48 +08:00
Nexus Deploy 5a56e5a8cb fix(1panel): 安装向导脱敏、配置一致性与 nx update 门控回滚
- /state 白名单脱敏;init-db db_port 修复;connection-check 后清空 redis_pass
- install.html sessionStorage token 与无 token 友好提示
- nx update: 镜像 import 门控、健康失败自动回滚、PENDING=0 回写、卷双写
- Redis 容器改名密码迁移;备份失败默认 WARN;update.sh 管道 ROOT 回退
2026-06-06 17:40:30 +08:00
Nexus Deploy 850038a3ec fix(install): 安装向导 Redis 密码不被 nx update/Compose 覆盖
Compose 不再注入 NEXUS_REDIS_URL;nx update 保留卷内带密码 URL;步骤 4 可从 install state 修复无密码 URL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 11:34:26 +08:00
Nexus Deploy beb802802a chore: 添加 scripts/git-push.sh 与本地 Gitea secrets 约定
push 读取 deploy/nexus-1panel.secrets.sh(gitignore),clone/pull 仍可匿名。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 11:22:07 +08:00
Nexus Deploy 476d87c678 docs: Gitea 公共仓库无需凭据
更新 .cursorrules 与 linux-dev-paths,明确不配置/探测 GITEA token。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 11:13:07 +08:00
Nexus Deploy 49f88a2cd8 fix(install): Docker .env 持久化与 entrypoint 覆盖 Compose 占位符
向导 init-db 后立即同步 nexus-state 卷;entrypoint 跳过不完整 env 并在启动前 source /app/.env,避免缺 DATABASE_URL 与无密码 REDIS_URL 导致容器崩溃。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 11:08:22 +08:00
135 changed files with 4185 additions and 436 deletions
+1
View File
@@ -12,6 +12,7 @@ FastAPI + Async SQLAlchemy + Redis · Vue 3 + Vuetify 4 SPA14 页)· 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
View File
@@ -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
View File
@@ -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
# Supervisortar 上传到 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
View File
@@ -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
View File
@@ -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
+130
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Nexus — 安装/更新 host crontabhealth_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"
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env bash
# Nexus — MySQL dumpDocker 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
+5 -2
View File
@@ -1,7 +1,10 @@
# 可选 — 仅私人 Gitea 仓库需要(公共仓库匿名 clone,新机无需此文件)
# 复制: cp deploy/nexus-1panel.secrets.sh.example deploy/nexus-1panel.secrets.sh
# 用于 git pushscripts/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
View File
@@ -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_hostNEXUS_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_hostNEXUS_REDIS_URL 已保留向导密码)"
else
info "1Panel Redis 容器: $redis_hostNEXUS_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-networkMySQL/Redis 使用 1Panel 容器名互联)"
fi
info "档位: ${NEXUS_PROFILE}(仅 Nexus 容器;MySQL/Redis 请自行安装)"
info "构建并启动(首次约 1020 分钟)..."
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 ;;
+40 -3
View File
@@ -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] 安装巡检/备份 cronhealth_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 已合并进主菜单(兼容别名)
+25 -5
View File
@@ -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.pyDocker 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
}
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 upgradeSupervisor 环境走 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 ""
+101 -9
View File
@@ -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_monitorLayer 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. 步骤 2MySQL/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 "$@"
+5 -1
View File
@@ -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
View File
@@ -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 写入
## 生产注意
-3
View File
@@ -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:
+4 -4
View File
@@ -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
View File
@@ -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` 仅 supervisorctlDocker 生产无效;验收/热同步仍有误报路径 |
## 变更摘要
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:00crontab。
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 列表,重试重新轮询全部预设
+7 -1
View File
@@ -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` 自动 syncb25d079+
### 4.3 `nx update` 自动 sync2026-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')
+23 -11
View File
@@ -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 }
}
+272
View File
@@ -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>
+14
View File
@@ -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
View File
@@ -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 cronhealth_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()}
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+25
View File
@@ -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"""
+233
View File
@@ -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"
+168
View File
@@ -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
+1
View File
@@ -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};
+1
View File
@@ -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};
+1
View File
@@ -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};
+1
View File
@@ -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};
+1
View File
@@ -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};
+1
View File
@@ -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
+1
View File
@@ -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};
+1
View File
@@ -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}
+1
View File
@@ -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
+1
View File
@@ -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};
+1
View File
@@ -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
+1
View File
@@ -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};
+1
View File
@@ -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
+1
View File
@@ -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};
+1
View File
@@ -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};
+1
View File
@@ -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};
+1
View File
@@ -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