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 %} - -
-

📦 {{ appName }}

-
- - -
-
- - -
- ⚠️ {{ 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 ? '编辑资产' : '新增资产' }}

- -
-
- -
- - - - - - - - -
- - -
- -
- - -
- VPS 详情 -
- - - - - - - - - - -
-
- - -
- 域名详情 -
- - - - - - -
-
- - -
- AI 账号详情 -
- - - - - - - -
-
- -
- - -
-
-
-
-{% 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 }}
+
{{ 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 的「监控」查看服务器资源与安全状态 +
+ +
`, + 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: ` +
+
+
+

{{ store.assetModal.editing ? '编辑资产' : '新增资产' }}

+ +
+
+
+ + + + + + + + + +
+ + +
+
+ +
+ VPS 详情 +
+ + + + + + + + + + + + + +
+
+ +
+ 域名详情 +
+ + + + + + +
+
+ +
+ AI 账号详情 +
+ + + + + +
+
+ + + +
+ + +
+
+
+
`, + 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: ` +
+
+
+

{{ store.providerModal.editing ? '编辑平台' : '新增平台' }}

+ +
+
+
+ + + + + + + + +
+
+ + +
+
+
+
`, + setup() { return { store, Fmt, f: computed(() => store.providerModal.form), save: saveProvider }; }, +}; + +/* ================= 根组件 ================= */ +const AppRoot = { + components: { DashboardView, AssetsView, ProvidersView, ServersView, DomainsView, SettingsView, AssetModal, ProviderModal }, + template: ` +
+ + +
+
+

{{ currentNav.label }}

+
+ + +
+
+ +
+ ⚠️ {{ 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');