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
+10 -4
View File
@@ -34,15 +34,21 @@ def collect_metrics() -> dict:
def _get_ip() -> str: def _get_ip() -> str:
"""获取本机出口 IP(不实际发包)""" """获取本机出口 IPUDP 不实际发包;设超时避免无网环境阻塞"""
s = None
try: try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2) # 无网络环境下 connect 可能阻塞,加超时保护
s.connect(("8.8.8.8", 80)) s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0] return s.getsockname()[0]
s.close()
return ip
except Exception: except Exception:
return "" return ""
finally:
if s is not None:
try:
s.close()
except Exception:
pass
def collect_server_info() -> dict: def collect_server_info() -> dict:
+20 -10
View File
@@ -4,10 +4,12 @@
VPS_MANAGER_URL 中心地址,如 http://100.89.0.11:8000 VPS_MANAGER_URL 中心地址,如 http://100.89.0.11:8000
VPS_ASSET_ID 本机对应的资产 ID VPS_ASSET_ID 本机对应的资产 ID
VPS_AGENT_KEY Agent Key(中心配置了 AGENT_KEY 时必填) VPS_AGENT_KEY Agent Key(中心配置了 AGENT_KEY 时必填)
VPS_AGENT_RETRY 上报失败重试次数(默认 2,指数退避)
""" """
import os import os
import sys import sys
import time
import httpx import httpx
@@ -18,6 +20,7 @@ def main() -> int:
url = os.environ.get("VPS_MANAGER_URL", "").rstrip("/") url = os.environ.get("VPS_MANAGER_URL", "").rstrip("/")
asset_id = os.environ.get("VPS_ASSET_ID", "") asset_id = os.environ.get("VPS_ASSET_ID", "")
key = os.environ.get("VPS_AGENT_KEY", "") key = os.environ.get("VPS_AGENT_KEY", "")
max_retry = int(os.environ.get("VPS_AGENT_RETRY", "2"))
if not url or not asset_id: if not url or not asset_id:
print("错误:需设置 VPS_MANAGER_URL 和 VPS_ASSET_ID") print("错误:需设置 VPS_MANAGER_URL 和 VPS_ASSET_ID")
@@ -31,16 +34,23 @@ def main() -> int:
} }
headers = {"X-Agent-Key": key} if key else {} headers = {"X-Agent-Key": key} if key else {}
try: for attempt in range(max_retry + 1):
resp = httpx.post( try:
f"{url}/api/agent/report", json=payload, headers=headers, timeout=30 resp = httpx.post(
) f"{url}/api/agent/report", json=payload, headers=headers, timeout=30
print(f"上报完成 HTTP {resp.status_code}: {resp.text}") )
resp.raise_for_status() print(f"上报完成 HTTP {resp.status_code}: {resp.text}")
return 0 resp.raise_for_status()
except Exception as e: # noqa: BLE001 return 0
print(f"上报失败: {e}") except Exception as e: # noqa: BLE001
return 1 if attempt < max_retry:
wait = 2 ** attempt
print(f"上报失败({attempt + 1}/{max_retry + 1}),{wait}s 后重试: {e}")
time.sleep(wait)
else:
print(f"上报失败(已重试 {max_retry} 次): {e}")
return 1
return 1
if __name__ == "__main__": if __name__ == "__main__":
+35 -5
View File
@@ -100,11 +100,41 @@ class OpenAIAdapter(_AIBase):
@register("minimax-api") @register("minimax-api")
class MinimaxAdapter(_AIBase): class MinimaxAdapter(_AIBase):
# Minimax 需 group_id,余额接口因账号类型而异,此处为骨架待完善 """Minimax 适配器(余额查询)
required_config = ["api_key", "group_id"]
接口: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" 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: def test_connection(self) -> dict:
if not self.config.get("group_id"): try:
return {"ok": False, "message": "Minimax 需配置 group_id(适配器余额接口待完善)"} acc = self._balance()
return {"ok": False, "message": "Minimax 适配器余额接口待完善(请提供具体接口文档)"} 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 """Cloudflare 适配器(REST API v4Bearer Token
API 文档:https://developers.cloudflare.com/api/ 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 子资产留待后续阶段。 Cloudflare 无传统 VPS,主要同步托管域名(zones);Workers/R2/Tunnel 子资产留待后续阶段。
域名到期日通过 Registrar API/accounts/{id}/registrar/domains)补充获取。
""" """
import httpx import httpx
@@ -42,16 +43,21 @@ class CloudflareAdapter(BaseAdapter):
return {"ok": False, "message": str(e)} return {"ok": False, "message": str(e)}
def list_domains(self) -> list: def list_domains(self) -> list:
# 先拉取 Registrar 域名到期日映射(domain_name -> expires_at ISO 日期)
expiry_map = self._registrar_expiry_map()
result = [] result = []
page = 1 page = 1
while True: while True:
data = self._get(f"/zones?per_page=50&page={page}") data = self._get(f"/zones?per_page=50&page={page}")
for z in data.get("result", []): for z in data.get("result", []):
name = z.get("name")
result.append( result.append(
NormalizedDomain( NormalizedDomain(
external_id=z.get("id"), external_id=z.get("id"),
domain_name=z.get("name"), domain_name=name,
registrar="cloudflare", registrar="cloudflare",
expiry_date=expiry_map.get(name),
status="active" if z.get("status") == "active" else (z.get("status") or "unknown"), status="active" if z.get("status") == "active" else (z.get("status") or "unknown"),
raw=z, raw=z,
) )
@@ -61,3 +67,31 @@ class CloudflareAdapter(BaseAdapter):
break break
page += 1 page += 1
return result 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")) 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() settings = Settings()
+3 -1
View File
@@ -4,6 +4,7 @@
MASTER_KEY 从 .env 读取,不入库。 MASTER_KEY 从 .env 读取,不入库。
""" """
from functools import lru_cache
from typing import Optional from typing import Optional
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
@@ -11,8 +12,9 @@ from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings from app.core.config import settings
@lru_cache(maxsize=1)
def _get_fernet() -> Fernet: def _get_fernet() -> Fernet:
"""获取 Fernet 实例(MASTER_KEY 未配置时抛错)""" """获取 Fernet 实例(进程内缓存,避免每次加解密重建;MASTER_KEY 未配置时抛错)"""
key = settings.MASTER_KEY key = settings.MASTER_KEY
if not key: if not key:
raise RuntimeError( raise RuntimeError(
+27 -6
View File
@@ -10,57 +10,78 @@ from app.models.provider import Provider
PRESET_PROVIDERS = [ PRESET_PROVIDERS = [
# ---- VPS / 云服务商 ---- # ---- VPS / 云服务商 ----
# 综合平台:services 列出可提供的服务,卡片上会显示多标签
{"slug": "aliyun", "name": "阿里云", "name_en": "Aliyun", "category": "vps", {"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"}, "website": "https://aliyun.com", "console_url": "https://ecs.console.aliyun.com", "sdk_type": "aliyun-sdk"},
{"slug": "tencent", "name": "腾讯云", "name_en": "Tencent Cloud", "category": "vps", {"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"}, "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", {"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"}, "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", {"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"}, "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", {"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"}, "website": "https://vultr.com", "console_url": "https://my.vultr.com", "sdk_type": "vultr-api"},
{"slug": "digitalocean", "name": "DigitalOcean", "name_en": "DigitalOcean", "category": "vps", {"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"}, "website": "https://digitalocean.com", "console_url": "https://cloud.digitalocean.com", "sdk_type": "do-api"},
{"slug": "linode", "name": "Linode", "name_en": "Akamai Linode", "category": "vps", {"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"}, "website": "https://linode.com", "console_url": "https://cloud.linode.com", "sdk_type": "linode-api"},
{"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps", {"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"}, "website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"},
{"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps", {"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"}, "website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"},
# ---- 域名注册商 ---- # ---- 域名注册商 ----
{"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain", {"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"}, "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", {"slug": "xinwang", "name": "新网", "name_en": "Xinnet", "category": "domain",
"services": "domain,dns",
"website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None}, "website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None},
{"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain", {"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"}, "website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"},
# ---- AI 服务商 ---- # ---- AI 服务商 ----
{"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent", {"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"}, "website": "https://openai.com", "console_url": "https://platform.openai.com", "sdk_type": "openai-api"},
{"slug": "minimax", "name": "Minimax", "name_en": "Minimax", "category": "ai_agent", {"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"}, "website": "https://minimax.io", "console_url": "https://platform.minimaxi.com", "sdk_type": "minimax-api"},
{"slug": "kimi", "name": "Kimi", "name_en": "Moonshot", "category": "ai_agent", {"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"}, "website": "https://moonshot.cn", "console_url": "https://platform.moonshot.cn", "sdk_type": "moonshot-api"},
{"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "ai_agent", {"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "ai_agent",
"services": "ai_agent",
"website": None, "console_url": None, "sdk_type": None}, "website": None, "console_url": None, "sdk_type": None},
{"slug": "deepseek", "name": "DeepSeek", "name_en": "DeepSeek", "category": "ai_agent", {"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"}, "website": "https://deepseek.com", "console_url": "https://platform.deepseek.com", "sdk_type": "deepseek-api"},
# ---- Cloudflare ---- # ---- Cloudflare ----
{"slug": "cloudflare", "name": "Cloudflare", "name_en": "Cloudflare", "category": "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"}, "website": "https://cloudflare.com", "console_url": "https://dash.cloudflare.com", "sdk_type": "cloudflare-api"},
] ]
def seed_providers(session: Session) -> int: def seed_providers(session: Session) -> int:
"""初始化预设平台(已存在的 slug 跳过),返回新增数量""" """初始化预设平台(已存在的 slug 跳过),返回新增数量
单次查询获取全部已存在 slug,避免逐条 SELECT 的 N 次往返。
"""
existing_slugs = set(session.exec(select(Provider.slug)).all())
added = 0 added = 0
for data in PRESET_PROVIDERS: for data in PRESET_PROVIDERS:
existing = session.exec( if data["slug"] not in existing_slugs:
select(Provider).where(Provider.slug == data["slug"])
).first()
if not existing:
session.add(Provider(**data)) session.add(Provider(**data))
added += 1 added += 1
session.commit() if added:
session.commit()
return added 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'}" METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}"
assets_engine = create_engine( 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_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 指向资产库
engine = assets_engine engine = assets_engine
@@ -37,8 +55,9 @@ def init_db() -> None:
ServerInfo, ServerInfo,
) )
from app.models.provider import Provider # noqa: F401 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] metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
SQLModel.metadata.create_all( SQLModel.metadata.create_all(
@@ -48,6 +67,7 @@ def init_db() -> None:
metrics_engine, tables=[m.__table__ for m in metric_models] metrics_engine, tables=[m.__table__ for m in metric_models]
) )
_migrate_assets_db() _migrate_assets_db()
_migrate_indexes()
def _migrate_assets_db() -> None: def _migrate_assets_db() -> None:
@@ -68,10 +88,67 @@ def _migrate_assets_db() -> None:
cols = {c["name"] for c in insp.get_columns("providers")} cols = {c["name"] for c in insp.get_columns("providers")}
if "last_synced_at" not in cols: if "last_synced_at" not in cols:
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN last_synced_at DATETIME")) 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"): if insp.has_table("ai_accounts"):
cols = {c["name"] for c in insp.get_columns("ai_accounts")} cols = {c["name"] for c in insp.get_columns("ai_accounts")}
if "api_key_encrypted" not in cols: if "api_key_encrypted" not in cols:
conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR")) 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]: def get_session() -> Generator[Session, None, None]:
+53 -10
View File
@@ -8,44 +8,69 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import asyncio
import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles from starlette.staticfiles import StaticFiles as StarletteStaticFiles
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from sqlmodel import Session from sqlmodel import Session
from app.core.config import settings from app.core.config import settings
from app.core.seed import seed_providers from app.core.seed import seed_providers
from app.database import assets_engine, init_db 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 BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "static" STATIC_DIR = BASE_DIR / "static"
TEMPLATE_DIR = BASE_DIR / "app" / "templates" TEMPLATE_DIR = BASE_DIR / "app" / "templates"
STATIC_DIR.mkdir(parents=True, exist_ok=True) 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 下的缓存兼容问题) # 直接使用 Jinja2 Environment 渲染(规避 Starlette Jinja2Templates 在 Python 3.14 下的缓存兼容问题)
jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True) jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
"""应用启动时自动建表初始化预设平台""" """应用启动时自动建表初始化预设平台,并启动监控数据定期清理任务"""
init_db() init_db()
with Session(assets_engine) as session: with Session(assets_engine) as session:
seed_providers(session) seed_providers(session)
cleanup_task = asyncio.create_task(cleanup_service.cleanup_loop())
yield yield
cleanup_task.cancel()
app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan) app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["Content-Type", "X-API-Key", "X-Agent-Key"],
allow_credentials=True,
) )
app.include_router(assets.router) app.include_router(assets.router)
@@ -55,24 +80,42 @@ app.include_router(agent.router)
app.include_router(monitor.router) app.include_router(monitor.router)
app.include_router(sync.router) app.include_router(sync.router)
app.include_router(notify.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) @app.get("/", include_in_schema=False)
def index() -> HTMLResponse: def index() -> HTMLResponse:
"""前端 SPA 入口""" """前端 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) return HTMLResponse(html)
@app.get("/sw.js", include_in_schema=False) @app.get("/sw.js", include_in_schema=False)
def service_worker() -> FileResponse: def service_worker() -> FileResponse:
"""Service Worker(置于根路径以使 scope 覆盖全站)""" """Service Worker(置于根路径以使 scope 覆盖全站)
no-cache:保证浏览器每次导航都校验 SW 是否有更新,
否则浏览器可能长时间持有旧版 SW(默认更新检查间隔长)。
"""
return FileResponse( return FileResponse(
STATIC_DIR / "sw.js", STATIC_DIR / "sw.js",
media_type="application/javascript", 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 enum import Enum
from typing import Optional from typing import Optional
from sqlalchemy import Index
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from app.core.timeutils import utcnow
class AssetType(str, Enum): class AssetType(str, Enum):
"""资产类型""" """资产类型"""
@@ -38,6 +41,8 @@ class Asset(SQLModel, table=True):
"""资产主表:所有资产在此统一登记,用于续费提醒与状态总览""" """资产主表:所有资产在此统一登记,用于续费提醒与状态总览"""
__tablename__ = "assets" __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) id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, description="资产名称,如:博客主站 VPS") name: str = Field(index=True, description="资产名称,如:博客主站 VPS")
@@ -72,10 +77,10 @@ class Asset(SQLModel, table=True):
default=False, description="已归档:不再使用但可能仍在续费,需重点排查" default=False, description="已归档:不再使用但可能仍在续费,需重点排查"
) )
remark: Optional[str] = Field(default=None, 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( updated_at: datetime = Field(
default_factory=datetime.utcnow, default_factory=utcnow,
sa_column_kwargs={"onupdate": datetime.utcnow}, sa_column_kwargs={"onupdate": utcnow},
description="更新时间", description="更新时间",
) )
+9 -4
View File
@@ -9,17 +9,21 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import Index
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from app.core.timeutils import utcnow
class MetricPoint(SQLModel, table=True): class MetricPoint(SQLModel, table=True):
"""资源监控时序数据""" """资源监控时序数据"""
__tablename__ = "metric_points" __tablename__ = "metric_points"
__table_args__ = (Index("ix_metric_points_asset_ts", "asset_id", "ts"),)
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
asset_id: int = Field(index=True, description="关联资产 ID") 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 使用率 %") cpu_pct: Optional[float] = Field(default=None, description="CPU 使用率 %")
mem_pct: Optional[float] = Field(default=None, description="内存使用率 %") mem_pct: Optional[float] = Field(default=None, description="内存使用率 %")
disk_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) public_ip: Optional[str] = Field(default=None)
tailscale_ip: Optional[str] = Field(default=None) tailscale_ip: Optional[str] = Field(default=None)
status: Optional[str] = Field(default="online", description="online/offline") 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): class SecurityCheck(SQLModel, table=True):
"""安全检查项结果""" """安全检查项结果"""
__tablename__ = "security_checks" __tablename__ = "security_checks"
__table_args__ = (Index("ix_security_checks_asset_ts", "asset_id", "ts"),)
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
asset_id: int = Field(index=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") check_item: str = Field(description="检查项,如 ssh_config/firewall/updates")
status: str = Field(default="unknown", description="pass/warn/fail/unknown") status: str = Field(default="unknown", description="pass/warn/fail/unknown")
detail: Optional[str] = Field(default=None, description="检查详情") detail: Optional[str] = Field(default=None, description="检查详情")
@@ -69,7 +74,7 @@ class EventLog(SQLModel, table=True):
__tablename__ = "event_logs" __tablename__ = "event_logs"
id: Optional[int] = Field(default=None, primary_key=True) 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") level: str = Field(default="info", index=True, description="info/warning/error")
source: str = Field(default="system", description="来源,如 agent/api/backup") source: str = Field(default="system", description="来源,如 agent/api/backup")
asset_id: Optional[int] = Field(default=None, index=True) 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 sqlmodel import Field, SQLModel
from app.core.timeutils import utcnow
class ProviderCategory(str, Enum): class ProviderCategory(str, Enum):
"""平台分类""" """平台分类"""
@@ -31,6 +33,9 @@ class Provider(SQLModel, table=True):
name: str = Field(index=True, description="显示名,如 阿里云") name: str = Field(index=True, description="显示名,如 阿里云")
name_en: Optional[str] = Field(default=None, description="英文名") name_en: Optional[str] = Field(default=None, description="英文名")
category: ProviderCategory = Field(index=True, 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="官网") website: Optional[str] = Field(default=None, description="官网")
console_url: Optional[str] = Field(default=None, description="管理面板 URL") console_url: Optional[str] = Field(default=None, description="管理面板 URL")
sdk_type: Optional[str] = Field( sdk_type: Optional[str] = Field(
@@ -44,4 +49,4 @@ class Provider(SQLModel, table=True):
last_synced_at: Optional[datetime] = Field( last_synced_at: Optional[datetime] = Field(
default=None, description="最近一次 SDK 同步时间" 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)""" """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 sqlmodel import Session, select
from app.core.config import settings
from app.core.security import require_agent_key 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.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
from app.schemas.agent import AgentReport from app.schemas.agent import AgentReport
router = APIRouter(prefix="/api/agent", tags=["agent"]) 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( @router.post(
"/report", "/report",
@@ -19,6 +55,9 @@ router = APIRouter(prefix="/api/agent", tags=["agent"])
dependencies=[Depends(require_agent_key)], dependencies=[Depends(require_agent_key)],
) )
def report(data: AgentReport, session: Session = Depends(get_metrics_session)) -> dict: 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: if data.metrics:
session.add(MetricPoint(asset_id=data.asset_id, **data.metrics.model_dump())) 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: if data.server_info:
info = data.server_info.model_dump() info = data.server_info.model_dump()
info["last_seen"] = datetime.utcnow() info["last_seen"] = utcnow()
existing = session.exec( existing = session.exec(
select(ServerInfo).where(ServerInfo.asset_id == data.asset_id) select(ServerInfo).where(ServerInfo.asset_id == data.asset_id)
).first() ).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: def export_assets(session: Session = Depends(get_session)) -> dict:
return asset_service.export_assets(session) 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 fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.security import require_api_key
from app.database import get_metrics_session from app.database import get_metrics_session
from app.models.monitor import MetricPoint, SecurityCheck, ServerInfo 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"]) 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) 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="资源监控时序(倒序,最新在前)") @router.get("/{asset_id}/metrics", summary="资源监控时序(倒序,最新在前)")
def metrics( def metrics(
asset_id: int, 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 typing import List, Optional
from pydantic import Field
from sqlmodel import SQLModel from sqlmodel import SQLModel
@@ -40,4 +41,5 @@ class AgentReport(SQLModel):
asset_id: int asset_id: int
metrics: Optional[MetricsIn] = None metrics: Optional[MetricsIn] = None
server_info: Optional[ServerInfoIn] = 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: str
name_en: Optional[str] = None name_en: Optional[str] = None
category: ProviderCategory category: ProviderCategory
services: Optional[str] = None
website: Optional[str] = None website: Optional[str] = None
console_url: Optional[str] = None console_url: Optional[str] = None
sdk_type: Optional[str] = None sdk_type: Optional[str] = None
@@ -31,6 +32,7 @@ class ProviderUpdate(SQLModel):
name: Optional[str] = None name: Optional[str] = None
name_en: Optional[str] = None name_en: Optional[str] = None
category: Optional[ProviderCategory] = None category: Optional[ProviderCategory] = None
services: Optional[str] = None
website: Optional[str] = None website: Optional[str] = None
console_url: Optional[str] = None console_url: Optional[str] = None
sdk_type: 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)的联动。 统一处理 Asset 主表与其一对一详情表(VPSDetail/DomainDetail/AIAccount)的联动。
""" """
from datetime import date, datetime from datetime import date, datetime, timedelta, timezone
from typing import List, Optional from typing import List, Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -30,6 +30,10 @@ from app.schemas.asset import (
VPSDetailRead, VPSDetailRead,
) )
import logging
logger = logging.getLogger("vps-manager.assets")
# 资产类型 -> (AssetCreate/Update 中的字段名, 详情表模型) # 资产类型 -> (AssetCreate/Update 中的字段名, 详情表模型)
DETAIL_MAP = { DETAIL_MAP = {
AssetType.VPS: ("vps_detail", VPSDetail), AssetType.VPS: ("vps_detail", VPSDetail),
@@ -140,11 +144,49 @@ def _apply_detail_update(asset_type: AssetType, existing, detail_in) -> None:
setattr(existing, key, value) 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 # 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) item = DETAIL_MAP.get(data.asset_type)
detail_in = None detail_in = None
model = None model = None
@@ -162,17 +204,21 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead:
) )
asset = Asset(**asset_data) asset = Asset(**asset_data)
session.add(asset) session.add(asset)
session.commit() session.flush() # 获取 asset.id,但不提交
session.refresh(asset)
detail = None
if model is not None and detail_in is not None: if model is not None and detail_in is not None:
detail = _build_detail(data.asset_type, asset.id, detail_in) detail = _build_detail(data.asset_type, asset.id, detail_in)
session.add(detail) session.add(detail)
session.commit() return asset
session.refresh(detail)
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: 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)) 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) asset = session.get(Asset, asset_id)
if not asset: if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在") 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) session.add(asset)
# 详情表:以更新后的 asset_type 为准 # 详情表:以更新后的 asset_type 为准
detail = None
item = DETAIL_MAP.get(asset.asset_type) item = DETAIL_MAP.get(asset.asset_type)
if item: if item:
field, model = item field, model = item
@@ -209,22 +254,22 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
if existing: if existing:
_apply_detail_update(asset.asset_type, existing, detail_in) _apply_detail_update(asset.asset_type, existing, detail_in)
session.add(existing) session.add(existing)
detail = existing
else: else:
detail = _build_detail(asset.asset_type, asset.id, detail_in) detail = _build_detail(asset.asset_type, asset.id, detail_in)
session.add(detail) session.add(detail)
else: return asset
detail = existing
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
"""更新资产主表及详情(单次事务提交)"""
asset = _update_asset_no_commit(session, asset_id, data)
session.commit() session.commit()
session.refresh(asset) session.refresh(asset)
if detail is not None: return _to_read(session, asset, _get_detail(session, asset))
session.refresh(detail)
return _to_read(session, asset, detail)
def delete_asset(session: Session, asset_id: int) -> None: def delete_asset(session: Session, asset_id: int) -> None:
"""删除资产及其详情""" """删除资产及其详情,并清理 metrics.db 中的关联监控数据(避免孤儿数据)"""
asset = session.get(Asset, asset_id) asset = session.get(Asset, asset_id)
if not asset: if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在") 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(detail)
session.delete(asset) session.delete(asset)
session.commit() 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: def export_assets(session: Session) -> dict:
"""导出所有资产(含 detail)为 JSON,用于数据迁移/备份""" """导出所有资产(含 detail)为 JSON,用于数据迁移/备份"""
assets = session.exec(select(Asset)).all() assets = session.exec(select(Asset)).all()
batch = _get_details_batch(session, assets)
result = [] result = []
for asset in assets: 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")) result.append(read.model_dump(mode="json"))
return { return {
"count": len(result), "count": len(result),
"exported_at": datetime.utcnow().isoformat(), "exported_at": datetime.now(timezone.utc).isoformat(),
"assets": result, "assets": result,
} }
def import_assets(session: Session, assets_data: list) -> dict: def import_assets(session: Session, assets_data: list) -> dict:
"""从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)""" """从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)
整个导入在单个事务中完成:全部成功才提交,任何一条失败则回滚,避免部分导入。
"""
created = 0 created = 0
updated = 0 updated = 0
errors = [] errors = []
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"} skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"}
for item in assets_data: try:
try: for item in assets_data:
payload = {k: v for k, v in item.items() if k not in skip_fields} try:
existing = session.exec( payload = {k: v for k, v in item.items() if k not in skip_fields}
select(Asset).where( existing = session.exec(
Asset.name == payload.get("name"), select(Asset).where(
Asset.asset_type == payload.get("asset_type"), Asset.name == payload.get("name"),
Asset.provider == payload.get("provider"), Asset.asset_type == payload.get("asset_type"),
) Asset.provider == payload.get("provider"),
).first() )
if existing: ).first()
update_asset(session, existing.id, AssetUpdate(**payload)) if existing:
updated += 1 _update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
else: updated += 1
create_asset(session, AssetCreate(**payload)) else:
created += 1 _create_asset_no_commit(session, AssetCreate(**payload))
except Exception as e: # noqa: BLE001 created += 1
errors.append(f"{item.get('name', '?')}: {e}") 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} 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()) stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
assets = session.exec(stmt).all() 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: 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() today = date.today()
by_type: dict = {} by_type: dict = {}
@@ -319,22 +395,24 @@ def get_overview(session: Session) -> dict:
year_cost = 0.0 year_cost = 0.0
month_cost = 0.0 month_cost = 0.0
for a in assets: for asset_type, status_val, expiry_date, cost in rows:
by_type[a.asset_type.value] = by_type.get(a.asset_type.value, 0) + 1 tval = asset_type.value if hasattr(asset_type, "value") else asset_type
by_status[a.status.value] = by_status.get(a.status.value, 0) + 1 sval = status_val.value if hasattr(status_val, "value") else status_val
if a.status in (AssetStatus.STOPPED, AssetStatus.EXPIRED): 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 abnormal += 1
if a.expiry_date: if expiry_date:
days = (a.expiry_date - today).days days = (expiry_date - today).days
if 0 <= days <= 30: if 0 <= days <= 30:
expiring_30 += 1 expiring_30 += 1
if a.expiry_date.year == today.year: if expiry_date.year == today.year:
year_cost += a.cost year_cost += cost
if a.expiry_date.month == today.month: if expiry_date.month == today.month:
month_cost += a.cost month_cost += cost
return { return {
"total": len(assets), "total": len(rows),
"by_type": by_type, "by_type": by_type,
"by_status": by_status, "by_status": by_status,
"abnormal_count": abnormal, "abnormal_count": abnormal,
@@ -345,13 +423,20 @@ def get_overview(session: Session) -> dict:
def get_expiring(session: Session, days: int = 30) -> List[AssetRead]: def get_expiring(session: Session, days: int = 30) -> List[AssetRead]:
"""N 天内到期资产列表(按剩余天数升序)""" """N 天内到期资产列表(按剩余天数升序)
性能优化:过滤条件下推到 SQL 层,仅查询 [today, today+days] 区间内的资产。
"""
today = date.today() today = date.today()
assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all() deadline = today + timedelta(days=days)
result = [] assets = session.exec(
for a in assets: select(Asset).where(
delta = (a.expiry_date - today).days Asset.expiry_date.is_not(None),
if 0 <= delta <= days: Asset.expiry_date >= today,
result.append(_to_read(session, a, _get_detail(session, a))) 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) result.sort(key=lambda x: x.days_to_expiry if x.days_to_expiry is not None else 10**9)
return result 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 sqlmodel import Session, select
from app.core import crypto from app.core import crypto
from app.models.asset import Asset
from app.models.provider import Provider, ProviderCategory from app.models.provider import Provider, ProviderCategory
from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate 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) provider = session.get(Provider, provider_id)
if not provider: if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在") 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.delete(provider)
session.commit() session.commit()
+15 -10
View File
@@ -3,7 +3,7 @@
检查即将到期的资产,生成提醒消息并通过通知渠道发送。 检查即将到期的资产,生成提醒消息并通过通知渠道发送。
""" """
from datetime import date from datetime import date, timedelta
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -16,16 +16,21 @@ _SKIP_STATUS = {AssetStatus.CANCELLED}
def get_expiring_assets(session: Session, threshold_days: int) -> list: def get_expiring_assets(session: Session, threshold_days: int) -> list:
"""返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序""" """返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序
性能优化:日期范围与状态过滤均下推到 SQL 层。
"""
today = date.today() today = date.today()
assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all() deadline = today + timedelta(days=threshold_days)
expiring = [] assets = session.exec(
for asset in assets: select(Asset).where(
if asset.status in _SKIP_STATUS: Asset.expiry_date.is_not(None),
continue Asset.expiry_date >= today,
days = (asset.expiry_date - today).days Asset.expiry_date <= deadline,
if 0 <= days <= threshold_days: Asset.status.notin_(_SKIP_STATUS),
expiring.append((asset, days)) )
).all()
expiring = [(asset, (asset.expiry_date - today).days) for asset in assets]
expiring.sort(key=lambda x: x[1]) expiring.sort(key=lambda x: x[1])
return expiring return expiring
+38 -3
View File
@@ -4,6 +4,7 @@
评分按检查项加权:pass 满分、warn 半分、fail/unknown 零分。 评分按检查项加权:pass 满分、warn 半分、fail/unknown 零分。
""" """
from sqlalchemy import func
from sqlmodel import Session, select from sqlmodel import Session, select
from app.models.monitor import SecurityCheck 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: 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)) result.sort(key=lambda x: (x["score"] is None, x["score"] if x["score"] is not None else 0))
return result 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 import json
from datetime import datetime import logging
from typing import Optional from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
logger = logging.getLogger("vps-manager.sync")
from app.adapters import registry from app.adapters import registry
from app.adapters.base import BaseAdapter from app.adapters.base import BaseAdapter
from app.core import crypto from app.core import crypto
from app.core.timeutils import utcnow
from app.models.asset import ( from app.models.asset import (
AIAccount, AIAccount,
Asset, Asset,
@@ -130,8 +133,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
currency=vps.currency, currency=vps.currency,
) )
session.add(asset) session.add(asset)
session.commit() session.flush() # 获取 asset.id,统一在循环外提交
session.refresh(asset)
session.add( session.add(
VPSDetail( VPSDetail(
asset_id=asset.id, asset_id=asset.id,
@@ -176,8 +178,7 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
expiry_date=dom.expiry_date, expiry_date=dom.expiry_date,
) )
session.add(asset) session.add(asset)
session.commit() session.flush() # 获取 asset.id,统一在循环外提交
session.refresh(asset)
session.add( session.add(
DomainDetail( DomainDetail(
asset_id=asset.id, asset_id=asset.id,
@@ -219,10 +220,11 @@ def sync_provider(session: Session, provider_id: int) -> dict:
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
result["account_error"] = str(e) result["account_error"] = str(e)
provider.last_synced_at = datetime.utcnow() provider.last_synced_at = utcnow()
session.add(provider) session.add(provider)
session.commit() session.commit()
result["last_synced_at"] = provider.last_synced_at.isoformat() result["last_synced_at"] = provider.last_synced_at.isoformat()
logger.info("同步平台 provider=%s result=%s", provider.slug, result)
return result return result
@@ -262,7 +264,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
detail=f"无法确定 AI 适配器({ai.provider}", 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: if not api_key:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="未配置 API Key" 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: if acc.balance is not None:
ai.balance = acc.balance ai.balance = acc.balance
ai.currency = acc.currency ai.currency = acc.currency
ai.last_synced_at = datetime.utcnow() ai.last_synced_at = utcnow()
session.add(ai) session.add(ai)
session.commit() session.commit()
session.refresh(ai) session.refresh(ai)
@@ -320,5 +322,5 @@ def sync_all_ai_balances(session: Session) -> dict:
"success": success, "success": success,
"failed": failed, "failed": failed,
"errors": errors, "errors": errors,
"synced_at": datetime.utcnow().isoformat(), "synced_at": utcnow().isoformat(),
} }
+19 -7
View File
@@ -4,16 +4,16 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{ app_name }}</title> <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"> <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-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="资产管理"> <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>tailwind.config = { darkMode: 'class' };</script>
<script src="/static/js/vue.global.prod.js"></script> <script src="/static/js/vue.global.prod.js?v={{ asset_version }}"></script>
<script src="/static/js/chart.umd.js"></script> <script src="/static/js/chart.umd.js?v={{ asset_version }}"></script>
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script> <script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script>
<style> <style>
html { -webkit-tap-highlight-color: transparent; } html { -webkit-tap-highlight-color: transparent; }
@@ -24,8 +24,20 @@
</head> </head>
<body class="bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-200 antialiased"> <body class="bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-200 antialiased">
<div id="app"></div> <div id="app"></div>
<script src="/static/js/api.js"></script> <script src="/static/js/api.js?v={{ asset_version }}"></script>
<script src="/static/js/app.js"></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> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', function () { window.addEventListener('load', function () {
+22 -8
View File
@@ -1,16 +1,28 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# vps-manager 自动更新脚本(由 systemd timer 定时触发) # vps-manager 自动更新脚本(由 systemd timer 定时触发)
# 从 Gitea 拉取最新代码,如有更新则重启服务 # 从 Gitea 拉取最新代码,如有更新则重启服务
# 部署位置:/opt/vps-manager/deploy/update.sh #
# 可配置项(环境变量):
# VPS_MANAGER_DIR 项目根目录(默认 /opt/vps-manager
# VPS_MANAGER_SVC systemd 服务名(默认 vps-manager
# GIT_BRANCH 跟踪的远端分支(默认 main)
#
# 使用示例:
# VPS_MANAGER_DIR=/srv/vps ./update.sh
# 或在 systemd unit 中设置 Environment=VPS_MANAGER_DIR=/srv/vps
set -euo pipefail set -euo pipefail
cd /opt/vps-manager APP_DIR="${VPS_MANAGER_DIR:-/opt/vps-manager}"
SERVICE_NAME="${VPS_MANAGER_SVC:-vps-manager}"
BRANCH="${GIT_BRANCH:-main}"
git fetch -q origin main cd "$APP_DIR"
git fetch -q origin "$BRANCH"
LOCAL=$(git rev-parse HEAD) LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main) REMOTE=$(git rev-parse "origin/$BRANCH")
if [ "$LOCAL" = "$REMOTE" ]; then if [ "$LOCAL" = "$REMOTE" ]; then
echo "[$(date '+%F %T')] 代码已是最新,无需更新" echo "[$(date '+%F %T')] 代码已是最新,无需更新"
@@ -18,11 +30,13 @@ if [ "$LOCAL" = "$REMOTE" ]; then
fi fi
echo "[$(date '+%F %T')] 检测到新版本: ${LOCAL:0:8} -> ${REMOTE:0:8}" echo "[$(date '+%F %T')] 检测到新版本: ${LOCAL:0:8} -> ${REMOTE:0:8}"
git pull --ff-only -q origin main git pull --ff-only -q origin "$BRANCH"
# 依赖如有变更自动安装(无变化时 pip 会快速跳过) # 依赖如有变更自动安装(无变化时 pip 会快速跳过)
.venv/bin/pip install -q -r requirements.txt 2>/dev/null || true if [ -x ".venv/bin/pip" ]; then
.venv/bin/pip install -q -r requirements.txt 2>/dev/null || true
fi
# 重启服务加载新代码 # 重启服务加载新代码
systemctl restart vps-manager systemctl restart "$SERVICE_NAME"
echo "[$(date '+%F %T')] 已重启 vps-manager 服务" echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 服务"
+55
View File
@@ -0,0 +1,55 @@
"""定时检查站点 SSL 证书并发送到期提醒(供 systemd timer 调用)
用法:python scripts/check_site_certs.py [threshold_days]
依赖 .env 中的通知渠道配置(TELEGRAM_*/SMTP_*)。
"""
import sys
from datetime import date
from pathlib import Path
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlmodel import Session, select
from app.database import assets_engine, init_db
from app.models.ssl import SiteCert
from app.services import notification_service, ssl_service
def main() -> int:
threshold = int(sys.argv[1]) if len(sys.argv) > 1 else ssl_service.EXPIRING_THRESHOLD
init_db()
with Session(assets_engine) as session:
result = ssl_service.check_all_site_certs(session)
stats = result["stats"]
# 即将到期 / 已过期 / 探测失败的证书 → 发提醒
certs = session.exec(select(SiteCert)).all()
urgent = [
c for c in certs
if c.status in ("expiring", "expired", "error")
]
notifications = []
if urgent:
lines = [ssl_service.build_cert_message(c) for c in urgent]
message = "【SSL 证书到期提醒】\n" + "\n".join(lines)
notifications = notification_service.notify(
message, subject="SSL 证书到期提醒"
)
print(
f"[SSL检查] 共 {stats['total']} 个站点 | 正常 {stats['ok']} | "
f"即将到期 {stats['expiring']} | 已过期 {stats['expired']} | 探测失败 {stats['error']}"
)
for p in result["problems"]:
print(f" - {p['hostname']}: {p['detail']}")
for n in notifications:
status = "成功" if n["ok"] else f"失败:{n['message']}"
print(f" 通知[{n['channel']}]: {status}")
return 0
if __name__ == "__main__":
sys.exit(main())
+2
View File
@@ -32,6 +32,8 @@ window.VpsFmt = {
TYPE_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' }, TYPE_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
STATUS_LABELS: { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' }, STATUS_LABELS: { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' },
CATEGORY_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' }, CATEGORY_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
// 平台可提供的服务(综合平台多标签):字段对应 Provider.services 逗号分隔列表
SERVICES_LABELS: { vps: '云服务器', domain: '域名', ai_agent: 'AI服务', cloudflare: 'Cloudflare', ssl_cert: 'SSL证书', cdn: 'CDN', dns: 'DNS', other: '其他' },
CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' }, CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' },
typeBadge(t) { typeBadge(t) {
return { vps: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', domain: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', ai_agent: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', cloudflare: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', other: 'bg-slate-500/10 text-slate-600 dark:text-slate-400' }[t] || 'bg-slate-500/10 text-slate-500'; return { vps: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', domain: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', ai_agent: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', cloudflare: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', other: 'bg-slate-500/10 text-slate-600 dark:text-slate-400' }[t] || 'bg-slate-500/10 text-slate-500';
+54 -1028
View File
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
/* 资产编辑模态框 */
const AssetModal = {
template: `
<div v-if="store.assetModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-lg md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
<h3 class="font-semibold text-sm">{{ store.assetModal.editing ? '编辑资产' : '新增资产' }}</h3>
<button @click="store.assetModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<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="f.name" required class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">类型 *</span>
<select v-model="f.asset_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option v-for="(l,k) in Fmt.TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">所属平台</span>
<select v-model="f.provider_id" @change="onProviderChange" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option :value="null">未指定</option>
<option v-for="p in providersForType" :key="p.id" :value="p.id">{{ p.name }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">到期时间</span>
<input type="date" v-model="f.expiry_date" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">续费周期</span>
<select v-model="f.renewal_cycle" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option value="">未设定</option><option v-for="(l,k) in Fmt.CYCLE_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">费用</span>
<input type="number" step="0.01" v-model.number="f.cost" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">币种</span>
<select v-model="f.currency" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option>USD</option><option>CNY</option><option>JPY</option><option>EUR</option>
</select></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">续费网址</span>
<input v-model="f.renew_url" placeholder="https://…(去哪续费)" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">取消订阅网址</span>
<input v-model="f.cancel_url" placeholder="https://…(去哪取消)" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">状态</span>
<select v-model="f.status" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">所属账号</span>
<input v-model="f.account" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<div class="col-span-2 flex gap-5 text-sm text-slate-600 dark:text-slate-300">
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.auto_renew"> 自动续费</label>
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.is_archived"> 已归档</label>
</div>
</div>
<fieldset v-if="f.asset_type==='vps'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">VPS 详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="block"><span class="text-xs text-slate-400">公网 IP *</span><input v-model="f.vps_detail.ip_address" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">Tailscale IP</span><input v-model="f.vps_detail.tailscale_ip" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">区域</span><input v-model="f.vps_detail.region" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">系统</span><input v-model="f.vps_detail.os" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">CPU核</span><input type="number" v-model.number="f.vps_detail.cpu_cores" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">内存GB</span><input type="number" step="0.5" v-model.number="f.vps_detail.memory_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">磁盘GB</span><input type="number" v-model.number="f.vps_detail.disk_gb" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">SSH端口</span><input type="number" v-model.number="f.vps_detail.ssh_port" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">SSH用户</span><input v-model="f.vps_detail.ssh_user" placeholder="root" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">登录方式</span><select v-model="f.vps_detail.login_method" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option value="key">密钥</option><option value="password">密码</option><option value="other">其他</option></select></label>
<label class="block"><span class="text-xs text-slate-400">SSH密钥</span><input v-model="f.vps_detail.ssh_key" type="password" :placeholder="store.assetModal.editing ? '留空不修改' : ''" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">密码</span><input v-model="f.vps_detail.password" type="password" :placeholder="store.assetModal.editing ? '留空不修改' : ''" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-400">用途/项目</span><input v-model="f.vps_detail.purpose" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
</fieldset>
<fieldset v-if="f.asset_type==='domain'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">域名详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="col-span-2 block"><span class="text-xs text-slate-400">域名 *</span><input v-model="f.domain_detail.domain_name" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">注册商</span><input v-model="f.domain_detail.registrar" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">DNS托管</span><input v-model="f.domain_detail.dns_provider" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">CF账号</span><input v-model="f.domain_detail.cloudflare_account" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">绑定资产ID</span><input type="number" v-model.number="f.domain_detail.bind_asset_id" placeholder="关联VPS的ID" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.domain_detail.is_using"> 仍在实际使用</label>
</div>
</fieldset>
<fieldset v-if="f.asset_type==='ai_agent'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">AI 账号详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="block"><span class="text-xs text-slate-400">服务商 *</span><input v-model="f.ai_detail.provider" required class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">计划</span><input v-model="f.ai_detail.plan" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-400">API Key</span><input v-model="f.ai_detail.api_key" type="password" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">余额</span><input type="number" step="0.01" v-model.number="f.ai_detail.balance" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">月限额</span><input type="number" step="0.01" v-model.number="f.ai_detail.monthly_limit" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
</fieldset>
<fieldset v-if="f.asset_type==='cloudflare'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">Cloudflare 子资产详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="block"><span class="text-xs text-slate-400">账号邮箱</span><input v-model="f.cloudflare_detail.account_email" placeholder="账号邮箱(区分多账号)" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">子资产类型</span><select v-model="f.cloudflare_detail.sub_type" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option value="zone">zone(解析)</option><option value="worker">Worker</option><option value="r2">R2</option><option value="tunnel">Tunnel</option><option value="mail">Mail</option><option value="dns_record">DNS记录</option><option value="other">其他</option></select></label>
<label class="block"><span class="text-xs text-slate-400">子资产名称</span><input v-model="f.cloudflare_detail.sub_name" placeholder="如 worker 名 / bucket 名" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">关联 zone/域名</span><input v-model="f.cloudflare_detail.zone_name" placeholder="example.com" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-400">状态</span><input v-model="f.cloudflare_detail.status" placeholder="active / paused 等" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
</fieldset>
<label class="block"><span class="text-xs text-slate-500">备注</span>
<textarea v-model="f.remark" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></textarea></label>
<div class="flex justify-end gap-2 pt-1">
<button type="button" @click="store.assetModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">{{ store.assetModal.editing ? '保存' : '创建' }}</button>
</div>
</form>
</div>
</div>`,
setup() {
const f = Vue.computed(() => store.assetModal.form);
const providersForType = Vue.computed(() => {
const t = f.value ? f.value.asset_type : null;
const map = { vps: 'vps', domain: 'domain', ai_agent: 'ai_agent', cloudflare: 'cloudflare' };
const cat = map[t];
if (!cat) return store.providers;
// 综合平台按 services 过滤(如阿里云可同时选 vps/domain);services 为空时回退分类匹配
return store.providers.filter(p => {
if (p.services) return p.services.split(',').map(s => s.trim()).includes(cat);
return p.category === cat || p.category === 'other';
});
});
function onProviderChange() {
const p = store.providers.find(x => x.id === f.value.provider_id);
if (p) f.value.provider = p.slug;
}
return { store, Fmt, f, providersForType, onProviderChange, save: saveAsset };
},
};
/* 平台编辑模态框 */
const ProviderModal = {
template: `
<div v-if="store.providerModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="font-semibold text-sm">{{ store.providerModal.editing ? '编辑平台' : '新增平台' }}</h3>
<button @click="store.providerModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">标识 slug *</span><input v-model="f.slug" required placeholder="aliyun" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">名称 *</span><input v-model="f.name" required class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">分类</span><select v-model="f.category" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option v-for="(l,k) in Fmt.CATEGORY_LABELS" :key="k" :value="k">{{ l }}</option></select></label>
<div class="col-span-2 block"><span class="text-xs text-slate-500">提供服务(可多选,综合平台全选)</span>
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1">
<label v-for="(l,k) in Fmt.SERVICES_LABELS" :key="k" class="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300"><input type="checkbox" :checked="servicesSet.has(k)" @change="toggleService(k)"> {{ l }}</label>
</div>
</div>
<label class="block"><span class="text-xs text-slate-500">SDK类型</span><select v-model="f.sdk_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option value="">未指定</option><option v-for="o in sdkOptions" :key="o.type" :value="o.type">{{ o.type }}</option></select></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">官网</span><input v-model="f.website" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">管理面板URL</span><input v-model="f.console_url" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<div v-if="currentFields.length" class="col-span-2 border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 凭证(加密存储)</div>
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span><input v-model="apiFields[field]" type="password" :placeholder="store.providerModal.editing ? '留空不修改' : '填写 ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<div v-else-if="f.sdk_type" class="col-span-2 text-xs text-amber-600 dark:text-amber-400">该 SDK 类型暂无预设凭证字段(可后续扩展)</div>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.enabled"> 启用</label>
</div>
<div class="flex justify-end gap-2">
<button type="button" @click="store.providerModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">保存</button>
</div>
</form>
</div>
</div>`,
setup() {
const f = Vue.computed(() => store.providerModal.form);
const adapterMeta = Vue.ref([]);
const apiFields = Vue.reactive({});
Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
});
const sdkOptions = Vue.computed(() => {
const opts = [];
for (const m of adapterMeta.value) for (const t of (m.sdk_types || [])) opts.push({ type: t, fields: m.required_config || [] });
return opts;
});
const currentFields = Vue.computed(() => {
const opt = sdkOptions.value.find(o => o.type === (f.value && f.value.sdk_type));
return opt ? opt.fields : [];
});
// 提供服务多选:services 逗号分隔字符串 <-> checkbox 集合
const servicesSet = Vue.computed(() => new Set((f.value && f.value.services || '').split(',').map(s => s.trim()).filter(Boolean)));
function toggleService(k) {
const arr = (f.value.services || '').split(',').map(s => s.trim()).filter(Boolean);
const idx = arr.indexOf(k);
if (idx >= 0) arr.splice(idx, 1); else arr.push(k);
f.value.services = arr.join(',');
}
// 模态框每次打开时清空上次残留的凭证输入,避免串号
Vue.watch(() => store.providerModal.show, (show) => {
if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; }
});
function save() {
const cfg = {};
for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field];
if (Object.keys(cfg).length) f.value.api_config = JSON.stringify(cfg);
saveProvider();
}
return { store, Fmt, f, sdkOptions, currentFields, servicesSet, toggleService, apiFields, save };
},
};
+193
View File
@@ -0,0 +1,193 @@
/* 全局响应式状态与工具函数 */
const { reactive, computed } = Vue;
const Api = window.VpsApi;
const Fmt = window.VpsFmt;
/* ---------------- 全局状态 ---------------- */
const store = reactive({
view: 'dashboard',
assets: [],
providers: [],
subdomains: [],
siteCerts: [],
overview: {},
expiring: [],
serverMetrics: {},
search: '',
filterType: '',
filterStatus: '',
assetTab: '', // 资产页 TAB'' 全部 / vps / domain / cloudflare / subscription(付费资产) / ai_agent
monitorTab: '', // 监控中心 TAB:同上
dark: localStorage.getItem('vps_dark') === '1',
loading: false,
error: '',
assetModal: { show: false, editing: null, form: null },
providerModal: { show: false, editing: null, form: null },
});
function applyDark() {
document.documentElement.classList.toggle('dark', store.dark);
localStorage.setItem('vps_dark', store.dark ? '1' : '0');
}
/* ---------------- 表单工厂 ---------------- */
function emptyAssetForm() {
return {
name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly', renew_url: '', cancel_url: '',
account: '', expiry_date: '', auto_renew: false, cost: 0, currency: 'USD',
status: 'active', is_archived: false, remark: '',
vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' },
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null },
ai_detail: { provider: '', api_key: '', plan: '', balance: null, currency: 'USD', monthly_usage: null, monthly_limit: null },
cloudflare_detail: { account_email: '', sub_type: 'zone', sub_name: '', zone_name: '', status: '' },
};
}
function buildAssetPayload(f) {
const p = {
name: f.name, asset_type: f.asset_type, provider: f.provider,
provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null,
renew_url: f.renew_url || null, cancel_url: f.cancel_url || null,
account: f.account || null, expiry_date: f.expiry_date || null,
auto_renew: !!f.auto_renew, cost: Number(f.cost) || 0, currency: f.currency,
status: f.status, is_archived: !!f.is_archived, remark: f.remark || null,
};
if (f.asset_type === 'vps') {
const v = f.vps_detail;
p.vps_detail = { ip_address: v.ip_address, tailscale_ip: v.tailscale_ip || null, region: v.region || null, os: v.os || null, cpu_cores: Number(v.cpu_cores) || 1, memory_gb: Number(v.memory_gb) || 1, disk_gb: Number(v.disk_gb) || 20, bandwidth_gb: v.bandwidth_gb ? Number(v.bandwidth_gb) : null, ssh_port: Number(v.ssh_port) || 22, panel_url: v.panel_url || null, ssh_user: v.ssh_user || null, login_method: v.login_method || 'key', ssh_key: v.ssh_key || null, password: v.password || null, purpose: v.purpose || null };
} else if (f.asset_type === 'domain') {
const d = f.domain_detail;
p.domain_detail = { domain_name: d.domain_name, registrar: d.registrar || null, dns_provider: d.dns_provider || null, cloudflare_account: d.cloudflare_account || null, is_using: !!d.is_using, redirect_target: d.redirect_target || null, bind_asset_id: d.bind_asset_id || null };
} else if (f.asset_type === 'ai_agent') {
const a = f.ai_detail;
p.ai_detail = { provider: a.provider, api_key: a.api_key || null, plan: a.plan || null, balance: a.balance ? Number(a.balance) : null, currency: a.currency || 'USD', monthly_usage: a.monthly_usage ? Number(a.monthly_usage) : null, monthly_limit: a.monthly_limit ? Number(a.monthly_limit) : null };
} else if (f.asset_type === 'cloudflare') {
const c = f.cloudflare_detail;
p.cloudflare_detail = { account_email: c.account_email || null, sub_type: c.sub_type || 'zone', sub_name: c.sub_name || null, zone_name: c.zone_name || null, status: c.status || null };
}
return p;
}
/* ---------------- 数据加载 ---------------- */
async function loadAssets() {
const params = new URLSearchParams();
if (store.filterType) params.set('asset_type', store.filterType);
if (store.filterStatus) params.set('status', store.filterStatus);
if (store.search) params.set('q', store.search);
params.set('sort', 'expiry_date'); params.set('order', 'asc');
store.assets = await Api.get('/assets?' + params.toString());
}
async function loadProviders() { store.providers = await Api.get('/providers'); }
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
async function loadAll() {
store.loading = true; store.error = '';
try { await Promise.all([loadAssets(), loadProviders(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); }
catch (e) { store.error = e.message; } finally { store.loading = false; }
}
/* ---------------- 资产操作 ---------------- */
function openAssetCreate(presetType) {
const form = emptyAssetForm();
if (presetType) form.asset_type = presetType;
store.assetModal = { show: true, editing: null, form };
}
function openAICreate(provider) {
const form = emptyAssetForm();
form.asset_type = 'ai_agent';
if (provider) {
form.provider = provider;
form.ai_detail.provider = provider;
const p = store.providers.find(x => x.slug === provider);
if (p) form.provider_id = p.id;
}
store.assetModal = { show: true, editing: null, form };
}
function openAssetEdit(a) {
const form = emptyAssetForm();
['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', 'renew_url', 'cancel_url', 'account', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark'].forEach(k => { form[k] = a[k]; });
if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);
if (a.cloudflare_detail) Object.assign(form.cloudflare_detail, a.cloudflare_detail);
form.vps_detail.ssh_key = ''; form.vps_detail.password = '';
store.assetModal = { show: true, editing: a.id, form };
}
async function saveAsset() {
store.error = '';
const f = store.assetModal.form;
try {
const payload = buildAssetPayload(f);
if (store.assetModal.editing) await Api.put('/assets/' + store.assetModal.editing, payload);
else await Api.post('/assets', payload);
store.assetModal.show = false;
await loadAll();
} catch (e) { store.error = e.message; }
}
async function deleteAsset(a) {
if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
store.error = '';
try { await Api.del('/assets/' + a.id); await loadAll(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 平台操作 ---------------- */
function openProviderCreate() {
store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '', api_config: '' } };
}
function openProviderEdit(p) {
store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '', api_config: '' } };
}
async function saveProvider() {
store.error = '';
const f = store.providerModal.form;
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null, api_config: f.api_config || null };
try {
if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload);
else await Api.post('/providers', payload);
store.providerModal.show = false;
await loadProviders();
} catch (e) { store.error = e.message; }
}
async function seedProviders() {
store.error = '';
try { const r = await Api.post('/providers/seed', {}); await loadProviders(); alert('已初始化预设平台,新增 ' + r.added + ' 个'); }
catch (e) { store.error = e.message; }
}
async function deleteProvider(p) {
if (!confirm('确认删除平台「' + p.name + '」?')) return;
try { await Api.del('/providers/' + p.id); await loadProviders(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 服务器监控 ---------------- */
async function viewServer(a) {
try {
const [metrics, info, checks, security] = await Promise.all([
Api.get('/monitor/' + a.id + '/metrics?limit=200'),
Api.get('/monitor/' + a.id + '/info'),
Api.get('/monitor/' + a.id + '/security'),
Api.get('/monitor/' + a.id + '/security-score'),
]);
store.serverMetrics = { asset: a, metrics, info, checks, security };
} catch (e) { store.serverMetrics = { asset: a, metrics: [], info: null, checks: [], security: null }; }
// 通过 hash 路由切换视图,保证刷新/分享链接后能回到 servers 视图
if (window.location.hash !== '#servers') {
window.location.hash = '#servers';
} else {
store.view = 'servers';
}
}
/* ---------------- 设置 ---------------- */
const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }
function normalizedMonthly(cost, cycle) {
if (!cost) return 0;
if (cycle === 'yearly') return cost / 12;
if (cycle === 'quarterly') return cost / 3;
return cost;
}
+60
View File
@@ -0,0 +1,60 @@
/* AI 账号视图 */
const AIAccountsView = {
template: `
<div class="space-y-4">
<div class="flex justify-between items-center flex-wrap gap-2">
<div class="text-sm text-slate-500">共 {{ totalAccounts }} 个账号 · 总余额 {{ totalBalance }}</div>
<button @click="addAny" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 新增账号</button>
</div>
<div v-for="g in groups" :key="g.provider" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="flex justify-between items-center mb-3 flex-wrap gap-2">
<div class="flex items-center gap-2">
<span class="font-semibold text-sm">{{ g.provider }}</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400">{{ g.accounts.length }} 个账号</span>
</div>
<div class="flex items-center gap-3 text-xs">
<span class="text-slate-500">余额合计 <b class="text-slate-800 dark:text-slate-200">{{ g.totalBalance }}</b></span>
<button @click="add(g.provider)" class="text-blue-600 dark:text-blue-400 hover:underline">+ 添加账号</button>
</div>
</div>
<div class="space-y-2">
<div v-for="a in g.accounts" :key="a.id" class="flex items-center justify-between px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 text-sm gap-2">
<div class="min-w-0">
<span class="font-medium">{{ a.name }}</span>
<span v-if="a.ai_detail && a.ai_detail.plan" class="text-xs text-slate-400 ml-2">{{ a.ai_detail.plan }}</span>
</div>
<div class="flex items-center gap-3 text-xs shrink-0">
<span :class="balanceClass(a.ai_detail && a.ai_detail.balance)">余额 {{ fmtBal(a.ai_detail && a.ai_detail.balance) }}{{ a.ai_detail && a.ai_detail.balance !== null && a.ai_detail.balance !== undefined ? ' ' + a.ai_detail.currency : '' }}</span>
<span v-if="a.ai_detail && a.ai_detail.monthly_usage !== null && a.ai_detail.monthly_usage !== undefined" class="text-slate-500">用量 {{ a.ai_detail.monthly_usage }}/{{ a.ai_detail.monthly_limit ?? '∞' }}</span>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
</div>
</div>
</div>
</div>
<div v-if="!groups.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">暂无 AI 账号,点右上角「新增账号」添加(同一平台可添加多个)</div>
</div>`,
setup() {
const aiList = () => store.assets.filter(a => a.asset_type === 'ai_agent');
const groups = Vue.computed(() => {
const map = {};
for (const a of aiList()) {
const prov = (a.ai_detail && a.ai_detail.provider) || a.provider || '其他';
if (!map[prov]) map[prov] = { provider: prov, accounts: [], totalBalance: 0 };
map[prov].accounts.push(a);
const bal = a.ai_detail && a.ai_detail.balance;
if (bal) map[prov].totalBalance += Number(bal);
}
return Object.values(map).map(g => ({ ...g, totalBalance: Math.round(g.totalBalance * 100) / 100 })).sort((x, y) => y.totalBalance - x.totalBalance);
});
const totalAccounts = Vue.computed(() => aiList().length);
const totalBalance = Vue.computed(() => Math.round(aiList().reduce((s, a) => s + (a.ai_detail && a.ai_detail.balance ? Number(a.ai_detail.balance) : 0), 0) * 100) / 100);
function balanceClass(b) {
if (b === null || b === undefined) return 'text-slate-400';
if (Number(b) < 10) return 'text-red-600 dark:text-red-400 font-semibold';
if (Number(b) < 50) return 'text-amber-600 dark:text-amber-400';
return 'text-emerald-600 dark:text-emerald-400';
}
function fmtBal(b) { return (b === null || b === undefined) ? '—' : b; }
return { store, groups, totalAccounts, totalBalance, balanceClass, fmtBal, add: openAICreate, addAny: () => openAICreate(''), edit: openAssetEdit };
},
};
+137
View File
@@ -0,0 +1,137 @@
/* TAB /VPS//Cloudflare//AI
* 注意专有视图DomainsView 在后续脚本才定义必须在 setup 运行时求值
* 不能在组件定义处直接引用会因加载顺序抛 ReferenceError
*/
const AssetsView = {
template: `
<div class="space-y-3">
<!-- 类型 TAB -->
<div class="flex flex-wrap gap-1.5">
<button v-for="t in tabs" :key="t.key" @click="switchTab(t.key)" :title="t.tip"
class="text-sm px-3 py-1.5 rounded-full transition"
:class="store.assetTab===t.key ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'">
{{ t.label }}
</button>
</div>
<!-- 通用列表全部 / VPS -->
<template v-if="store.assetTab==='' || store.assetTab==='vps'">
<div class="flex flex-wrap gap-2 items-center">
<select v-model="store.filterStatus" @change="reload" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<option value="">全部状态</option>
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select>
<button v-if="store.assetTab===''" @click="syncAllAI" class="text-sm px-3 py-1.5 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20">同步AI余额</button>
<span class="ml-auto text-xs text-slate-400"> {{ list.length }} </span>
</div>
<!-- 桌面表格 -->
<div class="hidden md:block bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
<tr>
<th class="px-4 py-2.5 font-medium">名称</th>
<th class="px-4 py-2.5 font-medium">类型</th>
<th class="px-4 py-2.5 font-medium">平台</th>
<th class="px-4 py-2.5 font-medium">到期</th>
<th class="px-4 py-2.5 font-medium">费用</th>
<th class="px-4 py-2.5 font-medium">状态</th>
<th class="px-4 py-2.5 font-medium text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-if="!list.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无资产</td></tr>
<tr v-for="a in list" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30" :class="{ 'opacity-50': a.is_archived }">
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span></td>
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null && a.days_to_expiry!==undefined" class="text-xs ml-1">({{ a.days_to_expiry }})</span></td>
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">监控</button>
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400 hover:underline mr-3 text-xs">刷新余额</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动卡片 -->
<div class="md:hidden space-y-2">
<div v-if="!list.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">暂无资产</div>
<div v-for="a in list" :key="a.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3" :class="{ 'opacity-50': a.is_archived }">
<div class="flex justify-between items-start">
<div class="min-w-0">
<div class="font-medium text-sm truncate">{{ a.name }}</div>
<div class="text-xs text-slate-400 mt-0.5">{{ a.provider_name || a.provider }}</div>
</div>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
</div>
<div class="flex items-center gap-3 mt-2 text-xs">
<span :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '无到期' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined"> ({{ a.days_to_expiry }})</span></span>
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
<span class="px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span>
</div>
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400">监控</button>
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400">刷新余额</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
</template>
<!-- 专有视图域名/Cloudflare/订阅/AI账号 -->
<component v-else :is="tabView"></component>
</div>`,
setup() {
// tip 为 TAB 悬浮说明:让用户一眼明白每个分类装的是什么
const tabs = [
{ key: '', label: '全部', tip: '所有类型的资产' },
{ key: 'vps', label: 'VPS', tip: '云服务器(可查看资源监控)' },
{ key: 'domain', label: '域名', tip: '域名与解析' },
{ key: 'cloudflare', label: 'Cloudflare', tip: 'Cloudflare 账号下的资源' },
{ key: 'subscription', label: '付费资产', tip: '有费用或到期日、需要续费的项目(跨类型汇总)' },
{ key: 'ai_agent', label: 'AI账号', tip: 'ChatGPT 等 AI 服务账号与余额' },
];
const tabView = Vue.computed(() => {
const t = store.assetTab;
if (t === 'domain') return DomainsView;
if (t === 'cloudflare') return CloudflareView;
if (t === 'subscription') return SubscriptionsView;
if (t === 'ai_agent') return AIAccountsView;
return null;
});
// 通用列表:全部显示所有,VPS 前端过滤类型
const list = Vue.computed(() => {
if (store.assetTab === 'vps') return store.assets.filter(a => a.asset_type === 'vps');
return store.assets;
});
function switchTab(k) {
store.assetTab = k;
// 专有视图需要全量数据;若此前按类型过滤过则清空重载
if (k !== '' && k !== 'vps' && store.filterType) { store.filterType = ''; loadAssets(); }
}
async function refreshBalance(a) {
if (!confirm('刷新「' + a.name + '」的余额?')) return;
try {
const r = await Api.post('/assets/' + a.id + '/refresh-balance', {});
alert('余额已更新:' + r.balance + ' ' + r.currency);
await loadAssets();
} catch (e) { alert('刷新失败:' + e.message); }
}
async function syncAllAI() {
if (!confirm('同步所有 AI 账号的余额?')) return;
try {
const r = await Api.post('/sync/ai-balances', {});
alert('同步完成:成功 ' + r.success + ',失败 ' + r.failed + (r.errors && r.errors.length ? '\n' + r.errors.join('\n') : ''));
await loadAssets();
} catch (e) { alert('同步失败:' + e.message); }
}
return { store, Fmt, tabs, tabView, list, switchTab, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI };
},
};
+46
View File
@@ -0,0 +1,46 @@
/* Cloudflare 子资产视图 */
const SUB_TYPE_LABELS = { zone: '解析', worker: 'Worker', r2: 'R2', tunnel: 'Tunnel', mail: 'Mail', dns_record: 'DNS记录', other: '其他' };
const CloudflareView = {
template: `
<div class="space-y-4">
<div v-if="!groups.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">
暂无 Cloudflare 子资产新增资产时选 Cloudflare 类型即可
</div>
<div v-for="g in groups" :key="g.account" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="flex justify-between items-center mb-3">
<div class="font-semibold text-sm"> {{ g.account || '未指定账号' }}</div>
<span class="text-xs text-slate-400">{{ g.items.length }} 个子资产</span>
</div>
<div class="space-y-2">
<div v-for="a in g.items" :key="a.id" class="flex items-center justify-between px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 text-sm">
<div class="min-w-0 flex items-center gap-2">
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="subTypeBadge(a.cloudflare_detail.sub_type)">{{ SUB_TYPE_LABELS[a.cloudflare_detail.sub_type] || a.cloudflare_detail.sub_type }}</span>
<span class="font-medium truncate">{{ a.cloudflare_detail.sub_name || a.cloudflare_detail.zone_name || a.name }}</span>
<span v-if="a.cloudflare_detail.zone_name && a.cloudflare_detail.sub_type !== 'zone'" class="text-xs text-slate-400">@{{ a.cloudflare_detail.zone_name }}</span>
</div>
<div class="flex items-center gap-3 text-xs shrink-0">
<span v-if="a.cloudflare_detail.status" class="text-slate-500">{{ a.cloudflare_detail.status }}</span>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
</div>
</div>
</div>
</div>
</div>`,
setup() {
const groups = Vue.computed(() => {
const cfAssets = store.assets.filter(a => a.asset_type === 'cloudflare' && a.cloudflare_detail);
const map = {};
for (const a of cfAssets) {
const acc = a.cloudflare_detail.account_email || '';
if (!map[acc]) map[acc] = { account: a.cloudflare_detail.account_email, items: [] };
map[acc].items.push(a);
}
return Object.values(map);
});
function subTypeBadge(t) {
return { zone: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', worker: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', r2: 'bg-purple-500/10 text-purple-600 dark:text-purple-400', tunnel: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', mail: 'bg-pink-500/10 text-pink-600 dark:text-pink-400', dns_record: 'bg-slate-500/10 text-slate-600', other: 'bg-slate-500/10 text-slate-500' }[t] || 'bg-slate-500/10 text-slate-500';
}
return { store, groups, subTypeBadge, SUB_TYPE_LABELS, edit: openAssetEdit };
},
};
+46
View File
@@ -0,0 +1,46 @@
/* 总览视图 */
const DashboardView = {
template: `
<div class="space-y-5">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
<div class="text-xs text-slate-500">总资产</div>
<div class="text-2xl font-bold mt-1">{{ store.overview.total || 0 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-red-200 dark:border-red-900/50">
<div class="text-xs text-slate-500">30天内到期</div>
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ store.overview.expiring_30 || 0 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
<div class="text-xs text-slate-500">本年支出</div>
<div class="text-2xl font-bold mt-1">{{ store.overview.year_cost || 0 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-amber-200 dark:border-amber-900/50">
<div class="text-xs text-slate-500">异常状态</div>
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ store.overview.abnormal_count || 0 }}</div>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4" v-if="store.expiring.length">
<h2 class="text-sm font-semibold mb-3">续费倒计时30天内</h2>
<div class="space-y-2">
<div v-for="a in store.expiring" :key="a.id" class="flex justify-between items-center px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">
<div class="min-w-0">
<span class="font-medium text-sm truncate">{{ a.name }}</span>
<span class="text-xs text-slate-400 ml-2">{{ a.provider_name || a.provider }}</span>
</div>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.expiryBadge(a.days_to_expiry)">{{ a.days_to_expiry }} · {{ a.cost }}{{ a.currency }}</span>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h2 class="text-sm font-semibold mb-3">按类型分布</h2>
<div class="flex flex-wrap gap-2">
<span v-for="(n, t) in store.overview.by_type" :key="t" class="text-xs px-3 py-1.5 rounded-lg" :class="Fmt.typeBadge(t)">{{ Fmt.TYPE_LABELS[t] }} {{ n }}</span>
<span v-if="!store.overview.by_type || !Object.keys(store.overview.by_type).length" class="text-xs text-slate-400">暂无数据</span>
</div>
</div>
</div>`,
setup() { return { store, Fmt }; },
};
+172
View File
@@ -0,0 +1,172 @@
/* + + SSL
* 交互每行可展开子域名面板子域名可添加/编辑/删除一键探测 SSL 证书
* 域名行显示该域名下最近的证书到期徽章
*/
const DomainsView = {
template: `
<div class="space-y-3">
<!-- 说明条 -->
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
🌐 域名资产下管理子域名与站点 SSL 证书展开任意域名添加子域名 www/api探测SSL即可监控该站点证书到期情况
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-x-auto">
<table class="w-full text-sm min-w-[820px]">
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
<tr>
<th class="px-4 py-2.5 font-medium">域名</th>
<th class="px-4 py-2.5 font-medium">注册商</th>
<th class="px-4 py-2.5 font-medium">DNS</th>
<th class="px-4 py-2.5 font-medium">到期</th>
<th class="px-4 py-2.5 font-medium">站点SSL</th>
<th class="px-4 py-2.5 font-medium">使用中</th>
<th class="px-4 py-2.5 font-medium text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-if="!domains.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无域名资产</td></tr>
<template v-for="a in domains" :key="a.id">
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
<td class="px-4 py-2.5 font-medium">{{ a.domain_detail.domain_name }}</td>
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.registrar || '—' }}</td>
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.dns_provider || '—' }}</td>
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null" class="text-xs ml-1">({{ a.days_to_expiry }})</span></td>
<td class="px-4 py-2.5">
<span v-if="sslByAsset[a.id]" class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap" :class="certBadge(sslByAsset[a.id].days_to_expiry)" :title="sslByAsset[a.id].issuer || ''">
{{ sslByAsset[a.id].hostname }} · {{ certText(sslByAsset[a.id]) }}
</span>
<span v-else class="text-xs text-slate-400"></span>
</td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="a.domain_detail.is_using ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-slate-500/10 text-slate-400'">{{ a.domain_detail.is_using ? '使用' : '闲置' }}</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<button @click="toggle(a)" class="text-indigo-600 dark:text-indigo-400 hover:underline mr-3 text-xs">{{ expanded[a.id] ? '收起' : '子域名' }}</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline text-xs">编辑/绑定</button>
</td>
</tr>
<!-- 子域名展开区 -->
<tr v-if="expanded[a.id]">
<td colspan="7" class="px-4 py-3 bg-slate-50/60 dark:bg-slate-800/20">
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="text-xs font-semibold text-slate-600 dark:text-slate-300">子域名{{ domainName(a) }}</span>
<button @click="resetForm(a)" class="text-xs px-2 py-1 rounded-lg bg-blue-600 text-white">+ 新增子域名</button>
<button @click="probeSsl(domainName(a), a)" class="text-xs px-2 py-1 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400">🔒 探测根域名SSL</button>
<span class="ml-auto text-xs text-slate-400"> {{ (subsByAsset[a.id]||[]).length }} </span>
</div>
<div v-if="(subsByAsset[a.id]||[]).length" class="space-y-1 mb-2">
<div v-for="s in subsByAsset[a.id]||[]" :key="s.id" class="flex flex-wrap items-center gap-2 bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 px-3 py-2 text-xs">
<span class="font-mono font-medium text-slate-700 dark:text-slate-200">{{ fullHost(s, a) }}</span>
<span v-if="s.record_type" class="px-1.5 py-0.5 rounded bg-slate-500/10 text-slate-500">{{ s.record_type }}</span>
<span v-if="s.record_value" class="text-slate-400 truncate max-w-[180px]" :title="s.record_value">{{ s.record_value }}</span>
<span :class="s.is_active ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ s.is_active ? '启用' : '停用' }}</span>
<span v-if="s.note" class="text-slate-400 truncate max-w-[120px]" :title="s.note">{{ s.note }}</span>
<span class="ml-auto flex gap-3">
<button @click="probeSsl(fullHost(s, a), a)" class="text-violet-600 dark:text-violet-400 hover:underline">🔒 探测SSL</button>
<button @click="startEdit(a, s)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
<button @click="delSub(s)" class="text-red-600 dark:text-red-400 hover:underline">删除</button>
</span>
</div>
</div>
<div v-else class="text-xs text-slate-400 mb-2">暂无子域名+ 新增子域名开始管理</div>
<!-- 新增/编辑表单 -->
<form v-if="forms[a.id]" @submit.prevent="saveSub(a)" class="flex flex-wrap gap-2 items-end bg-white dark:bg-slate-900 rounded-lg border border-blue-200 dark:border-blue-900/50 px-3 py-2">
<label class="text-xs text-slate-500">主机名
<input v-model="forms[a.id].host" placeholder="www / api@=根域名)" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-28 mt-0.5">
</label>
<label class="text-xs text-slate-500">类型
<select v-model="forms[a.id].record_type" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 mt-0.5">
<option v-for="t in ['A','CNAME','AAAA','MX','TXT']" :key="t" :value="t">{{ t }}</option>
</select>
</label>
<label class="text-xs text-slate-500 flex-1 min-w-[130px]">记录值
<input v-model="forms[a.id].record_value" placeholder="IP 或目标域名" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-full mt-0.5">
</label>
<label class="text-xs text-slate-500">备注
<input v-model="forms[a.id].note" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-24 mt-0.5">
</label>
<label class="text-xs text-slate-500 flex items-center gap-1 pb-1.5"><input type="checkbox" v-model="forms[a.id].is_active" class="accent-blue-600">启用</label>
<button type="submit" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white">{{ forms[a.id].editing ? '保存' : '添加' }}</button>
<button type="button" @click="resetForm(a)" class="text-sm px-2 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-slate-500">取消</button>
</form>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>`,
setup() {
const domains = Vue.computed(() => store.assets.filter(a => a.asset_type === 'domain' && a.domain_detail));
const expanded = Vue.reactive({}); // asset_id -> 是否展开子域名面板
const forms = Vue.reactive({}); // asset_id -> 新增/编辑表单
const subsByAsset = Vue.computed(() => {
const m = {};
for (const s of store.subdomains) { (m[s.asset_id] = m[s.asset_id] || []).push(s); }
return m;
});
// 每个域名资产下最近到期的一条证书(用于主行徽章)
const sslByAsset = Vue.computed(() => {
const m = {};
for (const c of store.siteCerts) {
if (!c.asset_id) continue;
if (!m[c.asset_id] || (c.days_to_expiry ?? 9999) < (m[c.asset_id].days_to_expiry ?? 9999)) m[c.asset_id] = c;
}
return m;
});
function domainName(a) { return a.domain_detail.domain_name; }
function fullHost(s, a) { return s.host === '@' ? domainName(a) : s.host + '.' + domainName(a); }
function toggle(a) {
expanded[a.id] = !expanded[a.id];
if (expanded[a.id] && !forms[a.id]) resetForm(a);
}
function resetForm(a) {
forms[a.id] = { host: '', record_type: 'A', record_value: '', note: '', is_active: true, editing: null };
}
function startEdit(a, s) {
expanded[a.id] = true;
forms[a.id] = { host: s.host, record_type: s.record_type || 'A', record_value: s.record_value || '', note: s.note || '', is_active: !!s.is_active, editing: s.id };
}
async function saveSub(a) {
const f = forms[a.id];
if (!f.host.trim()) { alert('请填写主机名'); return; }
const payload = { host: f.host.trim(), record_type: f.record_type, record_value: f.record_value, note: f.note, is_active: !!f.is_active };
try {
if (f.editing) await Api.put('/subdomains/' + f.editing, payload);
else await Api.post('/subdomains', { ...payload, asset_id: a.id });
await loadSubdomains();
resetForm(a);
} catch (e) { alert('保存失败:' + e.message); }
}
async function delSub(s) {
if (!confirm('删除子域名「' + s.host + '」?')) return;
try { await Api.del('/subdomains/' + s.id); await loadSubdomains(); }
catch (e) { alert('删除失败:' + e.message); }
}
async function probeSsl(defaultHost, a) {
const target = prompt('探测 SSL 证书的站点(留空取消):', defaultHost);
if (!target) return;
try {
await Api.post('/site-certs', { hostname: target, asset_id: a.id });
await loadSiteCerts();
alert('已探测:' + target);
} catch (e) { alert('探测失败:' + e.message); }
}
function certText(c) {
const d = c.days_to_expiry;
if (d === null || d === undefined) return '未知';
if (d < 0) return '已过期' + Math.abs(d) + '天';
if (d === 0) return '今天到期';
return d + '天';
}
function certBadge(d) {
if (d === null || d === undefined) return 'bg-slate-500/10 text-slate-500';
if (d < 0 || d === 0 || d <= 7) return 'bg-red-500/10 text-red-600 dark:text-red-400';
if (d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400';
if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
return 'bg-slate-500/10 text-slate-500';
}
return { store, Fmt, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit };
},
};
+246
View File
@@ -0,0 +1,246 @@
/* 监控中心视图:默认到期倒计时,可按类别筛选 */
const MonitorView = {
template: `
<div class="space-y-4">
<!-- 说明条让用户明白这个页面是干嘛的 -->
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span> 所有设置了到期日的资产按剩余天数倒计时排序越靠前越紧急</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-600 dark:text-red-400">=7天内</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400">=30天内</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">绿=90天内</span>
</div>
<!-- 顶部统计资产 -->
<div v-if="store.monitorTab!=='ssl'" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">30 天内到期</div>
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ stats.soon30 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">7 天内到期</div>
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ stats.soon7 }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">已过期</div>
<div class="text-2xl font-bold mt-1 text-slate-700 dark:text-slate-200">{{ stats.expired }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">有到期日的资产</div>
<div class="text-2xl font-bold mt-1">{{ total }}</div>
</div>
</div>
<!-- 顶部统计SSL 证书 -->
<div v-if="store.monitorTab==='ssl'" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">监控站点</div>
<div class="text-2xl font-bold mt-1">{{ sslStats.total }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">证书正常</div>
<div class="text-2xl font-bold mt-1 text-emerald-600 dark:text-emerald-400">{{ sslStats.valid }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">即将到期</div>
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ sslStats.expiring }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">已过期 / 失败</div>
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ sslStats.expired + sslStats.error }}</div>
</div>
</div>
<!-- 类别 TAB -->
<div class="flex flex-wrap gap-1.5">
<button v-for="t in tabs" :key="t.key" @click="switchTab(t.key)" :title="t.tip"
class="text-sm px-3 py-1.5 rounded-full transition"
:class="store.monitorTab===t.key ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'">
{{ t.label }}
</button>
</div>
<!-- 资产表格 SSL TAB -->
<template v-if="store.monitorTab!=='ssl'">
<!-- 桌面表格 -->
<div class="hidden md:block bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
<tr>
<th class="px-4 py-2.5 font-medium">名称</th>
<th class="px-4 py-2.5 font-medium">类型</th>
<th class="px-4 py-2.5 font-medium">平台</th>
<th class="px-4 py-2.5 font-medium">到期日</th>
<th class="px-4 py-2.5 font-medium">倒计时</th>
<th class="px-4 py-2.5 font-medium">费用</th>
<th class="px-4 py-2.5 font-medium text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-if="!items.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">{{ emptyText }}</td></tr>
<tr v-for="a in items" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span></td>
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
<td class="px-4 py-2.5">{{ a.expiry_date }}</td>
<td class="px-4 py-2.5"><span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="countdownBadge(a.days_to_expiry)">{{ countdownText(a.days_to_expiry) }}</span></td>
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动卡片 -->
<div class="md:hidden space-y-2">
<div v-if="!items.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">{{ emptyText }}</div>
<div v-for="a in items" :key="a.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3">
<div class="flex justify-between items-start">
<div class="min-w-0">
<div class="font-medium text-sm truncate">{{ a.name }}</div>
<div class="text-xs text-slate-400 mt-0.5">{{ a.provider_name || a.provider }}</div>
</div>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="countdownBadge(a.days_to_expiry)">{{ countdownText(a.days_to_expiry) }}</span>
</div>
<div class="flex items-center gap-3 mt-2 text-xs">
<span class="text-slate-500">{{ a.expiry_date }}</span>
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
<span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
</div>
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400">续费</a>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
</template>
<!-- SSL 证书表格 -->
<template v-if="store.monitorTab==='ssl'">
<div class="hidden md:block bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
<tr>
<th class="px-4 py-2.5 font-medium">站点</th>
<th class="px-4 py-2.5 font-medium">所属资产</th>
<th class="px-4 py-2.5 font-medium">签发机构</th>
<th class="px-4 py-2.5 font-medium">到期日</th>
<th class="px-4 py-2.5 font-medium">倒计时</th>
<th class="px-4 py-2.5 font-medium">状态</th>
<th class="px-4 py-2.5 font-medium text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-if="!sslList.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无证书监控资产 域名的子域名面板点探测SSL或稍后支持直接添加</td></tr>
<tr v-for="c in sslList" :key="c.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
<td class="px-4 py-2.5 font-mono font-medium">{{ c.hostname }}<span v-if="c.port!==443" class="text-xs text-slate-400">:{{ c.port }}</span></td>
<td class="px-4 py-2.5 text-slate-500">{{ c.asset_name || '—' }}</td>
<td class="px-4 py-2.5 text-slate-500 max-w-[220px] truncate" :title="c.issuer || ''">{{ c.issuer || '—' }}</td>
<td class="px-4 py-2.5">{{ c.valid_to || '—' }}</td>
<td class="px-4 py-2.5"><span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="countdownBadge(c.days_to_expiry)">{{ countdownText(c.days_to_expiry) }}</span></td>
<td class="px-4 py-2.5">
<span v-if="c.status==='error'" class="text-xs text-red-600 dark:text-red-400" :title="c.error || ''">探测失败</span>
<span v-else class="text-xs text-slate-500" :title="c.error || ''">{{ c.last_checked_at ? '已检查 ' + c.last_checked_at.slice(5,16).replace('T',' ') : '未检查' }}</span>
</td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<button @click="recheck(c)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">重测</button>
<button @click="delCert(c)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- SSL 移动卡片 -->
<div class="md:hidden space-y-2">
<div v-if="!sslList.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">暂无证书监控</div>
<div v-for="c in sslList" :key="c.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3">
<div class="flex justify-between items-start">
<div class="min-w-0">
<div class="font-mono font-medium text-sm truncate">{{ c.hostname }}</div>
<div class="text-xs text-slate-400 mt-0.5">{{ c.asset_name || '独立站点' }}</div>
</div>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="countdownBadge(c.days_to_expiry)">{{ countdownText(c.days_to_expiry) }}</span>
</div>
<div class="flex items-center gap-3 mt-2 text-xs text-slate-500">
<span>{{ c.valid_to || '—' }}</span>
<span v-if="c.status==='error'" class="text-red-600 dark:text-red-400" :title="c.error || ''">探测失败</span>
</div>
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
<button @click="recheck(c)" class="text-emerald-600 dark:text-emerald-400">重测</button>
<button @click="delCert(c)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
</template>
</div>`,
setup() {
const tabs = [
{ key: '', label: '全部', tip: '所有有到期日的资产' },
{ key: 'vps', label: 'VPS', tip: '仅看云服务器' },
{ key: 'domain', label: '域名', tip: '仅看域名' },
{ key: 'cloudflare', label: 'Cloudflare', tip: '仅看 Cloudflare 资源' },
{ key: 'subscription', label: '付费资产', tip: '仅看有费用、需要续费的项目' },
{ key: 'ai_agent', label: 'AI账号', tip: '仅看 AI 服务账号' },
{ key: 'ssl', label: 'SSL证书', tip: '站点 HTTPS 证书到期倒计时(独立于资产)' },
];
const hasExpiry = a => a.expiry_date && a.status !== 'cancelled';
const items = Vue.computed(() => {
if (store.monitorTab === 'ssl') return [];
let list = store.assets.filter(hasExpiry);
const t = store.monitorTab;
if (t === 'subscription') list = list.filter(a => a.cost > 0);
else if (t) list = list.filter(a => a.asset_type === t);
return list.sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999));
});
// SSL 证书:按剩余天数倒计时排序
const sslList = Vue.computed(() =>
[...store.siteCerts].sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999))
);
const sslStats = Vue.computed(() => {
const s = { total: store.siteCerts.length, valid: 0, expiring: 0, expired: 0, error: 0 };
for (const c of store.siteCerts) if (s[c.status] !== undefined) s[c.status]++;
return s;
});
async function recheck(c) {
try { await Api.post('/site-certs/' + c.id + '/check', {}); await loadSiteCerts(); }
catch (e) { alert('重测失败:' + e.message); }
}
async function delCert(c) {
if (!confirm('删除证书监控「' + c.hostname + '」?')) return;
try { await Api.del('/site-certs/' + c.id); await loadSiteCerts(); }
catch (e) { alert('删除失败:' + e.message); }
}
const total = Vue.computed(() => items.value.length);
const stats = Vue.computed(() => {
const s = { soon30: 0, soon7: 0, expired: 0 };
for (const a of store.assets.filter(hasExpiry)) {
const d = a.days_to_expiry;
if (d === null || d === undefined) continue;
if (d < 0) s.expired++;
else if (d <= 7) { s.soon7++; s.soon30++; }
else if (d <= 30) s.soon30++;
}
return s;
});
const emptyText = Vue.computed(() => store.monitorTab ? '该类别暂无到期资产' : '暂无到期资产(资产设置到期日即可出现在这里)');
function switchTab(k) { store.monitorTab = k; }
function countdownText(d) {
if (d === null || d === undefined) return '—';
if (d < 0) return '已过期 ' + Math.abs(d) + ' 天';
if (d === 0) return '今天到期';
return d + ' 天';
}
function countdownBadge(d) {
if (d === null || d === undefined) return 'bg-slate-500/10 text-slate-500';
if (d < 0 || d === 0 || d <= 7) return 'bg-red-500/10 text-red-600 dark:text-red-400';
if (d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400';
if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
return 'bg-slate-500/10 text-slate-500';
}
return { store, Fmt, tabs, items, total, stats, sslList, sslStats, emptyText, switchTab, countdownText, countdownBadge, recheck, delCert, edit: openAssetEdit, del: deleteAsset };
},
};
+101
View File
@@ -0,0 +1,101 @@
/* 平台管理视图 */
const ProvidersView = {
template: `
<div class="space-y-3">
<!-- 说明条让用户明白平台是什么 -->
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
🏢 平台 = 资产的服务商Vultr / 新网 / OpenAI资产中添加项目时选择归属平台支持 API 对接的平台配置好密钥后测试连接同步资产拉取真实数据
</div>
<div class="flex gap-2">
<button @click="seed" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">初始化预设平台</button>
<button @click="create" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 自定义平台</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="p in store.providers" :key="p.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="flex justify-between items-start">
<div>
<div class="font-medium text-sm">{{ p.name }}</div>
<div class="text-xs text-slate-400 mt-0.5">{{ p.slug }}</div>
</div>
<div v-if="serviceList(p).length" class="flex flex-wrap gap-1 justify-end max-w-[60%]">
<span v-for="s in serviceList(p)" :key="s" class="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400">{{ Fmt.SERVICES_LABELS[s] || s }}</span>
</div>
<span v-else class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(p.category)">{{ Fmt.CATEGORY_LABELS[p.category] }}</span>
</div>
<div class="text-xs text-slate-500 mt-2 space-y-1">
<div v-if="p.sdk_type">API 对接: {{ p.sdk_type }}</div>
<div v-if="p.console_url"><a :href="p.console_url" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">管理面板 </a></div>
<div class="flex items-center gap-2 mt-1">
<span class="text-slate-500">📎 关联 {{ countOf(p) }} 个资产</span>
<span v-if="p.has_api_config" class="text-emerald-600 dark:text-emerald-400">已配置 API</span>
<span v-else class="text-slate-400">未配置 API</span>
<span :class="p.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ p.enabled ? '启用' : '停用' }}</span>
</div>
<div v-if="p.last_synced_at" class="text-slate-400">最后同步{{ fmtTime(p.last_synced_at) }}</div>
</div>
<div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap">
<button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button v-if="isSupported(p.sdk_type)" @click="test(p)" :disabled="testing[p.id]" class="text-emerald-600 dark:text-emerald-400 disabled:opacity-50">{{ testing[p.id] ? '测试中…' : '测试连接' }}</button>
<button v-if="canSync(p.sdk_type)" @click="sync(p)" :disabled="syncing[p.id]" class="text-violet-600 dark:text-violet-400 disabled:opacity-50">{{ syncing[p.id] ? '同步中…' : '同步资产' }}</button>
<button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button>
</div>
<div v-if="results[p.id]" class="mt-2 text-xs px-2 py-1.5 rounded-lg break-words" :class="results[p.id].ok ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'">{{ results[p.id].text }}</div>
</div>
</div>
</div>`,
setup() {
const testing = Vue.reactive({});
const syncing = Vue.reactive({});
const results = Vue.reactive({});
const supported = Vue.ref([]);
const capabilities = Vue.ref({});
// 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug
const assetCounts = Vue.computed(() => {
const m = {};
for (const a of store.assets) {
if (a.provider_id) m['id:' + a.provider_id] = (m['id:' + a.provider_id] || 0) + 1;
else if (a.provider) m['p:' + a.provider] = (m['p:' + a.provider] || 0) + 1;
}
return m;
});
function countOf(p) {
return (assetCounts.value['id:' + p.id] || 0) + (assetCounts.value['p:' + p.slug] || 0) + (assetCounts.value['p:' + p.name] || 0);
}
function serviceList(p) {
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
}
Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; capabilities.value = r.capabilities || {}; } catch (e) { /* ignore */ }
});
function isSupported(t) { return !!t && supported.value.includes(t); }
function canSync(t) { const c = capabilities.value[t]; return !!(c && (c.list_vps || c.list_domains)); }
function fmtTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
async function test(p) {
testing[p.id] = true; delete results[p.id];
try {
const r = await Api.post('/providers/' + p.id + '/test', {});
results[p.id] = { ok: r.ok, text: (r.ok ? '✓ ' : '✗ ') + r.message };
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
finally { testing[p.id] = false; }
}
async function sync(p) {
syncing[p.id] = true; delete results[p.id];
try {
const r = await Api.post('/providers/' + p.id + '/sync', {});
const parts = [];
if (r.vps) parts.push('VPS 新增' + r.vps.created + '/更新' + r.vps.updated);
if (r.domains) parts.push('域名 新增' + r.domains.created + '/更新' + r.domains.updated);
if (r.vps_error) parts.push('VPS错误:' + r.vps_error);
if (r.domains_error) parts.push('域名错误:' + r.domains_error);
if (r.account && r.account.balance !== null && r.account.balance !== undefined) parts.push('余额 ' + r.account.balance);
results[p.id] = { ok: true, text: '✓ 同步完成:' + (parts.join('') || '无变化') };
await loadAll();
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
finally { syncing[p.id] = false; }
}
return { store, Fmt, testing, syncing, results, countOf, serviceList, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider };
},
};
+106
View File
@@ -0,0 +1,106 @@
/* 服务器监控视图 */
const ServersView = {
template: `
<div class="space-y-4">
<div v-if="!m.asset" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">
请在资产页点击 VPS 监控查看服务器资源与安全状态
</div>
<template v-else>
<div class="flex items-center gap-2">
<button @click="goBack" class="text-sm text-slate-500 hover:text-slate-700"> 返回</button>
<h2 class="font-semibold">{{ m.asset.name }}</h2>
</div>
<div v-if="m.info" class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">CPU</div>
<div class="text-xl font-bold mt-1">{{ latest ? latest.cpu_pct : '—' }}%</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.cpu_cores }} </div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">内存</div>
<div class="text-xl font-bold mt-1">{{ latest ? latest.mem_pct : '—' }}%</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.mem_total_gb }} GB</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">磁盘</div>
<div class="text-xl font-bold mt-1">{{ latest ? latest.disk_pct : '—' }}%</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.disk_total_gb }} GB</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">负载/在线</div>
<div class="text-xl font-bold mt-1">{{ latest && latest.load_1m !== null ? latest.load_1m : '—' }}</div>
<div class="text-xs text-slate-400 mt-1">{{ m.info.hostname }}</div>
</div>
</div>
<div v-if="m.info" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4 text-sm">
<h3 class="font-semibold mb-2">服务器信息</h3>
<div class="grid grid-cols-2 gap-y-1 text-xs text-slate-500">
<div>系统{{ m.info.os }}</div><div>{{ m.info.kernel }}</div>
<div>公网 IP{{ m.info.public_ip || '—' }}</div><div>{{ m.info.status }}</div>
</div>
</div>
<div v-if="m.metrics && m.metrics.length > 1" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold mb-2 text-sm">资源趋势最近 {{ m.metrics.length }} 次采样</h3>
<canvas id="metricsChart" height="90"></canvas>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="flex justify-between items-center mb-2">
<h3 class="font-semibold text-sm">安全检查</h3>
<div v-if="m.security && m.security.score !== null && m.security.score !== undefined" class="flex items-center gap-2">
<span class="text-xs text-slate-500">安全评分</span>
<span class="text-lg font-bold" :class="scoreClass(m.security.level)">{{ m.security.score }}</span>
</div>
</div>
<div class="space-y-2">
<div v-for="c in m.checks" :key="c.id" class="flex items-start gap-2 text-sm">
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.checkBadge(c.status)">{{ c.status }}</span>
<div class="min-w-0">
<div class="font-medium text-xs">{{ c.check_item }}</div>
<div class="text-xs text-slate-500">{{ c.detail }}</div>
<div v-if="c.suggestion" class="text-xs text-amber-600 dark:text-amber-400">建议{{ c.suggestion }}</div>
</div>
</div>
<div v-if="!m.checks.length" class="text-xs text-slate-400">暂无安全检查数据 Agent 上报</div>
</div>
</div>
</template>
</div>`,
setup() {
const m = Vue.computed(() => store.serverMetrics);
const latest = Vue.computed(() => (m.value.metrics && m.value.metrics.length) ? m.value.metrics[0] : null);
let chartInstance = null;
function renderChart() {
const canvas = document.getElementById('metricsChart');
if (!canvas || !window.Chart) return;
if (!m.value.metrics || m.value.metrics.length < 2) return;
if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
const data = [...m.value.metrics].reverse();
const labels = data.map(p => new Date(p.ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }));
chartInstance = new Chart(canvas, {
type: 'line',
data: {
labels,
datasets: [
{ label: 'CPU %', data: data.map(p => p.cpu_pct), borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)', tension: 0.3, pointRadius: 0 },
{ label: '内存 %', data: data.map(p => p.mem_pct), borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,0.1)', tension: 0.3, pointRadius: 0 },
{ label: '磁盘 %', data: data.map(p => p.disk_pct), borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.1)', tension: 0.3, pointRadius: 0 },
],
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
plugins: { legend: { position: 'top' } },
},
});
}
Vue.watch(m, () => { Vue.nextTick(renderChart); }, { deep: true });
Vue.onMounted(() => { Vue.nextTick(renderChart); });
Vue.onUnmounted(() => { if (chartInstance) { chartInstance.destroy(); chartInstance = null; } });
function scoreClass(level) {
return { good: 'text-emerald-600 dark:text-emerald-400', warning: 'text-amber-600 dark:text-amber-400', risk: 'text-red-600 dark:text-red-400', unknown: 'text-slate-400' }[level] || 'text-slate-400';
}
function goBack() { window.location.hash = '#assets'; }
return { store, Fmt, m, latest, scoreClass, goBack };
},
};
+96
View File
@@ -0,0 +1,96 @@
/* 设置视图 */
const SettingsView = {
template: `
<div class="max-w-lg space-y-4">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold text-sm mb-3">API Key写操作鉴权</h3>
<p class="text-xs text-slate-500 mb-2">若服务端 .env 配置了 API_KEY请在此填写相同的值前端写操作会自动携带</p>
<div class="flex gap-2">
<input v-model="settings.apiKey" type="password" placeholder="留空表示服务端未启用鉴权" class="flex-1 text-sm px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<button @click="save" class="text-sm px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700">{{ settings.saved ? '已保存' : '保存' }}</button>
</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold text-sm mb-2">通知与续费提醒</h3>
<div class="text-xs text-slate-500 mb-3">已配置渠道
<span v-if="notifyChannels.length" class="text-emerald-600 dark:text-emerald-400">{{ notifyChannels.join('、') }}</span>
<span v-else class="text-slate-400">未配置 .env 设置 TELEGRAM_*/SMTP_*</span>
</div>
<div class="flex gap-2 flex-wrap">
<button @click="testNotify" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">测试通知</button>
<button @click="checkRenewals" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">检查续费并提醒</button>
</div>
<div v-if="notifyResult" class="mt-3 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 whitespace-pre-wrap break-words">{{ notifyResult }}</div>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold text-sm mb-2">外观</h3>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" v-model="store.dark" @change="applyDark"> 深色模式
</label>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold text-sm mb-2">数据管理迁移/备份</h3>
<p class="text-xs text-slate-500 mb-2">导出所有资产为 JSON可导入到新部署的实例实现数据迁移</p>
<div class="flex gap-2 flex-wrap items-center">
<button @click="exportData" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">导出数据</button>
<label class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer">导入数据<input type="file" accept=".json" @change="onImportFile" class="hidden"></label>
</div>
<div v-if="dataResult" class="mt-2 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">{{ dataResult }}</div>
</div>
</div>`,
setup() {
const notifyChannels = Vue.ref([]);
const notifyResult = Vue.ref('');
const dataResult = Vue.ref('');
Vue.onMounted(async () => {
try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ }
});
async function testNotify() {
notifyResult.value = '发送中…';
try {
const r = await Api.post('/notify/test', {});
notifyResult.value = r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join('\n');
} catch (e) { notifyResult.value = '失败:' + e.message; }
}
async function checkRenewals() {
notifyResult.value = '检查中…';
try {
const r = await Api.post('/notify/renewals?send=true', {});
let text = '即将到期 ' + r.expiring_count + ' 项(阈值 ' + r.threshold_days + ' 天)';
if (r.expiring.length) text += '\n' + r.expiring.map(i => '• ' + i.name + ' [' + i.provider + '] ' + i.days + ' 天后').join('\n');
if (r.notifications && r.notifications.length) text += '\n\n通知:' + r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join('');
notifyResult.value = text;
} catch (e) { notifyResult.value = '失败:' + e.message; }
}
async function exportData() {
dataResult.value = '导出中…';
try {
const r = await Api.get('/assets/export');
const blob = new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'vps-manager-export-' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(url);
dataResult.value = '导出成功:' + r.count + ' 个资产';
} catch (e) { dataResult.value = '导出失败:' + e.message; }
}
function onImportFile(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (ev) => {
dataResult.value = '导入中…';
try {
const data = JSON.parse(ev.target.result);
const r = await Api.post('/assets/import', data);
dataResult.value = '导入完成:新增 ' + r.created + ',更新 ' + r.updated + (r.errors && r.errors.length ? ',错误 ' + r.errors.length : '');
await loadAssets();
} catch (err) { dataResult.value = '导入失败:' + err.message; }
};
reader.readAsText(file);
}
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals, dataResult, exportData, onImportFile };
},
};
+97
View File
@@ -0,0 +1,97 @@
/* 订阅视图 */
const SubscriptionsView = {
template: `
<div class="space-y-4">
<!-- 说明条让用户明白这个视图是什么 -->
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
💸 这里是付费资产汇总自动收集所有设置了费用或到期日的资产 VPS / 域名 / AI 账号统一看每个月花多少钱哪些该续费
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">付费资产数</div>
<div class="text-2xl font-bold mt-1">{{ subs.length }}</div>
</div>
<div v-for="(v, cur) in monthlyByCurrency" :key="cur" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="text-xs text-slate-500">月度支出{{ cur }}</div>
<div class="text-2xl font-bold mt-1">{{ v.toFixed(2) }}</div>
<div class="text-xs text-slate-400 mt-1">年度约 {{ (v*12).toFixed(2) }}</div>
</div>
</div>
<div v-if="subs.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold mb-2 text-sm">按平台月度支出换算为月均</h3>
<canvas id="billingChart" height="80"></canvas>
</div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
<tr>
<th class="px-4 py-2.5 font-medium">项目</th>
<th class="px-4 py-2.5 font-medium">平台</th>
<th class="px-4 py-2.5 font-medium">费用</th>
<th class="px-4 py-2.5 font-medium">到期</th>
<th class="px-4 py-2.5 font-medium text-right">管理</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-if="!subs.length"><td colspan="5" class="px-4 py-10 text-center text-slate-400">暂无付费资产给资产设置费用和到期日即可出现</td></tr>
<tr v-for="a in subs" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined" class="text-xs ml-1">({{ a.days_to_expiry }})</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
<a v-if="a.cancel_url" :href="a.cancel_url" target="_blank" class="text-red-600 dark:text-red-400 hover:underline text-xs">取消</a>
<span v-if="!a.renew_url && !a.cancel_url" class="text-xs text-slate-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>`,
setup() {
const subs = Vue.computed(() => store.assets
.filter(a => (a.cost > 0 || a.expiry_date) && a.status !== 'cancelled')
.sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999)));
const monthlyByCurrency = Vue.computed(() => {
const m = {};
for (const a of subs.value) {
const cur = a.currency || 'USD';
m[cur] = (m[cur] || 0) + normalizedMonthly(a.cost, a.renewal_cycle);
}
return m;
});
const byProviderMonthly = Vue.computed(() => {
const m = {};
for (const a of subs.value) {
const prov = a.provider_name || a.provider;
m[prov] = (m[prov] || 0) + normalizedMonthly(a.cost, a.renewal_cycle);
}
return m;
});
let billingChart = null;
function renderBillingChart() {
const canvas = document.getElementById('billingChart');
if (!canvas || !window.Chart) return;
const entries = Object.entries(byProviderMonthly.value).sort((x, y) => y[1] - x[1]);
if (!entries.length) return;
if (billingChart) { billingChart.destroy(); billingChart = null; }
billingChart = new Chart(canvas, {
type: 'bar',
data: {
labels: entries.map(e => e[0]),
datasets: [{ label: '月度支出', data: entries.map(e => e[1].toFixed(2)), backgroundColor: '#6366f1', borderRadius: 6 }],
},
options: {
responsive: true,
scales: { y: { beginAtZero: true } },
plugins: { legend: { display: false } },
},
});
}
Vue.watch([subs, byProviderMonthly], () => { Vue.nextTick(renderBillingChart); }, { deep: true });
Vue.onMounted(() => { Vue.nextTick(renderBillingChart); });
Vue.onUnmounted(() => { if (billingChart) { billingChart.destroy(); billingChart = null; } });
return { store, Fmt, subs, monthlyByCurrency, byProviderMonthly };
},
};
+23 -6
View File
@@ -1,12 +1,13 @@
/* vps-manager Service Worker /* vps-manager Service Worker
* 缓存策略 * 缓存策略
* - 预缓存核心静态资源安装即可离线打开 * - 页面导航HTMLnetwork-first回源失败才用缓存保证刷新即拿到最新版本号
* - 静态资源与页面stale-while-revalidate先返回缓存秒开后台自动拉取最新版本 * - 静态资源?v= 版本号stale-while-revalidate先返回缓存秒开后台自动拉取最新
* 版本号变了 URL 就变缓存必 miss天然回源新文件
* - /api/* 数据请求始终走网络保证数据实时 * - /api/* 数据请求始终走网络保证数据实时
* 更新机制业务代码更新后后台自动同步到缓存下次访问生效 * 更新机制sw.js 由服务端 no-cache 提供改动后导航时立即被浏览器发现
* 若需强制刷新缓存递增 CACHE_NAME 即可清理旧缓存 * install skipWaiting + clients.claim 立即接管无需手动清缓存
*/ */
const CACHE_NAME = 'vps-manager-v1'; const CACHE_NAME = 'vps-manager-v3';
const PRECACHE_URLS = [ const PRECACHE_URLS = [
'/', '/',
@@ -48,10 +49,26 @@ self.addEventListener('fetch', (event) => {
// 数据接口走网络,不缓存 // 数据接口走网络,不缓存
if (url.pathname.startsWith('/api/')) return; if (url.pathname.startsWith('/api/')) return;
// 页面导航:network-first,保证每次刷新都拿到最新 HTML(含最新版本号)
if (request.mode === 'navigate') {
event.respondWith(
fetch(request, { cache: 'no-store' })
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
return response;
})
.catch(() => caches.match(request))
);
return;
}
// 静态资源:stale-while-revalidate(版本号控制缓存失效)
event.respondWith( event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => { caches.open(CACHE_NAME).then(async (cache) => {
const cached = await cache.match(request); const cached = await cache.match(request);
const network = fetch(request) // no-store:绕开浏览器 HTTP 磁盘缓存,保证回源拿到最新版本
const network = fetch(request, { cache: 'no-store' })
.then((response) => { .then((response) => {
if (response && response.ok) cache.put(request, response.clone()); if (response && response.ok) cache.put(request, response.clone());
return response; return response;
+2 -1
View File
@@ -247,7 +247,8 @@ def test_cloudflare_pagination_result_info():
adapter = CloudflareAdapter({"api_token": "x"}) adapter = CloudflareAdapter({"api_token": "x"})
page1 = {"result": [{"id": "z1", "name": "a.com", "status": "active"}], "result_info": {"total_pages": 2, "page": 1}} page1 = {"result": [{"id": "z1", "name": "a.com", "status": "active"}], "result_info": {"total_pages": 2, "page": 1}}
page2 = {"result": [{"id": "z2", "name": "b.com", "status": "active"}], "result_info": {"total_pages": 2, "page": 2}} page2 = {"result": [{"id": "z2", "name": "b.com", "status": "active"}], "result_info": {"total_pages": 2, "page": 2}}
with patch.object(CloudflareAdapter, "_get", side_effect=[page1, page2]): with patch.object(CloudflareAdapter, "_registrar_expiry_map", return_value={}), \
patch.object(CloudflareAdapter, "_get", side_effect=[page1, page2]):
result = adapter.list_domains() result = adapter.list_domains()
assert len(result) == 2 assert len(result) == 2
assert {d.domain_name for d in result} == {"a.com", "b.com"} assert {d.domain_name for d in result} == {"a.com", "b.com"}