<!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>
          <select v-if="sd.mode==='hub'" class="ipt max-w-[220px]" v-model="sd.hubNode">
            <option value="">选择 Hub 节点</option>
            <option v-for="n in nodes" :key="'hub'+n.name" :value="n.name">{{ n.alias || n.name }}</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 v-if="sd.mode==='hub'" class="text-xs text-slate-400">Hub 离线将自动回 Mesh</div>
        </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', hubNode:'', 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>
