feat: admin settings and audit UI

This commit is contained in:
2026-03-06 15:20:33 +08:00
parent 57b4dadd42
commit ba63085de9

View File

@@ -195,6 +195,44 @@
</div> </div>
</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 v-if="tab==='tenants'" class="space-y-4">
<div class="glass rounded-xl p-4 space-y-2"> <div class="glass rounded-xl p-4 space-y-2">
<div class="font-bold">创建租户</div> <div class="font-bold">创建租户</div>
@@ -297,7 +335,8 @@ createApp({
const tab = ref('dashboard'); const tab = ref('dashboard');
const tabs = [ const tabs = [
{id:'dashboard',name:'仪表盘'},{id:'nodes',name:'节点'},{id:'sdwan',name:'SDWAN'},{id:'p2p',name:'P2P'}, {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'); const loggedIn = ref(false), busy = ref(false), msg = ref(''), msgType = ref('ok');
@@ -315,8 +354,11 @@ createApp({
const userForm = ref({ role:'operator', email:'', password:'' }); const userForm = ref({ role:'operator', email:'', password:'' });
const tokenType = ref(''); 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 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 filteredNodes = computed(() => {
const k = (nodeKeyword.value || '').trim().toLowerCase(); const k = (nodeKeyword.value || '').trim().toLowerCase();
if (!k) return nodes.value; if (!k) return nodes.value;
@@ -392,6 +434,10 @@ createApp({
const nd = await api('/api/v1/nodes'); const nd = await api('/api/v1/nodes');
nodes.value = nd.nodes || []; nodes.value = nd.nodes || [];
sd.value = await api('/api/v1/sdwans'); 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) { } catch (e) {
toast(e.message || '刷新失败', 'error'); toast(e.message || '刷新失败', 'error');
} finally { } finally {
@@ -545,6 +591,27 @@ createApp({
try { const d = await api('/api/v1/tenants/enroll'); enrolls.value = d.enrolls || []; } try { const d = await api('/api/v1/tenants/enroll'); enrolls.value = d.enrolls || []; }
catch(e){ toast(e.message, 'error'); } 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 () => { const createEnroll = async () => {
try { try {
const d = await api('/api/v1/tenants/enroll', { method:'POST', body:'{}' }); const d = await api('/api/v1/tenants/enroll', { method:'POST', body:'{}' });
@@ -590,6 +657,7 @@ createApp({
loginUser, loginPass, loginErr, refreshSec, loginUser, loginPass, loginErr, refreshSec,
health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm, health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm,
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm, tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
settings, audit, auditTenant,
natText, uptime, fmtTime, natText, uptime, fmtTime,
login, logout, refreshAll, saveSDWAN, addSDWANNode, removeSDWANNode, addSubnetProxy, removeSubnetProxy, autoAssignIPs, login, logout, refreshAll, saveSDWAN, addSDWANNode, removeSDWANNode, addSubnetProxy, removeSubnetProxy, autoAssignIPs,
kickNode, renameNode, changeNodeIP, openAppManager, pushAppConfigs, openConnect, doConnect, kickNode, renameNode, changeNodeIP, openAppManager, pushAppConfigs, openConnect, doConnect,
@@ -597,6 +665,7 @@ createApp({
createKey, loadKeys, setKeyStatus, createKey, loadKeys, setKeyStatus,
createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser, createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser,
createEnroll, loadEnrolls, setEnrollStatus, consumeEnroll, createEnroll, loadEnrolls, setEnrollStatus, consumeEnroll,
saveSettings, loadAudit,
updateCharts updateCharts
}; };
} }