feat: 综合平台services多服务支持+SW网络优先缓存修复+适配层/定时任务完善
This commit is contained in:
+10
-4
@@ -34,15 +34,21 @@ def collect_metrics() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _get_ip() -> str:
|
def _get_ip() -> str:
|
||||||
"""获取本机出口 IP(不实际发包)"""
|
"""获取本机出口 IP(UDP 不实际发包;设超时避免无网环境阻塞)"""
|
||||||
|
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:
|
||||||
|
|||||||
+11
-1
@@ -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,6 +34,7 @@ def main() -> int:
|
|||||||
}
|
}
|
||||||
headers = {"X-Agent-Key": key} if key else {}
|
headers = {"X-Agent-Key": key} if key else {}
|
||||||
|
|
||||||
|
for attempt in range(max_retry + 1):
|
||||||
try:
|
try:
|
||||||
resp = httpx.post(
|
resp = httpx.post(
|
||||||
f"{url}/api/agent/report", json=payload, headers=headers, timeout=30
|
f"{url}/api/agent/report", json=payload, headers=headers, timeout=30
|
||||||
@@ -39,7 +43,13 @@ def main() -> int:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return 0
|
return 0
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print(f"上报失败: {e}")
|
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
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+35
-5
@@ -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_key(group_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()
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Cloudflare 适配器(REST API v4,Bearer Token)
|
"""Cloudflare 适配器(REST API v4,Bearer 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
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
+26
-5
@@ -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
|
||||||
|
if added:
|
||||||
session.commit()
|
session.commit()
|
||||||
return added
|
return added
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,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)
|
||||||
|
|||||||
@@ -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="创建时间")
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""SSL 监控路由:子域名管理与站点证书监控
|
||||||
|
|
||||||
|
- /api/subdomains:域名资产下的子域名 CRUD
|
||||||
|
- /api/site-certs:https 站点证书的探测与监控
|
||||||
|
写操作(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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(可选)")
|
||||||
+130
-45
@@ -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,28 +278,47 @@ 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"}
|
||||||
|
try:
|
||||||
for item in assets_data:
|
for item in assets_data:
|
||||||
try:
|
try:
|
||||||
payload = {k: v for k, v in item.items() if k not in skip_fields}
|
payload = {k: v for k, v in item.items() if k not in skip_fields}
|
||||||
@@ -266,13 +330,20 @@ def import_assets(session: Session, assets_data: list) -> dict:
|
|||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
update_asset(session, existing.id, AssetUpdate(**payload))
|
_update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
|
||||||
updated += 1
|
updated += 1
|
||||||
else:
|
else:
|
||||||
create_asset(session, AssetCreate(**payload))
|
_create_asset_no_commit(session, AssetCreate(**payload))
|
||||||
created += 1
|
created += 1
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
errors.append(f"{item.get('name', '?')}: {e}")
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""SSL 监控服务:站点证书探测 + 子域名管理
|
||||||
|
|
||||||
|
- probe_site_cert: 通过 ssl socket 探测站点证书信息(不校验证书链,仅读取到期信息)
|
||||||
|
- create/check/check_all: 站点证书的创建与刷新
|
||||||
|
- 子域名 CRUD:list/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})"
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 服务"
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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';
|
||||||
|
|||||||
+57
-1031
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 }; },
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
@@ -1,12 +1,13 @@
|
|||||||
/* vps-manager Service Worker
|
/* vps-manager Service Worker
|
||||||
* 缓存策略:
|
* 缓存策略:
|
||||||
* - 预缓存核心静态资源,安装即可离线打开
|
* - 页面导航(HTML):network-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;
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user