Files
vps-manager/static/js/views/monitor.js
T
gouki 46faadbcae feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)
评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐:

适配层
- 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补
  list_domains 与 get_account 余额
- NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到
  到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由
- base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商
  把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、
  腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本
  连同 MDN 链接弹给用户

动作层与 DNS
- 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计
- 新增 Cloudflare DNS 记录的增删改查

定时同步
- scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30)
- 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日
- sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期
  会显示「成功 1」,用户以为数据拉到了、实际一条没有

数据一致性
- AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染,
  缺了则所有按钮永远出不来
- import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步
  把所有云端资产当新增再建一份
- _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未
  实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量
  补标签不覆盖,用户自建平台不动

前端如实展示
- cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回
  价格,而汇总处又把 0 当「无费用」排除,两处口径不一致
- 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来
- DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底,
  否则 Cloudflare 代理勾选框永远不出现

测试:257 -> 309 passed
2026-10-12 00:43:37 +00:00

271 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 监控中心视图:默认到期倒计时,可按类别筛选 */
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">
<button type="button" @click="toggleExpiryFilter('soon30')" title="点击只看 30 天内到期的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorExpiryFilter==='soon30' ? 'border-red-500 ring-1 ring-red-500' : 'border-slate-200 dark:border-slate-800 hover:border-red-300 dark:hover:border-red-800'">
<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>
</button>
<button type="button" @click="toggleExpiryFilter('soon7')" title="点击只看 7 天内到期的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorExpiryFilter==='soon7' ? 'border-amber-500 ring-1 ring-amber-500' : 'border-slate-200 dark:border-slate-800 hover:border-amber-300 dark:hover:border-amber-800'">
<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>
</button>
<button type="button" @click="toggleExpiryFilter('expired')" title="点击只看已过期的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorExpiryFilter==='expired' ? 'border-slate-500 ring-1 ring-slate-500' : 'border-slate-200 dark:border-slate-800 hover:border-slate-400 dark:hover:border-slate-600'">
<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>
</button>
<button type="button" @click="clearExpiryFilter" title="点击显示当前类别下所有有到期日的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorExpiryFilter==='' ? 'border-blue-500 ring-1 ring-blue-500' : 'border-slate-200 dark:border-slate-800 hover:border-blue-300 dark:hover:border-blue-800'">
<div class="text-xs text-slate-500">有到期日的资产</div>
<div class="text-2xl font-bold mt-1">{{ total }}</div>
</button>
</div>
<!-- 顶部统计(SSL 证书):点卡片按证书状态筛选下方列表 -->
<div v-if="store.monitorTab==='ssl'" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<button type="button" @click="clearSslStatusFilter" title="点击显示全部监控站点" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorSslStatusFilter==='' ? 'border-blue-500 ring-1 ring-blue-500' : 'border-slate-200 dark:border-slate-800 hover:border-blue-300 dark:hover:border-blue-800'">
<div class="text-xs text-slate-500">监控站点</div>
<div class="text-2xl font-bold mt-1">{{ sslStats.total }}</div>
</button>
<button type="button" @click="toggleSslStatusFilter('valid')" title="点击只看证书正常的站点" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorSslStatusFilter==='valid' ? 'border-emerald-500 ring-1 ring-emerald-500' : 'border-slate-200 dark:border-slate-800 hover:border-emerald-300 dark:hover:border-emerald-800'">
<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>
</button>
<button type="button" @click="toggleSslStatusFilter('expiring')" title="点击只看即将到期的证书" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorSslStatusFilter==='expiring' ? 'border-amber-500 ring-1 ring-amber-500' : 'border-slate-200 dark:border-slate-800 hover:border-amber-300 dark:hover:border-amber-800'">
<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>
</button>
<button type="button" @click="toggleSslStatusFilter('expired_error')" title="点击只看已过期或探测失败的证书" class="text-left bg-white dark:bg-slate-900 rounded-xl border p-4 transition" :class="store.monitorSslStatusFilter==='expired_error' ? 'border-red-500 ring-1 ring-red-500' : 'border-slate-200 dark:border-slate-800 hover:border-red-300 dark:hover:border-red-800'">
<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>
</button>
</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" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && 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="a.cost ? 'text-slate-500' : 'text-slate-400'">{{ Fmt.costText(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">{{ sslEmptyText }}</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">{{ sslEmptyText }}</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';
// 当前 TAB 下、有到期日的资产(未叠加到期区间子筛选),作为统计卡片与列表的共同基础数据集
// 用 allAssets(全量口径):监控中心靠自己的 TAB 与子筛选限定范围,
// 不应该继承资产页的类型/状态/平台/搜索筛选,否则卡片数字会莫名变小
const tabScopedAssets = Vue.computed(() => {
if (store.monitorTab === 'ssl') return [];
let list = store.allAssets.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;
});
const items = Vue.computed(() => {
let list = tabScopedAssets.value;
const ef = store.monitorExpiryFilter;
if (ef === 'soon30') list = list.filter(a => a.days_to_expiry >= 0 && a.days_to_expiry <= 30);
else if (ef === 'soon7') list = list.filter(a => a.days_to_expiry >= 0 && a.days_to_expiry <= 7);
else if (ef === 'expired') list = list.filter(a => a.days_to_expiry < 0);
return [...list].sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999));
});
// SSL 证书:按剩余天数倒计时排序,可叠加状态子筛选
const sslList = Vue.computed(() => {
let list = store.siteCerts;
const f = store.monitorSslStatusFilter;
if (f === 'valid') list = list.filter(c => c.status === 'valid');
else if (f === 'expiring') list = list.filter(c => c.status === 'expiring');
else if (f === 'expired_error') list = list.filter(c => c.status === 'expired' || c.status === 'error');
return [...list].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(() => tabScopedAssets.value.length);
const stats = Vue.computed(() => {
const s = { soon30: 0, soon7: 0, expired: 0 };
for (const a of tabScopedAssets.value) {
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(() => {
if (store.monitorExpiryFilter) return '当前到期区间筛选下暂无资产,点上方已选中的卡片可取消筛选';
return store.monitorTab ? '该类别暂无到期资产' : '暂无到期资产(资产设置到期日即可出现在这里)';
});
const sslEmptyText = Vue.computed(() => {
if (store.monitorSslStatusFilter) return '当前状态筛选下暂无证书,点上方已选中的卡片可取消筛选';
return '暂无证书监控(在「资产 → 域名」的子域名面板点「探测SSL」,或稍后支持直接添加)';
});
function switchTab(k) { store.monitorTab = k; }
function toggleExpiryFilter(k) { store.monitorExpiryFilter = store.monitorExpiryFilter === k ? '' : k; }
function clearExpiryFilter() { store.monitorExpiryFilter = ''; }
function toggleSslStatusFilter(k) { store.monitorSslStatusFilter = store.monitorSslStatusFilter === k ? '' : k; }
function clearSslStatusFilter() { store.monitorSslStatusFilter = ''; }
// 文案统一走 Fmt.countdownText(不再自己维护一份);
// 此处保留窄列样式:无到期日显示 —,天数不带「后」
const countdownText = d => Fmt.countdownText(d, { unit: '', empty: '—' });
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, sslEmptyText, switchTab, toggleExpiryFilter, clearExpiryFilter, toggleSslStatusFilter, clearSslStatusFilter, countdownText, countdownBadge, recheck, delCert, edit: openAssetEdit, del: deleteAsset };
},
};