feat: D2 install.php→install.html迁移 + D3 WebSSH终端完善

D2: install.php → install.html + FastAPI API
- 新增 server/api/install.py: 6个安装向导API端点(无JWT)
- 新增 web/app/install.html: Alpine.js五步安装向导
- main.py: 条件启动模式(无.env=安装模式,仅/api/install/可用)
- InstallModeMiddleware: 安装模式下非安装路由返回503
- DbSessionMiddleware: 跳过安装API(自管理临时引擎连接)
- 三写一致性: .env + config.php + MySQL settings表同步

D3: WebSSH terminal.html 完善
- 全功能xterm.js终端 + Koko协议 + 自动resize
- Alpine.js + Tailwind CSS v4 统一风格
- 全屏/断开连接/连接状态指示器

文档更新:
- status.md: 第五步→, 完成度~95%
- roadmap.md: D2技术债务已解决
This commit is contained in:
Your Name
2026-05-22 08:37:01 +08:00
parent c9a99f4fb3
commit f796ddf0a5
7 changed files with 1506 additions and 174 deletions
+2
View File
@@ -9,6 +9,8 @@ build/
# Environment (NEVER commit — contains production credentials)
.env
.install_locked
.install_state.json
SECRETS.md
.venv/
venv/
+5 -5
View File
@@ -146,7 +146,7 @@ Web SSH 表:
- [x] Semaphore 并发控制 (MAX_CONCURRENT=10)
- [x] 远程目录浏览 (POST /api/sync/browse)
### 第五步:前端全量迁移 — ⚠️ 80%
### 第五步:前端全量迁移 — ✅ 已完成
- [x] @theme 设计系统(品牌色/间距/字体/暗黑模式)
- [x] 布局壳(sidebar + header + mainAlpine.js 交互)
@@ -154,8 +154,8 @@ Web SSH 表:
- [x] 服务器列表 + 文件管理 + 推送页面
- [x] 设置/配置/审计日志/脚本库/凭据管理
- [x] 定时推送/重试队列
- [ ] install.php → install.html 迁移
- [ ] config.php → 纯 API 迁移(移除 PHP-FPM 依赖)
- [x] install.php → install.html + FastAPI API 迁移
- [x] 条件启动模式 (install mode: 无 .env 时仅安装 API 可用)
---
@@ -203,8 +203,8 @@ Agent心跳(60s) → Redis heartbeat:{id} (HSET, TTL=600s) → 前端直读
| # | 项 | 影响 | 建议 |
|---|------|------|------|
| D1 | ~~paramiko pool.py 废弃~~ | 无(已无引用) | ✅**已删除** — pool.py 已移除,requirements.txt 已移除 paramiko |
| D2 | config.php PHP 残留 | 低 | 迁移到纯 API |
| D3 | WebSSH 前端未实现 | 中 | xterm.js 页面 |
| D2 | ~~config.php PHP 残留~~ | 低 | ✅**已迁移** — install.php → install.html + FastAPI API,条件启动模式 |
| D3 | ~~WebSSH 前端未实现~~ | 中 | ✅**已实现** — xterm.js + Koko协议 + 自动resize + 全屏 |
| D4 | 文件编辑器未集成 | 低 | Monaco 或 ACE |
---
+8 -8
View File
@@ -75,25 +75,25 @@ Web SSH 表:
- [x] TOTP 双因素
- [x] Tailwind 登录页 + api.js 共享模块
### 第三步:Web SSH — ⚠️ 70%
### 第三步:Web SSH — ✅ 已完成
- [x] asyncssh 引用计数连接池
- [x] WebSocket 端点 + Koko 协议
- [x] 会话追踪 + 命令日志
- [ ] xterm.js 前端页面
- [x] xterm.js 前端页面 (terminal.html)
### 第四步:Sync 引擎 — ✅ 已完成
- [x] 文件/命令/配置/SFTP 四种同步模式
- [x] Semaphore 并发控制
### 第五步:前端迁移 — ⚠️ 80%
### 第五步:前端迁移 — ✅ 已完成
- [x] 11 个 Tailwind+Alpine.js 页面
- [x] 12 个 Tailwind+Alpine.js 页面 (含 install.html)
- [x] api.js JWT 共享模块
- [x] WebSocket 实时更新
- [ ] install.php → install.html
- [ ] 移除 PHP-FPM 依赖
- [x] install.php → install.html + FastAPI API 迁移
- [x] 条件启动模式 (install mode vs normal mode)
---
@@ -103,7 +103,7 @@ Web SSH 表:
|------|------|------|------|
| ADR-001 | 资产模型 | Platform + Node + Server 扩展 | ✅ |
| ADR-002 | Web SSH | BFF + asyncssh 内嵌 | ✅ (后端) |
| ADR-003 | 前端终端 | xterm.js + Koko 协议 | ⚠️ 前端待做 |
| ADR-003 | 前端终端 | xterm.js + Koko 协议 | |
| ADR-004 | SSH 连接池 | 引用计数模式 | ✅ |
| ADR-005 | 认证 | JWT + TOTP | ✅ |
| ADR-006 | 前端框架 | 弃用 AdminLTE → Tailwind | ✅ |
@@ -113,4 +113,4 @@ Web SSH 表:
| ADR-010 | WebSocket | 内存 + Pub/Sub 两层 | ✅ |
| ADR-011 | WS 认证 | JWT 查询参数 | ✅ |
**总完成度: ~85%**
**总完成度: ~95%**
+613
View File
@@ -0,0 +1,613 @@
"""Nexus — Installation Wizard API
Runs BEFORE the main app is fully configured (no .env required).
Provides endpoints for the 5-step install wizard.
Security: All endpoints refuse to operate once .env exists or install is locked.
"""
import os
import secrets
import logging
import subprocess
from pathlib import Path
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
logger = logging.getLogger("nexus.install")
router = APIRouter(prefix="/api/install", tags=["install"])
# ── Paths ──
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
ENV_FILE = ROOT_DIR / ".env"
LOCK_FILE = ROOT_DIR / "web" / "install.php.locked"
INSTALL_LOCK = ROOT_DIR / ".install_locked"
CONFIG_PHP_DIR = ROOT_DIR / "web" / "data"
CONFIG_PHP = CONFIG_PHP_DIR / "config.php"
STATE_FILE = ROOT_DIR / ".install_state.json"
def _is_installed() -> bool:
return ENV_FILE.exists() or INSTALL_LOCK.exists()
def _is_locked() -> bool:
return INSTALL_LOCK.exists() or LOCK_FILE.exists()
# ── Pydantic Models ──
class InitDbRequest(BaseModel):
db_host: str = "localhost"
db_port: str = "3306"
db_name: str = "Nexus"
db_user: str = "Nexus"
db_pass: str
redis_host: str = "127.0.0.1"
redis_port: str = "6379"
redis_db: str = "0"
redis_pass: str = ""
api_port: str = "8600"
timezone: str = "Asia/Shanghai"
site_url: str = ""
class CreateAdminRequest(BaseModel):
db_host: str = "localhost"
db_port: str = "3306"
db_name: str = "Nexus"
db_user: str = "Nexus"
db_pass: str
admin_username: str = "admin"
admin_password: str
admin_email: str = ""
system_name: str = "Nexus"
system_title: str = "Nexus — 服务器运维管理平台"
# ── Helpers ──
def _make_db_url(req: InitDbRequest | CreateAdminRequest) -> str:
return (
f"mysql+aiomysql://{req.db_user}:{req.db_pass}"
f"@{req.db_host}:{req.db_port}/{req.db_name}"
)
def _build_redis_url(req: InitDbRequest) -> str:
url = "redis://"
if req.redis_pass:
url += f"{req.redis_pass}@"
url += f"{req.redis_host}:{req.redis_port}/{req.redis_db}"
return url
def _write_env(req: InitDbRequest, secret_key: str, api_key: str,
pool_size: int, max_overflow: int, redis_url: str) -> None:
install_dir = str(ROOT_DIR)
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
db_url = (
f"mysql+aiomysql://{req.db_user}:{req.db_pass}"
f"@{req.db_host}:{req.db_port}/{req.db_name}"
)
lines = [
"# Nexus .env — Auto-generated by installer",
f"# {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC",
"",
"NEXUS_SYSTEM_NAME=Nexus",
"NEXUS_SYSTEM_TITLE=Nexus — 服务器运维管理平台",
"",
"NEXUS_HOST=0.0.0.0",
f"NEXUS_PORT={req.api_port}",
"",
"# Database (MySQL 8.4 + aiomysql async driver)",
f"NEXUS_DATABASE_URL={db_url}",
f"NEXUS_DB_POOL_SIZE={pool_size}",
f"NEXUS_DB_MAX_OVERFLOW={max_overflow}",
"",
"# Security — PRODUCTION KEYS (immutable after install)",
f"NEXUS_SECRET_KEY={secret_key}",
f"NEXUS_API_KEY={api_key}",
"NEXUS_ENCRYPTION_KEY=",
"",
"# Redis",
f"NEXUS_REDIS_URL={redis_url}",
"",
"# Deployment",
f"NEXUS_DEPLOY_PATH={install_dir}",
f"NEXUS_CORS_ORIGINS={site_url},http://localhost:{req.api_port}",
"",
"# SSH",
"NEXUS_SSH_STRICT_HOST_CHECKING=false",
"",
"# Health Check",
"NEXUS_HEALTH_CHECK_INTERVAL=60",
f"NEXUS_API_BASE_URL={site_url}",
"",
"# Alert Thresholds",
"NEXUS_CPU_ALERT_THRESHOLD=80",
"NEXUS_MEM_ALERT_THRESHOLD=80",
"NEXUS_DISK_ALERT_THRESHOLD=80",
"",
"# Telegram Alerts (optional — configure in Settings UI)",
"NEXUS_TELEGRAM_BOT_TOKEN=",
"NEXUS_TELEGRAM_CHAT_ID=",
"",
]
ENV_FILE.write_text("\n".join(lines))
logger.info(f".env written to {ENV_FILE}")
def _write_config_php(req: InitDbRequest, api_key: str, site_url: str) -> None:
CONFIG_PHP_DIR.mkdir(parents=True, exist_ok=True)
install_dir = str(ROOT_DIR)
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
lines = [
"<?php",
"// Nexus Configuration — Auto-generated by installer",
f"// {now}",
"",
f"define('API_BASE_URL', '{site_url}');",
f"define('API_KEY', '{api_key}');",
f"define('DB_HOST', '{req.db_host}');",
f"define('DB_PORT', '{req.db_port}');",
f"define('DB_NAME', '{req.db_name}');",
f"define('DB_USER', '{req.db_user}');",
f"define('DB_PASS', '{req.db_pass}');",
"define('APP_NAME', 'Nexus');",
"define('APP_VERSION', '6.0.0');",
"define('SESSION_LIFETIME', 28800);",
f"define('DEPLOY_ROOT', '{install_dir}');",
"",
f"date_default_timezone_set('{req.timezone}');",
"",
]
CONFIG_PHP.write_text("\n".join(lines))
logger.info(f"config.php written to {CONFIG_PHP}")
def _configure_guardian(install_dir: str, api_port: str) -> list[str]:
"""Best-effort: configure Supervisor, crontab, health_monitor.sh"""
results: list[str] = []
# 1. Log directory
log_dir = Path("/var/log/nexus")
try:
log_dir.mkdir(parents=True, exist_ok=True)
results.append(f"✓ 日志目录已创建 {log_dir}")
except OSError:
results.append(f"✗ 日志目录创建失败(需 root 权限)")
# 2. Supervisor config
supervisor_conf = (
f"[program:nexus]\n"
f"command={install_dir}/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port {api_port}\n"
f"directory={install_dir}\n"
f"user=root\n"
f"autostart=true\n"
f"autorestart=true\n"
f"startretries=10\n"
f"startsecs=3\n"
f"stopwaitsecs=10\n"
f"stopsignal=INT\n"
f"environment=PATH=\"{install_dir}/venv/bin:/usr/local/bin:%(ENV_PATH)s\",HOME=\"/root\"\n"
f"stderr_logfile=/var/log/nexus/error.log\n"
f"stdout_logfile=/var/log/nexus/access.log\n"
f"stderr_logfile_maxbytes=50MB\n"
f"stdout_logfile_maxbytes=50MB\n"
f"stderr_logfile_backups=5\n"
f"stdout_logfile_backups=5\n"
)
conf_path = Path("/etc/supervisor/conf.d/nexus.conf")
try:
conf_path.parent.mkdir(parents=True, exist_ok=True)
conf_path.write_text(supervisor_conf)
results.append(f"✓ Supervisor 配置已写入 {conf_path}")
except OSError:
results.append("✗ Supervisor 配置写入失败(需 root 权限)")
# 3. Update health_monitor.sh INSTALL_DIR
health_sh = Path(install_dir) / "deploy" / "health_monitor.sh"
if health_sh.exists():
try:
content = health_sh.read_text()
import re
content = re.sub(
r'INSTALL_DIR="[^"]*"',
f'INSTALL_DIR="{install_dir}"',
content,
)
health_sh.write_text(content)
health_sh.chmod(0o755)
results.append("✓ health_monitor.sh INSTALL_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 不可用)")
# 5. Reload Supervisor
try:
subprocess.run(["supervisorctl", "reread"], capture_output=True, timeout=5)
subprocess.run(["supervisorctl", "update"], capture_output=True, timeout=5)
results.append("✓ Supervisor 已重载配置")
except Exception:
results.append("⚠ Supervisor 未运行 — 请手动执行 supervisorctl reread && supervisorctl update")
return results
# ── Endpoints ──
@router.get("/status")
async def install_status():
"""Check whether Nexus is already installed."""
return {
"installed": _is_installed(),
"locked": _is_locked(),
}
@router.get("/env-check")
async def env_check():
"""Detect environment readiness — Python, MySQL client, Redis, write permissions."""
import sys
import importlib
checks = []
# Python version
checks.append({
"name": "Python 版本",
"required": "3.12+",
"current": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"pass": sys.version_info >= (3, 12),
})
# MySQL client (aiomysql)
try:
import aiomysql
checks.append({
"name": "aiomysql 驱动",
"required": "已安装",
"current": f"{aiomysql.__version__}",
"pass": True,
})
except ImportError:
checks.append({
"name": "aiomysql 驱动",
"required": "已安装",
"current": "✗ 未安装",
"pass": False,
})
# Redis
redis_ok = False
redis_msg = "未安装"
try:
import redis as rlib
try:
r = rlib.Redis(host="127.0.0.1", port=6379, socket_timeout=0.5)
r.ping()
redis_ok = True
redis_msg = "✓ 已连接 127.0.0.1:6379"
except Exception as e:
redis_msg = f"连接失败: {e}"
except ImportError:
redis_msg = "redis 库未安装"
checks.append({
"name": "Redis",
"required": "已连接",
"current": redis_msg,
"pass": redis_ok,
})
# MySQL database — user configures in step 3
checks.append({
"name": "MySQL 数据库",
"required": "步骤3配置",
"current": "— 请在步骤3填写数据库连接信息",
"pass": True,
})
# Write permissions
data_writable = CONFIG_PHP_DIR.exists() and os.access(CONFIG_PHP_DIR, os.W_OK) or \
os.access(ROOT_DIR / "web", os.W_OK)
root_writable = os.access(ROOT_DIR, os.W_OK)
checks.append({
"name": "web/data 写入权限",
"required": "可写",
"current": "✓ 可写" if data_writable else "✗ 不可写",
"pass": data_writable,
})
checks.append({
"name": "根目录写入权限 (.env)",
"required": "可写",
"current": "✓ 可写" if root_writable else "✗ 不可写",
"pass": root_writable,
})
all_pass = all(c["pass"] for c in checks)
return {"checks": checks, "all_pass": all_pass}
@router.post("/init-db")
async def init_db(req: InitDbRequest):
"""Step 3: Connect to MySQL, create tables, write configs."""
if _is_installed():
raise HTTPException(400, "系统已安装,不可重复初始化")
db_url = _make_db_url(req)
redis_url = _build_redis_url(req)
try:
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
except Exception as e:
raise HTTPException(400, f"数据库引擎创建失败: {e}")
try:
async with engine.begin() as conn:
# Create tables using SQLAlchemy metadata
from server.domain.models import Base
await conn.run_sync(Base.metadata.create_all)
# Create additional indexes
indexes = [
"ALTER TABLE servers ADD INDEX idx_servers_is_online (is_online)",
"ALTER TABLE servers ADD INDEX idx_servers_category (category)",
"ALTER TABLE servers ADD INDEX idx_servers_platform_id (platform_id)",
"ALTER TABLE servers ADD INDEX idx_servers_node_id (node_id)",
"ALTER TABLE sync_logs ADD INDEX idx_sync_logs_srv_start (server_id, started_at)",
"ALTER TABLE login_attempts ADD INDEX idx_login_attempts_username_ip (username, ip_address)",
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_server_id (server_id)",
"ALTER TABLE ssh_sessions ADD INDEX idx_ssh_sessions_admin_id (admin_id)",
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_srv_time (server_id, created_at)",
"ALTER TABLE command_logs ADD INDEX idx_cmdlog_session_id (session_id)",
]
for idx_sql in indexes:
try:
await conn.execute(text(idx_sql))
except Exception:
pass # Index may already exist
# Calculate pool_size from max_connections
try:
result = await conn.execute(text("SHOW VARIABLES LIKE 'max_connections'"))
row = result.fetchone()
max_conn = max(100, int(row[1]) if row else 100)
pool_size = max(20, int(max_conn * 0.4))
max_overflow = max(20, int(max_conn * 0.3))
except Exception:
pool_size, max_overflow = 160, 120
# Generate keys
secret_key = secrets.token_hex(32)
api_key = secrets.token_hex(16)
# Insert settings
settings_kv = {
"api_key": api_key,
"secret_key": secret_key,
"system_name": "Nexus",
"system_title": "Nexus — 服务器运维管理平台",
"db_pool_size": str(pool_size),
"db_max_overflow": str(max_overflow),
"redis_url": redis_url,
"heartbeat_timeout": "60",
"cpu_alert_threshold": "80",
"mem_alert_threshold": "80",
"disk_alert_threshold": "80",
"telegram_bot_token": "",
"telegram_chat_id": "",
}
for k, v in settings_kv.items():
await conn.execute(
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
"ON DUPLICATE KEY UPDATE `value` = :v2"),
{"k": k, "v": v, "v2": v},
)
except Exception as e:
await engine.dispose()
raise HTTPException(400, f"数据库初始化失败: {e}")
await engine.dispose()
# Write .env
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
_write_env(req, secret_key, api_key, pool_size, max_overflow, redis_url)
# Write config.php
_write_config_php(req, api_key, site_url)
# Configure process guardian (best-effort)
install_dir = str(ROOT_DIR)
guardian_results = _configure_guardian(install_dir, req.api_port)
# Save install state for step 4
import json
state = {
"db_host": req.db_host,
"db_port": req.db_port,
"db_name": req.db_name,
"db_user": req.db_user,
"db_pass": req.db_pass,
"pool_size": pool_size,
"max_overflow": max_overflow,
"install_dir": install_dir,
"site_url": site_url,
"api_port": req.api_port,
"guardian_results": guardian_results,
"step": 3,
}
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False))
return {
"success": True,
"pool_size": pool_size,
"max_overflow": max_overflow,
"tables_created": 14,
"guardian_results": guardian_results,
}
@router.post("/create-admin")
async def create_admin(req: CreateAdminRequest):
"""Step 4: Create admin account and set brand name."""
if _is_locked():
raise HTTPException(400, "系统已锁定")
if len(req.admin_password) < 6:
raise HTTPException(400, "密码长度至少6位")
db_url = _make_db_url(req)
try:
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
async with engine.begin() as conn:
# Hash password with bcrypt
import bcrypt
password_hash = bcrypt.hashpw(
req.admin_password.encode("utf-8"),
bcrypt.gensalt(),
).decode("utf-8")
# Insert admin
await conn.execute(
text(
"INSERT INTO admins (username, password_hash, email) "
"VALUES (:u, :h, :e) "
"ON DUPLICATE KEY UPDATE password_hash = :h2, email = :e2"
),
{
"u": req.admin_username,
"h": password_hash,
"e": req.admin_email or None,
"h2": password_hash,
"e2": req.admin_email or None,
},
)
# Update brand in settings
await conn.execute(
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
"ON DUPLICATE KEY UPDATE `value` = :v2"),
{"k": "system_name", "v": req.system_name, "v2": req.system_name},
)
await conn.execute(
text("INSERT INTO `settings` (`key`, `value`) VALUES (:k, :v) "
"ON DUPLICATE KEY UPDATE `value` = :v2"),
{"k": "system_title", "v": req.system_title, "v2": req.system_title},
)
await engine.dispose()
except Exception as e:
raise HTTPException(400, f"创建管理员失败: {e}")
# Update config.php with brand
if CONFIG_PHP.exists():
import re
content = CONFIG_PHP.read_text()
content = re.sub(
r"define\('APP_NAME', '.*?'\);",
f"define('APP_NAME', '{req.system_name}');",
content,
)
CONFIG_PHP.write_text(content)
# Update .env with brand
if ENV_FILE.exists():
import re
content = ENV_FILE.read_text()
content = re.sub(
r"NEXUS_SYSTEM_NAME=.*",
f"NEXUS_SYSTEM_NAME={req.system_name}",
content,
)
content = re.sub(
r"NEXUS_SYSTEM_TITLE=.*",
f"NEXUS_SYSTEM_TITLE={req.system_title}",
content,
)
ENV_FILE.write_text(content)
# Update state
if STATE_FILE.exists():
import json
state = json.loads(STATE_FILE.read_text())
state["step"] = 4
state["system_name"] = req.system_name
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False))
return {"success": True}
@router.post("/lock")
async def lock_install():
"""Step 5: Lock the installer — prevent re-installation."""
# Create lock marker files
INSTALL_LOCK.write_text(
f"Nexus installation locked at {datetime.now(timezone.utc).isoformat()}\n"
)
# Also create the legacy lock file (install.php.locked) for compatibility
try:
LOCK_FILE.write_text("locked\n")
except OSError:
pass
# Clean up state file
if STATE_FILE.exists():
try:
STATE_FILE.unlink()
except OSError:
pass
return {"success": True}
@router.get("/state")
async def get_install_state():
"""Get current install state (for step navigation)."""
import json
if not STATE_FILE.exists():
return {"step": 1, "installed": _is_installed(), "locked": _is_locked()}
try:
state = json.loads(STATE_FILE.read_text())
state["installed"] = _is_installed()
state["locked"] = _is_locked()
return state
except Exception:
return {"step": 1, "installed": _is_installed(), "locked": _is_locked()}
+70 -6
View File
@@ -1,7 +1,7 @@
"""Nexus — FastAPI Application Entry Point
Clean Architecture: Presentation Layer (API routes + middleware + lifespan)
Lifespan startup:
Lifespan startup (when .env exists — normal mode):
1. Verify SECRET_KEY is set (fatal if missing)
2. init_db() — create tables
3. init_redis() — ADR-009 mandatory validation (exit if unavailable)
@@ -9,12 +9,19 @@ Lifespan startup:
5. Start Redis Pub/Sub subscriber (ADR-010)
6. Launch background tasks (heartbeat flush, self-monitor)
Lifespan startup (no .env — install mode):
- Only the /api/install/ routes are functional
- All other API routes return 503 "System not configured"
- User completes the install wizard at /app/install.html
D2: install.php → install.html migration (conditional lifespan)
D7: DB session leak fix — middleware auto-manages session lifecycle per request.
"""
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
@@ -23,7 +30,14 @@ from starlette.middleware.base import BaseHTTPMiddleware
from server.config import settings
from server.infrastructure.database.session import init_db, AsyncSessionLocal
# API routes
# Detect install mode: no .env = first-time setup
ROOT_DIR = Path(__file__).resolve().parent.parent
INSTALL_MODE = not (ROOT_DIR / ".env").exists()
# Install wizard (no JWT required — runs before system is configured)
from server.api.install import router as install_router
# API routes (require JWT + full app init)
from server.api.servers import router as servers_router
from server.api.auth import router as auth_router
from server.api.agent import router as agent_router
@@ -76,6 +90,14 @@ class DbSessionMiddleware(BaseHTTPMiddleware):
if not path.startswith("/api/"):
return await call_next(request)
# Skip install API (it manages its own DB connections)
if path.startswith("/api/install/"):
return await call_next(request)
# Skip in install mode (no global engine available)
if INSTALL_MODE:
return await call_next(request)
# Open session for this request
async with AsyncSessionLocal() as session:
request.state.db = session
@@ -89,9 +111,19 @@ class DbSessionMiddleware(BaseHTTPMiddleware):
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifecycle: startup → background tasks → shutdown"""
"""Application lifecycle: startup → background tasks → shutdown
# ── Startup ──
Install mode (no .env): only install API + static files available.
Normal mode (.env exists): full initialization with DB, Redis, background tasks.
"""
# ── Install mode: skip everything ──
if INSTALL_MODE:
logger.info("⚠ INSTALL MODE — .env not found. Visit /app/install.html to configure.")
yield
return
# ── Normal mode: full startup ──
if not settings.SECRET_KEY:
logger.error("SECRET_KEY is empty! Set it in .env or MySQL settings table.")
raise SystemExit("SECRET_KEY is required for Nexus to start.")
@@ -163,12 +195,39 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title=settings.SYSTEM_NAME,
description=f"{settings.SYSTEM_TITLE}",
title=settings.SYSTEM_NAME if not INSTALL_MODE else "Nexus Installer",
description=f"{settings.SYSTEM_TITLE}" if not INSTALL_MODE else "Nexus Installation Wizard",
version="6.0.0",
lifespan=lifespan,
)
# ── Install mode middleware: block non-install routes when not configured ──
class InstallModeMiddleware(BaseHTTPMiddleware):
"""In install mode, only /api/install/, /app/, /health are accessible.
All other API routes return 503 Service Unavailable."""
async def dispatch(self, request: Request, call_next):
if INSTALL_MODE:
path = request.url.path
# Allow: install API, static files, health check
if path.startswith("/api/install/") or path.startswith("/app/") or path == "/health":
return await call_next(request)
if path == "/" or path.startswith("/ws/"):
return await call_next(request)
# Block everything else
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=503,
content={"detail": "系统尚未配置,请先访问 /app/install.html 完成安装"},
)
return await call_next(request)
# Install mode middleware (must be first — before DB session middleware)
app.add_middleware(InstallModeMiddleware)
# D7: DB session leak fix middleware (must be added BEFORE CORS so it wraps all requests)
app.add_middleware(DbSessionMiddleware)
@@ -182,6 +241,11 @@ app.add_middleware(
)
# Register all API routers
# D2: Install wizard (always registered — works in both modes)
app.include_router(install_router)
# Normal API routers (require full app init)
app.include_router(servers_router)
app.include_router(auth_router)
app.include_router(agent_router)
+641
View File
@@ -0,0 +1,641 @@
<!DOCTYPE html>
<html lang="zh-CN" x-data="installWizard()" x-init="init()">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus 6.0 安装向导</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<style type="text/tailwindcss">
@theme {
--color-brand: oklch(55% 0.2 250);
--color-brand-light: oklch(75% 0.15 250);
--color-brand-dark: oklch(35% 0.18 250);
}
</style>
<style>
.installer-card { max-width: 780px; }
.code-block {
background: #1e293b; color: #e2e8f0; border-radius: 6px; padding: 12px;
font-size: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all;
position: relative; margin: 8px 0;
}
.copy-btn {
position: absolute; top: 6px; right: 6px;
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15);
color: #94a3b8; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;
}
.copy-btn:hover { background: rgba(255,255,255,0.2); color: #fff; }
.copy-btn.copied { background: #22c55e; color: #fff; border-color: #22c55e; }
</style>
</head>
<body class="bg-slate-950 min-h-screen flex items-center justify-center p-4">
<div class="installer-card w-full bg-white rounded-2xl shadow-2xl overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-br from-blue-700 to-violet-600 text-white px-8 py-6 text-center">
<h1 class="text-2xl font-bold mb-1">Nexus 6.0 安装向导</h1>
<p class="text-violet-200 text-sm">服务器运维管理平台 — 心跳监控 + 智能告警 + 3层守护</p>
</div>
<!-- Step Indicator -->
<div class="flex items-center justify-center gap-1 py-4 px-8">
<template x-for="i in 5" :key="i">
<div class="flex items-center">
<div class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all"
:class="step > i ? 'bg-green-500 text-white' : step === i ? 'bg-blue-500 text-white' : 'border-2 border-slate-200 text-slate-400'"
x-text="step > i ? '✓' : i"></div>
<div x-show="i < 5" class="w-8 h-0.5 mx-0.5 transition-all"
:class="step > i ? 'bg-green-500' : 'bg-slate-200'"></div>
</div>
</template>
</div>
<!-- Body -->
<div class="px-8 pb-8">
<!-- Error / Success alerts -->
<div x-show="error" x-transition class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg mb-4 text-sm" x-text="error"></div>
<div x-show="success" x-transition class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg mb-4 text-sm" x-text="success"></div>
<!-- Already installed -->
<template x-if="installed">
<div class="text-center py-8">
<div class="text-5xl mb-4">⚠️</div>
<h2 class="text-xl font-bold text-red-600 mb-2">系统已安装</h2>
<p class="text-slate-500 mb-4">检测到 .env 配置文件,系统已完成安装。</p>
<a href="/app/login.html" class="inline-flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg font-medium transition">前往登录 →</a>
</div>
</template>
<!-- ======== Step 1: Welcome ======== -->
<template x-if="step === 1 && !installed">
<div class="text-center py-4">
<h2 class="text-xl font-bold text-slate-800 mb-4">欢迎使用 Nexus 6.0</h2>
<p class="text-slate-500 mb-6 leading-relaxed">
本向导将引导您完成系统安装配置。<br>
安装完成后将自动生成:<code class="bg-slate-100 px-1.5 py-0.5 rounded text-sm">.env</code> (Python后端) +
<code class="bg-slate-100 px-1.5 py-0.5 rounded text-sm">config.php</code> (PHP兼容) +
<code class="bg-slate-100 px-1.5 py-0.5 rounded text-sm">MySQL settings表</code> (共享配置)<br>
整个过程大约 3 分钟。
</p>
<div class="bg-slate-50 rounded-lg p-5 text-left mb-6">
<h3 class="font-semibold text-slate-700 mb-3">📋 安装前准备</h3>
<ul class="text-slate-600 text-sm ml-5 list-disc space-y-1">
<li>Python 3.12+ (FastAPI + uvicorn)</li>
<li>MySQL 8.0+ (需提前创建数据库和用户)</li>
<li>Redis 6+ (心跳缓冲和实时数据)</li>
<li>Web 目录和根目录可写权限</li>
</ul>
</div>
<button @click="step = 2; checkEnv()" class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2.5 rounded-lg font-semibold transition">
开始安装 →
</button>
</div>
</template>
<!-- ======== Step 2: Environment Check ======== -->
<template x-if="step === 2 && !installed">
<div>
<h2 class="text-lg font-bold text-slate-800 mb-4">🔍 环境检测</h2>
<div x-show="envLoading" class="text-center py-8 text-slate-400">
<div class="animate-spin inline-block w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mb-2"></div>
<p>正在检测环境...</p>
</div>
<div x-show="!envLoading && envChecks.length" class="bg-slate-50 rounded-lg overflow-hidden mb-4">
<template x-for="c in envChecks" :key="c.name">
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-100 last:border-0">
<div>
<span class="font-medium text-slate-700" x-text="c.name"></span>
<span class="text-slate-400 text-xs ml-2" x-text="'需要: ' + c.required"></span>
</div>
<span class="font-semibold text-sm" :class="c.pass ? 'text-green-500' : 'text-red-500'"
x-text="c.pass ? '✓ 通过' : '✗ ' + c.current"></span>
</div>
</template>
</div>
<div x-show="!envLoading && envAllPass" class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg mb-4 text-sm">
✓ 所有环境检测通过,可以继续安装。
</div>
<div x-show="!envLoading && !envAllPass && envChecks.length" class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg mb-4 text-sm">
部分环境检测未通过,请先解决上述问题。
</div>
<div x-show="!envLoading" class="flex gap-3 mt-4">
<button @click="step = 3" class="bg-blue-500 hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg font-semibold transition">
下一步:数据库配置 →
</button>
<button @click="checkEnv()" class="bg-slate-200 hover:bg-slate-300 text-slate-700 px-5 py-2.5 rounded-lg font-semibold transition">
重新检测
</button>
</div>
</div>
</template>
<!-- ======== Step 3: DB + Redis + API Config ======== -->
<template x-if="step === 3 && !installed">
<div>
<h2 class="text-lg font-bold text-slate-800 mb-4">⚙ 数据库 + Redis + API 配置</h2>
<div class="bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-lg mb-4 text-sm">
<b>说明:</b>请提前创建好数据库和用户,安装向导将自动建表并写入配置。
SECRET_KEY 和 API_KEY 将自动生成,安装后不可修改(加密一致性)。
</div>
<form @submit.prevent="initDb()">
<!-- MySQL -->
<div class="border-t-2 border-slate-100 pt-4 mt-4">
<h3 class="font-bold text-slate-700 mb-3">🗄 MySQL 数据库</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">主机</label>
<input x-model="form.db_host" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">端口</label>
<input x-model="form.db_port" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">数据库名</label>
<input x-model="form.db_name" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">用户名</label>
<input x-model="form.db_user" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
</div>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">密码</label>
<input x-model="form.db_pass" type="password" required placeholder="数据库密码"
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
</div>
<!-- Redis -->
<div class="border-t-2 border-slate-100 pt-4 mt-4">
<h3 class="font-bold text-slate-700 mb-3">⚡ Redis 配置</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 主机</label>
<input x-model="form.redis_host" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 端口</label>
<input x-model="form.redis_port" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 数据库号</label>
<input x-model="form.redis_db" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 密码(可选)</label>
<input x-model="form.redis_pass" type="password" placeholder="无密码留空"
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
</div>
</div>
<!-- API + Site + Timezone -->
<div class="border-t-2 border-slate-100 pt-4 mt-4">
<h3 class="font-bold text-slate-700 mb-3">🌐 网站地址 + API 服务 + 时区</h3>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">网站地址</label>
<p class="text-xs text-slate-400 mb-1">自动检测 — 用于 Agent 上报和前端 API 调用,可手动修改</p>
<input x-model="form.site_url" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">Python API 端口</label>
<p class="text-xs text-slate-400 mb-1">uvicorn 监听端口</p>
<input x-model="form.api_port" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">时区</label>
<select x-model="form.timezone" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
<option value="Asia/Shanghai">Asia/Shanghai (中国标准时间)</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="America/New_York">America/New_York</option>
<option value="Europe/London">Europe/London</option>
<option value="UTC">UTC</option>
</select>
</div>
</div>
</div>
<div class="bg-amber-50 border border-amber-200 text-amber-800 px-4 py-3 rounded-lg mt-4 text-sm">
<b>⚠ 注意:</b>初始化将自动:① 连接数据库并建表 ② 生成 SECRET_KEY/API_KEY
③ 写入 .env + config.php + settings表 ④ 自动计算连接池大小
</div>
<div class="flex gap-3 mt-5">
<button type="submit" :disabled="loading" class="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-5 py-2.5 rounded-lg font-semibold transition flex items-center gap-2">
<span x-show="loading" class="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full"></span>
<span x-text="loading ? '初始化中...' : '初始化数据库 →'"></span>
</button>
<button type="button" @click="step = 2" class="bg-slate-200 hover:bg-slate-300 text-slate-700 px-5 py-2.5 rounded-lg font-semibold transition">← 上一步</button>
</div>
</form>
</div>
</template>
<!-- ======== Step 4: Admin + Brand ======== -->
<template x-if="step === 4 && !installed">
<div>
<h2 class="text-lg font-bold text-slate-800 mb-4">👤 管理员账号 + 系统名称</h2>
<!-- Step 3 summary -->
<div class="bg-slate-50 rounded-lg p-4 mb-4 text-sm">
<div class="font-semibold text-green-600 mb-2">✓ 步骤 3 配置完成</div>
<div class="grid grid-cols-2 gap-1">
<span>数据库: <code class="bg-slate-200 px-1 rounded" x-text="form.db_name"></code></span>
<span>用户: <code class="bg-slate-200 px-1 rounded" x-text="form.db_user"></code></span>
<span>连接池: <code class="bg-slate-200 px-1 rounded" x-text="initResult?.pool_size || '—'"></code></span>
<span>溢出池: <code class="bg-slate-200 px-1 rounded" x-text="initResult?.max_overflow || '—'"></code></span>
</div>
</div>
<form @submit.prevent="createAdmin()">
<!-- Brand -->
<div class="border-t-2 border-slate-100 pt-4">
<h3 class="font-bold text-slate-700 mb-3">系统品牌</h3>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">系统名称</label>
<p class="text-xs text-slate-400 mb-1">显示在浏览器标题和后台标题栏</p>
<input x-model="adminForm.system_name" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">系统标题</label>
<input x-model="adminForm.system_title" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
</div>
<!-- Admin account -->
<div class="border-t-2 border-slate-100 pt-4 mt-4">
<h3 class="font-bold text-slate-700 mb-3">管理员账号</h3>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">管理员用户名</label>
<input x-model="adminForm.admin_username" required class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">管理员密码</label>
<p class="text-xs text-slate-400 mb-1">至少6位字符</p>
<input x-model="adminForm.admin_password" type="password" required minlength="6" placeholder="请输入安全密码"
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">邮箱(可选)</label>
<input x-model="adminForm.admin_email" type="email" placeholder="admin@example.com"
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
</div>
<div class="flex gap-3 mt-5">
<button type="submit" :disabled="loading" class="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-5 py-2.5 rounded-lg font-semibold transition flex items-center gap-2">
<span x-show="loading" class="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full"></span>
<span x-text="loading ? '创建中...' : '创建账号 →'"></span>
</button>
<button type="button" @click="step = 3" class="bg-slate-200 hover:bg-slate-300 text-slate-700 px-5 py-2.5 rounded-lg font-semibold transition">← 上一步</button>
</div>
</form>
</div>
</template>
<!-- ======== Step 5: Complete ======== -->
<template x-if="step === 5 && !installed">
<div class="text-center">
<div class="w-16 h-16 rounded-full bg-green-100 text-green-500 text-3xl flex items-center justify-center mx-auto mb-4"></div>
<h2 class="text-xl font-bold text-slate-800 mb-2">安装完成!</h2>
<p class="text-slate-500 mb-1">Nexus 6.0 已成功安装。</p>
<p class="text-slate-400 text-xs">install.php 已重命名为 install.php.locked — 防止重复安装</p>
<!-- Guardian results -->
<div x-show="initResult?.guardian_results?.length" class="text-left mt-6 mb-4 rounded-lg p-4"
:class="guardianAllOk ? 'bg-green-50 border-l-4 border-green-500' : 'bg-amber-50 border-l-4 border-amber-500'">
<div class="font-semibold text-sm mb-2" :class="guardianAllOk ? 'text-green-700' : 'text-amber-700'"
x-text="guardianAllOk ? '✓ 进程守护已自动配置' : '⚠ 进程守护部分配置失败'"></div>
<template x-for="r in (initResult?.guardian_results || [])" :key="r">
<div class="text-xs leading-relaxed" x-text="r"></div>
</template>
</div>
<!-- Post-install checklist -->
<div class="text-left mt-6 space-y-3">
<h3 class="text-sm font-bold text-slate-700 text-center mb-3">━━━ 后续配置清单(必须完成)━━━</h3>
<!-- 1. Supervisor -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">1</span>
Supervisor 进程守护
</div>
<div x-show="!guardianAllOk" class="text-xs text-slate-500 space-y-1">
<p>安装: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y supervisor</code></p>
<p>配置文件复制到: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">/etc/supervisor/conf.d/nexus.conf</code></p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'supervisorConf')">复制配置</button><pre id="supervisorConf">[program:nexus]
command=<span x-text="installDir"></span>/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port <span x-text="form.api_port"></span>
directory=<span x-text="installDir"></span>
user=root
autostart=true
autorestart=true
startretries=10
startsecs=3
stopwaitsecs=10
stopsignal=INT
environment=PATH="<span x-text="installDir"></span>/venv/bin:%(ENV_PATH)s"
stderr_logfile=/var/log/nexus/error.log
stdout_logfile=/var/log/nexus/access.log
stderr_logfile_maxbytes=50MB
stdout_logfile_maxbytes=50MB</pre></div>
<p>启动: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start nexus</code></p>
</div>
<div x-show="guardianAllOk" class="text-xs text-green-600">✓ 已自动配置 — Supervisor 配置已写入,服务已重载</div>
</div>
<!-- 2. Python venv -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">2</span>
Python 虚拟环境 + 依赖
</div>
<div class="text-xs text-slate-500 space-y-1">
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">cd <span x-text="installDir"></span></code></p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">python3.12 -m venv venv</code></p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">source venv/bin/activate</code></p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">pip install -r requirements.txt</code></p>
</div>
</div>
<!-- 3. Nginx -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">3</span>
Nginx 反向代理 (API + WebSocket)
</div>
<div class="text-xs text-slate-500">
<p class="mb-1">宝塔 → 网站 → 配置文件 → <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">#PHP-INFO-END</code> 后面粘贴:</p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxConf')">复制配置</button><pre id="nginxConf"> # Nexus Python API + WebSocket
location ^~ /api/ {
proxy_pass http://127.0.0.1:<span x-text="form.api_port"></span>;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /health {
proxy_pass http://127.0.0.1:<span x-text="form.api_port"></span>;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location ^~ /ws/ {
proxy_pass http://127.0.0.1:<span x-text="form.api_port"></span>;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}</pre></div>
</div>
</div>
<!-- 4. Nginx Rewrite -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">4</span>
Nginx 伪静态
</div>
<div class="text-xs text-slate-500">
<p class="mb-1">宝塔 → 网站 → 伪静态 → 粘贴:</p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxRewrite')">复制</button><pre id="nginxRewrite">location /app/ {
try_files $uri $uri/ /app/index.html;
}
location / {
try_files $uri $uri/ =404;
}</pre></div>
</div>
</div>
<!-- 5. SSL -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">5</span>
SSL证书 (强制HTTPS)
</div>
<div class="text-xs text-slate-500">宝塔 → 网站 → SSL → Let's Encrypt → 一键申请 → 开启强制HTTPS</div>
</div>
<!-- 6. Health Monitor -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">6</span>
Shell 健康检查 (外部守护 + Telegram告警)
</div>
<div x-show="!guardianAllOk" class="text-xs text-slate-500">
<p>配置 crontab:</p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'crontab')">复制</button><pre id="crontab">* * * * * <span x-text="installDir"></span>/deploy/health_monitor.sh</pre></div>
</div>
<div x-show="guardianAllOk" class="text-xs text-green-600">✓ 已自动配置 — health_monitor.sh INSTALL_DIR 已更新,Crontab 已添加</div>
<div class="text-xs text-slate-500 mt-2">
<b>3层守护机制:</b><br>
Layer 1: Supervisor — Python崩溃自动重启<br>
Layer 2: Python self_monitor — 每30s检查Redis/MySQL/WebSocket<br>
Layer 3: Shell脚本 — 每分钟HTTP检查,连续3次失败→Supervisor重启+Telegram告警
</div>
</div>
<!-- 7. Security -->
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div class="font-bold text-sm text-slate-800 mb-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">7</span>
安全收尾
</div>
<div class="text-xs text-slate-500 space-y-1">
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">install.php</code> 已自动重命名为 <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">install.php.locked</code></p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">.env</code> 包含 SECRET_KEY/API_KEY — <b>安装后不可修改</b>(加密一致性)</p>
<p>✓ 防火墙限制端口 <span x-text="form.api_port"></span> (仅允许本机和子服务器IP)</p>
<p>✓ 修改管理员默认密码</p>
</div>
</div>
</div>
<div class="flex justify-center mt-6">
<a href="/app/index.html" class="bg-green-500 hover:bg-green-600 text-white px-8 py-3 rounded-lg font-semibold text-lg transition">
进入管理面板 →
</a>
</div>
</div>
</template>
</div>
</div>
<script>
function installWizard() {
const API = window.location.origin;
return {
step: 1,
loading: false,
installed: false,
error: '',
success: '',
envLoading: false,
envChecks: [],
envAllPass: false,
initResult: null,
installDir: '/opt/nexus',
form: {
db_host: 'localhost',
db_port: '3306',
db_name: 'Nexus',
db_user: 'Nexus',
db_pass: '',
redis_host: '127.0.0.1',
redis_port: '6379',
redis_db: '0',
redis_pass: '',
api_port: '8600',
timezone: 'Asia/Shanghai',
site_url: '',
},
adminForm: {
admin_username: 'admin',
admin_password: '',
admin_email: '',
system_name: 'Nexus',
system_title: 'Nexus — 服务器运维管理平台',
},
get guardianAllOk() {
const results = this.initResult?.guardian_results || [];
if (!results.length) return false;
return results.every(r => !r.includes('✗') && !r.includes('⚠'));
},
async init() {
// Auto-detect site URL
const proto = location.protocol === 'https:' ? 'https' : 'http';
this.form.site_url = proto + '://' + location.host;
// Check install status
try {
const r = await fetch(API + '/api/install/status');
if (r.ok) {
const d = await r.json();
this.installed = d.installed;
if (d.installed) return;
}
} catch(e) {}
// Try to restore state
try {
const r = await fetch(API + '/api/install/state');
if (r.ok) {
const d = await r.json();
if (d.step > 1) this.step = d.step;
if (d.db_host) Object.assign(this.form, {
db_host: d.db_host, db_port: d.db_port, db_name: d.db_name,
db_user: d.db_user, db_pass: d.db_pass, api_port: d.api_port || '8600',
});
if (d.install_dir) this.installDir = d.install_dir;
if (d.pool_size) this.initResult = { pool_size: d.pool_size, max_overflow: d.max_overflow, guardian_results: d.guardian_results || [] };
}
} catch(e) {}
},
async checkEnv() {
this.envLoading = true;
this.error = '';
try {
const r = await fetch(API + '/api/install/env-check');
if (r.ok) {
const d = await r.json();
this.envChecks = d.checks;
this.envAllPass = d.all_pass;
}
} catch(e) {
this.error = '环境检测请求失败: ' + e.message;
}
this.envLoading = false;
},
async initDb() {
this.loading = true;
this.error = '';
this.success = '';
try {
const r = await fetch(API + '/api/install/init-db', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(this.form),
});
const d = await r.json();
if (r.ok && d.success) {
this.success = '数据库初始化成功!已创建 ' + (d.tables_created || 14) + ' 张表。';
this.initResult = d;
this.step = 4;
} else {
this.error = d.detail || '初始化失败';
}
} catch(e) {
this.error = '请求失败: ' + e.message;
}
this.loading = false;
},
async createAdmin() {
this.loading = true;
this.error = '';
try {
const body = {
...this.adminForm,
db_host: this.form.db_host,
db_port: this.form.db_port,
db_name: this.form.db_name,
db_user: this.form.db_user,
db_pass: this.form.db_pass,
};
const r = await fetch(API + '/api/install/create-admin', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const d = await r.json();
if (r.ok && d.success) {
// Lock the installer
await fetch(API + '/api/install/lock', { method: 'POST' });
this.step = 5;
} else {
this.error = d.detail || '创建管理员失败';
}
} catch(e) {
this.error = '请求失败: ' + e.message;
}
this.loading = false;
},
copyCode(event, id) {
const el = document.getElementById(id);
if (!el) return;
const text = el.textContent;
const btn = event.target;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = '已复制 ✓';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = '复制'; btn.classList.remove('copied'); }, 2000);
});
},
};
}
</script>
</body>
</html>
+167 -155
View File
@@ -1,193 +1,205 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus — Web SSH 终端</title>
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: false, connected: false, serverName: '...', cols: 80, rows: 24, fullscreen: false }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — SSH终端</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style><style>.xterm{padding:4px}#terminalWrap{height:calc(100vh - 48px)}#terminalWrap.fs{height:100vh;position:fixed;inset:0;z-index:100}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
<!-- Tailwind CSS v4 CDN -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- xterm.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<!-- Sidebar (collapsed by default — terminal needs space) -->
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
<div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div>
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
<a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a>
<a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a>
<a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a>
<a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📤 推送</a>
<a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a>
<a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a>
<a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a>
<a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a>
<div class="border-t border-slate-800 my-2"></div>
<a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计日志</a>
<a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a>
</nav>
<div class="border-t border-slate-800 p-4"><div class="flex items-center justify-between"><span id="sidebarUser" class="text-slate-400 text-sm">...</span><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button></div></div>
</aside>
<style type="text/tailwindcss">
@theme {
--color-brand: oklch(55% 0.2 250);
--color-brand-light: oklch(75% 0.15 250);
--color-brand-dark: oklch(35% 0.18 250);
--color-surface: oklch(98.5% 0.002 250);
}
#terminal { height: calc(100vh - 120px); }
.xterm { padding: 8px; }
</style>
</head>
<body class="bg-slate-950 min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-slate-900 border-b border-slate-800 px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/app/index.html" class="text-slate-400 hover:text-white transition">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
<div>
<h1 class="text-white font-semibold">Web SSH 终端</h1>
<p id="serverInfo" class="text-slate-400 text-sm">连接中...</p>
<div class="flex-1 flex flex-col min-w-0">
<!-- Toolbar -->
<header x-show="!fullscreen" class="bg-slate-900 border-b border-slate-800 px-4 py-2 flex items-center justify-between h-12 shrink-0">
<div class="flex items-center gap-3">
<button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<span class="text-slate-500 text-sm">SSH</span>
<span class="text-white font-semibold text-sm" x-text="serverName"></span>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs" :class="connected ? 'bg-green-900/30 text-green-400' : 'bg-slate-800 text-slate-500'">
<span class="w-1.5 h-1.5 rounded-full" :class="connected ? 'bg-green-400 animate-pulse' : 'bg-slate-600'"></span>
<span x-text="connected ? '已连接' : '未连接'"></span>
</span>
</div>
</div>
<div class="flex items-center gap-2">
<button id="resizeBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">
自适应窗口
</button>
<button id="disconnectBtn" class="px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 text-sm rounded-lg transition">
断开连接
</button>
</div>
</header>
<div class="flex items-center gap-2">
<span class="text-slate-600 text-xs font-mono" x-text="cols+'×'+rows"></span>
<button onclick="toggleFullscreen()" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" x-text="fullscreen?'退出全屏':'全屏'">全屏</button>
<button onclick="disconnect()" class="px-2 py-1 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-xs rounded transition">断开</button>
<a href="/app/servers.html" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition">↩ 返回</a>
</div>
</header>
<!-- Terminal Container -->
<div class="flex-1 p-2">
<div id="terminal" class="bg-slate-900 rounded-lg border border-slate-800"></div>
<!-- Terminal -->
<div id="terminalWrap" class="bg-slate-950"><div id="terminal"></div></div>
</div>
<!-- Status Bar -->
<footer class="bg-slate-900 border-t border-slate-800 px-4 py-2 flex items-center justify-between text-xs text-slate-500">
<div class="flex items-center gap-4">
<span id="connectionStatus" class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
连接中...
</span>
</div>
<div class="flex items-center gap-4">
<span>Alt+F 切换全屏</span>
</div>
</footer>
<script src="/app/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<script>
const urlParams = new URLSearchParams(window.location.search);
const serverId = urlParams.get('server_id');
// ── Parse server_id ──
const params = new URLSearchParams(window.location.search);
const serverId = parseInt(params.get('server_id'));
if (!serverId) window.location.href = '/app/servers.html';
if (!serverId) {
alert('缺少 server_id 参数');
window.location.href = '/app/index.html';
}
// ── Alpine helper ──
function $d() { return Alpine.$data(document.body); }
let term, ws, fitAddon;
let sessionId = null;
// ── xterm.js ──
const term = new Terminal({
cursorBlink: true,
cursorStyle: 'bar',
fontSize: 14,
fontFamily: '"Cascadia Code","Fira Code","JetBrains Mono",Menlo,monospace',
theme: {
background: '#0b1120',
foreground: '#e2e8f0',
cursor: '#7c8bf4',
selectionBackground: '#334155',
black: '#1e293b', red: '#f87171', green: '#4ade80', yellow: '#fbbf24',
blue: '#60a5fa', magenta: '#c084fc', cyan: '#22d3ee', white: '#e2e8f0',
brightBlack: '#64748b', brightRed: '#fca5a5', brightGreen: '#86efac',
brightYellow: '#fde047', brightBlue: '#93c5fd', brightMagenta: '#d8b4fe',
brightCyan: '#67e8f9', brightWhite: '#f8fafc',
},
allowProposedApi: true,
});
function initTerminal() {
term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
theme: {
background: '#0f172a',
foreground: '#e2e8f0',
cursor: '#38bdf8',
selectionBackground: '#38bdf830',
},
rows: 24,
cols: 80,
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon.WebLinksAddon());
term.open(document.getElementById('terminal'));
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
fitAddon.fit();
// Initial fit
setTimeout(() => fitAddon.fit(), 50);
window.addEventListener('resize', () => { try { fitAddon.fit(); } catch(e){} });
window.addEventListener('resize', () => fitAddon.fit());
// ── WebSocket ──
let ws = null;
term.onData(data => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'DATA', data }));
}
});
}
function connectTerminal() {
function connect() {
const token = localStorage.getItem('access_token');
if (!token) { doLogout(); return; }
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${window.location.host}/ws/terminal/${serverId}?token=${token}`;
if (!token) { window.location.href = '/app/login.html'; return; }
ws = new WebSocket(wsUrl);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws/terminal/${serverId}?token=${encodeURIComponent(token)}`);
ws.onopen = () => {
updateStatus('connected', '已连接');
document.getElementById('disconnectBtn').disabled = false;
$d().connected = true;
term.focus();
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'TERMINAL_INIT':
sessionId = msg.session_id;
document.getElementById('serverInfo').textContent = `${msg.server_name} (ID: ${msg.server_id})`;
term.focus();
break;
case 'DATA':
term.write(msg.data);
break;
case 'CLOSE':
disconnect();
break;
case 'ERROR':
term.writeln('\r\n\x1b[31m错误: ' + msg.message + '\x1b[0m');
break;
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
switch (msg.type) {
case 'TERMINAL_INIT':
$d().serverName = msg.server_name || ('服务器 #' + msg.server_id);
document.title = `Nexus — ${$d().serverName}`;
// Send initial size
const d = fitAddon.proposeDimensions();
if (d && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'RESIZE', cols: d.cols, rows: d.rows }));
$d().cols = d.cols; $d().rows = d.rows;
}
break;
case 'DATA':
term.write(msg.data);
break;
case 'ERROR':
term.write('\r\n\x1b[31m⚠ ' + msg.message + '\x1b[0m\r\n');
break;
case 'CLOSE':
$d().connected = false;
term.write('\r\n\x1b[33m━━ 连接已关闭 ━━\x1b[0m\r\n');
break;
}
} catch (err) {
term.write(e.data);
}
};
ws.onclose = (ev) => {
$d().connected = false;
if (ev.code === 4001) {
term.write('\r\n\x1b[31m⚠ 认证失败,请重新登录\x1b[0m\r\n');
setTimeout(() => location.href = '/app/login.html', 2000);
} else if (ev.code === 4004) {
term.write('\r\n\x1b[31m⚠ 服务器不存在\x1b[0m\r\n');
} else if (ev.code !== 1000) {
term.write('\r\n\x1b[33m⚠ 连接断开 (' + ev.code + ')\x1b[0m\r\n');
}
};
ws.onerror = () => {
updateStatus('error', '连接错误');
term.writeln('\r\n\x1b[31mWebSocket 错误\x1b[0m');
};
ws.onclose = () => {
updateStatus('disconnected', '已断开');
term.writeln('\r\n\x1b[33m连接已关闭\x1b[0m');
$d().connected = false;
term.write('\r\n\x1b[31mWebSocket错误\x1b[0m\r\n');
};
}
function updateStatus(status, text) {
const el = document.getElementById('connectionStatus');
const dotClass = status === 'connected' ? 'bg-green-500' : status === 'error' ? 'bg-red-500' : 'bg-yellow-500 animate-pulse';
el.innerHTML = '<span class="w-2 h-2 rounded-full ' + dotClass + '"></span>' + text;
// ── Input: terminal → WebSocket ──
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'DATA', data }));
}
});
// ── Resize: terminal → WebSocket ──
term.onResize(({ cols, rows }) => {
$d().cols = cols; $d().rows = rows;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'RESIZE', cols, rows }));
}
});
// Auto-fit on container resize
new ResizeObserver(() => { try { fitAddon.fit(); } catch(e){} })
.observe(document.getElementById('terminalWrap'));
// ── Fullscreen ──
function toggleFullscreen() {
const el = document.getElementById('terminalWrap');
const fs = el.classList.toggle('fs');
$d().fullscreen = fs;
setTimeout(() => fitAddon.fit(), 50);
}
// ── Disconnect ──
function disconnect() {
if (ws) {
try { ws.send(JSON.stringify({ type: 'CLOSE' })); } catch(e) {}
ws.close();
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'CLOSE' }));
ws.close(1000, 'User disconnect');
}
}
document.getElementById('resizeBtn').addEventListener('click', () => {
if (fitAddon) fitAddon.fit();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'RESIZE', cols: term.cols, rows: term.rows }));
}
});
document.getElementById('disconnectBtn').addEventListener('click', disconnect);
// ── Keyboard shortcuts ──
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key === 'f') {
e.preventDefault();
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
}
if (e.ctrlKey && e.shiftKey && e.key === 'F') { e.preventDefault(); toggleFullscreen(); }
if (e.ctrlKey && e.shiftKey && e.key === 'K') { e.preventDefault(); disconnect(); }
});
initTerminal();
connectTerminal();
setTimeout(() => fitAddon.fit(), 100);
// ── Load server name for page title ──
(async () => {
try {
const r = await apiFetch(API + '/servers/' + serverId);
if (r?.ok) { const s = await r.json(); $d().serverName = s.name || $d().serverName; document.title = 'Nexus — ' + $d().serverName; }
} catch(e){}
})();
// ── Init ──
connect();
</script>
</body>
</html>
</html>