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:
@@ -9,6 +9,8 @@ build/
|
||||
|
||||
# Environment (NEVER commit — contains production credentials)
|
||||
.env
|
||||
.install_locked
|
||||
.install_state.json
|
||||
SECRETS.md
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
@@ -146,7 +146,7 @@ Web SSH 表:
|
||||
- [x] Semaphore 并发控制 (MAX_CONCURRENT=10)
|
||||
- [x] 远程目录浏览 (POST /api/sync/browse)
|
||||
|
||||
### 第五步:前端全量迁移 — ⚠️ 80%
|
||||
### 第五步:前端全量迁移 — ✅ 已完成
|
||||
|
||||
- [x] @theme 设计系统(品牌色/间距/字体/暗黑模式)
|
||||
- [x] 布局壳(sidebar + header + main,Alpine.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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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%**
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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[31m⚠ WebSocket错误\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>
|
||||
|
||||
Reference in New Issue
Block a user