Files
inp2p/web/index.html

590 lines
31 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>INP2P Console</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
[v-cloak]{display:none!important}
body{background:#070a14;color:#e2e8f0;font-family:system-ui,sans-serif}
.glass{background:rgba(15,20,37,.7);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,.05)}
.ipt{width:100%;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.1);border-radius:.75rem;padding:.65rem .9rem;font-size:.82rem;outline:none;color:#93c5fd}
.ipt:focus{border-color:#3b82f6}
.btn{background:#3b82f6;color:#fff;font-weight:700;padding:.6rem 1rem;border-radius:.7rem;font-size:.78rem}
.btn:hover{background:#2563eb}
.btn:disabled{opacity:.55;cursor:not-allowed}
.btn2{background:rgba(255,255,255,.06);color:#cbd5e1;font-weight:700;padding:.6rem 1rem;border-radius:.7rem;font-size:.78rem;border:1px solid rgba(255,255,255,.1)}
.btn2:hover{background:rgba(255,255,255,.1);color:#fff}
.chip{font-size:.7rem;border-radius:999px;padding:.15rem .5rem;border:1px solid rgba(255,255,255,.1)}
.tab{padding:.7rem .8rem;border-radius:.65rem;font-size:.82rem;color:#94a3b8;font-weight:700;cursor:pointer}
.tab:hover{background:rgba(255,255,255,.05);color:#fff}
.tab.active{background:rgba(59,130,246,.14);border:1px solid rgba(59,130,246,.45);color:#fff}
.ok{color:#22c55e}.warn{color:#eab308}.err{color:#ef4444}
</style>
</head>
<body>
<div id="app" v-cloak class="min-h-screen">
<div v-if="!loggedIn" class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-md glass rounded-3xl p-8">
<h1 class="text-2xl font-black text-white mb-2">INP2P 控制台</h1>
<p class="text-slate-500 text-sm mb-6">登录后可管理节点、SDWAN、连接与租户</p>
<div class="space-y-3">
<input v-model="loginTenant" class="ipt" placeholder="Tenant ID用户登录" @keyup.enter="login">
<input v-model="loginUser" class="ipt" placeholder="用户名(如 admin" @keyup.enter="login">
<input v-model="loginPass" class="ipt" type="password" placeholder="密码" @keyup.enter="login">
<div class="text-xs text-slate-500 text-center">或使用主 Token 登录(管理员)</div>
<input v-model="loginToken" class="ipt" type="password" placeholder="Master Token" @keyup.enter="login">
<button class="btn w-full" :disabled="busy" @click="login">{{ busy ? '登录中...' : '登录' }}</button>
<div class="text-[11px] text-slate-500 text-center">Build: {{ buildVersion }}</div>
<div v-if="loginErr" class="text-red-400 text-sm">{{ loginErr }}</div>
</div>
</div>
</div>
<div v-else class="max-w-7xl mx-auto p-4 md:p-6">
<div class="glass rounded-2xl p-4 flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<div class="text-white font-black">INP2P Console</div>
<div class="text-xs text-slate-500">Role: {{ role || 'unknown' }} · Build: {{ buildVersion }}</div>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-slate-500">自动刷新(s)</label>
<input class="ipt w-20" type="number" min="5" max="300" v-model.number="refreshSec">
<button class="btn2" :disabled="busy" @click="refreshAll">刷新</button>
<button class="btn" @click="logout">登出</button>
</div>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<div v-for="t in filteredTabs" :key="t.id" class="tab" :class="{active: tab===t.id}" @click="tab=t.id">{{ t.name }}</div>
</div>
<div v-if="msg" class="mb-4 text-sm" :class="msgType==='error'?'err':'ok'">{{ msg }}</div>
<div v-if="tab==='dashboard'" class="space-y-4">
<div class="grid grid-cols-2 md:grid-cols-6 gap-3">
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">在线节点</div><div class="text-xl font-black">{{ stats.nodes || 0 }}</div></div>
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">中继</div><div class="text-xl font-black">{{ stats.relay || 0 }}</div></div>
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">Cone</div><div class="text-xl font-black">{{ stats.cone || 0 }}</div></div>
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">Symmetric</div><div class="text-xl font-black">{{ stats.symmetric || 0 }}</div></div>
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">Unknown</div><div class="text-xl font-black">{{ stats.unknown || 0 }}</div></div>
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">SDWAN</div><div class="text-xl font-black" :class="stats.sdwan ? 'ok':'warn'">{{ stats.sdwan ? 'ON':'OFF' }}</div></div>
</div>
<div class="glass rounded-xl p-4 text-sm text-slate-300">
<div>服务版本:{{ stats.version || '-' }}</div>
<div>健康状态:<span :class="health.status==='ok'?'ok':'err'">{{ health.status || '-' }}</span></div>
<div>健康上报节点:{{ health.nodes || 0 }}</div>
</div>
</div>
<div v-if="tab==='nodes'" class="glass rounded-2xl overflow-hidden">
<div class="p-3 border-b border-white/10 flex gap-2">
<input v-model="nodeKeyword" class="ipt" placeholder="筛选节点名 / IP">
</div>
<div class="overflow-auto">
<table class="w-full text-sm min-w-[900px]">
<thead class="text-slate-400"><tr>
<th class="p-3 text-left">节点</th><th class="p-3 text-left">唯一ID</th><th class="p-3 text-left">虚拟IP</th><th class="p-3 text-left">公网</th><th class="p-3 text-left">NAT</th><th class="p-3 text-left">租户</th><th class="p-3 text-left">版本</th><th class="p-3 text-left">在线时长</th><th class="p-3 text-left">动作</th>
</tr></thead>
<tbody>
<tr v-for="n in filteredNodes" :key="n.nodeUUID || n.name" class="border-t border-white/5">
<td class="p-3">
<div class="font-semibold">{{ n.alias || n.name }}</div>
<div class="text-xs text-slate-500">hostname: {{ n.name }}</div>
</td>
<td class="p-3 text-xs text-slate-400">{{ n.nodeUUID || '-' }}</td>
<td class="p-3">{{ n.virtualIP || '-' }}</td>
<td class="p-3">{{ n.publicIP }}:{{ n.publicPort }}</td>
<td class="p-3">{{ natText(n.natType) }}</td>
<td class="p-3">{{ n.tenantId || 0 }}</td>
<td class="p-3">{{ n.version || '-' }}</td>
<td class="p-3">{{ uptime(n.loginTime) }}</td>
<td class="p-3">
<div class="flex gap-2 flex-wrap">
<button class="btn2" @click="renameNode(n)">改昵称</button>
<button class="btn2" @click="changeNodeIP(n)">改IP</button>
<button class="btn2" @click="openConnect(n.name)">发起连接</button>
<button class="btn2" @click="openAppManager(n.name)">推配置</button>
<button class="btn2" @click="kickNode(n.name)">踢下线</button>
</div>
</td>
</tr>
<tr v-if="!filteredNodes.length"><td class="p-6 text-center text-slate-500" colspan="9">暂无节点</td></tr>
</tbody>
</table>
</div>
</div>
<div v-if="tab==='sdwan'" class="space-y-4">
<div class="glass rounded-xl p-4 space-y-3">
<div class="flex flex-wrap items-center gap-3">
<label class="text-sm"><input type="checkbox" v-model="sd.enabled"> 启用 SDWAN</label>
<input class="ipt max-w-xs" v-model="sd.name" placeholder="名称">
<input class="ipt max-w-xs" v-model="sd.gatewayCIDR" placeholder="网段,如 10.10.0.0/24">
<select class="ipt max-w-[140px]" v-model="sd.mode"><option value="mesh">mesh</option><option value="hub">hub</option></select>
<input class="ipt max-w-[120px]" type="number" min="1200" max="9000" v-model.number="sd.mtu" placeholder="MTU">
</div>
<div class="flex gap-2">
<button class="btn2" @click="autoAssignIPs">自动分配 IP</button>
<button class="btn" :disabled="busy" @click="saveSDWAN">保存 SDWAN</button>
</div>
</div>
<div class="glass rounded-xl p-4">
<div class="font-bold mb-3">节点映射</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>
</select>
<input class="ipt md:col-span-2" v-model="n.ip" placeholder="10.10.0.X">
<button class="btn2" @click="removeSDWANNode(i)">删除</button>
</div>
</div>
<button class="btn2 mt-3" @click="addSDWANNode">+ 添加节点</button>
</div>
</div>
<div v-if="tab==='p2p'" class="space-y-4">
<div class="glass rounded-xl p-4 space-y-3">
<div class="font-bold">手动触发连接</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-2">
<select class="ipt" v-model="connectForm.from"><option value="">From</option><option v-for="n in nodes" :key="'f'+n.name" :value="n.name">{{ n.name }}</option></select>
<select class="ipt" v-model="connectForm.to"><option value="">To</option><option v-for="n in nodes" :key="'t'+n.name" :value="n.name">{{ n.name }}</option></select>
<input class="ipt" type="number" v-model.number="connectForm.srcPort" placeholder="srcPort">
<input class="ipt" type="number" v-model.number="connectForm.dstPort" placeholder="dstPort">
</div>
<input class="ipt" v-model="connectForm.appName" placeholder="appName可空">
<button class="btn" :disabled="busy" @click="doConnect">发送连接请求</button>
</div>
<div class="glass rounded-xl p-4 space-y-3">
<div class="font-bold">远程推配置(/api/v1/nodes/apps</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<select class="ipt" v-model="appPushNode">
<option value="">选择目标节点</option>
<option v-for="n in nodes" :key="'p'+n.name" :value="n.name">{{ n.name }}</option>
</select>
<div class="md:col-span-2 text-xs text-slate-400">示例:[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]</div>
</div>
<textarea class="ipt" style="min-height:130px" v-model="appPushRaw"></textarea>
<button class="btn2" :disabled="busy" @click="pushAppConfigs">发送配置</button>
</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>
<div class="grid grid-cols-1 md:grid-cols-4 gap-2">
<input class="ipt" v-model="tenantForm.name" placeholder="租户名">
<input class="ipt" v-model="tenantForm.admin_password" placeholder="admin 密码">
<input class="ipt" v-model="tenantForm.operator_password" placeholder="operator 密码">
<button class="btn" :disabled="busy" @click="createTenant">创建</button>
</div>
</div>
<div class="glass rounded-xl p-4 overflow-auto">
<table class="w-full text-sm min-w-[700px]">
<thead class="text-slate-400"><tr><th class="p-2 text-left">ID</th><th class="p-2 text-left">名称</th><th class="p-2 text-left">子网</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">动作</th></tr></thead>
<tbody>
<tr v-for="t in tenants" :key="t.id" class="border-t border-white/5">
<td class="p-2">{{ t.id }}</td><td class="p-2">{{ t.name }}</td><td class="p-2">{{ t.subnet || '-' }}</td><td class="p-2">{{ t.status===1?'启用':'停用' }}</td>
<td class="p-2 flex gap-2">
<button class="btn2" @click="setTenantStatus(t.id, t.status===1?0:1)">{{ t.status===1?'停用':'启用' }}</button>
<button class="btn2" @click="activeTenant=t.id;tab='apikeys';loadKeys()">Key</button>
<button class="btn2" @click="activeTenant=t.id;tab='users';loadUsers()">用户</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="tab==='apikeys'" class="space-y-4">
<div class="glass rounded-xl p-4 flex flex-wrap gap-2 items-center">
<input class="ipt max-w-[120px]" type="number" v-model.number="activeTenant" placeholder="Tenant ID">
<input class="ipt max-w-[120px]" type="number" v-model.number="keyForm.ttl" placeholder="TTL(s)">
<input class="ipt max-w-[140px]" v-model="keyForm.scope" placeholder="scope(all)">
<button class="btn" @click="createKey">创建 API Key</button>
<button class="btn2" @click="loadKeys">刷新 Key</button>
</div>
<div class="glass rounded-xl p-4 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">Scope</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">过期</th><th class="p-2 text-left">动作</th></tr></thead>
<tbody>
<tr v-for="k in keys" :key="k.id" class="border-t border-white/5">
<td class="p-2">{{ k.id }}</td><td class="p-2">{{ k.scope }}</td><td class="p-2">{{ k.status===1?'启用':'停用' }}</td><td class="p-2">{{ fmtTime(k.expires_at) }}</td>
<td class="p-2"><button class="btn2" @click="setKeyStatus(k.id, k.status===1?0:1)">{{ k.status===1?'停用':'启用' }}</button></td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="tab==='users'" class="space-y-4">
<div class="glass rounded-xl p-4 grid grid-cols-1 md:grid-cols-6 gap-2">
<input class="ipt" type="number" v-model.number="activeTenant" placeholder="Tenant ID">
<select class="ipt" v-model="userForm.role"><option value="admin">admin</option><option value="operator">operator</option></select>
<input class="ipt" v-model="userForm.email" placeholder="email/username">
<input class="ipt" v-model="userForm.password" placeholder="password">
<button class="btn" @click="createUser">创建用户</button>
<button class="btn2" @click="loadUsers">刷新用户</button>
</div>
<div class="glass rounded-xl p-4 overflow-auto">
<table class="w-full text-sm min-w-[1000px]">
<thead class="text-slate-400"><tr><th class="p-2 text-left">ID</th><th class="p-2 text-left">Role</th><th class="p-2 text-left">Email</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">动作</th></tr></thead>
<tbody>
<tr v-for="u in users" :key="u.id" class="border-t border-white/5">
<td class="p-2">{{ u.id }}</td><td class="p-2">{{ u.role }}</td><td class="p-2">{{ u.email }}</td><td class="p-2">{{ u.status===1?'启用':'停用' }}</td>
<td class="p-2 flex gap-2">
<button class="btn2" @click="setUserStatus(u.id, u.status===1?0:1)">{{ u.status===1?'停用':'启用' }}</button>
<button class="btn2" @click="resetUserPassword(u.id)">重置密码</button>
<button class="btn2" @click="deleteUser(u.id)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="tab==='enroll'" class="space-y-4">
<div class="glass rounded-xl p-4 flex gap-2">
<button class="btn" @click="createEnroll">生成 enroll_code</button>
<button class="btn2" @click="loadEnrolls">刷新 enroll</button>
</div>
<div class="glass rounded-xl p-4 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">Code</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">过期</th><th class="p-2 text-left">动作</th></tr></thead>
<tbody>
<tr v-for="e in enrolls" :key="e.id" class="border-t border-white/5">
<td class="p-2">{{ e.id }}</td><td class="p-2">{{ e.code || '-' }}</td><td class="p-2">{{ e.status===1?'可用':'停用' }}</td><td class="p-2">{{ fmtTime(e.expires_at) }}</td>
<td class="p-2"><button class="btn2" @click="setEnrollStatus(e.id,0)">作废</button></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted, watch } = Vue;
createApp({
setup(){
const buildVersion = ref('20260303-1838');
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'}
];
const loggedIn = ref(false), busy = ref(false), msg = ref(''), msgType = ref('ok');
const role = ref(''), status = ref(1);
const loginTenant = ref('1'), loginUser = ref('admin'), loginPass = ref('admin'), loginToken = ref(''), loginErr = ref('');
const refreshSec = ref(15), timer = ref(null);
const health = ref({}), stats = ref({}), nodes = ref([]), nodeKeyword = ref('');
const sd = ref({ enabled:false, name:'sdwan-main', gatewayCIDR:'10.10.0.0/24', mode:'mesh', mtu:1420, nodes:[], routes:['10.10.0.0/24'] });
const connectForm = ref({ from:'', to:'', srcPort:80, dstPort:80, appName:'manual-connect' });
const tenants = ref([]), activeTenant = ref(1), keys = ref([]), users = ref([]), enrolls = ref([]);
const tenantForm = ref({ name:'', admin_password:'', operator_password:'' });
const keyForm = ref({ scope:'all', ttl:0 });
const userForm = ref({ role:'operator', email:'', password:'' });
const tokenType = ref('');
const isAdmin = computed(() => role.value === 'admin' && tokenType.value !== 'session');
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll'].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 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') || '') });
const api = async (path, opt={}) => {
const headers = { 'Content-Type': 'application/json', ...(opt.headers||{}), ...bearer() };
const r = await fetch(path, { ...opt, headers });
let d = {};
try { d = await r.json(); } catch(_) {}
if (!r.ok) {
if (r.status === 401) {
loggedIn.value = false;
throw new Error('401 登录已过期');
}
throw new Error(d.message || ('HTTP ' + r.status));
}
return d;
};
const natText = t => t===1?'Cone':(t===2?'Symmetric':'Unknown');
const uptime = ts => {
if(!ts) return '-';
const sec = Math.max(0, Math.floor((Date.now() - new Date(ts).getTime()) / 1000));
if(sec < 60) return sec + 's';
if(sec < 3600) return Math.floor(sec/60) + 'm';
if(sec < 86400) return Math.floor(sec/3600) + 'h';
return Math.floor(sec/86400) + 'd';
};
const fmtTime = t => t ? new Date(t).toLocaleString() : '-';
const login = async () => {
loginErr.value = '';
busy.value = true;
try {
let d;
if ((loginToken.value || '').trim()) {
d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ token: loginToken.value.trim() }) }).then(r=>r.json());
if (d.error) throw new Error(d.message || 'token 登录失败');
localStorage.setItem('master_t', d.token || '');
} else {
d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tenant: Number(loginTenant.value || 1), username: loginUser.value, password: loginPass.value }) }).then(r=>r.json());
if (d.error) throw new Error(d.message || '用户名密码登录失败');
}
localStorage.setItem('t', d.token || '');
role.value = d.role || '';
status.value = d.status ?? 1;
tokenType.value = d.token_type || (localStorage.getItem('t') === localStorage.getItem('master_t') ? 'master' : 'apikey');
if (status.value !== 1) throw new Error('账号已停用');
loggedIn.value = true;
await refreshAll();
toast('登录成功');
} catch (e) {
loginErr.value = e.message || '登录失败';
} finally {
busy.value = false;
}
};
const logout = () => {
localStorage.removeItem('t');
localStorage.removeItem('master_t');
loggedIn.value = false;
role.value = '';
tokenType.value = '';
stopTimer();
};
const refreshAll = async () => {
if (!loggedIn.value) return;
busy.value = true;
try {
[health.value, stats.value] = await Promise.all([api('/api/v1/health'), api('/api/v1/stats')]);
const nd = await api('/api/v1/nodes');
nodes.value = nd.nodes || [];
sd.value = await api('/api/v1/sdwans');
} catch (e) {
toast(e.message || '刷新失败', 'error');
} finally {
busy.value = false;
}
};
const saveSDWAN = async () => {
try {
await api('/api/v1/sdwan/edit', { method:'POST', body: JSON.stringify(sd.value) });
toast('SDWAN 保存成功');
await refreshAll();
} catch (e) { toast(e.message, 'error'); }
};
const addSDWANNode = () => sd.value.nodes = [...(sd.value.nodes || []), { node:'', ip:'' }];
const removeSDWANNode = i => sd.value.nodes.splice(i, 1);
const autoAssignIPs = () => {
const used = new Set();
(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 sd.value.nodes || []) {
if (!n.ip) {
while (used.has(k) && k < 254) k++;
n.ip = `10.10.0.${k}`;
used.add(k);
k++;
}
}
toast('已自动分配缺失 IP');
};
const kickNode = async (node) => {
if (!confirm(`确认踢下线节点 ${node} ?`)) return;
try { await api('/api/v1/nodes/kick', { method:'POST', body: JSON.stringify({ node }) }); toast('已发送踢下线'); refreshAll(); }
catch(e){ toast(e.message, 'error'); }
};
const renameNode = async (node) => {
if (!node.nodeUUID) return toast('该节点尚无UUID稍后重连后再试', 'error');
const nextAlias = prompt('输入新昵称(留空则清除)', node.alias || '');
if (nextAlias === null) return;
try {
await api('/api/v1/nodes/alias', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, alias: nextAlias }) });
toast('昵称已更新');
refreshAll();
} catch(e){ toast(e.message, 'error'); }
};
const changeNodeIP = async (node) => {
if (!node.nodeUUID) return toast('该节点尚无UUID稍后重连后再试', 'error');
const nextIP = prompt('输入新的虚拟IP必须在本网络CIDR内', node.virtualIP || '');
if (!nextIP) return;
try {
await api('/api/v1/nodes/ip', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, virtual_ip: nextIP }) });
toast('IP已更新节点将按新IP重连');
refreshAll();
} catch(e){ toast(e.message, 'error'); }
};
const appPushNode = ref('');
const appPushRaw = ref('[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]');
const openAppManager = (node) => { appPushNode.value = node; toast(`已选中 ${node},请在控制台执行推配置`); tab.value = 'p2p'; };
const pushAppConfigs = async () => {
if (!appPushNode.value) return toast('请先选择节点', 'error');
let apps;
try { apps = JSON.parse(appPushRaw.value); } catch(_) { return toast('配置 JSON 格式错误', 'error'); }
try { await api('/api/v1/nodes/apps', { method:'POST', body: JSON.stringify({ node: appPushNode.value, apps }) }); toast('配置已推送'); }
catch(e){ toast(e.message, 'error'); }
};
const openConnect = (from) => { connectForm.value.from = from; tab.value = 'p2p'; };
const doConnect = async () => {
const req = { ...connectForm.value };
if (!req.from || !req.to) return toast('请选择 from/to 节点', 'error');
try {
await api('/api/v1/connect', { method:'POST', body: JSON.stringify(req) });
toast('连接请求已发送');
} catch(e){ toast(e.message, 'error'); }
};
const loadTenants = async () => {
if (!isAdmin.value) { tenants.value = []; return; }
try { const d = await api('/api/v1/admin/tenants'); tenants.value = d.tenants || []; }
catch(e){ toast(e.message, 'error'); }
};
const createTenant = async () => {
if (!tenantForm.value.name) return toast('请输入租户名', 'error');
try {
await api('/api/v1/admin/tenants', { method:'POST', body: JSON.stringify(tenantForm.value) });
tenantForm.value = { name:'', admin_password:'', operator_password:'' };
toast('租户创建成功');
await loadTenants();
} catch(e){ toast(e.message, 'error'); }
};
const setTenantStatus = async (id, st) => {
try { await api(`/api/v1/admin/tenants/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('租户状态已更新'); loadTenants(); }
catch(e){ toast(e.message, 'error'); }
};
const loadKeys = async () => {
if (!activeTenant.value) return;
try { const d = await api(`/api/v1/admin/tenants/${activeTenant.value}/keys`); keys.value = d.keys || []; }
catch(e){ toast(e.message, 'error'); }
};
const createKey = async () => {
if (!activeTenant.value) return toast('请先填写 Tenant ID', 'error');
try {
const d = await api(`/api/v1/admin/tenants/${activeTenant.value}/keys`, { method:'POST', body: JSON.stringify(keyForm.value) });
toast(`API Key 创建成功: ${d.api_key || ''}`);
await loadKeys();
} catch(e){ toast(e.message, 'error'); }
};
const setKeyStatus = async (id, st) => {
try { await api(`/api/v1/admin/tenants/${activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('Key 状态已更新'); loadKeys(); }
catch(e){ toast(e.message, 'error'); }
};
const loadUsers = async () => {
if (!activeTenant.value) return;
try { const d = await api(`/api/v1/admin/users?tenant=${activeTenant.value}`); users.value = d.users || []; }
catch(e){ toast(e.message, 'error'); }
};
const createUser = async () => {
if (!activeTenant.value || !userForm.value.email || !userForm.value.password) return toast('请补全用户信息', 'error');
try {
await api('/api/v1/admin/users', { method:'POST', body: JSON.stringify({ tenant: Number(activeTenant.value), ...userForm.value }) });
userForm.value = { role:'operator', email:'', password:'' };
toast('用户创建成功');
loadUsers();
} catch(e){ toast(e.message, 'error'); }
};
const setUserStatus = async (id, st) => {
try { await api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('用户状态已更新'); loadUsers(); }
catch(e){ toast(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 }) }); toast('密码已重置'); }
catch(e){ toast(e.message, 'error'); }
};
const deleteUser = async (id) => {
if (!confirm('确认删除该用户?')) return;
try { await api(`/api/v1/admin/users/${id}/delete`, { method:'POST', body:'{}' }); toast('用户已删除'); loadUsers(); }
catch(e){ toast(e.message, 'error'); }
};
const loadEnrolls = async () => {
try { const d = await api('/api/v1/tenants/enroll'); enrolls.value = d.enrolls || []; }
catch(e){ toast(e.message, 'error'); }
};
const createEnroll = async () => {
try {
const d = await api('/api/v1/tenants/enroll', { method:'POST', body:'{}' });
toast(`enroll_code: ${d.enroll_code || ''}`);
loadEnrolls();
} catch(e){ toast(e.message, 'error'); }
};
const setEnrollStatus = async (id, st) => {
try { await api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('enroll 状态已更新'); loadEnrolls(); }
catch(e){ toast(e.message, 'error'); }
};
const consumeEnroll = async () => {
toast('consumeEnroll 为客户端配网流程,控制台当前不直接调用');
};
const updateCharts = () => {};
const stopTimer = () => {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
};
const startTimer = () => {
stopTimer();
if (!loggedIn.value) return;
const sec = Math.max(5, Number(refreshSec.value || 15));
timer.value = setInterval(refreshAll, sec * 1000);
};
watch(refreshSec, startTimer);
watch(loggedIn, (v) => {
if (v) startTimer();
else stopTimer();
});
onMounted(() => {
logout();
});
return {
buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status, tokenType,
loginTenant, loginUser, loginPass, loginToken, loginErr, refreshSec,
health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm,
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
natText, uptime, fmtTime,
login, logout, refreshAll, saveSDWAN, addSDWANNode, removeSDWANNode, autoAssignIPs,
kickNode, renameNode, changeNodeIP, openAppManager, pushAppConfigs, openConnect, doConnect,
createTenant, loadTenants, setTenantStatus,
createKey, loadKeys, setKeyStatus,
createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser,
createEnroll, loadEnrolls, setEnrollStatus, consumeEnroll,
updateCharts
};
}
}).mount('#app');
</script>
</body>
</html>