Files

635 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
window.INP2P = window.INP2P || {};
window.INP2P.createActions = function createActions(state, deps) {
const { api, loginByPassword, Vue } = deps;
const refreshAll = async () => {
if (!state.loggedIn.value) return;
state.globalBusy.value = true;
try {
const [h, s] = await Promise.all([api('/api/v1/health'), api('/api/v1/stats')]);
state.health.value = (h && typeof h === 'object') ? h : {};
state.stats.value = (s && typeof s === 'object') ? s : {};
const nd = await api('/api/v1/nodes');
state.nodes.value = Array.isArray(nd?.nodes) ? nd.nodes : [];
try {
const sdRes = await api('/api/v1/sdwans');
state.sd.value = (sdRes && typeof sdRes === 'object') ? sdRes : { nodes: [], routes: [], subnetProxies: [] };
if (!Array.isArray(state.sd.value.nodes)) state.sd.value.nodes = [];
if (!Array.isArray(state.sd.value.routes)) state.sd.value.routes = [];
if (!Array.isArray(state.sd.value.subnetProxies)) state.sd.value.subnetProxies = [];
// tenant subnet locked by backend: keep UI aligned
if (!state.isAdmin.value && state.subnet.value) {
state.sd.value.gatewayCIDR = state.subnet.value;
if (!state.sd.value.routes.length) {
state.sd.value.routes = [state.subnet.value];
}
}
} catch (e) {
state.toast('SDWAN 加载失败:' + (state.humanizeError ? state.humanizeError(e.message || '') : (e.message || '')), 'error');
}
if (state.isAdmin.value) {
try {
const settingsRes = await api('/api/v1/admin/settings');
state.settings.value = (settingsRes && typeof settingsRes === 'object') ? settingsRes : state.settings.value;
} catch(_) {}
try {
const a = await api('/api/v1/admin/audit?limit=50');
state.audit.value = Array.isArray(a?.logs) ? a.logs : [];
} catch(_) {}
}
} catch (e) {
state.toast(state.humanizeError ? state.humanizeError(e.message || '刷新失败') : (e.message || '刷新失败'), 'error');
} finally {
state.globalBusy.value = false;
}
};
const stopTimer = () => {
if (state.timer.value) {
clearInterval(state.timer.value);
state.timer.value = null;
}
};
const startTimer = () => {
stopTimer();
if (!state.loggedIn.value) return;
const sec = Math.max(5, Number(state.refreshSec.value || 15));
state.timer.value = setInterval(refreshAll, sec * 1000);
};
const login = async () => {
state.loginErr.value = '';
state.loginBusy.value = true;
try {
const uname = (state.loginUser.value || '').trim();
const uerr = state.validateUsername ? state.validateUsername(uname) : '';
if (uerr) throw new Error(uerr);
const d = await loginByPassword(uname, state.loginPass.value);
if (d.error) throw new Error(d.message || '用户名密码登录失败');
localStorage.setItem('t', d.token || '');
state.role.value = d.role || '';
state.status.value = d.status ?? 1;
state.tokenType.value = d.token_type || 'session';
state.tenantId.value = d.tenant_id || 0;
state.subnet.value = d.subnet || '';
if (state.status.value !== 1) throw new Error('账号已停用');
state.loggedIn.value = true;
await refreshAll();
state.toast('登录成功');
} catch (e) {
state.loginErr.value = e.message || '登录失败';
} finally {
state.loginBusy.value = false;
}
};
const logout = () => {
localStorage.removeItem('t');
localStorage.removeItem('master_t');
state.loggedIn.value = false;
state.role.value = '';
state.tokenType.value = '';
state.tenantId.value = 0;
state.subnet.value = '';
stopTimer();
};
const addSDWANNode = () => state.sd.value.nodes = [...(state.sd.value.nodes || []), { node:'', ip:'' }];
const removeSDWANNode = i => state.sd.value.nodes.splice(i, 1);
const addSubnetProxy = () => state.sd.value.subnetProxies = [...(state.sd.value.subnetProxies || []), { node:'', localCIDR:'', virtualCIDR:'' }];
const removeSubnetProxy = i => state.sd.value.subnetProxies.splice(i, 1);
const autoAssignIPs = () => {
const used = new Set();
(state.sd.value.nodes || []).forEach(n => { const p = (n.ip||'').split('.'); if (p.length===4) used.add(Number(p[3])); });
let k = 2;
for (const n of state.sd.value.nodes || []) {
if (!n.ip) {
while (used.has(k) && k < 254) k++;
n.ip = `10.10.0.${k}`;
used.add(k);
k++;
}
}
state.toast('已自动分配缺失 IP');
};
const saveSDWAN = async () => {
const verr = state.validateSDWAN ? state.validateSDWAN(state.sd.value) : '';
if (verr) {
state.toast(verr, 'error');
state.logEvent && state.logEvent('error', { tab:'sdwan', action:'save', message:verr });
return;
}
state.beginTab('sdwan');
try {
await api('/api/v1/sdwan/edit', { method:'POST', body: JSON.stringify(state.sd.value) });
state.toast('SDWAN 保存成功');
await refreshAll();
state.endTabOk('sdwan', !(state.sd.value?.nodes||[]).length);
} catch (e) { state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('sdwan'); state.logEvent && state.logEvent('error', { tab:'sdwan', action:'save', message:e.message||'' }); }
};
const kickNode = async (node) => {
if (!confirm(`高风险操作:踢下线节点 ${node}\n这会中断该节点现有连接。确认继续?`)) return;
try { await api('/api/v1/nodes/kick', { method:'POST', body: JSON.stringify({ node }) }); state.toast('已发送踢下线'); refreshAll(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const openNodeEdit = (node, mode='alias') => {
if (!node || !node.nodeUUID) return state.toast('该节点尚无UUID,稍后重连后再试', 'error');
state.nodeEditNode.value = node;
state.nodeEditMode.value = mode;
state.nodeEditTitle.value = mode === 'alias' ? '编辑设备昵称' : '编辑设备虚拟IP';
state.nodeEditValue.value = mode === 'alias' ? (node.alias || '') : (node.virtualIP || '');
state.nodeEditVisible.value = true;
};
const closeNodeEdit = () => {
state.nodeEditVisible.value = false;
state.nodeEditNode.value = null;
state.nodeEditValue.value = '';
};
const submitNodeEdit = async () => {
const node = state.nodeEditNode.value;
if (!node || !node.nodeUUID) return;
const val = String(state.nodeEditValue.value || '').trim();
try {
if (state.nodeEditMode.value === 'alias') {
await api('/api/v1/nodes/alias', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, alias: val }) });
state.toast('昵称已更新');
} else {
if (!val) return state.toast('虚拟IP不能为空', 'error');
await api('/api/v1/nodes/ip', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, virtual_ip: val }) });
state.toast('IP已更新,节点将按新IP重连');
}
closeNodeEdit();
refreshAll();
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const renameNode = async (node) => openNodeEdit(node, 'alias');
const changeNodeIP = async (node) => openNodeEdit(node, 'ip');
const appPushNode = Vue.ref('');
const appPushRaw = Vue.ref('[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]');
let switchTabRef = null;
const setSwitchTab = fn => { switchTabRef = fn; };
const openAppManager = (node) => { appPushNode.value = node; state.toast(`已选中 ${node},请在控制台执行推配置`); if (switchTabRef) switchTabRef('p2p'); };
const openConnect = (from) => { state.connectForm.value.from = from; if (switchTabRef) switchTabRef('p2p'); };
const pushAppConfigs = async () => {
if (!appPushNode.value) return state.toast('请先选择节点', 'error');
let apps;
try { apps = JSON.parse(appPushRaw.value); } catch(_) { return state.toast('配置 JSON 格式错误', 'error'); }
state.setTabBusy('p2p', true);
try { await api('/api/v1/nodes/apps', { method:'POST', body: JSON.stringify({ node: appPushNode.value, apps }) }); state.toast('配置已推送'); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
finally { state.setTabBusy('p2p', false); }
};
const doConnect = async () => {
const req = { ...state.connectForm.value };
if (!req.from || !req.to) return state.toast('请选择 from/to 节点', 'error');
state.setTabBusy('p2p', true);
try {
await api('/api/v1/connect', { method:'POST', body: JSON.stringify(req) });
state.toast('连接请求已发送');
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
finally { state.setTabBusy('p2p', false); }
};
const loadTenants = async () => {
if (!state.isAdmin.value) { state.tenants.value = []; state.tabState.value = { ...state.tabState.value, tenants:'empty' }; return; }
state.beginTab('tenants');
try {
const d = await api('/api/v1/admin/tenants');
state.tenants.value = d.tenants || [];
state.endTabOk('tenants', !state.tenants.value.length);
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const createTenant = async () => {
const terr = state.validateRequired ? state.validateRequired(state.tenantForm.value.name, '租户名') : (!state.tenantForm.value.name ? '租户名不能为空' : '');
if (terr) return state.toast(terr, 'error');
state.beginTab('tenants');
try {
const tenantName = state.tenantForm.value.name;
await api('/api/v1/admin/tenants', { method:'POST', body: JSON.stringify(state.tenantForm.value) });
state.tenantForm.value = { name:'', admin_password:'', operator_password:'' };
state.toast('租户创建成功'); state.opSummary.value = `已创建租户:${tenantName}`;
await loadTenants();
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const setTenantStatus = async (id, st) => {
if (!confirm(`确认将租户 ${id} ${st===1?'启用':'停用'}?`)) return;
try { await api(`/api/v1/admin/tenants/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('租户状态已更新'); state.opSummary.value = `租户 ${id} 已${st===1?'启用':'停用'}`; loadTenants(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const reassignTenantSubnet = async (id, currentSubnet='') => {
if (!state.isAdmin.value) return;
const next = prompt('输入新的租户子网(仅 10.200.x.0/24)', currentSubnet || '');
if (next === null) return;
const subnet = String(next || '').trim();
if (!subnet) return state.toast('子网不能为空', 'error');
try {
await api(`/api/v1/admin/tenants/${id}/subnet`, { method:'POST', body: JSON.stringify({ subnet }) });
state.toast('租户子网已重分配');
await loadTenants();
try { await refreshAll(); } catch(_) {}
} catch(e){ state.toast(e.message || '重分配失败', 'error'); }
};
const loadKeys = async () => {
if (!state.activeTenant.value) return;
state.beginTab('tenants');
try {
const d = await api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys`);
state.keys.value = d.keys || [];
state.selectedKeyIds.value = [];
if (!state.keepFailedPool.value) state.bulkFailedKeyIds.value = [];
state.endTabOk('tenants', !state.keys.value.length);
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const createKey = async () => {
if (!state.activeTenant.value) return state.toast('请先填写 Tenant ID', 'error');
const scopeErr = state.validateRequired ? state.validateRequired(state.keyForm.value.scope, 'scope') : '';
if (scopeErr) return state.toast(scopeErr, 'error');
state.beginTab('tenants');
try {
const d = await api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys`, { method:'POST', body: JSON.stringify(state.keyForm.value) });
state.toast(`API Key 创建成功: ${d.api_key || ''}`);
await loadKeys();
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const setKeyStatus = async (id, st) => {
if (!confirm(`确认将 Key ${id} ${st===1?'启用':'停用'}?`)) return;
try { await api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('Key 状态已更新'); state.opSummary.value = `Key ${id} 已${st===1?'启用':'停用'}`; loadKeys(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const loadUsers = async () => {
if (!state.activeTenant.value) return;
state.beginTab('tenants');
try {
const d = await api(`/api/v1/admin/users?tenant=${state.activeTenant.value}`);
state.users.value = d.users || [];
state.selectedUserIds.value = [];
if (!state.keepFailedPool.value) state.bulkFailedUserIds.value = [];
state.endTabOk('tenants', !state.users.value.length);
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const createUser = async () => {
if (!state.activeTenant.value) return state.toast('请先填写 Tenant ID', 'error');
const e1 = state.validateRequired ? state.validateRequired(state.userForm.value.email, 'email/username') : '';
const e2 = state.validateRequired ? state.validateRequired(state.userForm.value.password, 'password') : '';
if (e1 || e2) return state.toast(e1 || e2, 'error');
state.beginTab('tenants');
try {
const createdUser = state.userForm.value.email;
await api('/api/v1/admin/users', { method:'POST', body: JSON.stringify({ tenant: Number(state.activeTenant.value), ...state.userForm.value }) });
state.userForm.value = { role:'operator', email:'', password:'' };
state.toast('用户创建成功'); state.opSummary.value = `已创建用户:${createdUser}`;
loadUsers();
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const setUserStatus = async (id, st) => {
if (!confirm(`确认将用户 ${id} ${st===1?'启用':'停用'}?`)) return;
try { await api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('用户状态已更新'); state.opSummary.value = `用户 ${id} 已${st===1?'启用':'停用'}`; loadUsers(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const resetUserPassword = async (id) => {
const p = prompt('请输入新密码(至少6位)');
if (!p) return;
try { await api(`/api/v1/admin/users/${id}/password`, { method:'POST', body: JSON.stringify({ password: p }) }); state.toast('密码已重置'); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const deleteUser = async (id) => {
if (!confirm('高风险操作:删除用户后不可恢复。\n确认继续?')) return;
try { await api(`/api/v1/admin/users/${id}/delete`, { method:'POST', body:'{}' }); state.toast('用户已删除'); state.opSummary.value = `用户 ${id} 已删除` ; loadUsers(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const loadEnrolls = async () => {
state.beginTab('tenants');
try {
const d = await api('/api/v1/tenants/enroll');
state.enrolls.value = d.enrolls || [];
state.selectedEnrollIds.value = [];
if (!state.keepFailedPool.value) state.bulkFailedEnrollIds.value = [];
state.endTabOk('tenants', !state.enrolls.value.length);
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const createEnroll = async () => {
state.beginTab('tenants');
try {
const d = await api('/api/v1/tenants/enroll', { method:'POST', body:'{}' });
state.toast(`enroll_code: ${d.enroll_code || ''}`); state.opSummary.value = `已生成 enroll code(租户 ${state.activeTenant.value})`;
loadEnrolls();
} catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); }
};
const setEnrollStatus = async (id, st) => {
try { await api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('enroll 状态已更新'); state.opSummary.value = `enroll ${id} 状态已更新为 ${st}`; loadEnrolls(); }
catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); }
};
const consumeEnroll = async () => {
state.toast('consumeEnroll 为客户端配网流程,控制台当前不直接调用');
};
const toggleAllKeys = () => {
const ids = (state.filteredKeys.value || []).map(k => Number(k.id)).filter(Boolean);
if (!ids.length) return;
if ((state.selectedKeyIds.value || []).length === ids.length) state.selectedKeyIds.value = [];
else state.selectedKeyIds.value = ids;
};
const toggleAllUsers = () => {
const ids = (state.filteredUsers.value || []).map(u => Number(u.id)).filter(Boolean);
if (!ids.length) return;
if ((state.selectedUserIds.value || []).length === ids.length) state.selectedUserIds.value = [];
else state.selectedUserIds.value = ids;
};
const toggleAllEnrolls = () => {
const ids = (state.filteredEnrolls.value || []).map(e => Number(e.id)).filter(Boolean);
if (!ids.length) return;
if ((state.selectedEnrollIds.value || []).length === ids.length) state.selectedEnrollIds.value = [];
else state.selectedEnrollIds.value = ids;
};
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const runWithRetry = async (fn, retries = 1) => {
let lastErr = null;
for (let i = 0; i <= retries; i++) {
try { return await fn(); }
catch (e) { lastErr = e; if (i < retries) await sleep(120); }
}
throw lastErr || new Error('unknown error');
};
const runBulk = async ({ ids, confirmText, onEach, afterReload, summaryLabel, setFailedIds }) => {
const list = (ids || []).slice();
if (!list.length) return false;
if (!confirm(confirmText)) return false;
state.setTabBusy('tenants', true);
let ok = 0, fail = 0;
const failedIds = [];
let cursor = 0;
const workers = Array.from({ length: Math.min(6, list.length) }, async () => {
while (cursor < list.length) {
const idx = cursor++;
const id = list[idx];
try {
await runWithRetry(() => onEach(id), 1);
ok++;
} catch (_) {
fail++;
failedIds.push(id);
}
}
});
await Promise.all(workers);
state.toast(`${summaryLabel}完成:成功 ${ok},失败 ${fail}${fail ? '(失败ID可重试)' : ''}`);
if (setFailedIds) setFailedIds(failedIds);
const failedShow = failedIds.slice(0, 8).join(',');
state.opSummary.value = `${summaryLabel}:成功 ${ok} / 失败 ${fail}${failedShow ? `|失败ID: ${failedShow}${failedIds.length>8?'...':''}` : ''}`;
try { await afterReload(); } finally { state.setTabBusy('tenants', false); }
return true;
};
const bulkSetKeyStatus = async (st) => {
const ids = (state.selectedKeyIds.value || []).slice();
if (!ids.length) return state.toast('请先选择至少 1 个 Key', 'error');
if (!state.activeTenant.value) return state.toast('请先填写 Tenant ID', 'error');
await runBulk({
ids,
confirmText: `批量操作确认:将 ${ids.length} 个 Key ${st===1?'启用':'停用'},是否继续?`,
onEach: (id) => api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadKeys,
summaryLabel: `Key 批量${st===1?'启用':'停用'}`,
setFailedIds: (arr) => { state.bulkFailedKeyIds.value = arr; }
});
};
const bulkSetUserStatus = async (st) => {
const ids = (state.selectedUserIds.value || []).slice();
if (!ids.length) return state.toast('请先选择至少 1 个用户', 'error');
await runBulk({
ids,
confirmText: `批量操作确认:将 ${ids.length} 个用户 ${st===1?'启用':'停用'},是否继续?`,
onEach: (id) => api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadUsers,
summaryLabel: `用户批量${st===1?'启用':'停用'}`,
setFailedIds: (arr) => { state.bulkFailedUserIds.value = arr; }
});
};
const bulkSetEnrollStatus = async (st) => {
const ids = (state.selectedEnrollIds.value || []).slice();
if (!ids.length) return state.toast('请先选择至少 1 个 enroll', 'error');
await runBulk({
ids,
confirmText: `批量操作确认:将 ${ids.length} 个 enroll 置为状态 ${st},是否继续?`,
onEach: (id) => api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadEnrolls,
summaryLabel: `enroll 批量状态变更`,
setFailedIds: (arr) => { state.bulkFailedEnrollIds.value = arr; }
});
};
const retryFailedKeyOps = async (st=0) => {
const ids = (state.bulkFailedKeyIds.value || []).slice();
if (!ids.length) return state.toast('没有可重试的 Key 失败项', 'error');
await runBulk({
ids,
confirmText: `重试确认:重试 ${ids.length} 个失败 Key 操作,是否继续?`,
onEach: (id) => api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadKeys,
summaryLabel: 'Key 失败项重试',
setFailedIds: (arr) => { state.bulkFailedKeyIds.value = arr; }
});
};
const retryFailedUserOps = async (st=0) => {
const ids = (state.bulkFailedUserIds.value || []).slice();
if (!ids.length) return state.toast('没有可重试的用户失败项', 'error');
await runBulk({
ids,
confirmText: `重试确认:重试 ${ids.length} 个失败用户操作,是否继续?`,
onEach: (id) => api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadUsers,
summaryLabel: '用户失败项重试',
setFailedIds: (arr) => { state.bulkFailedUserIds.value = arr; }
});
};
const retryFailedEnrollOps = async (st=0) => {
const ids = (state.bulkFailedEnrollIds.value || []).slice();
if (!ids.length) return state.toast('没有可重试的 enroll 失败项', 'error');
await runBulk({
ids,
confirmText: `重试确认:重试 ${ids.length} 个失败 enroll 操作,是否继续?`,
onEach: (id) => api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }),
afterReload: loadEnrolls,
summaryLabel: 'enroll 失败项重试',
setFailedIds: (arr) => { state.bulkFailedEnrollIds.value = arr; }
});
};
const clearFailedPool = (scope='all') => {
if (scope === 'all' || scope === 'key') state.bulkFailedKeyIds.value = [];
if (scope === 'all' || scope === 'user') state.bulkFailedUserIds.value = [];
if (scope === 'all' || scope === 'enroll') state.bulkFailedEnrollIds.value = [];
state.toast(scope==='all' ? '全部失败池已清空' : '失败池已清空');
};
const saveSettings = async () => {
if (!state.isAdmin.value) return;
state.beginTab('settings');
try {
for (const k of Object.keys(state.settings.value || {})) {
await api('/api/v1/admin/settings', { method:'POST', body: JSON.stringify({ key: k, value: String(state.settings.value[k]) }) });
}
state.toast('高级设置已保存'); state.opSummary.value = '高级设置已保存';
state.settings.value = await api('/api/v1/admin/settings');
state.endTabOk('settings');
} catch (e) { state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('settings'); }
};
const loadAudit = async () => {
if (!state.isAdmin.value) return;
state.beginTab('audit');
try {
const p = new URLSearchParams();
const f = state.auditFilters.value || {};
if (state.auditTenant.value) p.set('tenant', String(state.auditTenant.value));
if (f.action) p.set('action', String(f.action));
if (f.actor_type) p.set('actor_type', String(f.actor_type));
if (f.actor_id) p.set('actor_id', String(f.actor_id));
if (f.target_type) p.set('target_type', String(f.target_type));
if (f.target_id) p.set('target_id', String(f.target_id));
if (f.from) p.set('from', String(f.from));
if (f.to) p.set('to', String(f.to));
p.set('limit', String(Number(f.limit || 100)));
p.set('offset', String(Number(f.offset || 0)));
const d = await api('/api/v1/admin/audit?' + p.toString());
state.audit.value = d.logs || [];
state.endTabOk('audit', !state.audit.value.length);
state.toast('审计日志已刷新');
} catch (e) { state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('audit'); }
};
const exportRowsAsCsv = (rows, tag='audit') => {
const esc = (v) => '"' + String(v ?? '').replace(/"/g, '""') + '"';
const head = ['id','actor_type','actor_id','action','target_type','target_id','detail','ip','created_at'];
const lines = [head.map(esc).join(',')];
for (const a of (rows || [])) {
lines.push([
a.id, a.actor_type, a.actor_id, a.action, a.target_type, a.target_id, a.detail, a.ip, a.created_at
].map(esc).join(','));
}
const csv = '' + lines.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const t = new Date();
const pad = (n) => String(n).padStart(2, '0');
const fn = `${tag}-${t.getFullYear()}${pad(t.getMonth()+1)}${pad(t.getDate())}-${pad(t.getHours())}${pad(t.getMinutes())}${pad(t.getSeconds())}.csv`;
const a = document.createElement('a');
a.href = url;
a.download = fn;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
const exportAuditCsv = () => {
if (!state.isAdmin.value) return;
const rows = state.audit.value || [];
if (!rows.length) return state.toast('当前无审计数据可导出', 'error');
exportRowsAsCsv(rows, 'audit');
state.toast('审计 CSV 已导出');
state.opSummary.value = `已导出审计 CSV(${rows.length} 条)`;
};
const exportAuditFailuresCsv = () => {
if (!state.isAdmin.value) return;
const rows = (state.audit.value || []).filter(a => /denied|failed|error|kick|delete/i.test(String(a.action||'')));
if (!rows.length) return state.toast('当前无失败/高风险审计可导出', 'error');
exportRowsAsCsv(rows, 'audit-failures');
state.toast('失败事件 CSV 已导出');
state.opSummary.value = `已导出失败/高风险审计 CSV(${rows.length} 条)`;
};
const applyAuditPreset = async (preset) => {
const f = { action:'', actor_type:'', actor_id:'', target_type:'', target_id:'', from:'', to:'', limit:100, offset:0 };
state.auditTenant.value = '';
if (preset === 'login_failed') f.action = 'auth_login_failed';
else if (preset === 'denied') f.action = 'enroll_consume_denied';
else if (preset === 'connect_denied') f.action = 'connect_request_denied';
else if (preset === 'all') {
// keep default
}
state.auditPreset.value = preset || 'all';
localStorage.setItem('inp2p_audit_preset', state.auditPreset.value);
state.auditFilters.value = f;
await loadAudit();
};
const clearAuditFilters = async () => {
state.auditFilters.value = { action:'', actor_type:'', actor_id:'', target_type:'', target_id:'', from:'', to:'', limit:100, offset:0 };
state.auditTenant.value = '';
await loadAudit();
};
const updateCharts = () => {};
const actions = {
refreshAll, startTimer, stopTimer,
login, logout,
saveSDWAN, addSDWANNode, removeSDWANNode, addSubnetProxy, removeSubnetProxy, autoAssignIPs,
kickNode, renameNode, changeNodeIP, openNodeEdit, closeNodeEdit, submitNodeEdit,
openAppManager, pushAppConfigs, openConnect, doConnect,
createTenant, loadTenants, setTenantStatus, reassignTenantSubnet,
createKey, loadKeys, setKeyStatus,
createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser,
createEnroll, loadEnrolls, setEnrollStatus, consumeEnroll,
saveSettings, loadAudit, clearAuditFilters, applyAuditPreset, exportAuditCsv, exportAuditFailuresCsv,
toggleAllKeys, toggleAllUsers, toggleAllEnrolls,
bulkSetKeyStatus, bulkSetUserStatus, bulkSetEnrollStatus,
retryFailedKeyOps, retryFailedUserOps, retryFailedEnrollOps, clearFailedPool,
updateCharts
};
return {
actions,
refs: { appPushNode, appPushRaw },
setSwitchTab
};
};