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
This commit is contained in:
+178
-1
@@ -28,6 +28,10 @@ const store = reactive({
|
||||
assets: [], // 列表口径:按当前筛选条件(类型/状态/平台/搜索)的结果
|
||||
allAssets: [], // 统计口径:不带筛选的全量资产(总览/监控卡片/平台计数用)
|
||||
providers: [],
|
||||
// 适配器能力矩阵(/providers/adapters):{adapters, supported_types, capabilities}
|
||||
// 前端据此如实展示「这个平台到底能不能同步/能不能启停/能不能管 DNS」,
|
||||
// 而不是只看 Provider.sdk_type 有没有值就宣称支持
|
||||
adapters: { adapters: [], supported_types: [], capabilities: {} },
|
||||
accounts: [],
|
||||
subdomains: [],
|
||||
siteCerts: [],
|
||||
@@ -134,6 +138,7 @@ async function loadAssets() {
|
||||
store.assets = await Api.get('/assets?' + params.toString());
|
||||
}
|
||||
async function loadProviders() { store.providers = await Api.get('/providers'); }
|
||||
async function loadAdapters() { store.adapters = await Api.get('/providers/adapters'); }
|
||||
async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
|
||||
async function loadCredentials() {
|
||||
const params = new URLSearchParams();
|
||||
@@ -147,7 +152,7 @@ async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs');
|
||||
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
|
||||
async function loadAll() {
|
||||
store.loading = true; store.error = '';
|
||||
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview()]); }
|
||||
try { await Promise.all([loadAssets(), loadProviders(), loadAdapters(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview()]); }
|
||||
catch (e) { store.error = e.message; } finally { store.loading = false; }
|
||||
}
|
||||
|
||||
@@ -523,6 +528,178 @@ async function viewServer(a) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 云端能力查询 ----------------
|
||||
* 能力矩阵来自后端 /providers/adapters(registry.sdk_capabilities()),
|
||||
* 是「适配器真的实现了这个方法」的事实源。前端一律以它为准,
|
||||
* 不再拿 Provider.sdk_type 有没有值当支持依据——填了不存在的 sdk_type
|
||||
* 会让按钮可点,用户点了才收到 400。 */
|
||||
function sdkCaps(sdkType) {
|
||||
if (!sdkType) return {};
|
||||
return (store.adapters.capabilities || {})[sdkType] || {};
|
||||
}
|
||||
function providerSdkType(slug) {
|
||||
const p = store.providers.find(x => x.slug === slug || x.name === slug);
|
||||
return p ? (p.sdk_type || null) : null;
|
||||
}
|
||||
/** 某平台是否声明了可用适配器(如实展示「未对接」而不是显示一个假的 sdk_type) */
|
||||
function providerIsSupported(slug) {
|
||||
const t = providerSdkType(slug);
|
||||
return !!t && (store.adapters.supported_types || []).includes(t);
|
||||
}
|
||||
/** 平台在某能力上是否可用,如 capsOf('aliyun','list_vps') */
|
||||
function capsOf(slug, cap) {
|
||||
return !!sdkCaps(providerSdkType(slug))[cap];
|
||||
}
|
||||
/** 资产 -> 所属平台的 sdk_type(优先 provider_id,回退平台名/slug) */
|
||||
function assetSdkType(a) {
|
||||
if (!a) return null;
|
||||
const p = a.provider_id
|
||||
? store.providers.find(x => x.id === a.provider_id)
|
||||
: store.providers.find(x => x.slug === a.provider || x.name === a.provider || x.slug === a.provider_name);
|
||||
return p ? (p.sdk_type || null) : null;
|
||||
}
|
||||
/** 资产在某能力上是否可用;没配适配器的平台一律 false,按钮不渲染 */
|
||||
function assetCan(a, cap) {
|
||||
return !!sdkCaps(assetSdkType(a))[cap];
|
||||
}
|
||||
const INSTANCE_CAPS = ['start_instance', 'stop_instance', 'reboot_instance', 'renew_instance'];
|
||||
/** VPS 是否可远程操作(任一实例动作可用即可) */
|
||||
function assetCanControl(a) {
|
||||
return a && a.asset_type === 'vps' && !!a.external_id && INSTANCE_CAPS.some(c => assetCan(a, c));
|
||||
}
|
||||
/** 域名是否可管 DNS 解析 */
|
||||
function assetCanDns(a) {
|
||||
return a && a.asset_type === 'domain' && assetCan(a, 'list_dns_records');
|
||||
}
|
||||
|
||||
/* ---------------- 云资源动作(启停/重启/续费) ----------------
|
||||
* 这些动作会真实改变云端资源:关机会中断线上服务、续费会真实扣款。
|
||||
* 因此一律走二次确认弹窗,且按钮可用性由后端 /actions/{id}/capabilities 决定。 */
|
||||
const ACTION_LABELS = { start: '开机', stop: '关机', reboot: '重启', renew: '续费' };
|
||||
const ACTION_WARNS = {
|
||||
start: '将向云端下发开机指令。',
|
||||
stop: '将向云端下发关机指令,机器上运行的服务会立即中断。',
|
||||
reboot: '将向云端下发重启指令,机器会短暂不可用。',
|
||||
renew: '将向云端下发续费指令,会产生真实扣款。',
|
||||
};
|
||||
|
||||
const actionModal = reactive({
|
||||
show: false, asset: null, caps: null, loading: true, busy: false,
|
||||
confirm: null, // 待二次确认的动作名
|
||||
periodMonths: 1,
|
||||
error: '',
|
||||
});
|
||||
|
||||
function openActions(asset) {
|
||||
actionModal.asset = asset;
|
||||
actionModal.caps = null;
|
||||
actionModal.loading = true;
|
||||
actionModal.confirm = null;
|
||||
actionModal.periodMonths = 1;
|
||||
actionModal.error = '';
|
||||
actionModal.show = true;
|
||||
Api.get('/actions/' + asset.id + '/capabilities')
|
||||
.then(r => { actionModal.caps = r; })
|
||||
.catch(e => { actionModal.error = e.message; })
|
||||
.finally(() => { actionModal.loading = false; });
|
||||
}
|
||||
function closeActions() { actionModal.show = false; actionModal.confirm = null; }
|
||||
function askAction(name) { actionModal.confirm = name; }
|
||||
async function runAction(name) {
|
||||
if (actionModal.busy) return;
|
||||
actionModal.busy = true; actionModal.error = '';
|
||||
try {
|
||||
const body = { action: name };
|
||||
if (name === 'renew') body.period_months = actionModal.periodMonths;
|
||||
const r = await Api.post('/actions/' + actionModal.asset.id + '/instance', body);
|
||||
if (r.ok) {
|
||||
showToast('✓ ' + ACTION_LABELS[name] + '指令已下发');
|
||||
actionModal.confirm = null;
|
||||
await loadAllAssets();
|
||||
if (store.view === 'assets') await loadAssets();
|
||||
} else {
|
||||
actionModal.error = r.message || '云端拒绝了该操作';
|
||||
}
|
||||
} catch (e) { actionModal.error = e.message; }
|
||||
finally { actionModal.busy = false; }
|
||||
}
|
||||
|
||||
/* ---------------- DNS 记录管理 ---------------- */
|
||||
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA'];
|
||||
const dnsModal = reactive({
|
||||
show: false, asset: null, zoneId: '', provider: '',
|
||||
records: [], loading: true, busy: false, error: '',
|
||||
editing: null, // null=关闭表单 / 'new'=新建 / record 对象=编辑
|
||||
form: null,
|
||||
});
|
||||
|
||||
function openDns(asset) {
|
||||
dnsModal.asset = asset;
|
||||
dnsModal.records = [];
|
||||
dnsModal.loading = true;
|
||||
dnsModal.error = '';
|
||||
dnsModal.editing = null;
|
||||
// 先用资产自带的平台 slug 兜底:列表请求失败时 provider 拿不到后端返回值,
|
||||
// 而 isCloudflare 靠它决定是否显示「走 Cloudflare 代理」勾选框。
|
||||
dnsModal.provider = asset.provider || '';
|
||||
dnsModal.show = true;
|
||||
Api.get('/actions/' + asset.id + '/dns')
|
||||
.then(r => { dnsModal.records = r.records || []; dnsModal.zoneId = r.zone_id; dnsModal.provider = r.provider; })
|
||||
.catch(e => { dnsModal.error = e.message; })
|
||||
.finally(() => { dnsModal.loading = false; });
|
||||
}
|
||||
function closeDns() { dnsModal.show = false; dnsModal.editing = null; }
|
||||
function reloadDns() {
|
||||
dnsModal.loading = true; dnsModal.error = '';
|
||||
Api.get('/actions/' + dnsModal.asset.id + '/dns')
|
||||
.then(r => { dnsModal.records = r.records || []; })
|
||||
.catch(e => { dnsModal.error = e.message; })
|
||||
.finally(() => { dnsModal.loading = false; });
|
||||
}
|
||||
function newDnsRecord() {
|
||||
dnsModal.form = { name: '', type: 'A', content: '', ttl: null, priority: null, proxied: null, comment: '' };
|
||||
dnsModal.editing = 'new';
|
||||
}
|
||||
function editDnsRecord(r) {
|
||||
dnsModal.form = {
|
||||
name: r.name || '', type: r.type || 'A', content: r.content || '',
|
||||
ttl: r.ttl || null, priority: r.priority ?? null, proxied: r.proxied ?? null, comment: r.comment || '',
|
||||
};
|
||||
dnsModal.editing = r;
|
||||
}
|
||||
function cancelDnsEdit() { dnsModal.editing = null; dnsModal.form = null; }
|
||||
async function saveDnsRecord() {
|
||||
if (dnsModal.busy) return;
|
||||
dnsModal.busy = true; dnsModal.error = '';
|
||||
const isNew = dnsModal.editing === 'new';
|
||||
const body = { ...dnsModal.form };
|
||||
// 空字符串会被后端的 ge/le 校验拒掉,未填的可选字段一律不发
|
||||
if (body.ttl === '' || body.ttl === null) delete body.ttl;
|
||||
if (body.priority === '' || body.priority === null) delete body.priority;
|
||||
if (body.proxied === null || body.proxied === '') delete body.proxied;
|
||||
if (!body.comment) delete body.comment;
|
||||
try {
|
||||
const r = isNew
|
||||
? await Api.post('/actions/' + dnsModal.asset.id + '/dns', body)
|
||||
: await Api.put('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(dnsModal.editing.record_id), body);
|
||||
if (r.ok) { showToast('✓ 记录已' + (isNew ? '创建' : '更新')); cancelDnsEdit(); reloadDns(); }
|
||||
else dnsModal.error = r.message || '操作被拒绝';
|
||||
} catch (e) { dnsModal.error = e.message; }
|
||||
finally { dnsModal.busy = false; }
|
||||
}
|
||||
async function deleteDnsRecord(r) {
|
||||
// 删解析会立刻影响线上访问,必须二次确认而不是直接删
|
||||
if (!window.confirm('确认删除解析记录?\n\n' + r.name + ' ' + r.type + ' ' + r.content + '\n\n删除后该记录立即失效,可能中断线上访问。')) return;
|
||||
if (dnsModal.busy) return;
|
||||
dnsModal.busy = true; dnsModal.error = '';
|
||||
try {
|
||||
const res = await Api.del('/actions/' + dnsModal.asset.id + '/dns/' + encodeURIComponent(r.record_id));
|
||||
if (res.ok) { showToast('✓ 记录已删除'); reloadDns(); }
|
||||
else dnsModal.error = res.message || '删除被拒绝';
|
||||
} catch (e) { dnsModal.error = e.message; }
|
||||
finally { dnsModal.busy = false; }
|
||||
}
|
||||
|
||||
/* ---------------- 设置 ---------------- */
|
||||
const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
|
||||
function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }
|
||||
|
||||
Reference in New Issue
Block a user