Compare commits

...

5 Commits

View File

@@ -136,12 +136,16 @@
</div>
<div class="glass rounded-xl p-4">
<div class="font-bold mb-3">节点映射</div>
<div class="flex items-center justify-between mb-3">
<div class="font-bold">节点映射</div>
<button class="btn2" :disabled="busy" @click="saveSDWAN">保存节点映射</button>
</div>
<div class="space-y-2">
<div v-for="(n,i) in sd.nodes" :key="i" class="grid grid-cols-1 md:grid-cols-5 gap-2">
<select class="ipt" v-model="n.node">
<option value="">选择节点</option>
<option v-for="x in nodes" :key="x.name" :value="x.name">{{ x.name }}</option>
<option v-for="x in nodes" :key="x.name" :value="x.name">{{ (x.alias || x.name) }}(在线)</option>
<option v-for="x in (sd.nodes||[])" :key="'off'+x.node" v-if="x.node && !isOnline(x.node)" :value="x.node">{{ x.node }}(离线)</option>
</select>
<input class="ipt md:col-span-2" v-model="n.ip" placeholder="10.10.0.X">
<button class="btn2" @click="removeSDWANNode(i)">删除</button>
@@ -151,7 +155,10 @@
</div>
<div class="glass rounded-xl p-4">
<div class="font-bold mb-3">子网代理Subnet Proxy</div>
<div class="flex items-center justify-between mb-3">
<div class="font-bold">子网代理Subnet Proxy</div>
<button class="btn2" :disabled="busy" @click="saveSDWAN">保存子网代理</button>
</div>
<div class="text-xs text-slate-400 mb-2">示例local 192.168.0.0/24 → virtual 10.0.100.0/24掩码需一致</div>
<div class="space-y-2">
<div v-for="(s,i) in sd.subnetProxies" :key="i" class="grid grid-cols-1 md:grid-cols-6 gap-2">
@@ -195,6 +202,44 @@
</div>
</div>
<div v-if="tab==='settings'" class="glass rounded-2xl p-4 space-y-4">
<div class="font-bold">高级设置(越权能力)</div>
<div class="text-xs text-slate-400">仅系统管理员可见。开启会记录审计日志。</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" v-model="settings.advanced_impersonate" true-value="1" false-value="0"> 代理租户Impersonate</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" v-model="settings.advanced_force_network" true-value="1" false-value="0"> 强制干预租户网络</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" v-model="settings.advanced_cross_tenant" true-value="1" false-value="0"> 跨租户互通策略</label>
</div>
<button class="btn" :disabled="busy" @click="saveSettings">保存高级设置</button>
</div>
<div v-if="tab==='audit'" class="glass rounded-2xl p-4 space-y-4">
<div class="font-bold">审计日志</div>
<div class="flex flex-wrap items-center gap-2">
<input class="ipt max-w-[180px]" v-model="auditTenant" placeholder="tenant id (可空)">
<button class="btn2" :disabled="busy" @click="loadAudit">刷新</button>
</div>
<div class="overflow-auto">
<table class="w-full text-sm min-w-[900px]">
<thead class="text-slate-400"><tr>
<th class="p-2 text-left">ID</th><th class="p-2 text-left">Actor</th><th class="p-2 text-left">Action</th><th class="p-2 text-left">Target</th><th class="p-2 text-left">Detail</th><th class="p-2 text-left">IP</th><th class="p-2 text-left">Time</th>
</tr></thead>
<tbody>
<tr v-for="a in audit" :key="a.id" class="border-t border-white/5">
<td class="p-2">{{ a.id }}</td>
<td class="p-2">{{ a.actor_type }}:{{ a.actor_id }}</td>
<td class="p-2">{{ a.action }}</td>
<td class="p-2">{{ a.target_type }}:{{ a.target_id }}</td>
<td class="p-2 text-xs text-slate-400">{{ a.detail }}</td>
<td class="p-2">{{ a.ip }}</td>
<td class="p-2">{{ fmtTime(a.created_at*1000) }}</td>
</tr>
<tr v-if="!audit.length"><td class="p-4 text-center text-slate-500" colspan="7">暂无审计记录</td></tr>
</tbody>
</table>
</div>
</div>
<div v-if="tab==='tenants'" class="space-y-4">
<div class="glass rounded-xl p-4 space-y-2">
<div class="font-bold">创建租户</div>
@@ -297,7 +342,8 @@ createApp({
const tab = ref('dashboard');
const tabs = [
{id:'dashboard',name:'仪表盘'},{id:'nodes',name:'节点'},{id:'sdwan',name:'SDWAN'},{id:'p2p',name:'P2P'},
{id:'tenants',name:'租户'},{id:'apikeys',name:'API Key'},{id:'users',name:'用户'},{id:'enroll',name:'Enroll'}
{id:'tenants',name:'租户'},{id:'apikeys',name:'API Key'},{id:'users',name:'用户'},{id:'enroll',name:'Enroll'},
{id:'settings',name:'高级设置'},{id:'audit',name:'审计日志'}
];
const loggedIn = ref(false), busy = ref(false), msg = ref(''), msgType = ref('ok');
@@ -315,13 +361,19 @@ createApp({
const userForm = ref({ role:'operator', email:'', password:'' });
const tokenType = ref('');
const settings = ref({ advanced_impersonate:'0', advanced_force_network:'0', advanced_cross_tenant:'0' });
const audit = ref([]);
const auditTenant = ref('');
const isAdmin = computed(() => role.value === 'admin');
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll'].includes(t.id)));
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll','settings','audit'].includes(t.id)));
const filteredNodes = computed(() => {
const k = (nodeKeyword.value || '').trim().toLowerCase();
if (!k) return nodes.value;
return nodes.value.filter(n => (n.name||'').toLowerCase().includes(k) || (n.publicIP||'').toLowerCase().includes(k));
});
const onlineMap = computed(() => new Set((nodes.value || []).map(n => n.name)));
const nodeStatus = (name) => (name && onlineMap.value.has(name)) ? '在线' : '离线';
const isOnline = (name) => (name && onlineMap.value.has(name));
const toast = (text, t='ok') => { msg.value = text; msgType.value = t; setTimeout(() => { if (msg.value === text) msg.value = ''; }, 2500); };
const bearer = () => ({ Authorization: 'Bearer ' + (localStorage.getItem('t') || '') });
@@ -392,6 +444,10 @@ createApp({
const nd = await api('/api/v1/nodes');
nodes.value = nd.nodes || [];
sd.value = await api('/api/v1/sdwans');
if (isAdmin.value) {
try { settings.value = await api('/api/v1/admin/settings'); } catch(_) {}
try { const a = await api('/api/v1/admin/audit?limit=50'); audit.value = a.logs || []; } catch(_) {}
}
} catch (e) {
toast(e.message || '刷新失败', 'error');
} finally {
@@ -545,6 +601,27 @@ createApp({
try { const d = await api('/api/v1/tenants/enroll'); enrolls.value = d.enrolls || []; }
catch(e){ toast(e.message, 'error'); }
};
const saveSettings = async () => {
if (!isAdmin.value) return;
try {
for (const k of Object.keys(settings.value || {})) {
await api('/api/v1/admin/settings', { method:'POST', body: JSON.stringify({ key: k, value: String(settings.value[k]) }) });
}
toast('高级设置已保存');
settings.value = await api('/api/v1/admin/settings');
} catch (e) { toast(e.message, 'error'); }
};
const loadAudit = async () => {
if (!isAdmin.value) return;
try {
const q = auditTenant.value ? `?tenant=${auditTenant.value}&limit=100` : '?limit=100';
const d = await api('/api/v1/admin/audit' + q);
audit.value = d.logs || [];
toast('审计日志已刷新');
} catch (e) { toast(e.message, 'error'); }
};
const createEnroll = async () => {
try {
const d = await api('/api/v1/tenants/enroll', { method:'POST', body:'{}' });
@@ -581,8 +658,12 @@ createApp({
else stopTimer();
});
// keep session token in localStorage; do not force logout on load
onMounted(() => {
logout();
if (localStorage.getItem('t')) {
loggedIn.value = true;
refreshAll();
}
});
return {
@@ -590,6 +671,8 @@ createApp({
loginUser, loginPass, loginErr, refreshSec,
health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm,
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
settings, audit, auditTenant,
onlineMap, nodeStatus, isOnline,
natText, uptime, fmtTime,
login, logout, refreshAll, saveSDWAN, addSDWANNode, removeSDWANNode, addSubnetProxy, removeSubnetProxy, autoAssignIPs,
kickNode, renameNode, changeNodeIP, openAppManager, pushAppConfigs, openConnect, doConnect,
@@ -597,6 +680,7 @@ createApp({
createKey, loadKeys, setKeyStatus,
createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser,
createEnroll, loadEnrolls, setEnrollStatus, consumeEnroll,
saveSettings, loadAudit,
updateCharts
};
}