feat: 综合平台services多服务支持+SW网络优先缓存修复+适配层/定时任务完善

This commit is contained in:
gouki
2026-08-05 11:57:17 +00:00
parent 7f1268f508
commit 1b7d4823c3
47 changed files with 2895 additions and 1189 deletions
+35 -5
View File
@@ -100,11 +100,41 @@ class OpenAIAdapter(_AIBase):
@register("minimax-api")
class MinimaxAdapter(_AIBase):
# Minimax 需 group_id,余额接口因账号类型而异,此处为骨架待完善
required_config = ["api_key", "group_id"]
"""Minimax 适配器(余额查询)
接口:GET https://api.minimax.chat/v1/balance
认证:Bearer api_keygroup_id 仅部分旧接口需要,余额查询非必需)
响应示例:{"balance": 123.45, "currency": "CNY", ...}
注:Minimax 国内版端点为 api.minimaxi.com,国际版为 api.minimax.chat
两者 API Key 不通用;默认使用国际版端点,可通过 config["base_url"] 覆盖。
"""
required_config = ["api_key"]
BASE = "https://api.minimax.chat/v1"
def _base_url(self) -> str:
return (self.config.get("base_url") or self.BASE).rstrip("/")
def _balance(self) -> AccountInfo:
data = self._get(self._base_url() + "/balance")
balance = data.get("balance")
try:
balance = float(balance) if balance is not None else None
except (TypeError, ValueError):
balance = None
currency = data.get("currency") or "CNY"
return AccountInfo(balance=balance, currency=currency, raw=data)
def test_connection(self) -> dict:
if not self.config.get("group_id"):
return {"ok": False, "message": "Minimax 需配置 group_id(适配器余额接口待完善)"}
return {"ok": False, "message": "Minimax 适配器余额接口待完善(请提供具体接口文档)"}
try:
acc = self._balance()
if acc.balance is not None:
return {"ok": True, "message": f"连接成功,余额 {acc.balance} {acc.currency}"}
return {"ok": True, "message": "连接成功(未返回余额字段)"}
except httpx.HTTPStatusError as e:
return self._http_error(e)
except Exception as e: # noqa: BLE001
return {"ok": False, "message": str(e)}
def get_account(self) -> AccountInfo:
return self._balance()
+36 -2
View File
@@ -1,8 +1,9 @@
"""Cloudflare 适配器(REST API v4Bearer Token
API 文档:https://developers.cloudflare.com/api/
所需配置:{"api_token": "..."}(建议用 API Token,权限含 Zone:Read / Account:Read
所需配置:{"api_token": "..."}(建议用 API Token,权限含 Zone:Read / Account:Read / Registrar:Read
Cloudflare 无传统 VPS,主要同步托管域名(zones);Workers/R2/Tunnel 子资产留待后续阶段。
域名到期日通过 Registrar API/accounts/{id}/registrar/domains)补充获取。
"""
import httpx
@@ -42,16 +43,21 @@ class CloudflareAdapter(BaseAdapter):
return {"ok": False, "message": str(e)}
def list_domains(self) -> list:
# 先拉取 Registrar 域名到期日映射(domain_name -> expires_at ISO 日期)
expiry_map = self._registrar_expiry_map()
result = []
page = 1
while True:
data = self._get(f"/zones?per_page=50&page={page}")
for z in data.get("result", []):
name = z.get("name")
result.append(
NormalizedDomain(
external_id=z.get("id"),
domain_name=z.get("name"),
domain_name=name,
registrar="cloudflare",
expiry_date=expiry_map.get(name),
status="active" if z.get("status") == "active" else (z.get("status") or "unknown"),
raw=z,
)
@@ -61,3 +67,31 @@ class CloudflareAdapter(BaseAdapter):
break
page += 1
return result
def _registrar_expiry_map(self) -> dict:
"""拉取 Cloudflare Registrar 域名到期日映射 {domain_name: YYYY-MM-DD}
流程:/accounts → 对每个 account 调 /accounts/{id}/registrar/domains。
若 Token 无 Registrar 权限或账号无 Registrar 域名,静默返回空映射。
"""
expiry = {}
try:
accounts = self._get("/accounts?per_page=50").get("result", [])
except Exception: # noqa: BLE001
return expiry
for acc in accounts:
acc_id = acc.get("id")
if not acc_id:
continue
try:
domains = self._get(f"/accounts/{acc_id}/registrar/domains").get("result", [])
except Exception: # noqa: BLE001
continue
for d in domains:
# Registrar API 返回的 id 是域名本身(如 example.com
name = d.get("id") or d.get("domain_name") or ""
expires_at = d.get("expires_at")
if name and expires_at:
# expires_at 为 ISO8601 时间戳,截取日期部分
expiry[name] = expires_at[:10]
return expiry
+20
View File
@@ -39,5 +39,25 @@ class Settings:
# ---- 续费提醒 ----
RENEWAL_THRESHOLD_DAYS: int = int(os.getenv("RENEWAL_THRESHOLD_DAYS", "30"))
# ---- 监控数据保留策略(自动清理)----
# MetricPoint 时序数据保留天数(Agent 高频上报,默认 30 天)
METRICS_RETENTION_DAYS: int = int(os.getenv("METRICS_RETENTION_DAYS", "30"))
# SecurityCheck 安全检查历史保留天数(默认 90 天)
SECURITY_RETENTION_DAYS: int = int(os.getenv("SECURITY_RETENTION_DAYS", "90"))
# EventLog 事件日志保留天数(默认 180 天)
EVENT_LOG_RETENTION_DAYS: int = int(os.getenv("EVENT_LOG_RETENTION_DAYS", "180"))
# 自动清理间隔(小时),0 表示禁用后台自动清理
CLEANUP_INTERVAL_HOURS: int = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
# ---- Agent 上报频率限制 ----
# 同一 asset_id 两次上报的最小间隔(秒),0 表示不限制
AGENT_REPORT_MIN_INTERVAL: int = int(os.getenv("AGENT_REPORT_MIN_INTERVAL", "30"))
# ---- CORS 允许来源(逗号分隔),默认本地 + Tailscale ----
CORS_ORIGINS: str = os.getenv(
"CORS_ORIGINS",
"http://127.0.0.1:8000,http://localhost:8000,https://dify.taile5765c.ts.net",
)
settings = Settings()
+3 -1
View File
@@ -4,6 +4,7 @@
MASTER_KEY 从 .env 读取,不入库。
"""
from functools import lru_cache
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
@@ -11,8 +12,9 @@ from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
@lru_cache(maxsize=1)
def _get_fernet() -> Fernet:
"""获取 Fernet 实例(MASTER_KEY 未配置时抛错)"""
"""获取 Fernet 实例(进程内缓存,避免每次加解密重建;MASTER_KEY 未配置时抛错)"""
key = settings.MASTER_KEY
if not key:
raise RuntimeError(
+27 -6
View File
@@ -10,57 +10,78 @@ from app.models.provider import Provider
PRESET_PROVIDERS = [
# ---- VPS / 云服务商 ----
# 综合平台:services 列出可提供的服务,卡片上会显示多标签
{"slug": "aliyun", "name": "阿里云", "name_en": "Aliyun", "category": "vps",
"services": "vps,domain,ssl_cert,cdn,dns",
"website": "https://aliyun.com", "console_url": "https://ecs.console.aliyun.com", "sdk_type": "aliyun-sdk"},
{"slug": "tencent", "name": "腾讯云", "name_en": "Tencent Cloud", "category": "vps",
"services": "vps,domain,ssl_cert,cdn,dns",
"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",
"services": "vps,domain,ssl_cert,cdn,dns",
"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",
"services": "vps,domain,ssl_cert,cdn,dns",
"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",
"services": "vps",
"website": "https://vultr.com", "console_url": "https://my.vultr.com", "sdk_type": "vultr-api"},
{"slug": "digitalocean", "name": "DigitalOcean", "name_en": "DigitalOcean", "category": "vps",
"services": "vps,domain",
"website": "https://digitalocean.com", "console_url": "https://cloud.digitalocean.com", "sdk_type": "do-api"},
{"slug": "linode", "name": "Linode", "name_en": "Akamai Linode", "category": "vps",
"services": "vps",
"website": "https://linode.com", "console_url": "https://cloud.linode.com", "sdk_type": "linode-api"},
{"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps",
"services": "vps",
"website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"},
{"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps",
"services": "vps,domain",
"website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"},
# ---- 域名注册商 ----
{"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain",
"services": "domain,dns",
"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",
"services": "domain,dns",
"website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None},
{"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain",
"services": "domain,dns",
"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",
"services": "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",
"services": "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",
"services": "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",
"services": "ai_agent",
"website": None, "console_url": None, "sdk_type": None},
{"slug": "deepseek", "name": "DeepSeek", "name_en": "DeepSeek", "category": "ai_agent",
"services": "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",
"services": "cloudflare,dns,ssl_cert,cdn",
"website": "https://cloudflare.com", "console_url": "https://dash.cloudflare.com", "sdk_type": "cloudflare-api"},
]
def seed_providers(session: Session) -> int:
"""初始化预设平台(已存在的 slug 跳过),返回新增数量"""
"""初始化预设平台(已存在的 slug 跳过),返回新增数量
单次查询获取全部已存在 slug,避免逐条 SELECT 的 N 次往返。
"""
existing_slugs = set(session.exec(select(Provider.slug)).all())
added = 0
for data in PRESET_PROVIDERS:
existing = session.exec(
select(Provider).where(Provider.slug == data["slug"])
).first()
if not existing:
if data["slug"] not in existing_slugs:
session.add(Provider(**data))
added += 1
session.commit()
if added:
session.commit()
return added
+19
View File
@@ -0,0 +1,19 @@
"""UTC 时间工具(替代已弃用的 datetime.utcnow
Python 3.12+ 起 datetime.utcnow() 被标记为弃用,
统一使用 datetime.now(timezone.utc) 的快捷封装。
注意:SQLite 不保存时区信息,为兼容既有数据与比较逻辑,
默认返回 naive UTC 时间(与 utcnow 行为一致,但来源非弃用 API)。
"""
from datetime import datetime, timezone
def utcnow() -> datetime:
"""返回当前 UTC 时间(naive,与 datetime.utcnow() 行为一致)"""
return datetime.now(timezone.utc).replace(tzinfo=None)
def utcnow_iso() -> str:
"""返回当前 UTC 时间的 ISO 格式字符串(带 Z 后缀标识 UTC)"""
return datetime.now(timezone.utc).isoformat()
+80 -3
View File
@@ -17,12 +17,30 @@ ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}"
METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}"
assets_engine = create_engine(
ASSETS_DB_URL, echo=False, connect_args={"check_same_thread": False}
ASSETS_DB_URL,
echo=False,
connect_args={"check_same_thread": False},
pool_pre_ping=True,
)
metrics_engine = create_engine(
METRICS_DB_URL, echo=False, connect_args={"check_same_thread": False}
METRICS_DB_URL,
echo=False,
connect_args={"check_same_thread": False},
pool_pre_ping=True,
)
def _enable_wal(engine) -> None:
"""启用 WAL 模式,提升 SQLite 并发读写能力"""
import sqlalchemy as sa
with engine.connect() as conn:
conn.execute(sa.text("PRAGMA journal_mode=WAL"))
conn.execute(sa.text("PRAGMA busy_timeout=5000"))
_enable_wal(assets_engine)
_enable_wal(metrics_engine)
# 兼容旧代码:默认 engine 指向资产库
engine = assets_engine
@@ -37,8 +55,9 @@ def init_db() -> None:
ServerInfo,
)
from app.models.provider import Provider # noqa: F401
from app.models.ssl import SiteCert, Subdomain # noqa: F401
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail]
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert]
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
SQLModel.metadata.create_all(
@@ -48,6 +67,7 @@ def init_db() -> None:
metrics_engine, tables=[m.__table__ for m in metric_models]
)
_migrate_assets_db()
_migrate_indexes()
def _migrate_assets_db() -> None:
@@ -68,10 +88,67 @@ def _migrate_assets_db() -> None:
cols = {c["name"] for c in insp.get_columns("providers")}
if "last_synced_at" not in cols:
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN last_synced_at DATETIME"))
if "services" not in cols:
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN services VARCHAR DEFAULT ''"))
# 为已有预设平台补充 services(仅补空值,用户自定义行不动)
_backfill_provider_services(conn)
if insp.has_table("ai_accounts"):
cols = {c["name"] for c in insp.get_columns("ai_accounts")}
if "api_key_encrypted" not in cols:
conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR"))
# 迁移:将明文 api_key 加密后存入 api_key_encrypted,并清空原字段
_migrate_plaintext_api_keys(conn)
def _migrate_indexes() -> None:
"""为已有数据库补充复合索引(create_all 不会为已存在的表补索引,IF NOT EXISTS 幂等)"""
import sqlalchemy as sa
stmts = [
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_provider_ext_type ON assets (provider_id, external_id, asset_type)"),
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_metric_points_asset_ts ON metric_points (asset_id, ts)"),
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_security_checks_asset_ts ON security_checks (asset_id, ts)"),
]
for eng, sql in stmts:
with eng.begin() as conn:
conn.execute(sa.text(sql))
def _migrate_plaintext_api_keys(conn) -> None:
"""一次性迁移:将 ai_accounts 中残留的明文 api_key 加密后存入 api_key_encrypted,并清空原字段"""
import sqlalchemy as sa
from app.core.crypto import encrypt
rows = conn.execute(
sa.text("SELECT id, api_key FROM ai_accounts WHERE api_key IS NOT NULL AND api_key != ''")
).fetchall()
if not rows:
return
for row in rows:
encrypted = encrypt(row[1])
if encrypted:
conn.execute(
sa.text("UPDATE ai_accounts SET api_key_encrypted = :enc, api_key = NULL WHERE id = :id"),
{"enc": encrypted, "id": row[0]},
)
def _backfill_provider_services(conn) -> None:
"""为已有预设平台补充 services 服务列表(只更新 services 为空的预设 slug"""
import sqlalchemy as sa
from app.core.seed import PRESET_PROVIDERS
for data in PRESET_PROVIDERS:
services = data.get("services")
if not services:
continue
conn.execute(
sa.text(
"UPDATE providers SET services = :services "
"WHERE slug = :slug AND (services IS NULL OR services = '')"
),
{"services": services, "slug": data["slug"]},
)
def get_session() -> Generator[Session, None, None]:
+53 -10
View File
@@ -8,44 +8,69 @@
from contextlib import asynccontextmanager
from pathlib import Path
import asyncio
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from starlette.staticfiles import StaticFiles as StarletteStaticFiles
from jinja2 import Environment, FileSystemLoader
from sqlmodel import Session
from app.core.config import settings
from app.core.seed import seed_providers
from app.database import assets_engine, init_db
from app.routers import agent, assets, monitor, notify, providers, stats, sync
from app.routers import agent, assets, monitor, notify, providers, ssl, stats, sync
from app.services import cleanup_service
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "static"
TEMPLATE_DIR = BASE_DIR / "app" / "templates"
STATIC_DIR.mkdir(parents=True, exist_ok=True)
def _asset_version() -> str:
"""静态资源版本号:取 static 目录内最新文件 mtime,代码更新后自动变更。
用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control
时存下的条目会被视为新鲜而不再回源验证)。
"""
latest = 0
for p in STATIC_DIR.rglob("*"):
if p.is_file():
latest = max(latest, int(p.stat().st_mtime))
return str(latest)
# 全局日志配置:统一格式,便于生产环境排查
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
# 直接使用 Jinja2 Environment 渲染(规避 Starlette Jinja2Templates 在 Python 3.14 下的缓存兼容问题)
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)
cleanup_task = asyncio.create_task(cleanup_service.cleanup_loop())
yield
cleanup_task.cancel()
app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-API-Key", "X-Agent-Key"],
allow_credentials=True,
)
app.include_router(assets.router)
@@ -55,24 +80,42 @@ app.include_router(agent.router)
app.include_router(monitor.router)
app.include_router(sync.router)
app.include_router(notify.router)
app.include_router(ssl.router)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# 静态资源:no-cache(每次回源校验 ETag)。避免启发式缓存导致旧版本残留,
# 与 SW 的 no-store 回源配合,保证代码更新后立即生效。
class NoCacheStaticFiles(StarletteStaticFiles):
def file_response(self, *args, **kwargs):
resp = super().file_response(*args, **kwargs)
resp.headers.setdefault("Cache-Control", "no-cache")
return resp
app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
@app.get("/", include_in_schema=False)
def index() -> HTMLResponse:
"""前端 SPA 入口"""
html = jinja_env.get_template("index.html").render(app_name=settings.APP_NAME)
html = jinja_env.get_template("index.html").render(
app_name=settings.APP_NAME, asset_version=_asset_version()
)
return HTMLResponse(html)
@app.get("/sw.js", include_in_schema=False)
def service_worker() -> FileResponse:
"""Service Worker(置于根路径以使 scope 覆盖全站)"""
"""Service Worker(置于根路径以使 scope 覆盖全站)
no-cache:保证浏览器每次导航都校验 SW 是否有更新,
否则浏览器可能长时间持有旧版 SW(默认更新检查间隔长)。
"""
return FileResponse(
STATIC_DIR / "sw.js",
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/"},
headers={
"Service-Worker-Allowed": "/",
"Cache-Control": "no-cache",
},
)
+8 -3
View File
@@ -11,8 +11,11 @@ from datetime import date, datetime
from enum import Enum
from typing import Optional
from sqlalchemy import Index
from sqlmodel import Field, SQLModel
from app.core.timeutils import utcnow
class AssetType(str, Enum):
"""资产类型"""
@@ -38,6 +41,8 @@ class Asset(SQLModel, table=True):
"""资产主表:所有资产在此统一登记,用于续费提醒与状态总览"""
__tablename__ = "assets"
# SDK 同步去重查询:provider_id + external_id + asset_type
__table_args__ = (Index("ix_assets_provider_ext_type", "provider_id", "external_id", "asset_type"),)
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, description="资产名称,如:博客主站 VPS")
@@ -72,10 +77,10 @@ class Asset(SQLModel, table=True):
default=False, description="已归档:不再使用但可能仍在续费,需重点排查"
)
remark: Optional[str] = Field(default=None, description="备注")
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
updated_at: datetime = Field(
default_factory=datetime.utcnow,
sa_column_kwargs={"onupdate": datetime.utcnow},
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
description="更新时间",
)
+9 -4
View File
@@ -9,17 +9,21 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Index
from sqlmodel import Field, SQLModel
from app.core.timeutils import utcnow
class MetricPoint(SQLModel, table=True):
"""资源监控时序数据"""
__tablename__ = "metric_points"
__table_args__ = (Index("ix_metric_points_asset_ts", "asset_id", "ts"),)
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="采集时间")
ts: datetime = Field(default_factory=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="磁盘使用率 %")
@@ -46,17 +50,18 @@ class ServerInfo(SQLModel, table=True):
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="最近上报时间")
last_seen: datetime = Field(default_factory=utcnow, description="最近上报时间")
class SecurityCheck(SQLModel, table=True):
"""安全检查项结果"""
__tablename__ = "security_checks"
__table_args__ = (Index("ix_security_checks_asset_ts", "asset_id", "ts"),)
id: Optional[int] = Field(default=None, primary_key=True)
asset_id: int = Field(index=True)
ts: datetime = Field(default_factory=datetime.utcnow, index=True)
ts: datetime = Field(default_factory=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="检查详情")
@@ -69,7 +74,7 @@ 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)
ts: datetime = Field(default_factory=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)
+6 -1
View File
@@ -10,6 +10,8 @@ from typing import Optional
from sqlmodel import Field, SQLModel
from app.core.timeutils import utcnow
class ProviderCategory(str, Enum):
"""平台分类"""
@@ -31,6 +33,9 @@ class Provider(SQLModel, table=True):
name: str = Field(index=True, description="显示名,如 阿里云")
name_en: Optional[str] = Field(default=None, description="英文名")
category: ProviderCategory = Field(index=True, description="平台分类")
services: str = Field(
default="", description="提供的服务列表(逗号分隔):vps/domain/ai_agent/cloudflare/ssl_cert/cdn/dns/other,综合平台可多项"
)
website: Optional[str] = Field(default=None, description="官网")
console_url: Optional[str] = Field(default=None, description="管理面板 URL")
sdk_type: Optional[str] = Field(
@@ -44,4 +49,4 @@ class Provider(SQLModel, table=True):
last_synced_at: Optional[datetime] = Field(
default=None, description="最近一次 SDK 同步时间"
)
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
+69
View File
@@ -0,0 +1,69 @@
"""SSL 相关数据库模型
- Subdomain: 域名资产下的子域名记录(关联域名资产 Asset)
- SiteCert: 站点证书监控记录(探测 https 站点证书的到期情况)
"""
from datetime import date, datetime
from typing import Optional
from sqlmodel import Field, SQLModel
from app.core.timeutils import utcnow
class Subdomain(SQLModel, table=True):
"""子域名记录(挂在域名资产下)"""
__tablename__ = "subdomains"
id: Optional[int] = Field(default=None, primary_key=True)
asset_id: int = Field(
foreign_key="assets.id", index=True, description="所属域名资产 ID"
)
host: str = Field(index=True, description="子域名主机名,如 www / api / blog@ 表示根域名)")
record_type: Optional[str] = Field(
default=None, description="DNS 记录类型:A/CNAME/AAAA/MX/TXT"
)
record_value: Optional[str] = Field(
default=None, description="DNS 记录值(IP 或目标域名)"
)
is_active: bool = Field(default=True, description="是否启用/解析中")
note: Optional[str] = Field(default=None, description="备注(用途)")
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
updated_at: datetime = Field(
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
description="更新时间",
)
class SiteCert(SQLModel, table=True):
"""站点证书监控记录(探测 https 站点证书到期情况)"""
__tablename__ = "site_certs"
id: Optional[int] = Field(default=None, primary_key=True)
hostname: str = Field(index=True, description="探测目标主机名,如 www.example.com")
port: int = Field(default=443, description="探测端口")
asset_id: Optional[int] = Field(
default=None, foreign_key="assets.id", index=True, description="关联资产 ID(可选)"
)
issuer: Optional[str] = Field(default=None, description="签发机构 CN")
subject_cn: Optional[str] = Field(default=None, description="证书主体 CN")
valid_from: Optional[date] = Field(default=None, description="证书生效日期")
valid_to: Optional[date] = Field(default=None, description="证书到期日期")
fingerprint: Optional[str] = Field(default=None, description="证书指纹 SHA-256")
status: str = Field(
default="unknown",
index=True,
description="状态:valid/expiring/expired/error/unknown",
)
error: Optional[str] = Field(default=None, description="探测失败原因")
last_checked_at: Optional[datetime] = Field(default=None, description="最近探测时间")
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
updated_at: datetime = Field(
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
description="更新时间",
)
+43 -4
View File
@@ -1,17 +1,53 @@
"""Agent 上报接收路由(数据写入 metrics.db)"""
from datetime import datetime
import threading
import time
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.config import settings
from app.core.security import require_agent_key
from app.database import get_metrics_session
from app.core.timeutils import utcnow
from app.database import assets_engine, get_metrics_session
from app.models.asset import Asset
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
from app.schemas.agent import AgentReport
router = APIRouter(prefix="/api/agent", tags=["agent"])
# 内存级频率限制:{asset_id: 上次上报的 monotonic 时间戳}
# 同步路由运行在线程池,多线程并发读写需加锁保证“读-判断-写”原子性
_last_report_ts: dict = {}
_rate_limit_lock = threading.Lock()
def _check_rate_limit(asset_id: int) -> None:
"""限制同一资产的上报频率,防止配置错误的 Agent 高频写入填满数据库"""
interval = settings.AGENT_REPORT_MIN_INTERVAL
if interval <= 0:
return
now = time.monotonic()
with _rate_limit_lock:
last = _last_report_ts.get(asset_id)
if last is not None and (now - last) < interval:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"上报过于频繁,同一资产最小间隔 {interval}",
)
_last_report_ts[asset_id] = now
def _validate_asset_id(asset_id: int) -> None:
"""校验上报的 asset_id 在资产库中真实存在,防止脏数据写入"""
with Session(assets_engine) as session:
asset = session.get(Asset, asset_id)
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"asset_id={asset_id} 不存在,请先在资产列表中登记该服务器",
)
@router.post(
"/report",
@@ -19,6 +55,9 @@ router = APIRouter(prefix="/api/agent", tags=["agent"])
dependencies=[Depends(require_agent_key)],
)
def report(data: AgentReport, session: Session = Depends(get_metrics_session)) -> dict:
_validate_asset_id(data.asset_id)
_check_rate_limit(data.asset_id)
# 资源监控时序
if data.metrics:
session.add(MetricPoint(asset_id=data.asset_id, **data.metrics.model_dump()))
@@ -26,7 +65,7 @@ def report(data: AgentReport, session: Session = Depends(get_metrics_session)) -
# 服务器信息快照(每资产一条,覆盖更新)
if data.server_info:
info = data.server_info.model_dump()
info["last_seen"] = datetime.utcnow()
info["last_seen"] = utcnow()
existing = session.exec(
select(ServerInfo).where(ServerInfo.asset_id == data.asset_id)
).first()
+1 -1
View File
@@ -32,7 +32,7 @@ def list_assets(
)
@router.get("/export", summary="导出所有资产(数据迁移/备份)")
@router.get("/export", summary="导出所有资产(数据迁移/备份)", dependencies=[Depends(require_api_key)])
def export_assets(session: Session = Depends(get_session)) -> dict:
return asset_service.export_assets(session)
+15 -1
View File
@@ -5,9 +5,10 @@ from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select
from app.core.security import require_api_key
from app.database import get_metrics_session
from app.models.monitor import MetricPoint, SecurityCheck, ServerInfo
from app.services import security_service
from app.services import cleanup_service, security_service
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
@@ -17,6 +18,19 @@ def security_overview(session: Session = Depends(get_metrics_session)):
return security_service.get_security_overview(session)
@router.post(
"/cleanup",
summary="手动触发监控数据清理(按保留周期删除过期数据)",
dependencies=[Depends(require_api_key)],
)
def cleanup(
metrics_days: Optional[int] = Query(default=None, description="时序数据保留天数(默认取配置)"),
security_days: Optional[int] = Query(default=None, description="安全检查保留天数(默认取配置)"),
event_log_days: Optional[int] = Query(default=None, description="事件日志保留天数(默认取配置)"),
):
return cleanup_service.cleanup_metrics(metrics_days, security_days, event_log_days)
@router.get("/{asset_id}/metrics", summary="资源监控时序(倒序,最新在前)")
def metrics(
asset_id: int,
+148
View File
@@ -0,0 +1,148 @@
"""SSL 监控路由:子域名管理与站点证书监控
- /api/subdomains:域名资产下的子域名 CRUD
- /api/site-certshttps 站点证书的探测与监控
写操作(POST/PUT/DELETE)受 API Key 保护。
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session
from app.core.security import require_api_key
from app.database import get_session
from app.schemas.ssl import SiteCertCreate, SubdomainCreate, SubdomainUpdate
from app.services import ssl_service
router = APIRouter(prefix="/api", tags=["ssl"])
def _guard(exc: ValueError) -> HTTPException:
"""service 抛出的 ValueError 统一转 404"""
return HTTPException(status_code=404, detail=str(exc))
# ---------------- 子域名 ----------------
@router.get("/subdomains", summary="子域名列表(可按域名资产筛选)")
def list_subdomains(
asset_id: Optional[int] = Query(default=None, description="按域名资产 ID 筛选"),
session: Session = Depends(get_session),
) -> list:
return ssl_service.list_subdomains(session, asset_id)
@router.post(
"/subdomains",
status_code=201,
summary="创建子域名",
dependencies=[Depends(require_api_key)],
)
def create_subdomain(data: SubdomainCreate, session: Session = Depends(get_session)) -> dict:
sub = ssl_service.create_subdomain(session, data.model_dump())
return {
"id": sub.id,
"asset_id": sub.asset_id,
"host": sub.host,
"record_type": sub.record_type,
"record_value": sub.record_value,
"is_active": sub.is_active,
"note": sub.note,
}
@router.put(
"/subdomains/{sub_id}",
summary="更新子域名",
dependencies=[Depends(require_api_key)],
)
def update_subdomain(sub_id: int, data: SubdomainUpdate, session: Session = Depends(get_session)) -> dict:
try:
sub = ssl_service.update_subdomain(session, sub_id, data.model_dump(exclude_unset=True))
except ValueError as e:
raise _guard(e) from e
return {
"id": sub.id,
"asset_id": sub.asset_id,
"host": sub.host,
"record_type": sub.record_type,
"record_value": sub.record_value,
"is_active": sub.is_active,
"note": sub.note,
}
@router.delete(
"/subdomains/{sub_id}",
status_code=204,
summary="删除子域名",
dependencies=[Depends(require_api_key)],
)
def delete_subdomain(sub_id: int, session: Session = Depends(get_session)):
try:
ssl_service.delete_subdomain(session, sub_id)
except ValueError as e:
raise _guard(e) from e
# ---------------- 站点证书监控 ----------------
@router.get("/site-certs", summary="站点证书监控列表(可按状态/资产筛选)")
def list_site_certs(
status: Optional[str] = Query(default=None, description="按状态筛选"),
asset_id: Optional[int] = Query(default=None, description="按关联资产筛选"),
session: Session = Depends(get_session),
) -> list:
return ssl_service.list_site_certs(session, status, asset_id)
@router.post(
"/site-certs",
status_code=201,
summary="创建证书探测目标(创建后立即探测一次)",
dependencies=[Depends(require_api_key)],
)
def create_site_cert(data: SiteCertCreate, session: Session = Depends(get_session)) -> dict:
try:
cert = ssl_service.create_site_cert(
session, data.hostname, data.port, data.asset_id
)
except ValueError as e:
raise _guard(e) from e
return ssl_service._to_dict(cert)
@router.post(
"/site-certs/check-all",
summary="全量重新探测所有站点证书",
dependencies=[Depends(require_api_key)],
)
def check_all_site_certs(session: Session = Depends(get_session)) -> dict:
return ssl_service.check_all_site_certs(session)
@router.post(
"/site-certs/{cert_id}/check",
summary="重新探测单条站点证书",
dependencies=[Depends(require_api_key)],
)
def check_one_site_cert(cert_id: int, session: Session = Depends(get_session)) -> dict:
cert = session.get(ssl_service.SiteCert, cert_id)
if not cert:
raise HTTPException(status_code=404, detail=f"证书监控记录不存在(id={cert_id}")
ssl_service.check_one(session, cert)
return ssl_service._to_dict(cert)
@router.delete(
"/site-certs/{cert_id}",
status_code=204,
summary="删除证书探测目标",
dependencies=[Depends(require_api_key)],
)
def delete_site_cert(cert_id: int, session: Session = Depends(get_session)):
try:
ssl_service.delete_site_cert(session, cert_id)
except ValueError as e:
raise _guard(e) from e
+3 -1
View File
@@ -2,6 +2,7 @@
from typing import List, Optional
from pydantic import Field
from sqlmodel import SQLModel
@@ -40,4 +41,5 @@ class AgentReport(SQLModel):
asset_id: int
metrics: Optional[MetricsIn] = None
server_info: Optional[ServerInfoIn] = None
security: Optional[List[SecurityCheckIn]] = None
# 限制单次上报的检查项数量,防止恶意/异常 Agent 发送超大数组撑爆内存
security: Optional[List[SecurityCheckIn]] = Field(default=None, max_length=100)
+2
View File
@@ -13,6 +13,7 @@ class ProviderBase(SQLModel):
name: str
name_en: Optional[str] = None
category: ProviderCategory
services: Optional[str] = None
website: Optional[str] = None
console_url: Optional[str] = None
sdk_type: Optional[str] = None
@@ -31,6 +32,7 @@ class ProviderUpdate(SQLModel):
name: Optional[str] = None
name_en: Optional[str] = None
category: Optional[ProviderCategory] = None
services: Optional[str] = None
website: Optional[str] = None
console_url: Optional[str] = None
sdk_type: Optional[str] = None
+28
View File
@@ -0,0 +1,28 @@
"""SSL 相关请求模型"""
from typing import Optional
from pydantic import BaseModel, Field
class SubdomainCreate(BaseModel):
asset_id: int = Field(description="所属域名资产 ID")
host: str = Field(min_length=1, max_length=63, description="子域名主机名,如 www/api(@ 表示根域名)")
record_type: Optional[str] = Field(default=None, description="DNS 记录类型")
record_value: Optional[str] = Field(default=None, description="DNS 记录值")
is_active: bool = Field(default=True, description="是否启用")
note: Optional[str] = Field(default=None, description="备注")
class SubdomainUpdate(BaseModel):
host: Optional[str] = Field(default=None, min_length=1, max_length=63)
record_type: Optional[str] = Field(default=None)
record_value: Optional[str] = Field(default=None)
is_active: Optional[bool] = Field(default=None)
note: Optional[str] = Field(default=None)
class SiteCertCreate(BaseModel):
hostname: str = Field(min_length=1, max_length=253, description="探测目标主机名")
port: int = Field(default=443, ge=1, le=65535, description="探测端口")
asset_id: Optional[int] = Field(default=None, description="关联资产 ID(可选)")
+146 -61
View File
@@ -3,7 +3,7 @@
统一处理 Asset 主表与其一对一详情表(VPSDetail/DomainDetail/AIAccount)的联动。
"""
from datetime import date, datetime
from datetime import date, datetime, timedelta, timezone
from typing import List, Optional
from fastapi import HTTPException, status
@@ -30,6 +30,10 @@ from app.schemas.asset import (
VPSDetailRead,
)
import logging
logger = logging.getLogger("vps-manager.assets")
# 资产类型 -> (AssetCreate/Update 中的字段名, 详情表模型)
DETAIL_MAP = {
AssetType.VPS: ("vps_detail", VPSDetail),
@@ -140,11 +144,49 @@ def _apply_detail_update(asset_type: AssetType, existing, detail_in) -> None:
setattr(existing, key, value)
def _get_details_batch(session: Session, assets: list) -> dict:
"""批量预加载所有资产的详情和 Provider 名,避免 N+1 查询"""
if not assets:
return {}
asset_ids = [a.id for a in assets]
# 批量查 Provider 名
provider_ids = {a.provider_id for a in assets if a.provider_id}
provider_map = {}
if provider_ids:
for p in session.exec(select(Provider).where(Provider.id.in_(provider_ids))).all():
provider_map[p.id] = p.name
# 批量查各类型详情
detail_map = {}
for asset_type, (_, model) in DETAIL_MAP.items():
typed_ids = [a.id for a in assets if a.asset_type == asset_type]
if typed_ids:
for d in session.exec(select(model).where(model.asset_id.in_(typed_ids))).all():
detail_map[d.asset_id] = d
return {"providers": provider_map, "details": detail_map}
def _to_read_batch(session: Session, asset: Asset, batch: dict) -> AssetRead:
"""用批量预加载的数据组装 AssetRead(避免逐个查询)"""
read = AssetRead.model_validate(asset)
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
read.provider_name = batch["providers"].get(asset.provider_id)
detail = batch["details"].get(asset.id)
if isinstance(detail, VPSDetail):
read.vps_detail = _detail_to_read(detail)
elif isinstance(detail, DomainDetail):
read.domain_detail = _detail_to_read(detail)
elif isinstance(detail, AIAccount):
read.ai_detail = _detail_to_read(detail)
elif isinstance(detail, CloudflareDetail):
read.cloudflare_detail = _detail_to_read(detail)
return read
# --------------------------------------------------------------------------- #
# CRUD
# --------------------------------------------------------------------------- #
def create_asset(session: Session, data: AssetCreate) -> AssetRead:
"""创建资产及详情"""
def _create_asset_no_commit(session: Session, data: AssetCreate) -> Asset:
"""创建资产及详情(不 commit,由调用方统一提交)"""
item = DETAIL_MAP.get(data.asset_type)
detail_in = None
model = None
@@ -162,17 +204,21 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead:
)
asset = Asset(**asset_data)
session.add(asset)
session.commit()
session.refresh(asset)
session.flush() # 获取 asset.id,但不提交
detail = None
if model is not None and detail_in is not None:
detail = _build_detail(data.asset_type, asset.id, detail_in)
session.add(detail)
session.commit()
session.refresh(detail)
return asset
return _to_read(session, asset, detail)
def create_asset(session: Session, data: AssetCreate) -> AssetRead:
"""创建资产及其详情(单次事务提交)"""
asset = _create_asset_no_commit(session, data)
session.commit()
session.refresh(asset)
logger.info("创建资产 id=%s name=%s type=%s", asset.id, asset.name, asset.asset_type)
return _to_read(session, asset, _get_detail(session, asset))
def get_asset(session: Session, asset_id: int) -> AssetRead:
@@ -183,8 +229,8 @@ def get_asset(session: Session, asset_id: int) -> AssetRead:
return _to_read(session, asset, _get_detail(session, asset))
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
"""更新资产主表及详情(仅更新传入字段"""
def _update_asset_no_commit(session: Session, asset_id: int, data: AssetUpdate) -> Asset:
"""更新资产主表及详情(不 commit,由调用方统一提交"""
asset = session.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
@@ -197,7 +243,6 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
session.add(asset)
# 详情表:以更新后的 asset_type 为准
detail = None
item = DETAIL_MAP.get(asset.asset_type)
if item:
field, model = item
@@ -209,22 +254,22 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
if existing:
_apply_detail_update(asset.asset_type, existing, detail_in)
session.add(existing)
detail = existing
else:
detail = _build_detail(asset.asset_type, asset.id, detail_in)
session.add(detail)
else:
detail = existing
return asset
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
"""更新资产主表及详情(单次事务提交)"""
asset = _update_asset_no_commit(session, asset_id, data)
session.commit()
session.refresh(asset)
if detail is not None:
session.refresh(detail)
return _to_read(session, asset, detail)
return _to_read(session, asset, _get_detail(session, asset))
def delete_asset(session: Session, asset_id: int) -> None:
"""删除资产及其详情"""
"""删除资产及其详情,并清理 metrics.db 中的关联监控数据(避免孤儿数据)"""
asset = session.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
@@ -233,46 +278,72 @@ def delete_asset(session: Session, asset_id: int) -> None:
session.delete(detail)
session.delete(asset)
session.commit()
logger.info("删除资产 id=%s name=%s", asset_id, asset.name)
_cleanup_metrics_for_asset(asset_id)
def _cleanup_metrics_for_asset(asset_id: int) -> None:
"""清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog"""
from app.database import metrics_engine
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
with Session(metrics_engine) as ms:
for model in (MetricPoint, ServerInfo, SecurityCheck, EventLog):
for row in ms.exec(select(model).where(model.asset_id == asset_id)).all():
ms.delete(row)
ms.commit()
def export_assets(session: Session) -> dict:
"""导出所有资产(含 detail)为 JSON,用于数据迁移/备份"""
assets = session.exec(select(Asset)).all()
batch = _get_details_batch(session, assets)
result = []
for asset in assets:
read = _to_read(session, asset, _get_detail(session, asset))
read = _to_read_batch(session, asset, batch)
result.append(read.model_dump(mode="json"))
return {
"count": len(result),
"exported_at": datetime.utcnow().isoformat(),
"exported_at": datetime.now(timezone.utc).isoformat(),
"assets": result,
}
def import_assets(session: Session, assets_data: list) -> dict:
"""从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)"""
"""从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)
整个导入在单个事务中完成:全部成功才提交,任何一条失败则回滚,避免部分导入。
"""
created = 0
updated = 0
errors = []
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"}
for item in assets_data:
try:
payload = {k: v for k, v in item.items() if k not in skip_fields}
existing = session.exec(
select(Asset).where(
Asset.name == payload.get("name"),
Asset.asset_type == payload.get("asset_type"),
Asset.provider == payload.get("provider"),
)
).first()
if existing:
update_asset(session, existing.id, AssetUpdate(**payload))
updated += 1
else:
create_asset(session, AssetCreate(**payload))
created += 1
except Exception as e: # noqa: BLE001
errors.append(f"{item.get('name', '?')}: {e}")
try:
for item in assets_data:
try:
payload = {k: v for k, v in item.items() if k not in skip_fields}
existing = session.exec(
select(Asset).where(
Asset.name == payload.get("name"),
Asset.asset_type == payload.get("asset_type"),
Asset.provider == payload.get("provider"),
)
).first()
if existing:
_update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
updated += 1
else:
_create_asset_no_commit(session, AssetCreate(**payload))
created += 1
except Exception as e: # noqa: BLE001
errors.append(f"{item.get('name', '?')}: {e}")
if errors:
session.rollback()
return {"created": 0, "updated": 0, "errors": errors}
session.commit()
except Exception: # noqa: BLE001
session.rollback()
raise
return {"created": created, "updated": updated, "errors": errors}
@@ -301,15 +372,20 @@ 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(session, a, _get_detail(session, a)) for a in assets]
batch = _get_details_batch(session, assets)
return [_to_read_batch(session, a, batch) for a in assets]
# --------------------------------------------------------------------------- #
# 统计
# --------------------------------------------------------------------------- #
def get_overview(session: Session) -> dict:
"""资产总览:数量分布、异常数、到期预警、支出合计"""
assets = session.exec(select(Asset)).all()
"""资产总览:数量分布、异常数、到期预警、支出合计
性能优化:仅查询统计所需的列,避免加载完整 Asset 实体。
"""
stmt = select(Asset.asset_type, Asset.status, Asset.expiry_date, Asset.cost)
rows = session.exec(stmt).all()
today = date.today()
by_type: dict = {}
@@ -319,22 +395,24 @@ def get_overview(session: Session) -> dict:
year_cost = 0.0
month_cost = 0.0
for a in assets:
by_type[a.asset_type.value] = by_type.get(a.asset_type.value, 0) + 1
by_status[a.status.value] = by_status.get(a.status.value, 0) + 1
if a.status in (AssetStatus.STOPPED, AssetStatus.EXPIRED):
for asset_type, status_val, expiry_date, cost in rows:
tval = asset_type.value if hasattr(asset_type, "value") else asset_type
sval = status_val.value if hasattr(status_val, "value") else status_val
by_type[tval] = by_type.get(tval, 0) + 1
by_status[sval] = by_status.get(sval, 0) + 1
if status_val in (AssetStatus.STOPPED, AssetStatus.EXPIRED):
abnormal += 1
if a.expiry_date:
days = (a.expiry_date - today).days
if expiry_date:
days = (expiry_date - today).days
if 0 <= days <= 30:
expiring_30 += 1
if a.expiry_date.year == today.year:
year_cost += a.cost
if a.expiry_date.month == today.month:
month_cost += a.cost
if expiry_date.year == today.year:
year_cost += cost
if expiry_date.month == today.month:
month_cost += cost
return {
"total": len(assets),
"total": len(rows),
"by_type": by_type,
"by_status": by_status,
"abnormal_count": abnormal,
@@ -345,13 +423,20 @@ def get_overview(session: Session) -> dict:
def get_expiring(session: Session, days: int = 30) -> List[AssetRead]:
"""N 天内到期资产列表(按剩余天数升序)"""
"""N 天内到期资产列表(按剩余天数升序)
性能优化:过滤条件下推到 SQL 层,仅查询 [today, today+days] 区间内的资产。
"""
today = date.today()
assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all()
result = []
for a in assets:
delta = (a.expiry_date - today).days
if 0 <= delta <= days:
result.append(_to_read(session, a, _get_detail(session, a)))
deadline = today + timedelta(days=days)
assets = session.exec(
select(Asset).where(
Asset.expiry_date.is_not(None),
Asset.expiry_date >= today,
Asset.expiry_date <= deadline,
)
).all()
batch = _get_details_batch(session, assets)
result = [_to_read_batch(session, a, batch) for a in assets]
result.sort(key=lambda x: x.days_to_expiry if x.days_to_expiry is not None else 10**9)
return result
+98
View File
@@ -0,0 +1,98 @@
"""监控数据自动清理服务
按配置的保留周期清理 metrics.db 中的历史数据,防止 SQLite 无限膨胀:
- MetricPoint: 高频时序数据,默认保留 30 天
- SecurityCheck: 安全检查历史,默认保留 90 天
- EventLog: 事件日志,默认保留 180 天
提供两种触发方式:
1. 应用启动后由 asyncio 后台任务按 CLEANUP_INTERVAL_HOURS 周期执行
2. 通过 API 手动触发(POST /api/monitor/cleanup
"""
import asyncio
import logging
from datetime import timedelta
from sqlmodel import Session, delete, select
from app.core.config import settings
from app.core.timeutils import utcnow
from app.database import metrics_engine
from app.models.monitor import EventLog, MetricPoint, SecurityCheck
logger = logging.getLogger("vps-manager.cleanup")
# 单批删除行数上限:避免大表单条 DELETE 长时间持有写锁,阻塞 Agent 上报
_BATCH_SIZE = 1000
def _delete_in_batches(session: Session, model, cutoff, batch_size: int = _BATCH_SIZE) -> int:
"""分批删除指定模型的过期数据(ts < cutoff),返回总删除行数
SQLite 不支持 DELETE ... LIMIT,用子查询 SELECT id ... LIMIT 实现分批。
"""
total = 0
while True:
subq = select(model.id).where(model.ts < cutoff).limit(batch_size)
ids = [row[0] if isinstance(row, tuple) else row for row in session.exec(subq).all()]
if not ids:
break
session.exec(delete(model).where(model.id.in_(ids)))
session.commit() # 每批独立提交,缩短写锁持有时间
total += len(ids)
if len(ids) < batch_size:
break
return total
def cleanup_metrics(
metrics_days: int | None = None,
security_days: int | None = None,
event_log_days: int | None = None,
) -> dict:
"""按保留天数清理过期监控数据,返回各类删除条数"""
metrics_days = metrics_days if metrics_days is not None else settings.METRICS_RETENTION_DAYS
security_days = security_days if security_days is not None else settings.SECURITY_RETENTION_DAYS
event_log_days = event_log_days if event_log_days is not None else settings.EVENT_LOG_RETENTION_DAYS
now = utcnow()
result = {"metric_points": 0, "security_checks": 0, "event_logs": 0}
with Session(metrics_engine) as session:
if metrics_days > 0:
cutoff = now - timedelta(days=metrics_days)
result["metric_points"] = _delete_in_batches(session, MetricPoint, cutoff)
if security_days > 0:
cutoff = now - timedelta(days=security_days)
result["security_checks"] = _delete_in_batches(session, SecurityCheck, cutoff)
if event_log_days > 0:
cutoff = now - timedelta(days=event_log_days)
result["event_logs"] = _delete_in_batches(session, EventLog, cutoff)
logger.info(
"监控数据清理完成:metric_points=%s, security_checks=%s, event_logs=%s",
result["metric_points"],
result["security_checks"],
result["event_logs"],
)
return result
async def cleanup_loop() -> None:
"""后台周期清理任务(CLEANUP_INTERVAL_HOURS=0 时不启动)"""
interval_hours = settings.CLEANUP_INTERVAL_HOURS
if interval_hours <= 0:
logger.info("监控数据自动清理已禁用(CLEANUP_INTERVAL_HOURS=0")
return
interval_sec = interval_hours * 3600
logger.info("监控数据自动清理已启动,间隔 %s 小时", interval_hours)
while True:
try:
# 在线程池执行同步 DB 操作,避免阻塞事件循环
await asyncio.to_thread(cleanup_metrics)
except Exception: # noqa: BLE001
logger.exception("监控数据自动清理执行失败")
await asyncio.sleep(interval_sec)
+10
View File
@@ -6,6 +6,7 @@ from fastapi import HTTPException, status
from sqlmodel import Session, select
from app.core import crypto
from app.models.asset import Asset
from app.models.provider import Provider, ProviderCategory
from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate
@@ -73,6 +74,15 @@ 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="平台不存在")
# 数据完整性:存在关联资产时禁止删除,避免 provider_id 悬空引用
linked = session.exec(
select(Asset.id).where(Asset.provider_id == provider_id).limit(1)
).first()
if linked is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="该平台下仍有关联资产,请先迁移或删除相关资产",
)
session.delete(provider)
session.commit()
+15 -10
View File
@@ -3,7 +3,7 @@
检查即将到期的资产生成提醒消息并通过通知渠道发送
"""
from datetime import date
from datetime import date, timedelta
from sqlmodel import Session, select
@@ -16,16 +16,21 @@ _SKIP_STATUS = {AssetStatus.CANCELLED}
def get_expiring_assets(session: Session, threshold_days: int) -> list:
"""返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序"""
"""返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序
性能优化日期范围与状态过滤均下推到 SQL
"""
today = date.today()
assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all()
expiring = []
for asset in assets:
if asset.status in _SKIP_STATUS:
continue
days = (asset.expiry_date - today).days
if 0 <= days <= threshold_days:
expiring.append((asset, days))
deadline = today + timedelta(days=threshold_days)
assets = session.exec(
select(Asset).where(
Asset.expiry_date.is_not(None),
Asset.expiry_date >= today,
Asset.expiry_date <= deadline,
Asset.status.notin_(_SKIP_STATUS),
)
).all()
expiring = [(asset, (asset.expiry_date - today).days) for asset in assets]
expiring.sort(key=lambda x: x[1])
return expiring
+38 -3
View File
@@ -4,6 +4,7 @@
评分按检查项加权pass 满分warn 半分fail/unknown 零分
"""
from sqlalchemy import func
from sqlmodel import Session, select
from app.models.monitor import SecurityCheck
@@ -70,8 +71,42 @@ def get_asset_security(session: Session, asset_id: int) -> dict:
def get_security_overview(session: Session) -> list:
"""所有有安全检查数据的服务器评分总览(按评分升序,风险高的在前)"""
asset_ids = session.exec(select(SecurityCheck.asset_id).distinct()).all()
result = [get_asset_security(session, aid) for aid in asset_ids]
"""所有有安全检查数据的服务器评分总览(按评分升序,风险高的在前)
性能优化用子查询取每个 (asset_id, check_item) 的最新 ts仅拉取最新记录
避免全表扫描历史数据量大时内存可控
"""
latest_ts = (
select(
SecurityCheck.asset_id,
SecurityCheck.check_item,
func.max(SecurityCheck.ts).label("max_ts"),
)
.group_by(SecurityCheck.asset_id, SecurityCheck.check_item)
.subquery()
)
stmt = select(SecurityCheck).join(
latest_ts,
(SecurityCheck.asset_id == latest_ts.c.asset_id)
& (SecurityCheck.check_item == latest_ts.c.check_item)
& (SecurityCheck.ts == latest_ts.c.max_ts),
)
all_checks = session.exec(stmt).all()
# 按 asset_id 分组
latest_by_asset: dict = {}
for check in all_checks:
latest_by_asset.setdefault(check.asset_id, []).append(check)
result = []
for asset_id, checks in latest_by_asset.items():
score = compute_security_score(checks)
result.append({
"asset_id": asset_id,
"score": score,
"level": score_level(score),
"checks_count": len(checks),
"suggestions": [c.suggestion for c in checks if c.suggestion],
})
result.sort(key=lambda x: (x["score"] is None, x["score"] if x["score"] is not None else 0))
return result
+258
View File
@@ -0,0 +1,258 @@
"""SSL 监控服务:站点证书探测 + 子域名管理
- probe_site_cert: 通过 ssl socket 探测站点证书信息不校验证书链仅读取到期信息
- create/check/check_all: 站点证书的创建与刷新
- 子域名 CRUDlist/create/update/delete
"""
import socket
import ssl
from datetime import date, datetime
from typing import Optional
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from sqlmodel import Session, select
from app.core.timeutils import utcnow
from app.models.asset import Asset
from app.models.ssl import SiteCert, Subdomain
PROBE_TIMEOUT = 8 # 探测连接超时(秒)
EXPIRING_THRESHOLD = 30 # 到期提醒阈值(天)
def days_until(d: Optional[date]) -> Optional[int]:
"""计算距今天数(负数为已过期)"""
if d is None:
return None
return (d - date.today()).days
def _judge_status(days: Optional[int]) -> str:
"""按剩余天数定级:expired / expiring / valid"""
if days is None:
return "unknown"
if days < 0:
return "expired"
if days <= EXPIRING_THRESHOLD:
return "expiring"
return "valid"
def probe_site_cert(hostname: str, port: int = 443) -> dict:
"""探测目标站点证书信息(探测失败时抛异常)
返回字段subject_cn / issuer / valid_from / valid_to / fingerprint / san_list
"""
ctx = ssl.create_default_context()
# 不校验证书链:即使证书已过期/自签名也能读到到期信息
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((hostname, port), timeout=PROBE_TIMEOUT) as sock:
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
der = ssock.getpeercert(binary_form=True)
cert = x509.load_der_x509_certificate(der)
try:
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
san_list = san.value.get_values_for_type(x509.DNSName)
except x509.ExtensionNotFound:
san_list = []
return {
"subject_cn": cert.subject.rfc4514_string() or None,
"issuer": cert.issuer.rfc4514_string() or None,
"valid_from": cert.not_valid_before_utc.date(),
"valid_to": cert.not_valid_after_utc.date(),
"fingerprint": cert.fingerprint(hashes.SHA256()).hex(),
"san_list": san_list,
}
def _to_dict(cert: SiteCert, asset_name: Optional[str] = None) -> dict:
"""SiteCert 模型转 API 返回结构(含动态计算的剩余天数)"""
days = days_until(cert.valid_to)
return {
"id": cert.id,
"hostname": cert.hostname,
"port": cert.port,
"asset_id": cert.asset_id,
"asset_name": asset_name,
"issuer": cert.issuer,
"subject_cn": cert.subject_cn,
"valid_from": cert.valid_from.isoformat() if cert.valid_from else None,
"valid_to": cert.valid_to.isoformat() if cert.valid_to else None,
"days_to_expiry": days,
"status": cert.status,
"error": cert.error,
"last_checked_at": cert.last_checked_at.isoformat() if cert.last_checked_at else None,
}
# ---------------- 子域名 CRUD ----------------
def list_subdomains(session: Session, asset_id: Optional[int] = None) -> list:
stmt = select(Subdomain).order_by(Subdomain.host)
if asset_id is not None:
stmt = stmt.where(Subdomain.asset_id == asset_id)
subs = session.exec(stmt).all()
# 带上所属域名,便于前端展示全名
assets = session.exec(select(Asset).where(Asset.id.in_({s.asset_id for s in subs}))).all()
name_map = {a.id: a.name for a in assets}
return [
{
"id": s.id,
"asset_id": s.asset_id,
"asset_name": name_map.get(s.asset_id),
"host": s.host,
"record_type": s.record_type,
"record_value": s.record_value,
"is_active": s.is_active,
"note": s.note,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
for s in subs
]
def create_subdomain(session: Session, data: dict) -> Subdomain:
sub = Subdomain(
asset_id=data["asset_id"],
host=data["host"].strip().lower(),
record_type=data.get("record_type"),
record_value=data.get("record_value"),
is_active=data.get("is_active", True),
note=data.get("note"),
)
session.add(sub)
session.commit()
session.refresh(sub)
return sub
def update_subdomain(session: Session, sub_id: int, data: dict) -> Subdomain:
sub = session.get(Subdomain, sub_id)
if not sub:
raise ValueError(f"子域名记录不存在(id={sub_id}")
if "host" in data and data["host"]:
sub.host = data["host"].strip().lower()
for key in ("record_type", "record_value", "note"):
if key in data:
setattr(sub, key, data[key])
if "is_active" in data:
sub.is_active = bool(data["is_active"])
session.add(sub)
session.commit()
session.refresh(sub)
return sub
def delete_subdomain(session: Session, sub_id: int) -> None:
sub = session.get(Subdomain, sub_id)
if not sub:
raise ValueError(f"子域名记录不存在(id={sub_id}")
session.delete(sub)
session.commit()
# ---------------- 站点证书监控 ----------------
def _apply_probe(cert: SiteCert, info: dict) -> SiteCert:
"""把探测结果写入模型并定级"""
cert.subject_cn = info["subject_cn"]
cert.issuer = info["issuer"]
cert.valid_from = info["valid_from"]
cert.valid_to = info["valid_to"]
cert.fingerprint = info["fingerprint"]
cert.error = None
cert.status = _judge_status(days_until(info["valid_to"]))
cert.last_checked_at = utcnow()
return cert
def check_one(session: Session, cert: SiteCert) -> SiteCert:
"""重新探测单条证书记录(失败则标记 error,保留旧到期信息)"""
try:
info = probe_site_cert(cert.hostname, cert.port)
_apply_probe(cert, info)
except Exception as e: # noqa: BLE001
cert.status = "error"
cert.error = str(e)[:200]
cert.last_checked_at = utcnow()
session.add(cert)
session.commit()
session.refresh(cert)
return cert
def create_site_cert(session: Session, hostname: str, port: int = 443, asset_id: Optional[int] = None) -> SiteCert:
"""创建探测目标并立即探测一次;hostname+port 已存在则复用并刷新"""
hostname = hostname.strip().lower()
existing = session.exec(
select(SiteCert).where(SiteCert.hostname == hostname, SiteCert.port == port)
).first()
if existing:
if asset_id is not None:
existing.asset_id = asset_id
return check_one(session, existing)
cert = SiteCert(hostname=hostname, port=port, asset_id=asset_id)
session.add(cert)
session.commit()
session.refresh(cert)
return check_one(session, cert)
def list_site_certs(session: Session, status: Optional[str] = None, asset_id: Optional[int] = None) -> list:
stmt = select(SiteCert)
if status:
stmt = stmt.where(SiteCert.status == status)
if asset_id is not None:
stmt = stmt.where(SiteCert.asset_id == asset_id)
certs = session.exec(stmt.order_by(SiteCert.id)).all()
ids = {c.asset_id for c in certs if c.asset_id}
assets = session.exec(select(Asset).where(Asset.id.in_(ids))).all() if ids else []
name_map = {a.id: a.name for a in assets}
return [_to_dict(c, name_map.get(c.asset_id)) for c in certs]
def delete_site_cert(session: Session, cert_id: int) -> None:
cert = session.get(SiteCert, cert_id)
if not cert:
raise ValueError(f"证书监控记录不存在(id={cert_id}")
session.delete(cert)
session.commit()
def check_all_site_certs(session: Session) -> dict:
"""全量刷新所有站点证书,返回统计与异常清单"""
certs = session.exec(select(SiteCert)).all()
stats = {"total": len(certs), "ok": 0, "error": 0, "expiring": 0, "expired": 0}
problems = []
for cert in certs:
check_one(session, cert)
if cert.status == "error":
stats["error"] += 1
problems.append({"hostname": cert.hostname, "detail": cert.error})
elif cert.status == "expired":
stats["expired"] += 1
problems.append(
{"hostname": cert.hostname, "detail": f"证书已过期 {abs(days_until(cert.valid_to))}"}
)
elif cert.status == "expiring":
stats["expiring"] += 1
problems.append(
{"hostname": cert.hostname, "detail": f"{days_until(cert.valid_to)} 天后到期"}
)
else:
stats["ok"] += 1
return {"stats": stats, "problems": problems}
def build_cert_message(cert: SiteCert) -> str:
"""生成单条证书提醒文案"""
days = days_until(cert.valid_to)
if days is None:
return f"- {cert.hostname}:证书信息未知"
if days < 0:
return f"- ⚠️ {cert.hostname}:证书已过期 {abs(days)}"
return f"- {cert.hostname}{days} 天后到期({cert.valid_to}"
+11 -9
View File
@@ -6,15 +6,18 @@
"""
import json
from datetime import datetime
import logging
from typing import Optional
from fastapi import HTTPException, status
from sqlmodel import Session, select
logger = logging.getLogger("vps-manager.sync")
from app.adapters import registry
from app.adapters.base import BaseAdapter
from app.core import crypto
from app.core.timeutils import utcnow
from app.models.asset import (
AIAccount,
Asset,
@@ -130,8 +133,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
currency=vps.currency,
)
session.add(asset)
session.commit()
session.refresh(asset)
session.flush() # 获取 asset.id,统一在循环外提交
session.add(
VPSDetail(
asset_id=asset.id,
@@ -176,8 +178,7 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
expiry_date=dom.expiry_date,
)
session.add(asset)
session.commit()
session.refresh(asset)
session.flush() # 获取 asset.id,统一在循环外提交
session.add(
DomainDetail(
asset_id=asset.id,
@@ -219,10 +220,11 @@ def sync_provider(session: Session, provider_id: int) -> dict:
except Exception as e: # noqa: BLE001
result["account_error"] = str(e)
provider.last_synced_at = datetime.utcnow()
provider.last_synced_at = utcnow()
session.add(provider)
session.commit()
result["last_synced_at"] = provider.last_synced_at.isoformat()
logger.info("同步平台 provider=%s result=%s", provider.slug, result)
return result
@@ -262,7 +264,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
detail=f"无法确定 AI 适配器({ai.provider}",
)
api_key = crypto.decrypt(ai.api_key_encrypted) or ai.api_key
api_key = crypto.decrypt(ai.api_key_encrypted)
if not api_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="未配置 API Key"
@@ -286,7 +288,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
if acc.balance is not None:
ai.balance = acc.balance
ai.currency = acc.currency
ai.last_synced_at = datetime.utcnow()
ai.last_synced_at = utcnow()
session.add(ai)
session.commit()
session.refresh(ai)
@@ -320,5 +322,5 @@ def sync_all_ai_balances(session: Session) -> dict:
"success": success,
"failed": failed,
"errors": errors,
"synced_at": datetime.utcnow().isoformat(),
"synced_at": utcnow().isoformat(),
}
+19 -7
View File
@@ -4,16 +4,16 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{ app_name }}</title>
<link rel="manifest" href="/static/manifest.json">
<link rel="manifest" href="/static/manifest.json?v={{ asset_version }}">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png?v={{ asset_version }}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="资产管理">
<script src="/static/js/tailwind.js"></script>
<script src="/static/js/tailwind.js?v={{ asset_version }}"></script>
<script>tailwind.config = { darkMode: 'class' };</script>
<script src="/static/js/vue.global.prod.js"></script>
<script src="/static/js/chart.umd.js"></script>
<script src="/static/js/vue.global.prod.js?v={{ asset_version }}"></script>
<script src="/static/js/chart.umd.js?v={{ asset_version }}"></script>
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script>
<style>
html { -webkit-tap-highlight-color: transparent; }
@@ -24,8 +24,20 @@
</head>
<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>
<script src="/static/js/api.js?v={{ asset_version }}"></script>
<script src="/static/js/store.js?v={{ asset_version }}"></script>
<script src="/static/js/views/dashboard.js?v={{ asset_version }}"></script>
<script src="/static/js/views/assets.js?v={{ asset_version }}"></script>
<script src="/static/js/views/subscriptions.js?v={{ asset_version }}"></script>
<script src="/static/js/views/cloudflare.js?v={{ asset_version }}"></script>
<script src="/static/js/views/providers.js?v={{ asset_version }}"></script>
<script src="/static/js/views/servers.js?v={{ asset_version }}"></script>
<script src="/static/js/views/domains.js?v={{ asset_version }}"></script>
<script src="/static/js/views/ai.js?v={{ asset_version }}"></script>
<script src="/static/js/views/monitor.js?v={{ asset_version }}"></script>
<script src="/static/js/views/settings.js?v={{ asset_version }}"></script>
<script src="/static/js/modals.js?v={{ asset_version }}"></script>
<script src="/static/js/app.js?v={{ asset_version }}"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {