<!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.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)}
    .active-tab{background:rgba(59,130,246,.1);border-left:3px solid #3b82f6;color:white}
    .sb::-webkit-scrollbar{width:4px}.sb::-webkit-scrollbar-thumb{background:#1e293b;border-radius:4px}
    .fade-in{animation:fi .3s ease-out}@keyframes fi{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
    .btn-blue{background:#3b82f6;color:white;font-weight:700;padding:.75rem 1.5rem;border-radius:.75rem;font-size:.75rem;transition:all .15s}
    .btn-blue:hover{background:#2563eb}.btn-blue:disabled{opacity:.5}
    .btn-red{background:#dc2626;color:white;font-weight:700;padding:.5rem 1rem;border-radius:.75rem;font-size:.7rem;transition:all .15s}
    .btn-red:hover{background:#b91c1c}
    .btn-ghost{background:transparent;color:#94a3b8;font-weight:700;padding:.5rem 1rem;border-radius:.75rem;font-size:.7rem;border:1px solid rgba(255,255,255,.1);transition:all .15s}
    .btn-ghost:hover{background:rgba(255,255,255,.05);color:white}
    .ipt{width:100%;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.1);border-radius:.75rem;padding:.75rem 1rem;font-size:.8rem;outline:none;color:#93c5fd;transition:border .15s}
    .ipt:focus{border-color:#3b82f6}
    .tag-cone{background:rgba(34,197,94,.1);color:#22c55e;border:1px solid rgba(34,197,94,.2)}
    .tag-symm{background:rgba(234,179,8,.1);color:#eab308;border:1px solid rgba(234,179,8,.2)}
    .tag-unk{background:rgba(239,68,68,.1);color:#ef4444;border:1px solid rgba(239,68,68,.2)}
  </style>
</head>
<body>
<div id="app" v-cloak class="flex h-screen">
  <!-- Login -->
  <div v-if="!loggedIn" class="fixed inset-0 z-50 flex items-center justify-center bg-[#070a14]">
    <div class="w-full max-w-sm glass rounded-3xl p-8">
      <div class="text-center mb-8">
        <div class="w-12 h-12 bg-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">⚡</div>
        <h1 class="text-2xl font-black text-white">INP2P</h1>
        <p class="text-slate-500 text-sm mt-1">输入 Master Token</p>
      </div>
      <input v-model="loginToken" type="password" placeholder="Token" class="ipt text-center mb-4" @keyup.enter="login">
      <button @click="login" :disabled="busy" class="btn-blue w-full py-4">{{ busy ? '验证中...' : '登 录' }}</button>
      <div v-if="loginErr" class="text-red-400 text-xs text-center mt-3">{{ loginErr }}</div>
    </div>
  </div>

  <!-- Sidebar -->
  <aside v-if="loggedIn" class="w-56 bg-[#0a0d1a] border-r border-white/5 flex flex-col flex-shrink-0">
    <div class="p-5">
      <div class="flex items-center gap-2 mb-8">
        <span class="text-lg font-black text-white italic">⚡ INP2P</span>
      </div>
      <nav class="space-y-1 text-sm">
        <button v-for="t in tabs" :key="t.id" @click="tab=t.id"
          :class="['w-full text-left px-4 py-2.5 rounded-r-xl transition-all', tab===t.id ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">
          {{ t.label }}
        </button>
      </nav>
    </div>
    <div class="mt-auto p-5 border-t border-white/5 text-[10px] text-slate-600">
      <div>v{{ st.version || '0.1.0' }} · {{ st.nodes || 0 }} 节点在线</div>
      <button @click="loggedIn=false;localStorage.removeItem('t')" class="text-slate-500 hover:text-red-400 mt-2">登出</button>
    </div>
  </aside>

  <!-- Main -->
  <main v-if="loggedIn" class="flex-1 flex flex-col overflow-hidden">
    <header class="h-14 border-b border-white/5 flex items-center justify-between px-6 bg-[#0a0d1a]/50 flex-shrink-0">
      <h2 class="font-bold text-white">{{ tabs.find(t=>t.id===tab)?.label }}</h2>
      <button @click="refresh" :disabled="busy" class="btn-ghost text-xs">{{ busy ? '刷新中...' : '刷新' }}</button>
    </header>
    <div class="flex-1 overflow-y-auto p-6 sb space-y-6">

      <!-- ===== 仪表盘 ===== -->
      <div v-if="tab==='dash'" class="fade-in space-y-6">
        <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
          <div class="glass rounded-2xl p-5 text-center">
            <div class="text-slate-500 text-[10px] font-bold uppercase">节点</div>
            <div class="text-3xl font-black text-white mt-1">{{ st.nodes||0 }}</div>
          </div>
          <div class="glass rounded-2xl p-5 text-center">
            <div class="text-slate-500 text-[10px] font-bold uppercase">中继</div>
            <div class="text-3xl font-black text-blue-400 mt-1">{{ st.relay||0 }}</div>
          </div>
          <div class="glass rounded-2xl p-5 text-center">
            <div class="text-slate-500 text-[10px] font-bold uppercase">Cone / Symm</div>
            <div class="text-xl font-black mt-1"><span class="text-green-400">{{ st.cone||0 }}</span> / <span class="text-yellow-400">{{ st.symmetric||0 }}</span></div>
          </div>
          <div class="glass rounded-2xl p-5 text-center">
            <div class="text-slate-500 text-[10px] font-bold uppercase">SDWAN</div>
            <div class="text-3xl font-black mt-1" :class="st.sdwan?'text-green-400':'text-slate-500'">{{ st.sdwan?'ON':'OFF' }}</div>
          </div>
        </div>
        <div class="glass rounded-2xl p-5">
          <h3 class="text-xs font-bold text-slate-500 uppercase mb-3">事件日志</h3>
          <div class="bg-black/40 rounded-xl p-4 font-mono text-[11px] max-h-60 overflow-y-auto sb">
            <div v-for="(l,i) in logs" :key="i" class="mb-1">
              <span class="text-slate-600">[{{ l.t }}]</span>
              <span :class="l.c==='err'?'text-red-400':l.c==='ok'?'text-green-400':'text-white'"> {{ l.m }}</span>
            </div>
            <div v-if="!logs.length" class="text-slate-700">暂无事件</div>
          </div>
        </div>
      </div>

      <!-- ===== 节点管理 ===== -->
      <div v-if="tab==='nodes'" class="fade-in space-y-4">
        <div class="flex gap-3">
          <input v-model="nf" placeholder="搜索节点..." class="ipt flex-1">
          <button @click="refresh" class="btn-ghost">刷新</button>
        </div>
        <table class="w-full text-sm">
          <thead><tr class="text-left text-slate-500 text-xs uppercase border-b border-white/5">
            <th class="p-3">节点</th><th class="p-3">公网 IP</th><th class="p-3">NAT</th><th class="p-3">中继</th><th class="p-3">在线</th><th class="p-3 text-right">操作</th>
          </tr></thead>
          <tbody>
            <tr v-for="n in fNodes" :key="n.name" class="border-b border-white/5 hover:bg-white/[0.02]">
              <td class="p-3 font-mono text-white text-xs">{{ n.name }}</td>
              <td class="p-3 text-slate-400 font-mono text-xs">{{ n.publicIP }}:{{ n.publicPort }}</td>
              <td class="p-3"><span :class="['px-2 py-0.5 rounded text-[10px] font-bold', n.natType===1?'tag-cone':n.natType===2?'tag-symm':'tag-unk']">{{ ['Cone','Symm'][n.natType-1]||'Unk' }}</span></td>
              <td class="p-3 text-xs" :class="n.relayEnabled?'text-blue-400':'text-slate-600'">{{ n.relayEnabled?'是':'否' }}</td>
              <td class="p-3 text-xs text-slate-400">{{ uptime(n.loginTime) }}</td>
              <td class="p-3 text-right space-x-2">
                <button @click="openTunnel(n)" class="btn-ghost">隧道</button>
                <button @click="openConnect(n)" class="btn-ghost">P2P连接</button>
                <button @click="kickNode(n)" class="btn-red">踢出</button>
              </td>
            </tr>
          </tbody>
        </table>
        <div v-if="!fNodes.length" class="text-center py-8 text-slate-600">无节点</div>
      </div>

      <!-- ===== SDWAN ===== -->
      <div v-if="tab==='sdwan'" class="fade-in space-y-6">
        <div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
          <div class="lg:col-span-4 glass rounded-2xl p-6 space-y-4">
            <div class="flex items-center justify-between">
              <h3 class="font-bold">网络配置</h3>
              <label class="flex items-center gap-2 cursor-pointer">
                <span class="text-xs text-slate-500">启用</span>
                <input type="checkbox" v-model="sd.enabled" class="w-5 h-5 accent-blue-600">
              </label>
            </div>
            <div>
              <label class="text-[10px] font-bold text-slate-500 uppercase">网关 CIDR</label>
              <input v-model="sd.gatewayCIDR" class="ipt mt-1 font-mono">
            </div>
            <div>
              <label class="text-[10px] font-bold text-slate-500 uppercase">模式</label>
              <select v-model="sd.mode" class="ipt mt-1">
                <option value="mesh">Mesh (全互联)</option>
                <option value="hub">Hub (星型)</option>
              </select>
            </div>
            <button @click="saveSD" :disabled="busy" class="btn-blue w-full">{{ busy ? '推送中...' : '保存并推送' }}</button>
          </div>
          <div class="lg:col-span-8 glass rounded-2xl overflow-hidden">
            <div class="px-6 py-4 border-b border-white/5 flex justify-between items-center">
              <h3 class="font-bold text-xs uppercase">IP 分配表</h3>
              <button @click="autoIP" class="text-[10px] font-bold text-blue-500 hover:text-blue-400">自动分配</button>
            </div>
            <table class="w-full text-xs font-mono">
              <thead><tr class="text-left text-slate-500 border-b border-white/5">
                <th class="px-6 py-3">节点</th><th class="px-6 py-3">虚拟 IP</th><th class="px-6 py-3">状态</th><th class="px-6 py-3 text-right">操作</th>
              </tr></thead>
              <tbody>
                <tr v-for="(sn,i) in sd.nodes" :key="sn.node" class="border-b border-white/5 hover:bg-white/[0.02]">
                  <td class="px-6 py-3 text-slate-300">{{ sn.node }}</td>
                  <td class="px-6 py-3"><input v-model="sn.ip" class="bg-transparent border-b border-white/10 outline-none w-28 text-blue-400 focus:border-blue-500"></td>
                  <td class="px-6 py-3"><span :class="nodeOnline(sn.node)?'text-green-400':'text-slate-600'">{{ nodeOnline(sn.node)?'在线':'离线' }}</span></td>
                  <td class="px-6 py-3 text-right"><button @click="sd.nodes.splice(i,1)" class="text-red-400 hover:text-red-300">删除</button></td>
                </tr>
              </tbody>
            </table>
            <div class="p-6 bg-black/20 border-t border-white/5 flex gap-3">
              <select v-model="addNode" class="ipt flex-1">
                <option value="">选择节点...</option>
                <option v-for="n in uaNodes" :key="n.name" :value="n.name">{{ n.name }}</option>
              </select>
              <input v-model="addIP" placeholder="10.10.0.x" class="ipt w-32">
              <button @click="addSDNode" :disabled="!addNode||!addIP" class="btn-blue disabled:opacity-50">添加</button>
            </div>
          </div>
        </div>
      </div>

      <!-- ===== P2P 连接 ===== -->
      <div v-if="tab==='p2p'" class="fade-in space-y-6">
        <div class="glass rounded-2xl p-6 space-y-4">
          <h3 class="font-bold">创建 P2P 隧道</h3>
          <p class="text-xs text-slate-500">选择两个在线节点，创建端口转发隧道。从 A 的本地端口转发到 B 的目标端口。</p>
          <div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
            <div>
              <label class="text-[10px] font-bold text-slate-500 uppercase">源节点 (A)</label>
              <select v-model="p2p.from" class="ipt mt-1">
                <option value="">选择...</option>
                <option v-for="n in nodes" :key="n.name" :value="n.name">{{ n.name }}</option>
              </select>
            </div>
            <div>
              <label class="text-[10px] font-bold text-slate-500 uppercase">目标节点 (B)</label>
              <select v-model="p2p.to" class="ipt mt-1">
                <option value="">选择...</option>
                <option v-for="n in nodes" :key="n.name" :value="n.name" :disabled="n.name===p2p.from">{{ n.name }}</option>
              </select>
            </div>
            <div>
              <label class="text-[10px] font-bold text-slate-500 uppercase">应用名称</label>
              <input v-model="p2p.name" placeholder="ssh-forward" class="ipt mt-1">
            </div>
            <div>
              <label class="text-[10px] font-bold text-slate-500 uppercase">A 监听端口</label>
              <input v-model.number="p2p.srcPort" type="number" class="ipt mt-1">
            </div>
            <div>
              <label class="text-[10px] font-bold text-slate-500 uppercase">B 目标端口</label>
              <input v-model.number="p2p.dstPort" type="number" class="ipt mt-1">
            </div>
          </div>
          <div class="pt-4 flex items-center gap-4">
            <button @click="doConnect" :disabled="!p2p.from||!p2p.to||busy" class="btn-blue">发起连接</button>
            <span v-if="p2p.from && p2p.to" class="text-xs text-slate-500">
              {{ p2p.from }} :{{ p2p.srcPort }} → {{ p2p.to }} :{{ p2p.dstPort }}
            </span>
          </div>
        </div>
      </div>

      <!-- ===== 租户管理 ===== -->
      <div v-if="tab==='tenant'" class="fade-in space-y-6">
        <div class="glass rounded-2xl p-5">
          <div class="text-slate-300 text-sm font-bold mb-3">创建租户</div>
          <div class="flex gap-3">
            <input v-model="newTenant" placeholder="租户名称" class="ipt">
            <button @click="createTenant" class="btn-blue">创建</button>
          </div>
        </div>

        <div class="glass rounded-2xl p-5">
          <div class="text-slate-300 text-sm font-bold mb-3">生成租户 API Key</div>
          <button @click="createKey" class="btn-blue">生成 Key</button>
          <div class="mt-3 text-xs text-slate-400">Key:</div>
          <textarea v-model="tenantKey" class="ipt mt-1" rows="2" placeholder="生成后显示"></textarea>
        </div>

        <div class="glass rounded-2xl p-5">
          <div class="text-slate-300 text-sm font-bold mb-3">生成 Enroll Code</div>
          <div class="flex gap-3">
            <button @click="createEnroll" class="btn-blue">生成</button>
            <input v-model="enrollCode" placeholder="Enroll Code" class="ipt">
          </div>
        </div>

        <div class="glass rounded-2xl p-5">
          <div class="text-slate-300 text-sm font-bold mb-3">兑换 Node Secret</div>
          <div class="flex gap-3">
            <input v-model="enrollNode" placeholder="节点名" class="ipt">
            <button @click="consumeEnroll" class="btn-blue">兑换</button>
          </div>
          <div class="mt-3 text-xs text-slate-400">Node Secret:</div>
          <textarea v-model="enrollSecret" class="ipt mt-1" rows="2" placeholder="兑换后显示"></textarea>
        </div>
      </div>
    </div>
  </main>

  <!-- Tunnel Modal -->
  <div v-if="tunNode" class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-xl p-4">
    <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-6 border-b border-white/5 flex justify-between items-center">
        <h3 class="font-bold">隧道配置: <span class="text-blue-400">{{ tunNode.name }}</span></h3>
        <button @click="tunNode=null" class="text-slate-500 hover:text-white text-xl">×</button>
      </div>
      <div class="flex-1 overflow-y-auto p-6 sb space-y-4">
        <div v-for="(a,i) in tunApps" :key="i" class="glass rounded-xl p-4 flex justify-between items-center">
          <div>
            <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>
          </div>
          <button @click="tunApps.splice(i,1)" class="text-red-400 hover:text-red-300 text-xs">删除</button>
        </div>
        <div v-if="!tunApps.length" class="text-center py-4 text-slate-600 text-sm">暂无隧道配置</div>
        <div class="glass rounded-xl p-4 space-y-3 mt-4">
          <div class="text-xs font-bold text-slate-400">添加隧道</div>
          <div class="grid grid-cols-2 gap-3">
            <input v-model="na.appName" placeholder="名称" class="ipt">
            <input v-model.number="na.srcPort" placeholder="本地端口" type="number" class="ipt">
            <input v-model="na.peerNode" placeholder="对端节点" class="ipt">
            <input v-model.number="na.dstPort" placeholder="对端端口" type="number" class="ipt">
          </div>
          <button @click="tunApps.push({...na});Object.assign(na,{appName:'',srcPort:0,peerNode:'',dstPort:0})" class="btn-ghost w-full">+ 添加</button>
        </div>
      </div>
      <div class="p-6 border-t border-white/5 flex justify-end gap-3">
        <button @click="tunNode=null" class="btn-ghost">取消</button>
        <button @click="pushTun" :disabled="busy" class="btn-blue">{{ busy ? '下发中...' : '下发配置' }}</button>
      </div>
    </div>
  </div>

  <!-- Toast -->
  <div v-if="toast" class="fixed top-4 left-1/2 -translate-x-1/2 z-[200] px-8 py-3 rounded-full text-xs font-bold shadow-2xl fade-in"
       :class="toastType==='err'?'bg-red-900 border border-red-500 text-red-200':toastType==='ok'?'bg-green-900 border border-green-500 text-green-200':'bg-blue-900 border border-blue-500 text-white'">
    {{ toast }}
  </div>
</div>

<script>
const{createApp,ref,reactive,computed,onMounted,watch}=Vue;
createApp({setup(){
  const loggedIn=ref(!!localStorage.getItem('t'));
  const loginToken=ref(''),loginErr=ref(''),busy=ref(false);
  const tab=ref('dash'),nf=ref(''),toast=ref(''),toastType=ref('');
  const st=ref({}),nodes=ref([]),sd=ref({enabled:false,gatewayCIDR:'10.10.0.0/24',mode:'mesh',nodes:[]});
  const logs=ref([]),tunNode=ref(null),tunApps=ref([]);
  const na=reactive({appName:'',srcPort:0,peerNode:'',dstPort:0});
  const addNode=ref(''),addIP=ref('');
  const p2p=reactive({from:'',to:'',name:'tunnel',srcPort:18080,dstPort:22});
  const tenants=ref([]),newTenant=ref('');
  const tenantKey=ref('');
  const enrollCode=ref('');
  const enrollNode=ref('');
  const enrollSecret=ref('');

  const tabs=[{id:'dash',label:'仪表盘'},{id:'nodes',label:'节点管理'},{id:'sdwan',label:'SDWAN 组网'},{id:'p2p',label:'P2P 连接'},{id:'tenant',label:'租户管理'}];

  const show=(m,c='info',d=3000)=>{toast.value=m;toastType.value=c;setTimeout(()=>toast.value='',d)};
  const log=(m,c='info')=>{logs.value.unshift({t:new Date().toLocaleTimeString(),m,c});if(logs.value.length>50)logs.value.pop()};

  const api=async(path,opt={})=>{
    const h={'Content-Type':'application/json',...opt.headers};
    const tk=localStorage.getItem('t');if(tk)h['Authorization']='Bearer '+tk;
    try{
      const r=await fetch(path,{...opt,headers:h});
      if(r.status===401){loggedIn.value=false;localStorage.removeItem('t');show('登录过期','err');return null}
      const d=await r.json();if(r.ok)return d;throw new Error(d.message||'失败')
    }catch(e){show(e.message,'err');return null}
  };

  const login=async()=>{
    if(!loginToken.value){loginErr.value='请输入 Token';return}
    busy.value=true;loginErr.value='';
    const d=await api('/api/v1/auth/login',{method:'POST',body:JSON.stringify({token:loginToken.value})});
    if(d&&d.error===0){localStorage.setItem('t',d.token);loggedIn.value=true;log('登录成功','ok');refresh()}
    else loginErr.value='Token 错误';
    busy.value=false
  };

  const refresh=async()=>{
    if(!loggedIn.value)return;busy.value=true;
    const[h,n,s]=await Promise.all([api('/api/v1/stats'),api('/api/v1/nodes'),api('/api/v1/sdwans')]);
    if(h)st.value=h;
    if(n)nodes.value=n.nodes||[];
    if(s)sd.value=s;
    busy.value=false
  };

  const createTenant=async()=>{
    if(!newTenant.value)return;busy.value=true;
    const r=await api('/api/v1/admin/tenants',{method:'POST',body:JSON.stringify({name:newTenant.value})});
    busy.value=false;
    if(r){show('租户已创建','ok');log('创建租户 '+newTenant.value,'ok');newTenant.value=''}
  };

  const createKey=async()=>{
    const id=prompt('输入 tenant_id');
    if(!id)return;busy.value=true;
    const r=await api(`/api/v1/admin/tenants/${id}/keys`,{method:'POST',body:JSON.stringify({scope:'all',ttl:0})});
    busy.value=false;
    if(r){tenantKey.value=r.api_key||'';show('API Key 已生成','ok')}
  };

  const createEnroll=async()=>{
    if(!tenantKey.value){show('先填入租户 API Key','err');return}
    busy.value=true;
    const r=await api('/api/v1/tenants/enroll',{method:'POST',headers:{'Authorization':'Bearer '+tenantKey.value}});
    busy.value=false;
    if(r){enrollCode.value=r.enroll_code||'';show('Enroll Code 已生成','ok')}
  };

  const consumeEnroll=async()=>{
    if(!enrollCode.value||!enrollNode.value){show('需要 enroll_code 与 node 名称','err');return}
    busy.value=true;
    const r=await api('/api/v1/enroll/consume',{method:'POST',body:JSON.stringify({code:enrollCode.value,node:enrollNode.value})});
    busy.value=false;
    if(r){enrollSecret.value=r.node_secret||'';show('Node Secret 已生成','ok')}
  };

  const saveSD=async()=>{
    busy.value=true;
    const r=await api('/api/v1/sdwan/edit',{method:'POST',body:JSON.stringify(sd.value)});
    busy.value=false;
    if(r){show('SDWAN 配置已推送','ok');log('SDWAN 配置更新','ok')}
  };

  const kickNode=async(n)=>{
    if(!confirm('确认踢出节点 '+n.name+'？'))return;
    const r=await api('/api/v1/nodes/kick',{method:'POST',body:JSON.stringify({node:n.name})});
    if(r){show(n.name+' 已踢出','ok');log('踢出 '+n.name,'ok');setTimeout(refresh,1000)}
  };

  const openTunnel=(n)=>{tunNode.value=n;tunApps.value=JSON.parse(JSON.stringify(n.apps||[]))};
  const pushTun=async()=>{
    busy.value=true;
    const r=await api('/api/v1/nodes/apps',{method:'POST',body:JSON.stringify({node:tunNode.value.name,apps:tunApps.value})});
    busy.value=false;
    if(r){show('隧道配置已下发','ok');log('下发隧道到 '+tunNode.value.name,'ok');tunNode.value=null;setTimeout(refresh,1000)}
  };

  const openConnect=(n)=>{tab.value='p2p';p2p.from=n.name};

  const doConnect=async()=>{
    busy.value=true;
    const r=await api('/api/v1/connect',{method:'POST',body:JSON.stringify({from:p2p.from,to:p2p.to,appName:p2p.name,srcPort:p2p.srcPort,dstPort:p2p.dstPort})});
    busy.value=false;
    if(r&&r.error===0){show('P2P 连接请求已发送','ok');log(`${p2p.from} → ${p2p.to} 连接请求`,'ok')}
  };

  const addSDNode=()=>{
    if(!addNode.value||!addIP.value)return;
    sd.value.nodes.push({node:addNode.value,ip:addIP.value});
    addNode.value='';addIP.value='';saveSD()
  };

  const autoIP=()=>{
    const b=sd.value.gatewayCIDR.replace('.0/24','.');let c=2;
    nodes.value.filter(n=>!sd.value.nodes.some(s=>s.node===n.name)).forEach(n=>{
      while(sd.value.nodes.some(s=>s.ip===b+c))c++;
      sd.value.nodes.push({node:n.name,ip:b+c});c++
    });saveSD()
  };

  const nodeOnline=(name)=>nodes.value.some(n=>n.name===name);

  const uptime=(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'-'}
  };

  const fNodes=computed(()=>nodes.value.filter(n=>n.name.toLowerCase().includes(nf.value.toLowerCase())));
  const uaNodes=computed(()=>nodes.value.filter(n=>!sd.value.nodes.some(s=>s.node===n.name)));

  let timer;
  onMounted(()=>{if(loggedIn.value){refresh();timer=setInterval(refresh,15000)}});

  return{loggedIn,loginToken,loginErr,busy,tab,tabs,nf,toast,toastType,
    st,nodes,sd,logs,tunNode,tunApps,na,addNode,addIP,p2p,
    tenants,newTenant,tenantKey,enrollCode,enrollNode,enrollSecret,
    login,refresh,saveSD,kickNode,openTunnel,pushTun,openConnect,doConnect,
    addSDNode,autoIP,nodeOnline,uptime,fNodes,uaNodes,
    createTenant,createKey,createEnroll,consumeEnroll}
}}).mount('#app');
</script>
</body>
</html>
