feat: 平台预设字典+凭证加密+分库备份+Agent采集+移动端优先前端重做
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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 无效或缺失",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
+41
-8
@@ -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
|
||||
|
||||
+10
-3
@@ -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")
|
||||
|
||||
|
||||
+17
-1
@@ -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):
|
||||
|
||||
@@ -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="事件内容")
|
||||
@@ -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="创建时间")
|
||||
@@ -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}
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
+29
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
+12
-252
@@ -2,262 +2,22 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>{{ app_name }}</title>
|
||||
<script src="/static/js/tailwind.js"></script>
|
||||
<script>tailwind.config = { darkMode: 'class' };</script>
|
||||
<script src="/static/js/vue.global.prod.js"></script>
|
||||
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script>
|
||||
<style>
|
||||
html { -webkit-tap-highlight-color: transparent; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(100,116,139,.3); border-radius: 4px; }
|
||||
[v-cloak] { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-100 text-slate-800 min-h-screen">
|
||||
<div id="app" class="max-w-7xl mx-auto px-4 py-6">
|
||||
{% raw %}
|
||||
<!-- 顶部栏 -->
|
||||
<header class="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||
<h1 class="text-2xl font-bold text-slate-900">📦 {{ appName }}</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<input v-model="search" @input="loadAssets" placeholder="搜索名称 / 服务商…"
|
||||
class="px-3 py-2 rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm w-56">
|
||||
<button @click="openCreate"
|
||||
class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition">+ 新增资产</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 全局错误提示 -->
|
||||
<div v-if="error" class="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm flex justify-between">
|
||||
<span>⚠️ {{ error }}</span>
|
||||
<button @click="error = ''" class="font-bold">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard 卡片 -->
|
||||
<section class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div class="text-sm text-slate-500">总资产</div>
|
||||
<div class="text-3xl font-bold text-slate-900 mt-1">{{ overview.total || 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm border-l-4 border-red-500">
|
||||
<div class="text-sm text-slate-500">30 天内到期</div>
|
||||
<div class="text-3xl font-bold text-red-600 mt-1">{{ overview.expiring_30 || 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div class="text-sm text-slate-500">本年支出</div>
|
||||
<div class="text-3xl font-bold text-slate-900 mt-1">{{ overview.year_cost || 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm border-l-4 border-amber-500">
|
||||
<div class="text-sm text-slate-500">异常状态</div>
|
||||
<div class="text-3xl font-bold text-amber-600 mt-1">{{ overview.abnormal_count || 0 }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 续费倒计时 -->
|
||||
<section v-if="expiring.length" class="bg-white rounded-xl p-4 shadow-sm mb-6">
|
||||
<h2 class="font-semibold text-slate-900 mb-3">⏰ 续费倒计时(30 天内)</h2>
|
||||
<div class="space-y-2">
|
||||
<div v-for="a in expiring" :key="a.id"
|
||||
class="flex justify-between items-center px-3 py-2 rounded-lg bg-slate-50">
|
||||
<span class="text-sm">
|
||||
<span class="font-medium">{{ a.name }}</span>
|
||||
<span class="text-slate-400"> · {{ a.provider }}</span>
|
||||
</span>
|
||||
<span class="text-sm px-2 py-0.5 rounded-full" :class="expiryBadge(a.days_to_expiry)">
|
||||
{{ a.days_to_expiry }} 天
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 筛选 + 资产表格 -->
|
||||
<section class="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="p-4 flex flex-wrap gap-2 border-b border-slate-100">
|
||||
<select v-model="filterType" @change="loadAssets"
|
||||
class="px-3 py-1.5 rounded-lg border border-slate-300 text-sm">
|
||||
<option value="">全部类型</option>
|
||||
<option v-for="(label, key) in typeLabels" :key="key" :value="key">{{ label }}</option>
|
||||
</select>
|
||||
<select v-model="filterStatus" @change="loadAssets"
|
||||
class="px-3 py-1.5 rounded-lg border border-slate-300 text-sm">
|
||||
<option value="">全部状态</option>
|
||||
<option v-for="(label, key) in statusLabels" :key="key" :value="key">{{ label }}</option>
|
||||
</select>
|
||||
<span class="ml-auto text-sm text-slate-400 self-center">共 {{ assets.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-slate-500 text-left">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">名称</th>
|
||||
<th class="px-4 py-3 font-medium">类型</th>
|
||||
<th class="px-4 py-3 font-medium">服务商</th>
|
||||
<th class="px-4 py-3 font-medium">到期时间</th>
|
||||
<th class="px-4 py-3 font-medium">剩余</th>
|
||||
<th class="px-4 py-3 font-medium">状态</th>
|
||||
<th class="px-4 py-3 font-medium text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tr v-if="!assets.length">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无资产,点击右上角「新增资产」开始录入</td>
|
||||
</tr>
|
||||
<tr v-for="a in assets" :key="a.id" class="hover:bg-slate-50" :class="{ 'opacity-50': a.is_archived }">
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-slate-900">{{ a.name }}</span>
|
||||
<span v-if="a.is_archived" class="ml-1 text-xs text-slate-400">(已归档)</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs" :class="typeBadge(a.asset_type)">{{ typeLabels[a.asset_type] }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ a.provider }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ a.expiry_date || '—' }}</td>
|
||||
<td class="px-4 py-3" :class="expiryText(a.days_to_expiry)">
|
||||
{{ a.days_to_expiry === null || a.days_to_expiry === undefined ? '—' : a.days_to_expiry + '天' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs" :class="statusBadge(a.status)">{{ statusLabels[a.status] }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
<button @click="openEdit(a)" class="text-blue-600 hover:underline mr-3">编辑</button>
|
||||
<button @click="deleteAsset(a)" class="text-red-600 hover:underline">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 新增 / 编辑模态框 -->
|
||||
<div v-if="showModal" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-xl">
|
||||
<div class="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-slate-900">{{ editing ? '编辑资产' : '新增资产' }}</h3>
|
||||
<button @click="showModal = false" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
<form @submit.prevent="saveAsset" class="px-6 py-4 space-y-4">
|
||||
<!-- 主表字段 -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="col-span-2 block">
|
||||
<span class="text-sm text-slate-600">名称 *</span>
|
||||
<input v-model="form.name" required class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-600">类型 *</span>
|
||||
<select v-model="form.asset_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
|
||||
<option v-for="(label, key) in typeLabels" :key="key" :value="key">{{ label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-600">服务商 *</span>
|
||||
<input v-model="form.provider" required placeholder="aliyun / vultr / namesilo" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-600">所属账号</span>
|
||||
<input v-model="form.account" placeholder="如 Cloudflare 邮箱" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-600">到期时间</span>
|
||||
<input type="date" v-model="form.expiry_date" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-600">续费费用</span>
|
||||
<input type="number" step="0.01" v-model.number="form.cost" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-600">币种</span>
|
||||
<select v-model="form.currency" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
|
||||
<option>USD</option><option>CNY</option><option>JPY</option><option>EUR</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-600">状态</span>
|
||||
<select v-model="form.status" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm">
|
||||
<option v-for="(label, key) in statusLabels" :key="key" :value="key">{{ label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="col-span-2 flex gap-6 text-sm text-slate-600">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" v-model="form.auto_renew"> 自动续费</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" v-model="form.is_archived"> 已归档(不再使用)</label>
|
||||
</div>
|
||||
<label class="col-span-2 block">
|
||||
<span class="text-sm text-slate-600">备注</span>
|
||||
<textarea v-model="form.remark" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 text-sm"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- VPS 详情 -->
|
||||
<fieldset v-if="form.asset_type === 'vps'" class="border border-slate-200 rounded-lg p-3">
|
||||
<legend class="text-sm font-medium text-slate-700 px-1">VPS 详情</legend>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block"><span class="text-xs text-slate-500">公网 IP *</span>
|
||||
<input v-model="form.vps_detail.ip_address" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">Tailscale IP</span>
|
||||
<input v-model="form.vps_detail.tailscale_ip" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">区域</span>
|
||||
<input v-model="form.vps_detail.region" placeholder="Japan / US West" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">操作系统</span>
|
||||
<input v-model="form.vps_detail.os" placeholder="Debian 12" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">CPU 核数</span>
|
||||
<input type="number" v-model.number="form.vps_detail.cpu_cores" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">内存 (GB)</span>
|
||||
<input type="number" step="0.5" v-model.number="form.vps_detail.memory_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">磁盘 (GB)</span>
|
||||
<input type="number" v-model.number="form.vps_detail.disk_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">月流量 (GB)</span>
|
||||
<input type="number" v-model.number="form.vps_detail.bandwidth_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">SSH 端口</span>
|
||||
<input type="number" v-model.number="form.vps_detail.ssh_port" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">面板 URL</span>
|
||||
<input v-model="form.vps_detail.panel_url" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 域名详情 -->
|
||||
<fieldset v-if="form.asset_type === 'domain'" class="border border-slate-200 rounded-lg p-3">
|
||||
<legend class="text-sm font-medium text-slate-700 px-1">域名详情</legend>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="col-span-2 block"><span class="text-xs text-slate-500">域名 *</span>
|
||||
<input v-model="form.domain_detail.domain_name" required placeholder="example.com" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">注册商</span>
|
||||
<input v-model="form.domain_detail.registrar" placeholder="namesilo / 新网" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">DNS 托管</span>
|
||||
<input v-model="form.domain_detail.dns_provider" placeholder="cloudflare" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">Cloudflare 账号</span>
|
||||
<input v-model="form.domain_detail.cloudflare_account" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">跳转目标</span>
|
||||
<input v-model="form.domain_detail.redirect_target" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" v-model="form.domain_detail.is_using"> 仍在实际使用</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- AI 账号详情 -->
|
||||
<fieldset v-if="form.asset_type === 'ai_agent'" class="border border-slate-200 rounded-lg p-3">
|
||||
<legend class="text-sm font-medium text-slate-700 px-1">AI 账号详情</legend>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block"><span class="text-xs text-slate-500">服务商 *</span>
|
||||
<input v-model="form.ai_detail.provider" required placeholder="openai / kimi / deepseek" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">订阅计划</span>
|
||||
<input v-model="form.ai_detail.plan" placeholder="ChatGPT Plus" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="col-span-2 block"><span class="text-xs text-slate-500">API Key</span>
|
||||
<input v-model="form.ai_detail.api_key" type="password" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">当前余额</span>
|
||||
<input type="number" step="0.01" v-model.number="form.ai_detail.balance" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">币种</span>
|
||||
<input v-model="form.ai_detail.currency" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">本月用量</span>
|
||||
<input type="number" step="0.01" v-model.number="form.ai_detail.monthly_usage" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">月度限额</span>
|
||||
<input type="number" step="0.01" v-model.number="form.ai_detail.monthly_limit" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 text-sm"></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" @click="showModal = false" class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-600 hover:bg-slate-50">取消</button>
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700">{{ editing ? '保存修改' : '创建资产' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<body class="bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-200 antialiased">
|
||||
<div id="app"></div>
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user