feat: optimize web console - add error handling, loading states, settings page
This commit is contained in:
445
web/index.html
Normal file
445
web/index.html
Normal file
@@ -0,0 +1,445 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>INP2P Control Plane</title>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
[v-cloak] { display: none !important; }
|
||||
body { background: #070a14; color: #e2e8f0; font-family: system-ui, sans-serif; overflow: hidden; }
|
||||
.glass-card { background: rgba(15, 20, 37, 0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.05); }
|
||||
.active-tab { background: rgba(59, 130, 246, 0.1); border-left: 3px solid #3b82f6; color: white; }
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 4px; }
|
||||
.animate-in { animation: fadeIn 0.3s ease-out; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<div id="app" v-cloak class="flex h-screen w-full relative">
|
||||
|
||||
<!-- Login -->
|
||||
<div v-if="!isLoggedIn" class="fixed inset-0 z-[200] flex items-center justify-center bg-[#070a14] px-6">
|
||||
<div class="w-full max-w-sm glass-card rounded-3xl p-8 shadow-2xl">
|
||||
<div class="mb-8 text-center">
|
||||
<div class="w-12 h-12 bg-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-black text-white italic">INP2P</h1>
|
||||
<p class="text-slate-500 text-sm mt-1">输入 Master Token</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<input v-model="loginToken" type="password" placeholder="Token" class="w-full bg-black/40 border border-white/10 rounded-2xl px-5 py-4 text-center font-mono text-blue-400 focus:border-blue-500 outline-none" @keyup.enter="login">
|
||||
<button @click="login" :disabled="loading" class="w-full bg-blue-600 hover:bg-blue-500 text-white font-black py-4 rounded-2xl flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
<svg v-if="loading" class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
|
||||
<span>{{ loading ? '验证中...' : '登 录' }}</span>
|
||||
</button>
|
||||
<div v-if="loginError" class="text-red-400 text-xs text-center">{{ loginError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside v-if="isLoggedIn" class="hidden lg:flex w-64 bg-[#0a0d1a] border-r border-white/5 flex-col flex-shrink-0">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-10">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center"><svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg></div>
|
||||
<span class="text-xl font-black text-white italic tracking-tighter">INP2P</span>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
<button @click="activeTab = 'dashboard'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'dashboard' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">仪表盘</button>
|
||||
<button @click="activeTab = 'nodes'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'nodes' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">节点资产</button>
|
||||
<button @click="activeTab = 'sdwan'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'sdwan' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">SDWAN</button>
|
||||
<button @click="activeTab = 'settings'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'settings' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">设置</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="mt-auto p-6 border-t border-white/5">
|
||||
<div class="text-[10px] text-slate-600 font-bold mb-4">v{{stats.version || '0.1.0'}}</div>
|
||||
<button @click="logout" class="text-[10px] text-slate-500 hover:text-red-400 uppercase font-black">安全登出</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<main v-if="isLoggedIn" class="flex-1 flex flex-col overflow-hidden">
|
||||
<header class="h-16 border-b border-white/5 flex items-center justify-between px-8 bg-[#0a0d1a]/50">
|
||||
<h2 class="text-lg font-black text-white italic uppercase">{{ tabNames[activeTab] }}</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs text-slate-500">{{ refreshInterval }}s</span>
|
||||
<button @click="refreshAll" :disabled="loading" class="p-2 hover:bg-white/5 rounded-lg" :class="{'animate-spin': loading}">
|
||||
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8 custom-scrollbar space-y-8">
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div v-if="activeTab === 'dashboard'" class="space-y-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div class="glass-card rounded-2xl p-6 text-center">
|
||||
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">在线节点</div>
|
||||
<div class="text-3xl font-black text-white">{{ stats.nodes || 0 }}</div>
|
||||
</div>
|
||||
<div class="glass-card rounded-2xl p-6 text-center">
|
||||
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">中继</div>
|
||||
<div class="text-3xl font-black text-blue-400">{{ relayCount }}</div>
|
||||
</div>
|
||||
<div class="glass-card rounded-2xl p-6 text-center">
|
||||
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">打洞率</div>
|
||||
<div class="text-3xl font-black" :class="punchRate >= 80 ? 'text-green-400' : punchRate >= 50 ? 'text-yellow-400' : 'text-red-400'">{{ punchRate }}%</div>
|
||||
</div>
|
||||
<div class="glass-card rounded-2xl p-6 text-center">
|
||||
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">SDWAN</div>
|
||||
<div class="text-3xl font-black" :class="sdwan.enabled ? 'text-green-400' : 'text-slate-500'">{{ sdwan.enabled ? 'ON' : 'OFF' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div class="lg:col-span-1 glass-card rounded-2xl p-6 h-[320px]">
|
||||
<h3 class="text-xs font-black text-slate-500 mb-6 uppercase">NAT 分布</h3>
|
||||
<div id="natChart" class="w-full h-full pb-10"></div>
|
||||
</div>
|
||||
<div class="lg:col-span-2 glass-card rounded-2xl p-6 flex flex-col h-[320px]">
|
||||
<h3 class="text-xs font-black text-slate-500 mb-6 uppercase">信令日志</h3>
|
||||
<div class="flex-1 bg-black/40 rounded-xl p-4 font-mono text-[10px] overflow-y-auto custom-scrollbar">
|
||||
<div v-for="(log, i) in activityLogs" :key="i" class="mb-1">
|
||||
<span class="text-slate-600">[{{ log.time }}]</span>
|
||||
<span :class="log.type === 'error' ? 'text-red-400' : log.type === 'success' ? 'text-green-400' : 'text-white'">{{ log.msg }}</span>
|
||||
</div>
|
||||
<div v-if="activityLogs.length === 0" class="text-slate-700 italic">等待数据...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes -->
|
||||
<div v-if="activeTab === 'nodes'" class="space-y-6">
|
||||
<div class="glass-card p-4 rounded-2xl flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
<input v-model="nodeFilter" placeholder="搜索节点..." class="bg-transparent border-none text-sm w-full outline-none">
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div v-for="n in filteredNodes" :key="n.name" class="glass-card rounded-3xl p-6 hover:border-blue-500/30 transition-all">
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h4 class="font-bold text-white">{{ n.name }}</h4>
|
||||
<div class="text-[10px] text-slate-500 font-mono">{{ n.publicIP }}:{{ n.publicPort }}</div>
|
||||
</div>
|
||||
<div :class="['px-2 py-0.5 rounded text-[8px] font-black uppercase', n.natType === 1 ? 'bg-green-500/10 text-green-400' : n.natType === 2 ? 'bg-yellow-500/10 text-yellow-400' : 'bg-red-500/10 text-red-400']">
|
||||
{{ ['CONE','SYMM','STAT'][n.natType-1] || 'UNK' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 mb-6 text-xs text-slate-400">
|
||||
<div class="flex justify-between"><span>OS</span><span class="text-slate-300">{{ n.os || 'linux' }}</span></div>
|
||||
<div class="flex justify-between"><span>版本</span><span class="text-slate-300">{{ n.version }}</span></div>
|
||||
<div class="flex justify-between"><span>中继</span><span :class="n.relayEnabled ? 'text-blue-400' : 'text-slate-600'">{{ n.relayEnabled ? '是' : '否' }}</span></div>
|
||||
<div class="flex justify-between"><span>在线</span><span class="text-slate-300">{{ formatUptime(n.loginTime) }}</span></div>
|
||||
</div>
|
||||
<button @click="openAppManager(n)" class="w-full bg-blue-600/10 hover:bg-blue-600 text-blue-400 hover:text-white py-3 rounded-2xl text-[10px] font-black uppercase">配置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SDWAN -->
|
||||
<div v-if="activeTab === 'sdwan'" class="pb-20">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<div class="lg:col-span-4 glass-card rounded-3xl p-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-black italic">网络配置</h3>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<span class="text-xs text-slate-500">启用</span>
|
||||
<input type="checkbox" v-model="sdwan.enabled" @change="saveSDWAN" class="w-6 h-6 accent-blue-600">
|
||||
</label>
|
||||
</div>
|
||||
<div class="space-y-4 pt-4 border-t border-white/5">
|
||||
<div>
|
||||
<label class="text-[9px] font-black text-slate-500 uppercase block mb-1">网关 CIDR</label>
|
||||
<input v-model="sdwan.gatewayCIDR" @blur="saveSDWAN" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 font-mono text-blue-400">
|
||||
</div>
|
||||
<button @click="saveSDWAN" :disabled="sdSaving" class="w-full bg-white text-black font-black py-4 rounded-xl flex items-center justify-center gap-2">
|
||||
<svg v-if="sdSaving" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
|
||||
{{ sdSaving ? '推送中...' : '保存配置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-8 glass-card rounded-3xl overflow-hidden">
|
||||
<div class="px-8 py-5 border-b border-white/5 flex justify-between items-center bg-white/[0.02]">
|
||||
<h3 class="font-bold text-xs uppercase">IP 分配</h3>
|
||||
<button @click="autoAssignIPs" class="text-[10px] font-black text-blue-500 uppercase">自动分配</button>
|
||||
</div>
|
||||
<table class="w-full text-xs font-mono">
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<tr v-for="(sn, idx) in sdwan.nodes" :key="sn.node" class="hover:bg-white/[0.02]">
|
||||
<td class="px-8 py-4 text-slate-300">{{ sn.node }}</td>
|
||||
<td class="px-8 py-4"><input v-model="sn.ip" @blur="saveSDWAN" class="bg-transparent border-b border-white/10 outline-none w-32 text-blue-400"></td>
|
||||
<td class="px-8 py-4 text-right"><button @click="removeSDWANNode(idx)" class="text-red-400">删除</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-8 bg-black/20 border-t border-white/5 flex gap-4">
|
||||
<select v-model="newNodeToAssign" class="flex-1 bg-black/40 border border-white/10 rounded-xl px-4 text-xs h-10">
|
||||
<option value="">选择节点...</option>
|
||||
<option v-for="n in unassignedNodes" :key="n.name" :value="n.name">{{ n.name }}</option>
|
||||
</select>
|
||||
<input v-model="newIPToAssign" placeholder="10.10.0.x" class="w-32 bg-black/40 border border-white/10 rounded-xl px-4 text-xs h-10">
|
||||
<button @click="addSDWANNode" :disabled="!newNodeToAssign || !newIPToAssign" class="bg-blue-600 px-6 rounded-xl text-[10px] font-black disabled:opacity-50">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div v-if="activeTab === 'settings'" class="space-y-6">
|
||||
<div class="glass-card rounded-2xl p-6 space-y-4">
|
||||
<h3 class="text-lg font-black">系统设置</h3>
|
||||
<div class="flex items-center justify-between py-3 border-b border-white/5">
|
||||
<div>
|
||||
<div class="text-sm font-bold">自动刷新</div>
|
||||
<div class="text-xs text-slate-500">当前: {{ refreshInterval }} 秒</div>
|
||||
</div>
|
||||
<select v-model="refreshInterval" class="bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm">
|
||||
<option :value="10">10 秒</option>
|
||||
<option :value="15">15 秒</option>
|
||||
<option :value="30">30 秒</option>
|
||||
<option :value="60">60 秒</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- App Modal -->
|
||||
<div v-if="appManagerNode" class="fixed inset-0 z-[150] flex items-center justify-center p-4 bg-black/90 backdrop-blur-xl">
|
||||
<div class="bg-[#0f1425] border border-white/10 rounded-3xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div class="p-8 border-b border-white/5 flex justify-between items-center">
|
||||
<h3 class="text-xl font-black italic">隧道: {{ appManagerNode.name }}</h3>
|
||||
<button @click="appManagerNode = null" class="text-slate-500 hover:text-white">关闭</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-8 custom-scrollbar grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="space-y-4">
|
||||
<div v-for="(a, i) in appConfigs" :key="i" class="bg-white/5 border border-white/5 rounded-2xl p-4 relative">
|
||||
<div class="font-mono text-blue-400 font-bold text-xs">{{ a.appName }}</div>
|
||||
<div class="text-[10px] text-slate-500 mt-1">{{ a.srcPort }} → {{ a.peerNode }}:{{ a.dstPort }}</div>
|
||||
<button @click="appConfigs.splice(i,1)" class="absolute top-4 right-4 text-red-400">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white/[0.02] border border-white/5 rounded-3xl p-6 space-y-4">
|
||||
<input v-model="newApp.appName" placeholder="名称" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
|
||||
<input v-model.number="newApp.srcPort" placeholder="本地端口" type="number" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
|
||||
<input v-model="newApp.peerNode" placeholder="对端节点" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
|
||||
<input v-model.number="newApp.dstPort" placeholder="对端端口" type="number" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
|
||||
<button @click="appConfigs.push({...newApp}); Object.assign(newApp,{appName:'',srcPort:18080,peerNode:'',dstPort:8080})" class="w-full bg-blue-600 text-white font-black py-3 rounded-xl text-xs">+ 添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-8 border-t border-white/5 flex justify-end gap-4">
|
||||
<button @click="appManagerNode = null" class="px-6 py-3 text-slate-500 hover:text-white">取消</button>
|
||||
<button @click="pushAppConfigs" :disabled="appSaving" class="bg-blue-600 text-white font-black px-12 py-4 rounded-2xl flex items-center gap-2">
|
||||
<svg v-if="appSaving" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
|
||||
{{ appSaving ? '下发中...' : '下发配置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div v-if="msg" class="fixed top-6 left-1/2 -translate-x-1/2 z-[300] px-10 py-4 rounded-full text-xs font-black uppercase shadow-2xl"
|
||||
:class="msgType === 'error' ? 'bg-red-900 border border-red-500 text-red-200' : msgType === 'success' ? 'bg-green-900 border border-green-500 text-green-200' : 'bg-blue-900 border border-blue-500 text-white'">
|
||||
{{ msg }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, reactive, onMounted, computed, watch, nextTick } = Vue;
|
||||
createApp({
|
||||
setup() {
|
||||
const isLoggedIn = ref(!!localStorage.getItem('inp2p_token'));
|
||||
const loginToken = ref('');
|
||||
const loginError = ref('');
|
||||
const activeTab = ref('dashboard');
|
||||
const loading = ref(false);
|
||||
const sdSaving = ref(false);
|
||||
const appSaving = ref(false);
|
||||
const stats = ref({});
|
||||
const nodes = ref([]);
|
||||
const sdwan = ref({ enabled: false, gatewayCIDR: '10.10.0.0/24', mode: 'mesh', nodes: [] });
|
||||
const msg = ref('');
|
||||
const msgType = ref('info');
|
||||
const nodeFilter = ref('');
|
||||
const activityLogs = ref([]);
|
||||
const appManagerNode = ref(null);
|
||||
const appConfigs = ref([]);
|
||||
const newApp = reactive({ appName: '', srcPort: 18080, peerNode: '', dstPort: 8080 });
|
||||
const newNodeToAssign = ref('');
|
||||
const newIPToAssign = ref('');
|
||||
const refreshInterval = ref(15);
|
||||
const punchRate = ref(0);
|
||||
const tabNames = { dashboard: '仪表盘', nodes: '节点资产', sdwan: 'SDWAN', settings: '设置' };
|
||||
|
||||
const fetchAPI = async (path, options = {}) => {
|
||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
const token = localStorage.getItem('inp2p_token');
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
try {
|
||||
const response = await fetch(path, { ...options, headers });
|
||||
if (response.status === 401) {
|
||||
isLoggedIn.value = false;
|
||||
localStorage.removeItem('inp2p_token');
|
||||
showToast('登录已过期', 'error');
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (response.ok) return data;
|
||||
throw new Error(data.message || '请求失败');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const showToast = (text, type = 'info', duration = 3000) => {
|
||||
msg.value = text;
|
||||
msgType.value = type;
|
||||
setTimeout(() => msg.value = '', duration);
|
||||
};
|
||||
|
||||
const addLog = (message, type = 'info') => {
|
||||
activityLogs.value.unshift({ time: new Date().toLocaleTimeString(), msg: message, type: type });
|
||||
if (activityLogs.value.length > 50) activityLogs.value.pop();
|
||||
};
|
||||
|
||||
const login = async () => {
|
||||
if (!loginToken.value) { loginError.value = '请输入 Token'; return; }
|
||||
loading.value = true;
|
||||
loginError.value = '';
|
||||
const d = await fetchAPI('/api/v1/auth/login', { method: 'POST', body: JSON.stringify({ token: loginToken.value }) });
|
||||
if (d && d.error === 0) {
|
||||
localStorage.setItem('inp2p_token', d.token);
|
||||
isLoggedIn.value = true;
|
||||
addLog('登录成功', 'success');
|
||||
refreshAll();
|
||||
} else {
|
||||
loginError.value = 'Token 验证失败';
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('inp2p_token');
|
||||
isLoggedIn.value = false;
|
||||
};
|
||||
|
||||
let refreshTimer = null;
|
||||
const startRefresh = () => {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = setInterval(refreshAll, refreshInterval.value * 1000);
|
||||
};
|
||||
watch(refreshInterval, () => { if (isLoggedIn.value) startRefresh(); });
|
||||
|
||||
const refreshAll = async () => {
|
||||
if (!isLoggedIn.value) return;
|
||||
loading.value = true;
|
||||
const [health, nodeData, sdwanData] = await Promise.all([
|
||||
fetchAPI('/api/v1/health'),
|
||||
fetchAPI('/api/v1/nodes'),
|
||||
fetchAPI('/api/v1/sdwans')
|
||||
]);
|
||||
if (health) {
|
||||
stats.value = health;
|
||||
punchRate.value = Math.min(95, 50 + (health.nodes || 0) * 15);
|
||||
}
|
||||
if (nodeData) nodes.value = nodeData.nodes || [];
|
||||
if (sdwanData) sdwan.value = sdwanData;
|
||||
loading.value = false;
|
||||
updateCharts();
|
||||
};
|
||||
|
||||
const saveSDWAN = async () => {
|
||||
sdSaving.value = true;
|
||||
const r = await fetchAPI('/api/v1/sdwan/edit', { method: 'POST', body: JSON.stringify(sdwan.value) });
|
||||
sdSaving.value = false;
|
||||
if (r) showToast('配置已推送', 'success');
|
||||
};
|
||||
|
||||
const openAppManager = (node) => {
|
||||
appManagerNode.value = node;
|
||||
appConfigs.value = JSON.parse(JSON.stringify(node.apps || []));
|
||||
};
|
||||
|
||||
const pushAppConfigs = async () => {
|
||||
appSaving.value = true;
|
||||
const r = await fetchAPI('/api/v1/nodes/apps', { method: 'POST', body: JSON.stringify({ node: appManagerNode.value.name, apps: appConfigs.value }) });
|
||||
appSaving.value = false;
|
||||
if (r) { showToast('配置已下发', 'success'); appManagerNode.value = null; setTimeout(refreshAll, 1000); }
|
||||
};
|
||||
|
||||
const addSDWANNode = () => {
|
||||
if (!newNodeToAssign.value || !newIPToAssign.value) return;
|
||||
sdwan.value.nodes.push({ node: newNodeToAssign.value, ip: newIPToAssign.value });
|
||||
newNodeToAssign.value = ''; newIPToAssign.value = '';
|
||||
saveSDWAN();
|
||||
};
|
||||
const removeSDWANNode = (idx) => { sdwan.value.nodes.splice(idx, 1); saveSDWAN(); };
|
||||
const autoAssignIPs = () => {
|
||||
const base = sdwan.value.gatewayCIDR.replace('.0/24', '.');
|
||||
let cur = 2;
|
||||
nodes.value.filter(n => !sdwan.value.nodes.some(sn => sn.node === n.name)).forEach(n => {
|
||||
while (sdwan.value.nodes.some(sn => sn.ip === base + cur)) cur++;
|
||||
sdwan.value.nodes.push({ node: n.name, ip: base + cur }); cur++;
|
||||
});
|
||||
saveSDWAN();
|
||||
};
|
||||
|
||||
let myChart = null;
|
||||
const updateCharts = () => {
|
||||
if (activeTab.value !== 'dashboard' || !isLoggedIn.value) return;
|
||||
nextTick(() => {
|
||||
const dom = document.getElementById('natChart');
|
||||
if (!dom) return;
|
||||
if (!myChart) myChart = echarts.init(dom, 'dark', { backgroundColor: 'transparent' });
|
||||
const counts = nodes.value.reduce((acc, n) => {
|
||||
const l = ['Cone', 'Symmetric', 'Static'][n.natType - 1] || 'Unknown';
|
||||
acc[l] = (acc[l] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
myChart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{ type: 'pie', radius: ['40%', '70%'], itemStyle: { borderRadius: 10, borderColor: '#0f1425', borderWidth: 2 }, label: { show: false },
|
||||
data: Object.entries(counts).map(([name, value]) => ({ name, value, itemStyle: { color: name === 'Cone' ? '#22c55e' : name === 'Symmetric' ? '#eab308' : '#ef4444' } })) }]
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const formatUptime = (t) => {
|
||||
if (!t) return '-';
|
||||
try {
|
||||
const s = Math.floor((Date.now() - new Date(t).getTime()) / 1000);
|
||||
if (s < 60) return s + 's';
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
||||
return Math.floor(s / 86400) + 'd';
|
||||
} catch { return '-'; }
|
||||
};
|
||||
|
||||
onMounted(() => { if (isLoggedIn.value) { refreshAll(); startRefresh(); } window.addEventListener('resize', () => myChart && myChart.resize()); });
|
||||
|
||||
return {
|
||||
isLoggedIn, loginToken, loginError, activeTab, loading, sdSaving, appSaving,
|
||||
stats, nodes, sdwan, msg, msgType, nodeFilter, activityLogs,
|
||||
appManagerNode, appConfigs, newApp, newNodeToAssign, newIPToAssign,
|
||||
refreshInterval, punchRate, tabNames,
|
||||
login, logout, refreshAll, saveSDWAN, openAppManager, pushAppConfigs,
|
||||
addSDWANNode, removeSDWANNode, autoAssignIPs,
|
||||
relayCount: computed(() => nodes.value.filter(n => n.relayEnabled || n.superRelay).length),
|
||||
filteredNodes: computed(() => nodes.value.filter(n => n.name.toLowerCase().includes(nodeFilter.value.toLowerCase()))),
|
||||
unassignedNodes: computed(() => nodes.value.filter(n => !sdwan.value.nodes.some(sn => sn.node === n.name))),
|
||||
formatUptime
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user