diff --git a/agent/collector.py b/agent/collector.py
new file mode 100644
index 0000000..87a3d0e
--- /dev/null
+++ b/agent/collector.py
@@ -0,0 +1,144 @@
+"""VPS Agent 采集模块
+
+采集本机资源、服务器信息与基础安全状态。
+依赖 psutil + 少量系统命令(ufw/iptables/sshd_config)。
+"""
+
+import platform
+import socket
+import subprocess
+import time
+
+import psutil
+
+
+def collect_metrics() -> dict:
+ """资源使用率(CPU/内存/磁盘/网络/负载/运行时长)"""
+ cpu = psutil.cpu_percent(interval=1)
+ mem = psutil.virtual_memory()
+ disk = psutil.disk_usage("/")
+ net = psutil.net_io_counters()
+ try:
+ load_1m = psutil.getloadavg()[0]
+ except (AttributeError, OSError):
+ load_1m = None
+ return {
+ "cpu_pct": round(cpu, 1),
+ "mem_pct": round(mem.percent, 1),
+ "disk_pct": round(disk.percent, 1),
+ "net_in_mb": round(net.bytes_recv / 1024 / 1024, 2),
+ "net_out_mb": round(net.bytes_sent / 1024 / 1024, 2),
+ "load_1m": round(load_1m, 2) if load_1m is not None else None,
+ "uptime_sec": int(time.time() - psutil.boot_time()),
+ }
+
+
+def _get_ip() -> str:
+ """获取本机出口 IP(不实际发包)"""
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(("8.8.8.8", 80))
+ ip = s.getsockname()[0]
+ s.close()
+ return ip
+ except Exception:
+ return ""
+
+
+def collect_server_info() -> dict:
+ """服务器基础信息"""
+ os_name = platform.system()
+ try:
+ with open("/etc/os-release") as f:
+ for line in f:
+ if line.startswith("PRETTY_NAME="):
+ os_name = line.split("=", 1)[1].strip().strip('"')
+ break
+ except Exception:
+ pass
+ return {
+ "hostname": socket.gethostname(),
+ "os": os_name,
+ "kernel": platform.release(),
+ "cpu_cores": psutil.cpu_count(),
+ "mem_total_gb": round(psutil.virtual_memory().total / 1024**3, 1),
+ "disk_total_gb": round(psutil.disk_usage("/").total / 1024**3, 1),
+ "public_ip": _get_ip(),
+ "status": "online",
+ }
+
+
+def _check_ssh_config() -> dict:
+ """检查 SSH 配置(root 登录 / 密码登录)"""
+ path = "/etc/ssh/sshd_config"
+ permit_root = "unknown"
+ password_auth = "unknown"
+ try:
+ with open(path) as f:
+ for line in f:
+ low = line.strip().lower()
+ if low.startswith("permitrootlogin"):
+ parts = low.split()
+ permit_root = parts[1] if len(parts) > 1 else "unknown"
+ elif low.startswith("passwordauthentication"):
+ parts = low.split()
+ password_auth = parts[1] if len(parts) > 1 else "unknown"
+ except Exception:
+ return {"check_item": "ssh_config", "status": "unknown",
+ "detail": "无法读取 sshd_config", "suggestion": None}
+
+ status = "pass"
+ suggestions = []
+ if permit_root == "yes":
+ status = "warn"
+ suggestions.append("设置 PermitRootLogin no 或 prohibit-password")
+ if password_auth == "yes":
+ status = "warn"
+ suggestions.append("关闭密码登录 PasswordAuthentication no,改用密钥")
+ return {
+ "check_item": "ssh_config",
+ "status": status,
+ "detail": f"PermitRootLogin={permit_root}, PasswordAuthentication={password_auth}",
+ "suggestion": ";".join(suggestions) if suggestions else None,
+ }
+
+
+def _check_firewall() -> dict:
+ """检查防火墙状态"""
+ try:
+ out = subprocess.run(["ufw", "status"], capture_output=True, text=True, timeout=5)
+ if "Status: active" in out.stdout:
+ return {"check_item": "firewall", "status": "pass",
+ "detail": "ufw 已启用", "suggestion": None}
+ return {"check_item": "firewall", "status": "warn",
+ "detail": "ufw 未启用", "suggestion": "启用防火墙 ufw enable"}
+ except Exception:
+ try:
+ out = subprocess.run(["iptables", "-L", "-n"], capture_output=True, text=True, timeout=5)
+ if out.stdout.strip():
+ return {"check_item": "firewall", "status": "pass",
+ "detail": "iptables 有规则", "suggestion": None}
+ except Exception:
+ pass
+ return {"check_item": "firewall", "status": "unknown",
+ "detail": "未检测到 ufw/iptables", "suggestion": None}
+
+
+def _check_listening_ports() -> dict:
+ """统计监听端口"""
+ try:
+ conns = psutil.net_connections(kind="inet")
+ listening = sorted({c.laddr.port for c in conns if c.status == "LISTEN"})
+ detail = f"开放端口 {len(listening)} 个: {','.join(map(str, listening[:15]))}"
+ status = "pass" if len(listening) < 10 else "warn"
+ suggestion = "端口偏多,建议关闭非必要端口" if status == "warn" else None
+ return {"check_item": "listening_ports", "status": status,
+ "detail": detail, "suggestion": suggestion}
+ except Exception as e: # noqa: BLE001
+ return {"check_item": "listening_ports", "status": "unknown",
+ "detail": str(e), "suggestion": None}
+
+
+def collect_security() -> list:
+ """基础安全检查项"""
+ return [_check_ssh_config(), _check_firewall(), _check_listening_ports()]
diff --git a/agent/reporter.py b/agent/reporter.py
new file mode 100644
index 0000000..5df5e15
--- /dev/null
+++ b/agent/reporter.py
@@ -0,0 +1,47 @@
+"""VPS Agent 主入口:采集本机信息并上报到中心服务器
+
+环境变量(建议写入 /etc/vps-agent.env):
+ VPS_MANAGER_URL 中心地址,如 http://100.89.0.11:8000
+ VPS_ASSET_ID 本机对应的资产 ID
+ VPS_AGENT_KEY Agent Key(中心配置了 AGENT_KEY 时必填)
+"""
+
+import os
+import sys
+
+import httpx
+
+from collector import collect_metrics, collect_security, collect_server_info
+
+
+def main() -> int:
+ url = os.environ.get("VPS_MANAGER_URL", "").rstrip("/")
+ asset_id = os.environ.get("VPS_ASSET_ID", "")
+ key = os.environ.get("VPS_AGENT_KEY", "")
+
+ if not url or not asset_id:
+ print("错误:需设置 VPS_MANAGER_URL 和 VPS_ASSET_ID")
+ return 1
+
+ payload = {
+ "asset_id": int(asset_id),
+ "metrics": collect_metrics(),
+ "server_info": collect_server_info(),
+ "security": collect_security(),
+ }
+ headers = {"X-Agent-Key": key} if key else {}
+
+ try:
+ resp = httpx.post(
+ f"{url}/api/agent/report", json=payload, headers=headers, timeout=30
+ )
+ print(f"上报完成 HTTP {resp.status_code}: {resp.text}")
+ resp.raise_for_status()
+ return 0
+ except Exception as e: # noqa: BLE001
+ print(f"上报失败: {e}")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/agent/vps-agent.env.example b/agent/vps-agent.env.example
new file mode 100644
index 0000000..4c1ae5c
--- /dev/null
+++ b/agent/vps-agent.env.example
@@ -0,0 +1,10 @@
+# VPS Agent 环境变量示例(复制为 /etc/vps-agent.env)
+
+# 中心服务器地址(Tailscale 内网)
+VPS_MANAGER_URL=http://100.89.0.11:8000
+
+# 本机在资产管理系统中对应的资产 ID
+VPS_ASSET_ID=1
+
+# Agent Key(中心 .env 配置了 AGENT_KEY 时必填,需一致)
+VPS_AGENT_KEY=
diff --git a/agent/vps-agent.service b/agent/vps-agent.service
new file mode 100644
index 0000000..9e67fa3
--- /dev/null
+++ b/agent/vps-agent.service
@@ -0,0 +1,13 @@
+# VPS Agent 服务(oneshot,由 timer 触发)
+# 部署位置:/etc/systemd/system/vps-agent.service
+# 需先部署 collector.py + reporter.py 到 /opt/vps-agent/,并配置 /etc/vps-agent.env
+
+[Unit]
+Description=VPS Manager Agent (collect & report)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+EnvironmentFile=-/etc/vps-agent.env
+ExecStart=/usr/bin/python3 /opt/vps-agent/reporter.py
diff --git a/agent/vps-agent.timer b/agent/vps-agent.timer
new file mode 100644
index 0000000..5819b91
--- /dev/null
+++ b/agent/vps-agent.timer
@@ -0,0 +1,13 @@
+# VPS Agent 定时器(每 5 分钟采集上报一次)
+# 部署位置:/etc/systemd/system/vps-agent.timer
+
+[Unit]
+Description=Run VPS Manager Agent every 5 minutes
+
+[Timer]
+OnBootSec=1min
+OnUnitActiveSec=5min
+Unit=vps-agent.service
+
+[Install]
+WantedBy=timers.target
diff --git a/app/core/config.py b/app/core/config.py
index 4b2abfc..457b3e9 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -17,7 +17,12 @@ class Settings:
"""应用配置项"""
APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统")
+ # 写操作(POST/PUT/DELETE)鉴权密钥;留空则不校验
API_KEY: str = os.getenv("API_KEY", "")
+ # Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key
+ AGENT_KEY: str = os.getenv("AGENT_KEY", "")
+ # 凭证加密主密钥(Fernet);生成:python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
+ MASTER_KEY: str = os.getenv("MASTER_KEY", "")
settings = Settings()
diff --git a/app/core/crypto.py b/app/core/crypto.py
new file mode 100644
index 0000000..2e8751c
--- /dev/null
+++ b/app/core/crypto.py
@@ -0,0 +1,40 @@
+"""凭证加密模块(Fernet 对称加密)
+
+用于加密存储 SSH 密钥、密码、API Key、平台 API 配置等敏感信息。
+MASTER_KEY 从 .env 读取,不入库。
+"""
+
+from typing import Optional
+
+from cryptography.fernet import Fernet, InvalidToken
+
+from app.core.config import settings
+
+
+def _get_fernet() -> Fernet:
+ """获取 Fernet 实例(MASTER_KEY 未配置时抛错)"""
+ key = settings.MASTER_KEY
+ if not key:
+ raise RuntimeError(
+ "MASTER_KEY 未配置,无法加解密凭证。请在 .env 设置 MASTER_KEY,"
+ "生成命令:python -c 'from cryptography.fernet import Fernet; "
+ "print(Fernet.generate_key().decode())'"
+ )
+ return Fernet(key.encode() if isinstance(key, str) else key)
+
+
+def encrypt(plain: Optional[str]) -> Optional[str]:
+ """加密明文,返回 token 字符串;空值原样返回 None"""
+ if plain is None or plain == "":
+ return None
+ return _get_fernet().encrypt(plain.encode()).decode()
+
+
+def decrypt(token: Optional[str]) -> Optional[str]:
+ """解密 token,返回明文;空值或解密失败返回 None"""
+ if not token:
+ return None
+ try:
+ return _get_fernet().decrypt(token.encode()).decode()
+ except (InvalidToken, ValueError):
+ return None
diff --git a/app/core/security.py b/app/core/security.py
index 3d18ec7..8ae618f 100644
--- a/app/core/security.py
+++ b/app/core/security.py
@@ -23,3 +23,16 @@ async def require_api_key(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API Key 无效或缺失",
)
+
+
+async def require_agent_key(
+ x_agent_key: Optional[str] = Header(default=None, alias="X-Agent-Key"),
+) -> None:
+ """校验 Agent 上报 Key(可选启用)"""
+ if not settings.AGENT_KEY:
+ return
+ if x_agent_key != settings.AGENT_KEY:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Agent Key 无效或缺失",
+ )
diff --git a/app/core/seed.py b/app/core/seed.py
new file mode 100644
index 0000000..1e22803
--- /dev/null
+++ b/app/core/seed.py
@@ -0,0 +1,66 @@
+"""平台预设种子数据
+
+首次部署或调用 POST /api/providers/seed 时初始化常见平台。
+每个平台有唯一 slug,供未来按 sdk_type 对接自动同步扩展。
+"""
+
+from sqlmodel import Session, select
+
+from app.models.provider import Provider
+
+PRESET_PROVIDERS = [
+ # ---- VPS / 云服务商 ----
+ {"slug": "aliyun", "name": "阿里云", "name_en": "Aliyun", "category": "vps",
+ "website": "https://aliyun.com", "console_url": "https://ecs.console.aliyun.com", "sdk_type": "aliyun-sdk"},
+ {"slug": "tencent", "name": "腾讯云", "name_en": "Tencent Cloud", "category": "vps",
+ "website": "https://cloud.tencent.com", "console_url": "https://console.cloud.tencent.com/cvm", "sdk_type": "tencent-sdk"},
+ {"slug": "aliyun-intl", "name": "阿里国际", "name_en": "Alibaba Cloud", "category": "vps",
+ "website": "https://alibabacloud.com", "console_url": "https://ecs.console.alibabacloud.com", "sdk_type": "alibabacloud-sdk"},
+ {"slug": "tencent-intl", "name": "腾讯国际", "name_en": "Tencent Cloud Intl", "category": "vps",
+ "website": "https://intl.cloud.tencent.com", "console_url": "https://console.intl.cloud.tencent.com", "sdk_type": "tencent-intl-sdk"},
+ {"slug": "vultr", "name": "Vultr", "name_en": "Vultr", "category": "vps",
+ "website": "https://vultr.com", "console_url": "https://my.vultr.com", "sdk_type": "vultr-api"},
+ {"slug": "digitalocean", "name": "DigitalOcean", "name_en": "DigitalOcean", "category": "vps",
+ "website": "https://digitalocean.com", "console_url": "https://cloud.digitalocean.com", "sdk_type": "do-api"},
+ {"slug": "linode", "name": "Linode", "name_en": "Akamai Linode", "category": "vps",
+ "website": "https://linode.com", "console_url": "https://cloud.linode.com", "sdk_type": "linode-api"},
+ {"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps",
+ "website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"},
+ {"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps",
+ "website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"},
+ # ---- 域名注册商 ----
+ {"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain",
+ "website": "https://namesilo.com", "console_url": "https://www.namesilo.com/account_domains.php", "sdk_type": "namesilo-api"},
+ {"slug": "xinwang", "name": "新网", "name_en": "Xinnet", "category": "domain",
+ "website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None},
+ {"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain",
+ "website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"},
+ # ---- AI 服务商 ----
+ {"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent",
+ "website": "https://openai.com", "console_url": "https://platform.openai.com", "sdk_type": "openai-api"},
+ {"slug": "minimax", "name": "Minimax", "name_en": "Minimax", "category": "ai_agent",
+ "website": "https://minimax.io", "console_url": "https://platform.minimaxi.com", "sdk_type": "minimax-api"},
+ {"slug": "kimi", "name": "Kimi", "name_en": "Moonshot", "category": "ai_agent",
+ "website": "https://moonshot.cn", "console_url": "https://platform.moonshot.cn", "sdk_type": "moonshot-api"},
+ {"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "ai_agent",
+ "website": None, "console_url": None, "sdk_type": None},
+ {"slug": "deepseek", "name": "DeepSeek", "name_en": "DeepSeek", "category": "ai_agent",
+ "website": "https://deepseek.com", "console_url": "https://platform.deepseek.com", "sdk_type": "deepseek-api"},
+ # ---- Cloudflare ----
+ {"slug": "cloudflare", "name": "Cloudflare", "name_en": "Cloudflare", "category": "cloudflare",
+ "website": "https://cloudflare.com", "console_url": "https://dash.cloudflare.com", "sdk_type": "cloudflare-api"},
+]
+
+
+def seed_providers(session: Session) -> int:
+ """初始化预设平台(已存在的 slug 跳过),返回新增数量"""
+ added = 0
+ for data in PRESET_PROVIDERS:
+ existing = session.exec(
+ select(Provider).where(Provider.slug == data["slug"])
+ ).first()
+ if not existing:
+ session.add(Provider(**data))
+ added += 1
+ session.commit()
+ return added
diff --git a/app/database.py b/app/database.py
index 137cbcc..86fbab3 100644
--- a/app/database.py
+++ b/app/database.py
@@ -1,4 +1,8 @@
-"""SQLite 数据库连接与初始化"""
+"""SQLite 数据库连接与初始化(分库)
+
+- assets.db :Provider / Asset / VPSDetail / DomainDetail / AIAccount
+- metrics.db:MetricPoint / ServerInfo / SecurityCheck / EventLog
+"""
from pathlib import Path
from typing import Generator
@@ -9,20 +13,49 @@ BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
-DB_URL = f"sqlite:///{DATA_DIR / 'vps_manager.db'}"
+ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}"
+METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}"
-engine = create_engine(DB_URL, echo=False, connect_args={"check_same_thread": False})
+assets_engine = create_engine(
+ ASSETS_DB_URL, echo=False, connect_args={"check_same_thread": False}
+)
+metrics_engine = create_engine(
+ METRICS_DB_URL, echo=False, connect_args={"check_same_thread": False}
+)
+
+# 兼容旧代码:默认 engine 指向资产库
+engine = assets_engine
def init_db() -> None:
- """创建所有表(幂等,可重复调用)"""
- # 显式导入模型,确保其注册到 SQLModel.metadata
+ """分库建表(幂等,可重复调用)"""
from app.models.asset import AIAccount, Asset, DomainDetail, VPSDetail # noqa: F401
+ from app.models.monitor import ( # noqa: F401
+ EventLog,
+ MetricPoint,
+ SecurityCheck,
+ ServerInfo,
+ )
+ from app.models.provider import Provider # noqa: F401
- SQLModel.metadata.create_all(engine)
+ asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount]
+ metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
+
+ SQLModel.metadata.create_all(
+ assets_engine, tables=[m.__table__ for m in asset_models]
+ )
+ SQLModel.metadata.create_all(
+ metrics_engine, tables=[m.__table__ for m in metric_models]
+ )
def get_session() -> Generator[Session, None, None]:
- """FastAPI 依赖:提供数据库会话(请求结束后自动关闭)"""
- with Session(engine) as session:
+ """资产库会话(默认)"""
+ with Session(assets_engine) as session:
+ yield session
+
+
+def get_metrics_session() -> Generator[Session, None, None]:
+ """监控 / 日志库会话"""
+ with Session(metrics_engine) as session:
yield session
diff --git a/app/main.py b/app/main.py
index 13b80d2..95fca83 100644
--- a/app/main.py
+++ b/app/main.py
@@ -14,10 +14,12 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader
+from sqlmodel import Session
from app.core.config import settings
-from app.database import init_db
-from app.routers import assets, stats
+from app.core.seed import seed_providers
+from app.database import assets_engine, init_db
+from app.routers import agent, assets, monitor, providers, stats
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "static"
@@ -30,8 +32,10 @@ jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
@asynccontextmanager
async def lifespan(_: FastAPI):
- """应用启动时自动建表"""
+ """应用启动时自动建表并初始化预设平台"""
init_db()
+ with Session(assets_engine) as session:
+ seed_providers(session)
yield
@@ -45,7 +49,10 @@ app.add_middleware(
)
app.include_router(assets.router)
+app.include_router(providers.router)
app.include_router(stats.router)
+app.include_router(agent.router)
+app.include_router(monitor.router)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
diff --git a/app/models/asset.py b/app/models/asset.py
index 2ded2aa..dbd71b8 100644
--- a/app/models/asset.py
+++ b/app/models/asset.py
@@ -42,7 +42,13 @@ class Asset(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, description="资产名称,如:博客主站 VPS")
asset_type: AssetType = Field(index=True, description="资产类型")
- provider: str = Field(index=True, description="服务商,如 aliyun/vultr/namesilo/openai")
+ provider: str = Field(index=True, description="服务商 slug,如 aliyun/vultr/namesilo/openai")
+ provider_id: Optional[int] = Field(
+ default=None, foreign_key="providers.id", index=True, description="关联平台 ID"
+ )
+ renewal_cycle: Optional[str] = Field(
+ default=None, description="续费周期:monthly/quarterly/yearly"
+ )
account: Optional[str] = Field(
default=None, description="所属账号标识(如 Cloudflare 账号邮箱)"
)
@@ -84,6 +90,13 @@ class VPSDetail(SQLModel, table=True):
bandwidth_gb: Optional[int] = Field(default=None, description="月流量配额(GB)")
ssh_port: int = Field(default=22, description="SSH 端口")
panel_url: Optional[str] = Field(default=None, description="服务商管理面板 URL")
+ ssh_user: Optional[str] = Field(default=None, description="SSH 登录用户,如 root")
+ login_method: Optional[str] = Field(
+ default="key", description="登录方式:key/password/other"
+ )
+ ssh_key_encrypted: Optional[str] = Field(default=None, description="加密的 SSH 私钥")
+ password_encrypted: Optional[str] = Field(default=None, description="加密的登录密码")
+ purpose: Optional[str] = Field(default=None, description="用途 / 运行的项目")
class DomainDetail(SQLModel, table=True):
@@ -109,6 +122,9 @@ class DomainDetail(SQLModel, table=True):
redirect_target: Optional[str] = Field(
default=None, description="若为跳转用途域名,填写目标地址"
)
+ bind_asset_id: Optional[int] = Field(
+ default=None, index=True, description="绑定的资产 ID(如关联的 VPS/项目)"
+ )
class AIAccount(SQLModel, table=True):
diff --git a/app/models/monitor.py b/app/models/monitor.py
new file mode 100644
index 0000000..206d5ba
--- /dev/null
+++ b/app/models/monitor.py
@@ -0,0 +1,76 @@
+"""监控与日志模型(存储于 metrics.db)
+
+- MetricPoint: 资源监控时序数据(Agent 上报)
+- ServerInfo: 服务器基础信息快照(Agent 上报)
+- SecurityCheck: 安全检查项结果
+- EventLog: 系统事件日志
+"""
+
+from datetime import datetime
+from typing import Optional
+
+from sqlmodel import Field, SQLModel
+
+
+class MetricPoint(SQLModel, table=True):
+ """资源监控时序数据"""
+
+ __tablename__ = "metric_points"
+
+ id: Optional[int] = Field(default=None, primary_key=True)
+ asset_id: int = Field(index=True, description="关联资产 ID")
+ ts: datetime = Field(default_factory=datetime.utcnow, index=True, description="采集时间")
+ cpu_pct: Optional[float] = Field(default=None, description="CPU 使用率 %")
+ mem_pct: Optional[float] = Field(default=None, description="内存使用率 %")
+ disk_pct: Optional[float] = Field(default=None, description="磁盘使用率 %")
+ net_in_mb: Optional[float] = Field(default=None, description="网络入流量 MB(采样间隔)")
+ net_out_mb: Optional[float] = Field(default=None, description="网络出流量 MB(采样间隔)")
+ load_1m: Optional[float] = Field(default=None, description="1 分钟负载")
+ uptime_sec: Optional[int] = Field(default=None, description="运行时长(秒)")
+
+
+class ServerInfo(SQLModel, table=True):
+ """服务器基础信息快照(每个资产一条,覆盖更新)"""
+
+ __tablename__ = "server_info"
+
+ id: Optional[int] = Field(default=None, primary_key=True)
+ # 逻辑关联 assets.id(跨库不设物理外键)
+ asset_id: int = Field(unique=True, index=True)
+ hostname: Optional[str] = Field(default=None)
+ os: Optional[str] = Field(default=None, description="操作系统发行版")
+ kernel: Optional[str] = Field(default=None, description="内核版本")
+ cpu_cores: Optional[int] = Field(default=None)
+ mem_total_gb: Optional[float] = Field(default=None)
+ disk_total_gb: Optional[float] = Field(default=None)
+ public_ip: Optional[str] = Field(default=None)
+ tailscale_ip: Optional[str] = Field(default=None)
+ status: Optional[str] = Field(default="online", description="online/offline")
+ last_seen: datetime = Field(default_factory=datetime.utcnow, description="最近上报时间")
+
+
+class SecurityCheck(SQLModel, table=True):
+ """安全检查项结果"""
+
+ __tablename__ = "security_checks"
+
+ id: Optional[int] = Field(default=None, primary_key=True)
+ asset_id: int = Field(index=True)
+ ts: datetime = Field(default_factory=datetime.utcnow, index=True)
+ check_item: str = Field(description="检查项,如 ssh_config/firewall/updates")
+ status: str = Field(default="unknown", description="pass/warn/fail/unknown")
+ detail: Optional[str] = Field(default=None, description="检查详情")
+ suggestion: Optional[str] = Field(default=None, description="加固建议")
+
+
+class EventLog(SQLModel, table=True):
+ """系统事件日志"""
+
+ __tablename__ = "event_logs"
+
+ id: Optional[int] = Field(default=None, primary_key=True)
+ ts: datetime = Field(default_factory=datetime.utcnow, index=True)
+ level: str = Field(default="info", index=True, description="info/warning/error")
+ source: str = Field(default="system", description="来源,如 agent/api/backup")
+ asset_id: Optional[int] = Field(default=None, index=True)
+ message: str = Field(description="事件内容")
diff --git a/app/models/provider.py b/app/models/provider.py
new file mode 100644
index 0000000..b660078
--- /dev/null
+++ b/app/models/provider.py
@@ -0,0 +1,44 @@
+"""平台/服务商注册表模型
+
+Provider 是资产的归属平台预设字典,提供唯一标识(slug),
+为未来按平台对接 SDK(自动同步 VPS/域名/AI 状态)打基础。
+"""
+
+from datetime import datetime
+from enum import Enum
+from typing import Optional
+
+from sqlmodel import Field, SQLModel
+
+
+class ProviderCategory(str, Enum):
+ """平台分类"""
+
+ VPS = "vps" # 云服务器
+ DOMAIN = "domain" # 域名注册商
+ AI_AGENT = "ai_agent" # AI 服务商
+ CLOUDFLARE = "cloudflare" # Cloudflare(DNS/Workers/R2/Tunnel/Mail)
+ OTHER = "other" # 其他
+
+
+class Provider(SQLModel, table=True):
+ """平台/服务商(预设 + 自定义)"""
+
+ __tablename__ = "providers"
+
+ id: Optional[int] = Field(default=None, primary_key=True)
+ slug: str = Field(index=True, unique=True, description="唯一标识,如 aliyun/vultr/openai")
+ name: str = Field(index=True, description="显示名,如 阿里云")
+ name_en: Optional[str] = Field(default=None, description="英文名")
+ category: ProviderCategory = Field(index=True, description="平台分类")
+ website: Optional[str] = Field(default=None, description="官网")
+ console_url: Optional[str] = Field(default=None, description="管理面板 URL")
+ sdk_type: Optional[str] = Field(
+ default=None, description="SDK 类型标识,如 aliyun-sdk/vultr-api(供扩展用)"
+ )
+ api_config_encrypted: Optional[str] = Field(
+ default=None, description="加密的 API 配置 JSON(access_key/secret 等)"
+ )
+ enabled: bool = Field(default=True, description="是否启用")
+ remark: Optional[str] = Field(default=None, description="备注")
+ created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
diff --git a/app/routers/agent.py b/app/routers/agent.py
new file mode 100644
index 0000000..88f8689
--- /dev/null
+++ b/app/routers/agent.py
@@ -0,0 +1,55 @@
+"""Agent 上报接收路由(数据写入 metrics.db)"""
+
+from datetime import datetime
+
+from fastapi import APIRouter, Depends
+from sqlmodel import Session, select
+
+from app.core.security import require_agent_key
+from app.database import get_metrics_session
+from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
+from app.schemas.agent import AgentReport
+
+router = APIRouter(prefix="/api/agent", tags=["agent"])
+
+
+@router.post(
+ "/report",
+ summary="Agent 上报资源/信息/安全状态",
+ dependencies=[Depends(require_agent_key)],
+)
+def report(data: AgentReport, session: Session = Depends(get_metrics_session)) -> dict:
+ # 资源监控时序
+ if data.metrics:
+ session.add(MetricPoint(asset_id=data.asset_id, **data.metrics.model_dump()))
+
+ # 服务器信息快照(每资产一条,覆盖更新)
+ if data.server_info:
+ info = data.server_info.model_dump()
+ info["last_seen"] = datetime.utcnow()
+ existing = session.exec(
+ select(ServerInfo).where(ServerInfo.asset_id == data.asset_id)
+ ).first()
+ if existing:
+ for key, value in info.items():
+ setattr(existing, key, value)
+ session.add(existing)
+ else:
+ session.add(ServerInfo(asset_id=data.asset_id, **info))
+
+ # 安全检查项
+ if data.security:
+ for check in data.security:
+ session.add(SecurityCheck(asset_id=data.asset_id, **check.model_dump()))
+
+ # 事件日志
+ session.add(
+ EventLog(
+ level="info",
+ source="agent",
+ asset_id=data.asset_id,
+ message=f"Agent 上报 asset_id={data.asset_id}",
+ )
+ )
+ session.commit()
+ return {"status": "ok", "asset_id": data.asset_id}
diff --git a/app/routers/monitor.py b/app/routers/monitor.py
new file mode 100644
index 0000000..8aeab2f
--- /dev/null
+++ b/app/routers/monitor.py
@@ -0,0 +1,49 @@
+"""监控数据查询路由(读取 metrics.db)"""
+
+from typing import Optional
+
+from fastapi import APIRouter, Depends, Query
+from sqlmodel import Session, select
+
+from app.database import get_metrics_session
+from app.models.monitor import MetricPoint, SecurityCheck, ServerInfo
+
+router = APIRouter(prefix="/api/monitor", tags=["monitor"])
+
+
+@router.get("/{asset_id}/metrics", summary="资源监控时序(倒序,最新在前)")
+def metrics(
+ asset_id: int,
+ limit: int = Query(default=50, ge=1, le=500),
+ session: Session = Depends(get_metrics_session),
+):
+ stmt = (
+ select(MetricPoint)
+ .where(MetricPoint.asset_id == asset_id)
+ .order_by(MetricPoint.ts.desc())
+ .limit(limit)
+ )
+ return session.exec(stmt).all()
+
+
+@router.get("/{asset_id}/info", summary="服务器信息快照")
+def info(asset_id: int, session: Session = Depends(get_metrics_session)) -> Optional[ServerInfo]:
+ return session.exec(
+ select(ServerInfo).where(ServerInfo.asset_id == asset_id)
+ ).first()
+
+
+@router.get("/{asset_id}/security", summary="安全检查项(每项最新一条)")
+def security(asset_id: int, session: Session = Depends(get_metrics_session)):
+ stmt = (
+ select(SecurityCheck)
+ .where(SecurityCheck.asset_id == asset_id)
+ .order_by(SecurityCheck.ts.desc())
+ .limit(50)
+ )
+ checks = session.exec(stmt).all()
+ latest_by_item = {}
+ for check in checks:
+ if check.check_item not in latest_by_item:
+ latest_by_item[check.check_item] = check
+ return list(latest_by_item.values())
diff --git a/app/routers/providers.py b/app/routers/providers.py
new file mode 100644
index 0000000..5c97478
--- /dev/null
+++ b/app/routers/providers.py
@@ -0,0 +1,72 @@
+"""平台/服务商路由"""
+
+from typing import List, Optional
+
+from fastapi import APIRouter, Depends, Query
+from sqlmodel import Session
+
+from app.core.security import require_api_key
+from app.core.seed import seed_providers
+from app.database import get_session
+from app.models.provider import ProviderCategory
+from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate
+from app.services import provider_service
+
+router = APIRouter(prefix="/api/providers", tags=["providers"])
+
+
+@router.post(
+ "/seed",
+ summary="初始化预设平台",
+ dependencies=[Depends(require_api_key)],
+)
+def seed(session: Session = Depends(get_session)) -> dict:
+ added = seed_providers(session)
+ return {"added": added}
+
+
+@router.get("", response_model=List[ProviderRead], summary="平台列表")
+def list_providers(
+ category: Optional[ProviderCategory] = Query(default=None),
+ enabled: Optional[bool] = Query(default=None),
+ session: Session = Depends(get_session),
+):
+ return provider_service.list_providers(session, category, enabled)
+
+
+@router.get("/{provider_id}", response_model=ProviderRead, summary="平台详情")
+def get_provider(provider_id: int, session: Session = Depends(get_session)):
+ return provider_service.get_provider(session, provider_id)
+
+
+@router.post(
+ "",
+ response_model=ProviderRead,
+ status_code=201,
+ summary="创建平台",
+ dependencies=[Depends(require_api_key)],
+)
+def create_provider(data: ProviderCreate, session: Session = Depends(get_session)):
+ return provider_service.create_provider(session, data)
+
+
+@router.put(
+ "/{provider_id}",
+ response_model=ProviderRead,
+ summary="更新平台",
+ dependencies=[Depends(require_api_key)],
+)
+def update_provider(
+ provider_id: int, data: ProviderUpdate, session: Session = Depends(get_session)
+):
+ return provider_service.update_provider(session, provider_id, data)
+
+
+@router.delete(
+ "/{provider_id}",
+ status_code=204,
+ summary="删除平台",
+ dependencies=[Depends(require_api_key)],
+)
+def delete_provider(provider_id: int, session: Session = Depends(get_session)):
+ provider_service.delete_provider(session, provider_id)
diff --git a/app/schemas/agent.py b/app/schemas/agent.py
new file mode 100644
index 0000000..c79ec27
--- /dev/null
+++ b/app/schemas/agent.py
@@ -0,0 +1,43 @@
+"""Agent 上报数据 Schema"""
+
+from typing import List, Optional
+
+from sqlmodel import SQLModel
+
+
+class MetricsIn(SQLModel):
+ cpu_pct: Optional[float] = None
+ mem_pct: Optional[float] = None
+ disk_pct: Optional[float] = None
+ net_in_mb: Optional[float] = None
+ net_out_mb: Optional[float] = None
+ load_1m: Optional[float] = None
+ uptime_sec: Optional[int] = None
+
+
+class ServerInfoIn(SQLModel):
+ hostname: Optional[str] = None
+ os: Optional[str] = None
+ kernel: Optional[str] = None
+ cpu_cores: Optional[int] = None
+ mem_total_gb: Optional[float] = None
+ disk_total_gb: Optional[float] = None
+ public_ip: Optional[str] = None
+ tailscale_ip: Optional[str] = None
+ status: Optional[str] = "online"
+
+
+class SecurityCheckIn(SQLModel):
+ check_item: str
+ status: str = "unknown" # pass/warn/fail/unknown
+ detail: Optional[str] = None
+ suggestion: Optional[str] = None
+
+
+class AgentReport(SQLModel):
+ """Agent 单次上报的完整数据包"""
+
+ asset_id: int
+ metrics: Optional[MetricsIn] = None
+ server_info: Optional[ServerInfoIn] = None
+ security: Optional[List[SecurityCheckIn]] = None
diff --git a/app/schemas/asset.py b/app/schemas/asset.py
index 4d4a6d6..683f03f 100644
--- a/app/schemas/asset.py
+++ b/app/schemas/asset.py
@@ -30,6 +30,11 @@ class VPSDetailIn(SQLModel):
bandwidth_gb: Optional[int] = None
ssh_port: int = 22
panel_url: Optional[str] = None
+ ssh_user: Optional[str] = None
+ login_method: Optional[str] = "key"
+ ssh_key: Optional[str] = None # 明文输入,服务端加密存储
+ password: Optional[str] = None # 明文输入,服务端加密存储
+ purpose: Optional[str] = None
class DomainDetailIn(SQLModel):
@@ -41,6 +46,7 @@ class DomainDetailIn(SQLModel):
cloudflare_account: Optional[str] = None
is_using: bool = True
redirect_target: Optional[str] = None
+ bind_asset_id: Optional[int] = None
class AIAccountIn(SQLModel):
@@ -64,6 +70,8 @@ class AssetBase(SQLModel):
name: str
asset_type: AssetType
provider: str
+ provider_id: Optional[int] = None
+ renewal_cycle: Optional[str] = None
account: Optional[str] = None
expiry_date: Optional[date] = None
auto_renew: bool = False
@@ -88,6 +96,8 @@ class AssetUpdate(SQLModel):
name: Optional[str] = None
asset_type: Optional[AssetType] = None
provider: Optional[str] = None
+ provider_id: Optional[int] = None
+ renewal_cycle: Optional[str] = None
account: Optional[str] = None
expiry_date: Optional[date] = None
auto_renew: Optional[bool] = None
@@ -104,9 +114,26 @@ class AssetUpdate(SQLModel):
# --------------------------------------------------------------------------- #
# 输出模型
# --------------------------------------------------------------------------- #
-class VPSDetailRead(VPSDetailIn):
+class VPSDetailRead(SQLModel):
+ """VPS 详情输出(不含敏感明文,仅返回凭证存在标记)"""
+
id: int
asset_id: int
+ ip_address: str
+ tailscale_ip: Optional[str] = None
+ region: Optional[str] = None
+ os: Optional[str] = None
+ cpu_cores: int = 1
+ memory_gb: float = 1
+ disk_gb: int = 20
+ bandwidth_gb: Optional[int] = None
+ ssh_port: int = 22
+ panel_url: Optional[str] = None
+ ssh_user: Optional[str] = None
+ login_method: Optional[str] = "key"
+ purpose: Optional[str] = None
+ has_ssh_key: bool = False
+ has_password: bool = False
class DomainDetailRead(DomainDetailIn):
@@ -127,6 +154,7 @@ class AssetRead(AssetBase):
created_at: datetime
updated_at: datetime
days_to_expiry: Optional[int] = None
+ provider_name: Optional[str] = None
vps_detail: Optional[VPSDetailRead] = None
domain_detail: Optional[DomainDetailRead] = None
ai_detail: Optional[AIAccountRead] = None
diff --git a/app/schemas/provider.py b/app/schemas/provider.py
new file mode 100644
index 0000000..e543463
--- /dev/null
+++ b/app/schemas/provider.py
@@ -0,0 +1,45 @@
+"""平台/服务商 Schema"""
+
+from datetime import datetime
+from typing import Optional
+
+from sqlmodel import SQLModel
+
+from app.models.provider import ProviderCategory
+
+
+class ProviderBase(SQLModel):
+ slug: str
+ name: str
+ name_en: Optional[str] = None
+ category: ProviderCategory
+ website: Optional[str] = None
+ console_url: Optional[str] = None
+ sdk_type: Optional[str] = None
+ enabled: bool = True
+ remark: Optional[str] = None
+
+
+class ProviderCreate(ProviderBase):
+ """创建平台;api_config 为明文 JSON 字符串,服务端加密存储"""
+
+ api_config: Optional[str] = None
+
+
+class ProviderUpdate(SQLModel):
+ slug: Optional[str] = None
+ name: Optional[str] = None
+ name_en: Optional[str] = None
+ category: Optional[ProviderCategory] = None
+ website: Optional[str] = None
+ console_url: Optional[str] = None
+ sdk_type: Optional[str] = None
+ enabled: Optional[bool] = None
+ remark: Optional[str] = None
+ api_config: Optional[str] = None
+
+
+class ProviderRead(ProviderBase):
+ id: int
+ created_at: datetime
+ has_api_config: bool = False
diff --git a/app/services/asset_service.py b/app/services/asset_service.py
index d479590..620c5b3 100644
--- a/app/services/asset_service.py
+++ b/app/services/asset_service.py
@@ -9,6 +9,7 @@ from typing import List, Optional
from fastapi import HTTPException, status
from sqlmodel import Session, select
+from app.core import crypto
from app.models.asset import (
AIAccount,
Asset,
@@ -17,6 +18,7 @@ from app.models.asset import (
DomainDetail,
VPSDetail,
)
+from app.models.provider import Provider
from app.schemas.asset import (
AIAccountRead,
AssetCreate,
@@ -56,19 +58,69 @@ def _get_detail(session: Session, asset: Asset):
return session.exec(select(model).where(model.asset_id == asset.id)).first()
-def _to_read(asset: Asset, detail) -> AssetRead:
+def _detail_to_read(detail):
+ """详情模型转输出模型(VPS 计算凭证存在标记,不输出敏感明文)"""
+ if isinstance(detail, VPSDetail):
+ read = VPSDetailRead.model_validate(detail)
+ read.has_ssh_key = bool(detail.ssh_key_encrypted)
+ read.has_password = bool(detail.password_encrypted)
+ return read
+ if isinstance(detail, DomainDetail):
+ return DomainDetailRead.model_validate(detail)
+ if isinstance(detail, AIAccount):
+ return AIAccountRead.model_validate(detail)
+ return None
+
+
+def _provider_name(session: Session, asset: Asset) -> Optional[str]:
+ """取关联平台显示名"""
+ if asset.provider_id:
+ provider = session.get(Provider, asset.provider_id)
+ if provider:
+ return provider.name
+ return None
+
+
+def _to_read(session: Session, asset: Asset, detail) -> AssetRead:
"""组装 AssetRead 输出(主表 + 详情 + 计算字段)"""
read = AssetRead.model_validate(asset)
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
+ read.provider_name = _provider_name(session, asset)
if isinstance(detail, VPSDetail):
- read.vps_detail = VPSDetailRead.model_validate(detail)
+ read.vps_detail = _detail_to_read(detail)
elif isinstance(detail, DomainDetail):
- read.domain_detail = DomainDetailRead.model_validate(detail)
+ read.domain_detail = _detail_to_read(detail)
elif isinstance(detail, AIAccount):
- read.ai_detail = AIAccountRead.model_validate(detail)
+ read.ai_detail = _detail_to_read(detail)
return read
+def _build_detail(asset_type: AssetType, asset_id: int, detail_in):
+ """构建详情模型(VPS 加密凭证)"""
+ _, model = DETAIL_MAP[asset_type]
+ if asset_type == AssetType.VPS:
+ data = detail_in.model_dump(exclude={"ssh_key", "password"})
+ data["ssh_key_encrypted"] = crypto.encrypt(detail_in.ssh_key)
+ data["password_encrypted"] = crypto.encrypt(detail_in.password)
+ return model(asset_id=asset_id, **data)
+ return model(asset_id=asset_id, **detail_in.model_dump())
+
+
+def _apply_detail_update(asset_type: AssetType, existing, detail_in) -> None:
+ """更新详情字段(VPS 加密凭证;凭证为 None 时保留原值)"""
+ if asset_type == AssetType.VPS:
+ data = detail_in.model_dump(exclude={"ssh_key", "password"})
+ for key, value in data.items():
+ setattr(existing, key, value)
+ if detail_in.ssh_key is not None:
+ existing.ssh_key_encrypted = crypto.encrypt(detail_in.ssh_key)
+ if detail_in.password is not None:
+ existing.password_encrypted = crypto.encrypt(detail_in.password)
+ else:
+ for key, value in detail_in.model_dump().items():
+ setattr(existing, key, value)
+
+
# --------------------------------------------------------------------------- #
# CRUD
# --------------------------------------------------------------------------- #
@@ -96,12 +148,12 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead:
detail = None
if model is not None and detail_in is not None:
- detail = model(asset_id=asset.id, **detail_in.model_dump())
+ detail = _build_detail(data.asset_type, asset.id, detail_in)
session.add(detail)
session.commit()
session.refresh(detail)
- return _to_read(asset, detail)
+ return _to_read(session, asset, detail)
def get_asset(session: Session, asset_id: int) -> AssetRead:
@@ -109,7 +161,7 @@ def get_asset(session: Session, asset_id: int) -> AssetRead:
asset = session.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
- return _to_read(asset, _get_detail(session, asset))
+ return _to_read(session, asset, _get_detail(session, asset))
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
@@ -136,12 +188,11 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
).first()
if detail_in is not None:
if existing:
- for key, value in detail_in.model_dump().items():
- setattr(existing, key, value)
+ _apply_detail_update(asset.asset_type, existing, detail_in)
session.add(existing)
detail = existing
else:
- detail = model(asset_id=asset.id, **detail_in.model_dump())
+ detail = _build_detail(asset.asset_type, asset.id, detail_in)
session.add(detail)
else:
detail = existing
@@ -150,7 +201,7 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
session.refresh(asset)
if detail is not None:
session.refresh(detail)
- return _to_read(asset, detail)
+ return _to_read(session, asset, detail)
def delete_asset(session: Session, asset_id: int) -> None:
@@ -190,7 +241,7 @@ def list_assets(
stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
assets = session.exec(stmt).all()
- return [_to_read(a, _get_detail(session, a)) for a in assets]
+ return [_to_read(session, a, _get_detail(session, a)) for a in assets]
# --------------------------------------------------------------------------- #
@@ -241,6 +292,6 @@ def get_expiring(session: Session, days: int = 30) -> List[AssetRead]:
for a in assets:
delta = (a.expiry_date - today).days
if 0 <= delta <= days:
- result.append(_to_read(a, _get_detail(session, a)))
+ result.append(_to_read(session, a, _get_detail(session, a)))
result.sort(key=lambda x: x.days_to_expiry if x.days_to_expiry is not None else 10**9)
return result
diff --git a/app/services/provider_service.py b/app/services/provider_service.py
new file mode 100644
index 0000000..76494d2
--- /dev/null
+++ b/app/services/provider_service.py
@@ -0,0 +1,85 @@
+"""平台/服务商业务逻辑"""
+
+from typing import List, Optional
+
+from fastapi import HTTPException, status
+from sqlmodel import Session, select
+
+from app.core import crypto
+from app.models.provider import Provider, ProviderCategory
+from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate
+
+
+def _to_read(provider: Provider) -> ProviderRead:
+ read = ProviderRead.model_validate(provider)
+ read.has_api_config = bool(provider.api_config_encrypted)
+ return read
+
+
+def list_providers(
+ session: Session,
+ category: Optional[ProviderCategory] = None,
+ enabled: Optional[bool] = None,
+) -> List[ProviderRead]:
+ stmt = select(Provider)
+ if category is not None:
+ stmt = stmt.where(Provider.category == category)
+ if enabled is not None:
+ stmt = stmt.where(Provider.enabled == enabled)
+ stmt = stmt.order_by(Provider.category.asc(), Provider.slug.asc())
+ return [_to_read(p) for p in session.exec(stmt).all()]
+
+
+def get_provider(session: Session, provider_id: int) -> ProviderRead:
+ provider = session.get(Provider, provider_id)
+ if not provider:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
+ return _to_read(provider)
+
+
+def create_provider(session: Session, data: ProviderCreate) -> ProviderRead:
+ exists = session.exec(select(Provider).where(Provider.slug == data.slug)).first()
+ if exists:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail=f"slug 已存在:{data.slug}"
+ )
+ provider_data = data.model_dump(exclude={"api_config"})
+ provider = Provider(**provider_data)
+ provider.api_config_encrypted = crypto.encrypt(data.api_config)
+ session.add(provider)
+ session.commit()
+ session.refresh(provider)
+ return _to_read(provider)
+
+
+def update_provider(
+ session: Session, provider_id: int, data: ProviderUpdate
+) -> ProviderRead:
+ provider = session.get(Provider, provider_id)
+ if not provider:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
+ fields = data.model_dump(exclude_unset=True, exclude={"api_config"})
+ for key, value in fields.items():
+ setattr(provider, key, value)
+ if data.api_config is not None:
+ provider.api_config_encrypted = crypto.encrypt(data.api_config)
+ session.add(provider)
+ session.commit()
+ session.refresh(provider)
+ return _to_read(provider)
+
+
+def delete_provider(session: Session, provider_id: int) -> None:
+ provider = session.get(Provider, provider_id)
+ if not provider:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
+ session.delete(provider)
+ session.commit()
+
+
+def get_api_config_plain(session: Session, provider_id: int) -> Optional[str]:
+ """获取平台 API 配置明文(受保护接口使用)"""
+ provider = session.get(Provider, provider_id)
+ if not provider:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
+ return crypto.decrypt(provider.api_config_encrypted)
diff --git a/app/templates/index.html b/app/templates/index.html
index 53f6bac..73d53a6 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -2,262 +2,22 @@
-
+
{{ app_name }}
+
+
-
-
-{% raw %}
-
-
-
-
-
- ⚠️ {{ error }}
-
-
-
-
-
-
-
总资产
-
{{ overview.total || 0 }}
-
-
-
30 天内到期
-
{{ overview.expiring_30 || 0 }}
-
-
-
本年支出
-
{{ overview.year_cost || 0 }}
-
-
-
异常状态
-
{{ overview.abnormal_count || 0 }}
-
-
-
-
-
- ⏰ 续费倒计时(30 天内)
-
-
-
- {{ a.name }}
- · {{ a.provider }}
-
-
- {{ a.days_to_expiry }} 天
-
-
-
-
-
-
-
-
-
-
- 共 {{ assets.length }} 条
-
-
-
-
-
-
- | 名称 |
- 类型 |
- 服务商 |
- 到期时间 |
- 剩余 |
- 状态 |
- 操作 |
-
-
-
-
- | 暂无资产,点击右上角「新增资产」开始录入 |
-
-
- |
- {{ a.name }}
- (已归档)
- |
-
- {{ typeLabels[a.asset_type] }}
- |
- {{ a.provider }} |
- {{ a.expiry_date || '—' }} |
-
- {{ a.days_to_expiry === null || a.days_to_expiry === undefined ? '—' : a.days_to_expiry + '天' }}
- |
-
- {{ statusLabels[a.status] }}
- |
-
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
{{ editing ? '编辑资产' : '新增资产' }}
-
-
-
-
-
-{% endraw %}
-
-
+
+
+
+
diff --git a/deploy/backup.sh b/deploy/backup.sh
new file mode 100755
index 0000000..33fa085
--- /dev/null
+++ b/deploy/backup.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# 数据库分库备份脚本
+# 用法:backup.sh [assets|metrics|all]
+# assets -> 备份 assets.db,保留 14 份(建议每日)
+# metrics -> 备份 metrics.db,保留 4 份(建议每周)
+# all -> 两者都备份
+set -euo pipefail
+
+APP_DIR="${APP_DIR:-/opt/vps-manager}"
+cd "$APP_DIR"
+
+TARGET="${1:-all}"
+
+python3 - "$TARGET" <<'PYEOF'
+import sqlite3
+import sys
+from datetime import datetime
+from pathlib import Path
+
+target = sys.argv[1]
+data_dir = Path("data")
+backup_root = data_dir / "backups"
+ts = datetime.now().strftime("%Y%m%d_%H%M%S")
+
+
+def backup(db_name: str, keep: int) -> None:
+ src = data_dir / db_name
+ stem = db_name.replace(".db", "")
+ if not src.exists():
+ print(f"[skip] {db_name} 不存在")
+ return
+ out_dir = backup_root / stem
+ out_dir.mkdir(parents=True, exist_ok=True)
+ dst = out_dir / f"{stem}_{ts}.db"
+
+ con = sqlite3.connect(str(src))
+ bkp = sqlite3.connect(str(dst))
+ con.backup(bkp)
+ bkp.close()
+ con.close()
+
+ files = sorted(
+ out_dir.glob(f"{stem}_*.db"),
+ key=lambda p: p.stat().st_mtime,
+ reverse=True,
+ )
+ for old in files[keep:]:
+ old.unlink()
+ print(f"[ok] {db_name} -> {dst}(保留 {keep} 份)")
+
+
+if target in ("assets", "all"):
+ backup("assets.db", 14)
+if target in ("metrics", "all"):
+ backup("metrics.db", 4)
+PYEOF
diff --git a/deploy/vps-backup-assets.service b/deploy/vps-backup-assets.service
new file mode 100644
index 0000000..1927207
--- /dev/null
+++ b/deploy/vps-backup-assets.service
@@ -0,0 +1,10 @@
+# 资产库备份服务(oneshot,由 timer 触发)
+# 部署位置:/etc/systemd/system/vps-backup-assets.service
+
+[Unit]
+Description=Backup vps-manager assets.db
+After=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart=/opt/vps-manager/deploy/backup.sh assets
diff --git a/deploy/vps-backup-assets.timer b/deploy/vps-backup-assets.timer
new file mode 100644
index 0000000..e29377d
--- /dev/null
+++ b/deploy/vps-backup-assets.timer
@@ -0,0 +1,13 @@
+# 资产库每日备份定时器
+# 部署位置:/etc/systemd/system/vps-backup-assets.timer
+
+[Unit]
+Description=Daily backup of vps-manager assets.db
+
+[Timer]
+OnCalendar=daily
+Persistent=true
+Unit=vps-backup-assets.service
+
+[Install]
+WantedBy=timers.target
diff --git a/deploy/vps-backup-metrics.service b/deploy/vps-backup-metrics.service
new file mode 100644
index 0000000..7ea2404
--- /dev/null
+++ b/deploy/vps-backup-metrics.service
@@ -0,0 +1,10 @@
+# 日志/监控库备份服务(oneshot,由 timer 触发)
+# 部署位置:/etc/systemd/system/vps-backup-metrics.service
+
+[Unit]
+Description=Backup vps-manager metrics.db
+After=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart=/opt/vps-manager/deploy/backup.sh metrics
diff --git a/deploy/vps-backup-metrics.timer b/deploy/vps-backup-metrics.timer
new file mode 100644
index 0000000..926b5d7
--- /dev/null
+++ b/deploy/vps-backup-metrics.timer
@@ -0,0 +1,13 @@
+# 日志/监控库每周备份定时器
+# 部署位置:/etc/systemd/system/vps-backup-metrics.timer
+
+[Unit]
+Description=Weekly backup of vps-manager metrics.db
+
+[Timer]
+OnCalendar=weekly
+Persistent=true
+Unit=vps-backup-metrics.service
+
+[Install]
+WantedBy=timers.target
diff --git a/requirements.txt b/requirements.txt
index 5f2ba6a..44f6c12 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,3 +4,6 @@ sqlmodel>=0.0.22
pydantic>=2.7
python-dotenv>=1.0
jinja2>=3.1
+cryptography>=42
+psutil>=5.9
+httpx>=0.27
diff --git a/static/js/api.js b/static/js/api.js
new file mode 100644
index 0000000..edbc566
--- /dev/null
+++ b/static/js/api.js
@@ -0,0 +1,57 @@
+/* API 客户端与格式化工具 */
+window.VpsApi = (function () {
+ const CFG = window.APP_CONFIG || { appName: 'VPS 资产管理系统' };
+
+ function getApiKey() { return localStorage.getItem('vps_api_key') || ''; }
+ function setApiKey(k) { localStorage.setItem('vps_api_key', k || ''); }
+
+ async function request(path, options = {}) {
+ const headers = { 'Content-Type': 'application/json' };
+ const key = getApiKey();
+ if (key) headers['X-API-Key'] = key;
+ const res = await fetch('/api' + path, { ...options, headers: { ...headers, ...(options.headers || {}) } });
+ if (!res.ok) {
+ let msg = res.statusText;
+ try { const e = await res.json(); msg = (typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail)) || msg; } catch (_) {}
+ throw new Error(msg);
+ }
+ if (res.status === 204) return null;
+ return res.json();
+ }
+
+ return {
+ CFG, getApiKey, setApiKey,
+ get: (p) => request(p),
+ post: (p, body) => request(p, { method: 'POST', body: JSON.stringify(body) }),
+ put: (p, body) => request(p, { method: 'PUT', body: JSON.stringify(body) }),
+ del: (p) => request(p, { method: 'DELETE' }),
+ };
+})();
+
+window.VpsFmt = {
+ TYPE_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
+ STATUS_LABELS: { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' },
+ CATEGORY_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
+ CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' },
+ typeBadge(t) {
+ return { vps: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', domain: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', ai_agent: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', cloudflare: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', other: 'bg-slate-500/10 text-slate-600 dark:text-slate-400' }[t] || 'bg-slate-500/10 text-slate-500';
+ },
+ statusBadge(s) {
+ return { active: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', expired: 'bg-red-500/10 text-red-600 dark:text-red-400', stopped: 'bg-slate-500/10 text-slate-500', cancelled: 'bg-slate-500/10 text-slate-400', unknown: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' }[s] || 'bg-slate-500/10 text-slate-500';
+ },
+ expiryText(d) {
+ if (d === null || d === undefined) return 'text-slate-400';
+ if (d < 0) return 'text-red-600 dark:text-red-400 font-semibold';
+ if (d <= 7) return 'text-red-600 dark:text-red-400 font-semibold';
+ if (d <= 30) return 'text-amber-600 dark:text-amber-400';
+ return 'text-slate-500 dark:text-slate-400';
+ },
+ expiryBadge(d) {
+ if (d !== null && d !== undefined && d <= 7) return 'bg-red-500/10 text-red-600 dark:text-red-400';
+ if (d !== null && d !== undefined && d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400';
+ return 'bg-slate-500/10 text-slate-500';
+ },
+ checkBadge(s) {
+ return { pass: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', fail: 'bg-red-500/10 text-red-600 dark:text-red-400', unknown: 'bg-slate-500/10 text-slate-500' }[s] || 'bg-slate-500/10 text-slate-500';
+ },
+};
diff --git a/static/js/app.js b/static/js/app.js
index 8cb102f..8665e35 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,157 +1,651 @@
-/* vps-manager 前端逻辑(Vue 3 全局构建) */
-const { createApp, ref, reactive, onMounted } = Vue;
+/* vps-manager 前端应用(Vue 3 组件化) */
+const { createApp, reactive, ref, computed, onMounted, watch } = Vue;
+const Api = window.VpsApi;
+const Fmt = window.VpsFmt;
-const CFG = window.APP_CONFIG || { appName: 'VPS 资产管理系统' };
-// 若服务端 .env 配置了 API_KEY,请在此填写以通过写操作鉴权
-const API_KEY = '';
+/* ---------------- 全局状态 ---------------- */
+const store = reactive({
+ view: 'dashboard',
+ assets: [],
+ providers: [],
+ overview: {},
+ expiring: [],
+ serverMetrics: {},
+ search: '',
+ filterType: '',
+ filterStatus: '',
+ dark: localStorage.getItem('vps_dark') === '1',
+ loading: false,
+ error: '',
+ assetModal: { show: false, editing: null, form: null },
+ providerModal: { show: false, editing: null, form: null },
+});
-const TYPE_LABELS = { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' };
-const STATUS_LABELS = { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' };
-
-async function api(path, options = {}) {
- const headers = { 'Content-Type': 'application/json' };
- if (API_KEY) headers['X-API-Key'] = API_KEY;
- const res = await fetch('/api' + path, { ...options, headers: { ...headers, ...(options.headers || {}) } });
- if (!res.ok) {
- let msg = res.statusText;
- try { const e = await res.json(); msg = (typeof e.detail === 'string' ? e.detail : JSON.stringify(e.detail)) || msg; } catch (_) {}
- throw new Error(msg);
- }
- if (res.status === 204) return null;
- return res.json();
+function applyDark() {
+ document.documentElement.classList.toggle('dark', store.dark);
+ localStorage.setItem('vps_dark', store.dark ? '1' : '0');
}
-function emptyForm() {
+/* ---------------- 表单工厂 ---------------- */
+function emptyAssetForm() {
return {
- name: '', asset_type: 'vps', provider: '', account: '', expiry_date: '',
- auto_renew: false, cost: 0, currency: 'USD', status: 'active', is_archived: false, remark: '',
- vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '' },
- domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '' },
+ name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly',
+ account: '', expiry_date: '', auto_renew: false, cost: 0, currency: 'USD',
+ status: 'active', is_archived: false, remark: '',
+ vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' },
+ domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null },
ai_detail: { provider: '', api_key: '', plan: '', balance: null, currency: 'USD', monthly_usage: null, monthly_limit: null },
};
}
-// 空字符串转 null(可选字段),保留数字与布尔
-function cleanDetail(obj) {
- const out = {};
- for (const [k, v] of Object.entries(obj)) {
- out[k] = (v === '' ? null : v);
+function buildAssetPayload(f) {
+ const p = {
+ name: f.name, asset_type: f.asset_type, provider: f.provider,
+ provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null,
+ account: f.account || null, expiry_date: f.expiry_date || null,
+ auto_renew: !!f.auto_renew, cost: Number(f.cost) || 0, currency: f.currency,
+ status: f.status, is_archived: !!f.is_archived, remark: f.remark || null,
+ };
+ if (f.asset_type === 'vps') {
+ const v = f.vps_detail;
+ p.vps_detail = { ip_address: v.ip_address, tailscale_ip: v.tailscale_ip || null, region: v.region || null, os: v.os || null, cpu_cores: Number(v.cpu_cores) || 1, memory_gb: Number(v.memory_gb) || 1, disk_gb: Number(v.disk_gb) || 20, bandwidth_gb: v.bandwidth_gb ? Number(v.bandwidth_gb) : null, ssh_port: Number(v.ssh_port) || 22, panel_url: v.panel_url || null, ssh_user: v.ssh_user || null, login_method: v.login_method || 'key', ssh_key: v.ssh_key || null, password: v.password || null, purpose: v.purpose || null };
+ } else if (f.asset_type === 'domain') {
+ const d = f.domain_detail;
+ p.domain_detail = { domain_name: d.domain_name, registrar: d.registrar || null, dns_provider: d.dns_provider || null, cloudflare_account: d.cloudflare_account || null, is_using: !!d.is_using, redirect_target: d.redirect_target || null, bind_asset_id: d.bind_asset_id || null };
+ } else if (f.asset_type === 'ai_agent') {
+ const a = f.ai_detail;
+ p.ai_detail = { provider: a.provider, api_key: a.api_key || null, plan: a.plan || null, balance: a.balance ? Number(a.balance) : null, currency: a.currency || 'USD', monthly_usage: a.monthly_usage ? Number(a.monthly_usage) : null, monthly_limit: a.monthly_limit ? Number(a.monthly_limit) : null };
}
- return out;
+ return p;
}
-createApp({
+/* ---------------- 数据加载 ---------------- */
+async function loadAssets() {
+ const params = new URLSearchParams();
+ if (store.filterType) params.set('asset_type', store.filterType);
+ if (store.filterStatus) params.set('status', store.filterStatus);
+ if (store.search) params.set('q', store.search);
+ params.set('sort', 'expiry_date'); params.set('order', 'asc');
+ store.assets = await Api.get('/assets?' + params.toString());
+}
+async function loadProviders() { store.providers = await Api.get('/providers'); }
+async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
+async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
+async function loadAll() {
+ store.loading = true; store.error = '';
+ try { await Promise.all([loadAssets(), loadProviders(), loadOverview(), loadExpiring()]); }
+ catch (e) { store.error = e.message; } finally { store.loading = false; }
+}
+
+/* ---------------- 资产操作 ---------------- */
+function openAssetCreate(presetType) {
+ const form = emptyAssetForm();
+ if (presetType) form.asset_type = presetType;
+ store.assetModal = { show: true, editing: null, form };
+}
+function openAssetEdit(a) {
+ const form = emptyAssetForm();
+ ['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', 'account', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark'].forEach(k => { form[k] = a[k]; });
+ if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
+ if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
+ if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);
+ form.vps_detail.ssh_key = ''; form.vps_detail.password = '';
+ store.assetModal = { show: true, editing: a.id, form };
+}
+async function saveAsset() {
+ store.error = '';
+ const f = store.assetModal.form;
+ try {
+ const payload = buildAssetPayload(f);
+ if (store.assetModal.editing) await Api.put('/assets/' + store.assetModal.editing, payload);
+ else await Api.post('/assets', payload);
+ store.assetModal.show = false;
+ await loadAll();
+ } catch (e) { store.error = e.message; }
+}
+async function deleteAsset(a) {
+ if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
+ store.error = '';
+ try { await Api.del('/assets/' + a.id); await loadAll(); }
+ catch (e) { store.error = e.message; }
+}
+
+/* ---------------- 平台操作 ---------------- */
+function openProviderCreate() {
+ store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', website: '', console_url: '', sdk_type: '', enabled: true, remark: '', api_config: '' } };
+}
+function openProviderEdit(p) {
+ store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '', api_config: '' } };
+}
+async function saveProvider() {
+ store.error = '';
+ const f = store.providerModal.form;
+ const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null, api_config: f.api_config || null };
+ try {
+ if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload);
+ else await Api.post('/providers', payload);
+ store.providerModal.show = false;
+ await loadProviders();
+ } catch (e) { store.error = e.message; }
+}
+async function seedProviders() {
+ store.error = '';
+ try { const r = await Api.post('/providers/seed', {}); await loadProviders(); alert('已初始化预设平台,新增 ' + r.added + ' 个'); }
+ catch (e) { store.error = e.message; }
+}
+async function deleteProvider(p) {
+ if (!confirm('确认删除平台「' + p.name + '」?')) return;
+ try { await Api.del('/providers/' + p.id); await loadProviders(); }
+ catch (e) { store.error = e.message; }
+}
+
+/* ---------------- 服务器监控 ---------------- */
+async function viewServer(a) {
+ store.view = 'servers';
+ try {
+ const [metrics, info, checks] = await Promise.all([
+ Api.get('/monitor/' + a.id + '/metrics'),
+ Api.get('/monitor/' + a.id + '/info'),
+ Api.get('/monitor/' + a.id + '/security'),
+ ]);
+ store.serverMetrics = { asset: a, metrics, info, checks };
+ } catch (e) { store.serverMetrics = { asset: a, metrics: [], info: null, checks: [] }; }
+}
+
+/* ---------------- 设置 ---------------- */
+const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
+function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }
+
+/* ================= 视图组件 ================= */
+const DashboardView = {
+ template: `
+
+
+
+
总资产
+
{{ store.overview.total || 0 }}
+
+
+
30天内到期
+
{{ store.overview.expiring_30 || 0 }}
+
+
+
本年支出
+
{{ store.overview.year_cost || 0 }}
+
+
+
异常状态
+
{{ store.overview.abnormal_count || 0 }}
+
+
+
+
+
续费倒计时(30天内)
+
+
+
+ {{ a.name }}
+ {{ a.provider_name || a.provider }}
+
+
{{ a.days_to_expiry }}天 · {{ a.cost }}{{ a.currency }}
+
+
+
+
+
+
按类型分布
+
+ {{ Fmt.TYPE_LABELS[t] }} {{ n }}
+ 暂无数据
+
+
+
`,
+ setup() { return { store, Fmt }; },
+};
+
+const AssetsView = {
+ template: `
+
+
+
+
+ 共 {{ store.assets.length }} 条
+
+
+
+
+
+
+
+ | 名称 |
+ 类型 |
+ 平台 |
+ 到期 |
+ 费用 |
+ 状态 |
+ 操作 |
+
+
+
+ | 暂无资产 |
+
+ | {{ a.name }} |
+ {{ Fmt.TYPE_LABELS[a.asset_type] }} |
+ {{ a.provider_name || a.provider }} |
+ {{ a.expiry_date || '—' }}({{ a.days_to_expiry }}天) |
+ {{ a.cost }} {{ a.currency }}/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }} |
+ {{ Fmt.STATUS_LABELS[a.status] }} |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
暂无资产
+
+
+
+
{{ a.name }}
+
{{ a.provider_name || a.provider }}
+
+
{{ Fmt.TYPE_LABELS[a.asset_type] }}
+
+
+ {{ a.expiry_date || '无到期' }} ({{ a.days_to_expiry }}天)
+ {{ a.cost }}{{ a.currency }}
+ {{ Fmt.STATUS_LABELS[a.status] }}
+
+
+
+
+
+
+
+
+
`,
setup() {
- const appName = CFG.appName;
- const assets = ref([]);
- const overview = ref({});
- const expiring = ref([]);
- const loading = ref(false);
- const search = ref('');
- const filterType = ref('');
- const filterStatus = ref('');
- const showModal = ref(false);
- const editing = ref(null);
- const form = reactive(emptyForm());
- const error = ref('');
- const typeLabels = TYPE_LABELS;
- const statusLabels = STATUS_LABELS;
-
- async function loadAssets() {
- loading.value = true;
- try {
- const params = new URLSearchParams();
- if (filterType.value) params.set('asset_type', filterType.value);
- if (filterStatus.value) params.set('status', filterStatus.value);
- if (search.value) params.set('q', search.value);
- params.set('sort', 'expiry_date');
- params.set('order', 'asc');
- assets.value = await api('/assets?' + params.toString());
- } catch (e) { error.value = e.message; } finally { loading.value = false; }
- }
- async function loadOverview() {
- try { overview.value = await api('/stats/overview'); } catch (e) { error.value = e.message; }
- }
- async function loadExpiring() {
- try { expiring.value = await api('/stats/expiring?days=30'); } catch (e) { error.value = e.message; }
- }
- async function loadAll() { await Promise.all([loadAssets(), loadOverview(), loadExpiring()]); }
-
- function resetForm() { Object.assign(form, emptyForm()); }
- function openCreate() { resetForm(); editing.value = null; showModal.value = true; }
- function openEdit(a) {
- resetForm();
- editing.value = a.id;
- ['name', 'asset_type', 'provider', 'account', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark']
- .forEach(k => { form[k] = a[k]; });
- if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
- if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
- if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);
- showModal.value = true;
- }
-
- function buildPayload() {
- const p = {
- name: form.name, asset_type: form.asset_type, provider: form.provider,
- account: form.account || null, expiry_date: form.expiry_date || null,
- auto_renew: !!form.auto_renew, cost: Number(form.cost) || 0, currency: form.currency,
- status: form.status, is_archived: !!form.is_archived, remark: form.remark || null,
- };
- if (form.asset_type === 'vps') p.vps_detail = cleanDetail(form.vps_detail);
- else if (form.asset_type === 'domain') p.domain_detail = cleanDetail(form.domain_detail);
- else if (form.asset_type === 'ai_agent') p.ai_detail = cleanDetail(form.ai_detail);
- return p;
- }
-
- async function saveAsset() {
- error.value = '';
- try {
- const payload = buildPayload();
- if (editing.value) {
- await api('/assets/' + editing.value, { method: 'PUT', body: JSON.stringify(payload) });
- } else {
- await api('/assets', { method: 'POST', body: JSON.stringify(payload) });
- }
- showModal.value = false;
- await loadAll();
- } catch (e) { error.value = e.message; }
- }
-
- async function deleteAsset(a) {
- if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
- error.value = '';
- try { await api('/assets/' + a.id, { method: 'DELETE' }); await loadAll(); }
- catch (e) { error.value = e.message; }
- }
-
- // 样式辅助
- function typeBadge(t) {
- return { vps: 'bg-blue-100 text-blue-700', domain: 'bg-green-100 text-green-700', ai_agent: 'bg-purple-100 text-purple-700', cloudflare: 'bg-orange-100 text-orange-700', other: 'bg-slate-100 text-slate-600' }[t] || 'bg-slate-100 text-slate-600';
- }
- function statusBadge(s) {
- return { active: 'bg-green-100 text-green-700', expired: 'bg-red-100 text-red-700', stopped: 'bg-slate-200 text-slate-600', cancelled: 'bg-slate-200 text-slate-500', unknown: 'bg-yellow-100 text-yellow-700' }[s] || 'bg-slate-100 text-slate-600';
- }
- function expiryText(d) {
- if (d === null || d === undefined) return 'text-slate-400';
- if (d < 0) return 'text-red-600 font-bold';
- if (d <= 7) return 'text-red-600 font-semibold';
- if (d <= 30) return 'text-amber-600';
- return 'text-slate-600';
- }
- function expiryBadge(d) {
- if (d < 0) return 'bg-red-100 text-red-700';
- if (d <= 7) return 'bg-red-100 text-red-700';
- if (d <= 30) return 'bg-amber-100 text-amber-700';
- return 'bg-slate-100 text-slate-600';
- }
-
- onMounted(loadAll);
-
- return {
- appName, assets, overview, expiring, loading, search, filterType, filterStatus,
- showModal, editing, form, error, typeLabels, statusLabels,
- loadAssets, openCreate, openEdit, saveAsset, deleteAsset,
- typeBadge, statusBadge, expiryText, expiryBadge,
- };
+ return { store, Fmt, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer };
},
-}).mount('#app');
+};
+
+const ProvidersView = {
+ template: `
+
+
+
+
+
+
+
+
+
+
{{ p.name }}
+
{{ p.slug }}
+
+
{{ Fmt.CATEGORY_LABELS[p.category] }}
+
+
+
SDK: {{ p.sdk_type }}
+
+
+ 已配置 API
+ 未配置 API
+ {{ p.enabled ? '启用' : '停用' }}
+
+
+
+
+
+
+
+
+
`,
+ setup() { return { store, Fmt, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider }; },
+};
+
+const ServersView = {
+ template: `
+
+
+ 请在「资产」页点击 VPS 的「监控」查看服务器资源与安全状态
+
+
+
+
+
{{ m.asset.name }}
+
+
+
+
CPU
+
{{ latest ? latest.cpu_pct : '—' }}%
+
{{ m.info.cpu_cores }} 核
+
+
+
内存
+
{{ latest ? latest.mem_pct : '—' }}%
+
{{ m.info.mem_total_gb }} GB
+
+
+
磁盘
+
{{ latest ? latest.disk_pct : '—' }}%
+
{{ m.info.disk_total_gb }} GB
+
+
+
负载/在线
+
{{ latest && latest.load_1m !== null ? latest.load_1m : '—' }}
+
{{ m.info.hostname }}
+
+
+
+
服务器信息
+
+
系统:{{ m.info.os }}
内核:{{ m.info.kernel }}
+
公网 IP:{{ m.info.public_ip || '—' }}
状态:{{ m.info.status }}
+
+
+
+
安全检查
+
+
+
{{ c.status }}
+
+
{{ c.check_item }}
+
{{ c.detail }}
+
建议:{{ c.suggestion }}
+
+
+
暂无安全检查数据(需 Agent 上报)
+
+
+
+
`,
+ setup() {
+ const m = computed(() => store.serverMetrics);
+ const latest = computed(() => (m.value.metrics && m.value.metrics.length) ? m.value.metrics[0] : null);
+ return { store, Fmt, m, latest };
+ },
+};
+
+const DomainsView = {
+ template: `
+
+
+
+
+ | 域名 | 注册商 | DNS | 到期 | 使用中 | 操作 |
+
+
+ | 暂无域名资产 |
+
+ | {{ a.domain_detail.domain_name }} |
+ {{ a.domain_detail.registrar || '—' }} |
+ {{ a.domain_detail.dns_provider || '—' }} |
+ {{ a.expiry_date || '—' }}({{ a.days_to_expiry }}天) |
+ {{ a.domain_detail.is_using ? '使用' : '闲置' }} |
+ |
+
+
+
+
+
`,
+ setup() {
+ const domains = computed(() => store.assets.filter(a => a.asset_type === 'domain'));
+ return { store, Fmt, domains, edit: openAssetEdit };
+ },
+};
+
+const SettingsView = {
+ template: `
+
+
+
API Key(写操作鉴权)
+
若服务端 .env 配置了 API_KEY,请在此填写相同的值,前端写操作会自动携带。
+
+
+
+
+
+
+
外观
+
+
+
`,
+ setup() { return { store, settings, save: saveSettings, applyDark }; },
+};
+
+/* ================= 资产编辑模态框 ================= */
+const AssetModal = {
+ template: `
+ `,
+ setup() {
+ const f = computed(() => store.assetModal.form);
+ const providersForType = computed(() => {
+ const t = f.value ? f.value.asset_type : null;
+ const map = { vps: 'vps', domain: 'domain', ai_agent: 'ai_agent', cloudflare: 'cloudflare' };
+ const cat = map[t];
+ return cat ? store.providers.filter(p => p.category === cat || p.category === 'other') : store.providers;
+ });
+ function onProviderChange() {
+ const p = store.providers.find(x => x.id === f.value.provider_id);
+ if (p) f.value.provider = p.slug;
+ }
+ return { store, Fmt, f, providersForType, onProviderChange, save: saveAsset };
+ },
+};
+
+const ProviderModal = {
+ template: `
+ `,
+ setup() { return { store, Fmt, f: computed(() => store.providerModal.form), save: saveProvider }; },
+};
+
+/* ================= 根组件 ================= */
+const AppRoot = {
+ components: { DashboardView, AssetsView, ProvidersView, ServersView, DomainsView, SettingsView, AssetModal, ProviderModal },
+ template: `
+
+
+
+
+
+
+
+ ⚠️ {{ store.error }}
+
+
+
+
+
+
+
+
+
+
+
+
`,
+ setup() {
+ const appName = Api.CFG.appName;
+ const navs = [
+ { key: 'dashboard', label: '总览', icon: '📊' },
+ { key: 'assets', label: '资产', icon: '📦' },
+ { key: 'servers', label: '服务器', icon: '🖥️' },
+ { key: 'domains', label: '域名', icon: '🌐' },
+ { key: 'providers', label: '平台', icon: '🏢' },
+ { key: 'settings', label: '设置', icon: '⚙️' },
+ ];
+ const viewMap = { dashboard: 'dashboard-view', assets: 'assets-view', servers: 'servers-view', domains: 'domains-view', providers: 'providers-view', settings: 'settings-view' };
+ const currentNav = computed(() => navs.find(n => n.key === store.view) || navs[0]);
+ const viewComponent = computed(() => viewMap[store.view] || 'dashboard-view');
+ function go(key) { store.view = key; if (key !== 'servers') store.serverMetrics = {}; }
+ function quickAdd() {
+ const preset = store.view === 'domains' ? 'domain' : (store.view === 'providers' ? null : 'vps');
+ if (store.view === 'providers') openProviderCreate();
+ else openAssetCreate(preset);
+ }
+ function toggleDark() { store.dark = !store.dark; applyDark(); }
+ return { store, appName, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: loadAssets };
+ },
+};
+
+/* ================= 挂载 ================= */
+const app = createApp(AppRoot);
+app.component('dashboard-view', DashboardView);
+app.component('assets-view', AssetsView);
+app.component('providers-view', ProvidersView);
+app.component('servers-view', ServersView);
+app.component('domains-view', DomainsView);
+app.component('settings-view', SettingsView);
+applyDark();
+loadAll();
+app.mount('#app');