Spaces:
Running
Running
| <html lang="zh"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Vertex Proxy ยท Control</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg:#0a0c0f; --bg2:#0f1215; --bg3:#141820; | |
| --bd:#1e2530; --bd2:#252d3a; | |
| --tx:#c8d4e8; --tx2:#6b7f9a; --tx3:#3d4f63; | |
| --ac:#00e5a0; --ac2:#00b87d; --ac3:rgba(0,229,160,.08); | |
| --red:#ff4d6a; --red2:rgba(255,77,106,.1); | |
| --yel:#f0c040; --blue:#4d9fff; | |
| --glow:0 0 20px rgba(0,229,160,.15); | |
| --shadow:0 4px 24px rgba(0,0,0,.4); | |
| --mono:'JetBrains Mono',ui-monospace,monospace; | |
| --sans:'Syne',system-ui,sans-serif; | |
| } | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| html,body{height:100%} | |
| body{font-family:var(--mono);background:var(--bg);color:var(--tx);font-size:13px;line-height:1.6;-webkit-font-smoothing:antialiased} | |
| body::before{content:'';position:fixed;inset:0;background-image:linear-gradient(rgba(0,229,160,.022) 1px,transparent 1px),linear-gradient(90deg,rgba(0,229,160,.022) 1px,transparent 1px);background-size:40px 40px;pointer-events:none;z-index:0} | |
| ::-webkit-scrollbar{width:4px;height:4px} ::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px} | |
| a{color:var(--ac);text-decoration:none} | |
| button{font-family:var(--mono);cursor:pointer} | |
| code{color:var(--ac);font-size:12px;background:var(--ac3);padding:1px 5px;border-radius:3px} | |
| input,select,textarea{font-family:var(--mono);background:var(--bg);color:var(--tx);border:1px solid var(--bd2);border-radius:7px;padding:9px 12px;font-size:13px;outline:none;width:100%;transition:border-color .15s,box-shadow .15s} | |
| input:-webkit-autofill,input:-webkit-autofill:hover,input:-webkit-autofill:focus,input:-webkit-autofill:active{-webkit-text-fill-color:var(--tx)!important;caret-color:var(--tx);box-shadow:0 0 0 1000px var(--bg) inset!important;border-color:var(--bd2);transition:background-color 9999s ease-out 0s} | |
| textarea{min-height:126px;resize:vertical;line-height:1.55;background:linear-gradient(180deg,rgba(0,229,160,.025),transparent),var(--bg)} | |
| input:focus,select:focus,textarea:focus{border-color:rgba(0,229,160,.4);box-shadow:0 0 0 3px rgba(0,229,160,.06)} | |
| select option{background:var(--bg3)} | |
| label{display:block;font-size:11px;color:var(--tx2);text-transform:uppercase;letter-spacing:.1em;margin-bottom:6px} | |
| input[type="file"].hidden-file{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none} | |
| /* btns */ | |
| .btn{display:inline-flex;align-items:center;gap:6px;background:transparent;color:var(--tx2);border:1px solid var(--bd2);border-radius:7px;padding:8px 16px;font-size:12px;font-family:var(--mono);transition:all .15s} | |
| .btn:hover{border-color:var(--tx3);color:var(--tx)} | |
| .btn-primary{background:var(--ac);color:#000;border-color:var(--ac);font-weight:600} | |
| .btn-primary:hover{background:var(--ac2);border-color:var(--ac2);color:#000} | |
| .btn-danger{color:var(--tx3)} | |
| .btn-danger:hover{border-color:var(--red);color:var(--red);background:var(--red2)} | |
| .btn-sm{padding:5px 10px;font-size:11px;border-radius:5px} | |
| .btn:disabled{opacity:.35;cursor:not-allowed} | |
| .file-picker{display:flex;align-items:center;gap:10px;width:100%;max-width:360px;background:rgba(255,255,255,.02);border:1px dashed rgba(0,229,160,.26);border-radius:10px;padding:8px 10px;transition:all .15s} | |
| .file-picker:hover{border-color:rgba(0,229,160,.55);background:rgba(0,229,160,.045)} | |
| .file-name{font-size:11px;color:var(--tx2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0} | |
| .toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:12px} | |
| .soft-card{border:1px solid rgba(0,229,160,.12);background:linear-gradient(135deg,rgba(0,229,160,.06),rgba(77,159,255,.025));border-radius:12px;padding:14px;margin-top:14px} | |
| .subtabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;padding:6px;background:rgba(255,255,255,.015);border:1px solid var(--bd);border-radius:10px} | |
| .subtab{border:1px solid transparent;background:transparent;color:var(--tx2);border-radius:7px;padding:8px 12px;font-size:12px;transition:all .15s} | |
| .subtab:hover{color:var(--tx);border-color:var(--bd2)} | |
| .subtab.active{color:#001b13;background:var(--ac);border-color:var(--ac);font-weight:600} | |
| .import-pane{display:none} | |
| .import-pane.active{display:block;animation:fi .18s ease} | |
| .collection-grid{display:grid;gap:12px;margin-top:14px} | |
| .collection-card{border:1px solid var(--bd);background:linear-gradient(180deg,rgba(255,255,255,.012),transparent),var(--bg);border-radius:10px;padding:14px;min-width:0} | |
| .collection-card.accent{border-color:rgba(0,229,160,.18);background:linear-gradient(135deg,rgba(0,229,160,.05),rgba(77,159,255,.02)),var(--bg)} | |
| .collection-title{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px} | |
| .collection-title .s-title{font-size:13px} | |
| .filter-row{display:grid;grid-template-columns:minmax(220px,1fr) 170px 170px;gap:10px} | |
| @media(max-width:760px){.filter-row{grid-template-columns:1fr}.collection-title{align-items:flex-start;flex-direction:column}} | |
| /* toggle */ | |
| .toggle-row{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:var(--bg);border:1px solid var(--bd2);border-radius:8px} | |
| .toggle-info .t-title{font-size:13px;color:var(--tx)} | |
| .toggle-info .t-sub{font-size:11px;color:var(--tx3);margin-top:2px} | |
| .tog{position:relative;width:40px;height:22px;flex-shrink:0;margin-left:14px} | |
| .tog input{display:none} | |
| .tog-track{position:absolute;inset:0;background:var(--bd2);border-radius:11px;cursor:pointer;transition:background .2s} | |
| .tog-thumb{position:absolute;top:3px;left:3px;width:16px;height:16px;background:var(--tx3);border-radius:50%;transition:all .2s;pointer-events:none} | |
| .tog input:checked~.tog-track{background:rgba(0,229,160,.25)} | |
| .tog input:checked~.tog-track .tog-thumb{left:21px;background:var(--ac);box-shadow:0 0 8px rgba(0,229,160,.5)} | |
| /* login */ | |
| .login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;position:relative;z-index:1} | |
| .login-card{width:100%;max-width:400px;background:var(--bg2);border:1px solid var(--bd);border-radius:14px;padding:44px 38px 36px;position:relative;box-shadow:var(--shadow)} | |
| .login-card::before{content:'';position:absolute;top:-1px;left:20px;right:20px;height:2px;background:var(--ac);border-radius:0 0 2px 2px;box-shadow:var(--glow)} | |
| .login-logo{display:flex;align-items:center;gap:12px;margin-bottom:28px} | |
| .login-logo .ico{width:42px;height:42px;background:var(--ac3);border:1px solid rgba(0,229,160,.3);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:var(--glow)} | |
| .login-logo h1{font-family:var(--sans);font-size:20px;font-weight:800;color:#fff} | |
| .login-logo p{font-size:11px;color:var(--tx2);margin-top:1px} | |
| .login-card form{display:flex;flex-direction:column;gap:14px} | |
| .err-msg{color:var(--red);font-size:11px} | |
| .hint{margin-top:20px;padding-top:16px;border-top:1px dashed var(--bd2);color:var(--tx3);font-size:11px;line-height:1.7} | |
| /* app */ | |
| .app{display:none;position:relative;z-index:1} | |
| .topbar{border-bottom:1px solid var(--bd);padding:13px 24px;display:flex;justify-content:space-between;align-items:center;background:var(--bg2);position:sticky;top:0;z-index:10;flex-wrap:wrap;gap:12px} | |
| .brand{display:flex;align-items:center;gap:11px} | |
| .brand-ico{width:34px;height:34px;background:var(--ac3);border:1px solid rgba(0,229,160,.25);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px} | |
| .brand-txt{font-family:var(--sans);font-size:16px;font-weight:800;color:#fff} | |
| .brand-txt span{color:var(--tx3);font-size:11px;font-family:var(--mono);font-weight:400;margin-left:8px} | |
| .topbar-r{display:flex;align-items:center;gap:12px;flex-wrap:wrap} | |
| .port-badge{font-size:11px;color:var(--tx2);padding:4px 10px;background:var(--bg3);border:1px solid var(--bd2);border-radius:6px;display:flex;align-items:center;gap:6px} | |
| .port-badge::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--ac);box-shadow:0 0 6px var(--ac);animation:blink 2.5s infinite} | |
| @keyframes blink{0%,100%{opacity:1}50%{opacity:.3}} | |
| .top-status{display:flex;align-items:center;gap:10px;min-width:0;max-width:58vw;background:rgba(0,229,160,.045);border:1px solid rgba(0,229,160,.14);border-radius:8px;padding:5px 8px;color:var(--tx2);font-size:11px;flex-wrap:wrap} | |
| .top-status.off{background:rgba(255,255,255,.015);border-color:var(--bd2)} | |
| .top-status .ab-l{gap:7px;flex-wrap:wrap} | |
| .top-status .ab-lbl{font-size:9px} | |
| .top-status .ab-name{max-width:260px} | |
| .top-status .btn{flex-shrink:0} | |
| .top-status.off .ab-dot{background:var(--tx3);box-shadow:none} | |
| @media(max-width:820px){ | |
| .topbar-r{flex:1 1 100%;justify-content:flex-end} | |
| .top-status{max-width:100%;flex:1 1 100%;order:3;margin-top:4px;justify-content:space-between} | |
| .port-badge{order:1} | |
| .topbar-r > .btn{order:2} | |
| } | |
| @media(max-width:500px){ | |
| .topbar{padding:12px 16px} | |
| .main{padding:20px 16px 80px} | |
| .tabs{padding:0 16px} | |
| } | |
| /* active bar */ | |
| .active-bar{padding:9px 24px;background:rgba(0,229,160,.05);border-bottom:1px solid rgba(0,229,160,.15);display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:12px;transition:all .3s;flex-wrap:wrap} | |
| .active-bar.off{background:transparent;border-bottom-color:var(--bd)} | |
| .ab-l{display:flex;align-items:center;gap:8px;min-width:0;flex:1;flex-wrap:wrap} | |
| .ab-dot{width:7px;height:7px;border-radius:50%;background:var(--ac);box-shadow:0 0 6px var(--ac);flex-shrink:0} | |
| .active-bar.off .ab-dot{background:var(--tx3);box-shadow:none} | |
| .ab-lbl{color:var(--tx3);text-transform:uppercase;letter-spacing:.1em;font-size:10px;flex-shrink:0} | |
| .active-bar:not(.off) .ab-lbl{color:var(--ac)} | |
| .ab-name{color:var(--tx);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| .ab-via{color:var(--tx3);font-size:11px;flex-shrink:0} | |
| .ab-pool-badge{font-size:10px;padding:2px 8px;background:rgba(77,159,255,.12);color:var(--blue);border:1px solid rgba(77,159,255,.2);border-radius:4px;flex-shrink:0} | |
| /* tabs */ | |
| .tabs{display:flex;border-bottom:1px solid var(--bd);padding:0 24px;background:var(--bg2);gap:2px;flex-wrap:wrap} | |
| .tab{background:transparent;border:none;border-bottom:2px solid transparent;padding:12px 16px;margin-bottom:-1px;color:var(--tx2);font-size:11px;font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase;font-weight:500;transition:all .15s;cursor:pointer} | |
| .tab:hover{color:var(--tx)} | |
| .tab.active{color:var(--ac);border-bottom-color:var(--ac)} | |
| /* main */ | |
| .main{padding:28px 24px 80px;max-width:1180px} | |
| .panel{display:none} | |
| .panel.active{display:block;animation:fi .2s ease} | |
| @keyframes fi{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}} | |
| /* section card */ | |
| .section{background:var(--bg2);border:1px solid var(--bd);border-radius:12px;margin-bottom:18px;overflow:hidden} | |
| .s-hd{display:flex;align-items:flex-start;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--bd);background:var(--bg3)} | |
| .s-title{font-family:var(--sans);font-size:14px;font-weight:700;color:#fff;display:flex;align-items:center;gap:8px} | |
| .s-sub{font-size:11px;color:var(--tx3);margin-top:3px} | |
| .s-body{padding:18px} | |
| /* grid */ | |
| .grid{display:grid;gap:14px;grid-template-columns:1fr 1fr} | |
| .grid .full{grid-column:1/-1} | |
| .grid3{grid-template-columns:1fr 1fr 1fr} | |
| @media(max-width:680px){.grid,.grid3{grid-template-columns:1fr}} | |
| /* fields */ | |
| .field{display:flex;flex-direction:column;gap:6px} | |
| .fhint{color:var(--tx3);font-size:11px;margin-top:4px} | |
| .fwarn{color:var(--yel);font-size:11px;margin-top:4px} | |
| .muted{color:var(--tx3);font-size:11px;line-height:1.7;word-break:break-all} | |
| .actions{margin-top:18px;display:flex;gap:10px;align-items:center;flex-wrap:wrap} | |
| /* table */ | |
| .table{width:100%;border-collapse:collapse;font-size:12px} | |
| .table th{text-align:left;padding:8px 12px;color:var(--tx3);font-size:10px;font-weight:500;letter-spacing:.15em;text-transform:uppercase;border-bottom:1px solid var(--bd)} | |
| .table td{padding:10px 12px;border-bottom:1px solid var(--bd)} | |
| .table tr:last-child td{border-bottom:none} | |
| .table tr:hover td{background:rgba(255,255,255,.012)} | |
| .mono-cell{color:var(--tx)} | |
| .masked{color:var(--tx2);cursor:pointer;letter-spacing:.04em} | |
| .empty{padding:24px;text-align:center;color:var(--tx3);font-size:12px} | |
| /* nodes */ | |
| .node-list{display:grid;gap:7px;margin-top:14px;max-height:480px;overflow-y:auto;padding-right:4px} | |
| .node{border:1px solid var(--bd);border-radius:8px;padding:10px 14px;display:flex;align-items:center;gap:12px;transition:border-color .15s;background:var(--bg);min-width:0} | |
| .node:hover{border-color:var(--bd2)} | |
| .node.active{border-color:rgba(0,229,160,.45);background:rgba(0,229,160,.03)} | |
| .node.selected{border-color:rgba(77,159,255,.65);box-shadow:0 0 0 1px rgba(77,159,255,.16) inset} | |
| .node.in-pool{border-color:rgba(77,159,255,.35);background:rgba(77,159,255,.03)} | |
| .node.active.in-pool{border-color:rgba(0,229,160,.6)} | |
| .node-cb{width:15px;height:15px;accent-color:var(--blue);cursor:pointer;flex-shrink:0;margin:0} | |
| .node-info{flex:1;min-width:0} | |
| .node-name{font-size:13px;color:var(--tx);margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-all} | |
| .node-meta{font-size:11px;color:var(--tx2);display:flex;gap:10px;flex-wrap:wrap} | |
| .node-meta span { min-width:0; word-break:break-all } | |
| .node-type{text-transform:uppercase;letter-spacing:.08em;color:var(--ac)} | |
| .node-badges{display:flex;gap:5px;flex-shrink:0} | |
| .node-actions{display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end} | |
| .nbadge{font-size:9px;padding:2px 6px;border-radius:3px;letter-spacing:.08em} | |
| .nbadge-active{background:var(--ac3);color:var(--ac);border:1px solid rgba(0,229,160,.25)} | |
| .nbadge-pool{background:rgba(77,159,255,.1);color:var(--blue);border:1px solid rgba(77,159,255,.2)} | |
| .nbadge-idx{background:rgba(77,159,255,.07);color:var(--tx2);border:1px solid var(--bd2)} | |
| .nbadge-off{background:rgba(255,255,255,.025);color:var(--tx3);border:1px solid var(--bd2)} | |
| /* subscriptions */ | |
| .sub-list{display:grid;gap:8px;margin-top:14px} | |
| .sub-item{border:1px solid var(--bd);border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px;background:linear-gradient(180deg,rgba(255,255,255,.012),transparent),var(--bg);min-width:0} | |
| .sub-item:hover{border-color:var(--bd2)} | |
| .sub-item.disabled{opacity:.58} | |
| .sub-cb{width:15px;height:15px;accent-color:var(--blue);cursor:pointer;flex-shrink:0;margin:0} | |
| @media(max-width:600px){ | |
| .node, .sub-item { flex-wrap: wrap; align-items: flex-start; } | |
| .node-cb, .sub-cb { margin-top: 2px; } | |
| .node-info, .sub-main { flex: 1 1 calc(100% - 30px); } | |
| .node-badges { flex: 1 1 100%; margin-top: 6px; padding-left: 27px; } | |
| .node-actions, .sub-actions { flex: 1 1 100%; margin-top: 6px; padding-left: 27px; justify-content: flex-start; gap: 8px; } | |
| .node-actions .btn, .sub-actions .btn { flex: 1; justify-content: center; } | |
| } | |
| .sub-main{flex:1;min-width:0} | |
| .sub-name{font-size:13px;color:var(--tx);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-all} | |
| .sub-url{font-size:11px;color:var(--tx3);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-all} | |
| .sub-meta{font-size:10px;color:var(--tx2);margin-top:2px;display:flex;gap:8px;flex-wrap:wrap} | |
| .sub-actions{display:flex;align-items:center;gap:6px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end} | |
| .mini-toggle{display:flex;align-items:center;gap:6px;color:var(--tx2);font-size:11px;white-space:nowrap} | |
| .sub-item.selected{border-color:rgba(77,159,255,.62);box-shadow:0 0 0 1px rgba(77,159,255,.14) inset;background:linear-gradient(135deg,rgba(77,159,255,.06),rgba(0,229,160,.025)),var(--bg)} | |
| .import-stack{display:grid;gap:14px} | |
| .import-block{border:1px solid var(--bd);background:linear-gradient(180deg,rgba(255,255,255,.012),transparent),var(--bg);border-radius:10px;padding:14px;min-width:0} | |
| .import-block .actions{margin-top:0} | |
| .single-node-card{border:1px solid rgba(0,229,160,.16);background:linear-gradient(135deg,rgba(0,229,160,.055),rgba(77,159,255,.025)),var(--bg);border-radius:12px;padding:16px} | |
| /* selection controls */ | |
| .select-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;background:rgba(0,229,160,.04);border:1px solid rgba(0,229,160,.12);border-radius:8px;margin-top:12px} | |
| .select-bar-label{font-size:11px;color:var(--ac);text-transform:uppercase;letter-spacing:.1em} | |
| .node-manager{display:grid;grid-template-columns:minmax(260px,1fr) minmax(260px,1fr);gap:14px;margin-top:14px} | |
| @media(max-width:860px){.node-manager{grid-template-columns:1fr}} | |
| /* pool controls */ | |
| .pool-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;background:rgba(77,159,255,.05);border:1px solid rgba(77,159,255,.15);border-radius:8px;margin-top:10px} | |
| .pool-bar-label{font-size:11px;color:var(--blue);text-transform:uppercase;letter-spacing:.1em} | |
| /* core badge */ | |
| .pill-warn{display:inline-block;padding:2px 9px;font-size:10px;letter-spacing:.08em;text-transform:uppercase;border:1px solid rgba(240,192,64,.35);color:var(--yel);border-radius:4px;background:rgba(240,192,64,.08)} | |
| /* toast */ | |
| .toast-wrap{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px} | |
| .toast{background:var(--bg3);border:1px solid var(--bd2);border-left:2px solid var(--ac);border-radius:9px;padding:11px 15px;min-width:220px;max-width:360px;font-size:12px;box-shadow:var(--shadow);animation:tin .2s cubic-bezier(.34,1.56,.64,1)} | |
| .toast.ok{border-left-color:var(--ac)} | |
| .toast.err{border-left-color:var(--red)} | |
| @keyframes tin{from{transform:translateY(10px);opacity:0}to{transform:translateY(0);opacity:1}} | |
| </style> | |
| </head> | |
| <body> | |
| <!-- LOGIN --> | |
| <div id="loginView" class="login-wrap"> | |
| <div class="login-card"> | |
| <div class="login-logo"> | |
| <div class="ico">โฌก</div> | |
| <div><h1>Vertex Proxy</h1><p>Control Panel</p></div> | |
| </div> | |
| <form id="loginForm"> | |
| <div class="field"> | |
| <label for="pw">Admin password</label> | |
| <input type="password" id="pw" autocomplete="current-password" autofocus required placeholder="โขโขโขโขโขโขโขโข"> | |
| </div> | |
| <div class="err-msg" id="loginErr" style="display:none"></div> | |
| <button class="btn btn-primary" type="submit" style="width:100%;justify-content:center;padding:11px;">Enter โ</button> | |
| </form> | |
| <div class="hint"> | |
| ้ฆๆฌก้จ็ฝฒ๏ผๅฏๅจๆฅๅฟ้ไผๆๅฐ้ๆบ็ๆ็็ฎก็ๅๅฏ็ ๏ผๆ็ดข <code>ๅฏ็ :</code>ใ<br> | |
| ไนๅฏไปฅ่ฎพ็ฝฎ็ฏๅขๅ้ <code>ADMIN_PASSWORD</code> ๅบๅฎๅฏ็ ใ | |
| </div> | |
| </div> | |
| </div> | |
| <!-- APP --> | |
| <div class="app" id="appView"> | |
| <div class="topbar"> | |
| <div class="brand"> | |
| <div class="brand-ico">โฌก</div> | |
| <div class="brand-txt">Vertex Proxy <span>// control</span></div> | |
| </div> | |
| <div class="topbar-r"> | |
| <div class="top-status off" id="activeBar"> | |
| <div class="ab-l"> | |
| <span class="ab-dot"></span> | |
| <span class="ab-lbl" id="activeLabel">ๆชๅฏ็จ่็น</span> | |
| <span class="ab-name" id="activeName"></span> | |
| <span class="ab-via" id="activeVia"></span> | |
| <span class="ab-pool-badge" id="poolBadge" style="display:none"></span> | |
| </div> | |
| <button class="btn btn-sm btn-danger" id="stopBtn" onclick="stopProxy()" style="display:none;">ๅๆญขๅนถ็ด่ฟ</button> | |
| </div> | |
| <div class="port-badge" id="portBadge">โ</div> | |
| <button class="btn btn-sm" onclick="logout()">้ๅบ็ปๅฝ</button> | |
| </div> | |
| </div> | |
| <div class="tabs"> | |
| <button class="tab active" data-tab="server">โ ็ณป็ป</button> | |
| <button class="tab" data-tab="keys">๐ API Keys</button> | |
| <button class="tab" data-tab="models">๐ค Models</button> | |
| <button class="tab" data-tab="proxy">๐ก ่็นไธ่ฎข้ </button> | |
| </div> | |
| <div class="main"> | |
| <!-- โโโ SERVER โโโ --> | |
| <div class="panel active" id="panel-server"> | |
| <div class="section"> | |
| <div class="s-hd"><div><div class="s-title">๐ Server</div><div class="s-sub">ๅบๆฌ่ฟ่กๅๆฐใ็ซฏๅฃไธ debug ๅๆด้้ๅฏๅฎนๅจ็ๆใ</div></div></div> | |
| <div class="s-body"> | |
| <div class="grid"> | |
| <div class="field"> | |
| <label>API ็ซฏๅฃ</label> | |
| <input type="number" id="port_api" min="1" max="65535"> | |
| <div class="fhint">ๅฎนๅจๆด้ฒ็ซฏๅฃ (1โ65535)</div> | |
| </div> | |
| <div class="field"> | |
| <label>Admin password</label> | |
| <input type="password" id="newPw" autocomplete="new-password" data-lpignore="true" data-form-type="other" placeholder="่ณๅฐ 6 ไฝ"> | |
| <div class="fhint">ไฟฎๆน็ฎก็้ขๆฟ็ปๅฝๅฏ็ ใ</div> | |
| </div> | |
| <div class="full"> | |
| <div class="toggle-row"> | |
| <div class="toggle-info"><div class="t-title">Debug ๆฅๅฟๆจกๅผ</div><div class="t-sub">่พๅบ DEBUG ็บงๅซ่ฏฆ็ปๆฅๅฟ๏ผๆญฃๅธธ่ฟ่กๅปบ่ฎฎๅ ณ้ญ</div></div> | |
| <label class="tog"><input type="checkbox" id="debug"><span class="tog-track"><span class="tog-thumb"></span></span></label> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="pwLockedWarn" style="display:none;margin-top:12px;color:var(--yel);font-size:11px"> | |
| ๅฝๅ็ฑ็ฏๅขๅ้ <code>ADMIN_PASSWORD</code> ้ๅฎ๏ผไธ่ฝๅจ้ขๆฟไฟฎๆนใ | |
| </div> | |
| <div class="actions"> | |
| <button class="btn btn-primary" onclick="saveSettings()">Save</button> | |
| <span id="saveTip" class="fwarn"></span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="s-hd"><div><div class="s-title">๐ฌ ไผ่ฏ</div><div class="s-sub">ๆฏไธชไธๅกไผ่ฏๅๅปบ่ฏทๆฑๆฑ ๏ผๅนถ่กๅฐ่ฏๅ้่็น๏ผๆตๅผ winner ็ดๆฅ่ฟๅ๏ผ้ๆตๅผ winner ๅฟ ้กปๅฎๆดๆๅใ</div></div></div> | |
| <div class="s-body"> | |
| <div style="display:flex;flex-direction:column;gap:10px"> | |
| <div class="grid"> | |
| <div class="field"> | |
| <label>ๅนถ่กๆฑ ๅคงๅฐ</label> | |
| <input type="number" id="parallel_pool_size" min="1" max="64" placeholder="10" title="ๆฏไธช่ฏทๆฑๆฑ ๅๆถ่ฟ่ก็ไธๆธธ่็นๅฐ่ฏๆฐ้๏ผ่ฐๅ ่ฟๅๆๆ้ฆๅ ๅฐฑๆไธบ winnerใ"> | |
| </div> | |
| <div class="field"> | |
| <label>ๅฎๅ จไธ้</label> | |
| <input type="number" id="parallel_pool_max_size" min="1" max="64" placeholder="20" title="ๅ ่ฎธ้ ็ฝฎ็ๆๅคงๅนถ่กๆฑ ๅคงๅฐ๏ผ็จไบ้ฒๆญข่ฏฏ้ ็ฝฎๅฏผ่ด่ตๆบ่ๅฐฝใ"> | |
| </div> | |
| <div class="field"> | |
| <label>ๅ้้ๅ้ฟๅบฆ</label> | |
| <input type="number" id="candidate_queue_length" min="1" max="100000" placeholder="100" title="ๆฏ่ฝฎ่ฏทๆฑๆฑ ็ๆ็ๅ้่็นๆฐ้๏ผๅ้ไผๆๅฅๅบท่ฏๅใๅทๅด็ถๆๅๅ ๆ้ๆบๆๅบใ"> | |
| </div> | |
| <div class="field"> | |
| <label>ๅ้่ทๅ่ฝฎๆฌก</label> | |
| <input type="number" id="candidate_queue_rounds" min="0" max="10000" placeholder="0" title="ๅฝๅ่ฏทๆฑๆฑ ๅ้้ๅ่ๅฐฝๅ๏ผๆๅค้ๆฐ่ทๅๅ้้ๅ็่ฝฎๆฌก๏ผ0 ่กจ็คบไธ้่ฝฎๆฌกใ"> | |
| </div> | |
| <div class="field"> | |
| <label>ๅ่็นๅ ้จ้่ฏ</label> | |
| <input type="number" id="node_retry_count" min="0" max="100" placeholder="1" title="ๅไธช่็นๅ็จๅ ้จ้่ฏๆฌกๆฐ๏ผ่ทๅ token ๆไธๆธธ่ฏทๆฑไปปไธๆญฅๅคฑ่ดฅ้ฝไผๆถ่่ฟไธช้ข็ฎ๏ผ่ๅฐฝๅไบค็ป่ฏทๆฑๆฑ ๆขไธไธไธชๅ้่็นใ"> | |
| </div> | |
| <div class="field"> | |
| <label>่ฏทๆฑๆฑ ้ฆๅ ่ถ ๆถ</label> | |
| <input type="number" id="request_pool_deadline_seconds" min="0" max="3600" step="1" placeholder="0" title="่ฏทๆฑๆฑ ไปๅฏๅจๅฐๆถๅฐไธๆธธๅๅบ้ฆๅ ๅนถ้ๅบ winner ็ๆป่ถ ๆถ๏ผ0 ่กจ็คบไฝฟ็จๅบๅฑ็ฝ็ป่ถ ๆถใwinner ้ๅบๅๅ็ปญๅๅบไธๅๆญค้ ็ฝฎๅฝฑๅใ"> | |
| </div> | |
| <div class="field"> | |
| <label>winner ๅ้กฟ่ถ ๆถ็ง</label> | |
| <input type="number" id="stream_winner_stall_timeout_seconds" min="0" max="3600" step="1" placeholder="0" title="winner ้ฆๅ ๅๅฆๆ่ถ ่ฟ่ฏฅ็งๆฐๆฒกๆๆฐ็ไธๆธธ raw chunk ไธๅๅบๆช็ปๆ๏ผๅ็ดๆฅ่ฟๅ 504 ไธๆธธๅๅบ่ถ ๆถๅนถๆธ ็่ตๆบ๏ผ0 ่กจ็คบๅ ณ้ญใ"> | |
| </div> | |
| <div class="field"> | |
| <label>Worker ่ตทๅง็ซฏๅฃ</label> | |
| <input type="number" id="parallel_worker_base_port" min="1" max="65535" placeholder="12080" title="ไธดๆถไปฃ็ worker ็ณ่ฏท็ซฏๅฃ็่ตทๅง็ซฏๅฃ๏ผ็จไบ vmess/vless/trojan ็ญ้่ฆๆฌๅฐ worker ็่็นใ"> | |
| </div> | |
| <div class="field"> | |
| <label>Worker ็ซฏๅฃ่ๅด</label> | |
| <input type="number" id="parallel_worker_port_span" min="1" max="20000" placeholder="2000" title="ไธดๆถ worker ๅฏ็ณ่ฏท็ซฏๅฃๆฐ้๏ผๅฎ้ ่ๅดไธบ่ตทๅง็ซฏๅฃๅฐ่ตทๅง็ซฏๅฃ+่ๅด-1ใ"> | |
| </div> | |
| <div class="field"> | |
| <label>ๆดปๅจไธๅกไผ่ฏไธ้</label> | |
| <input type="number" id="business_session_concurrency_limit" min="0" max="10000" placeholder="10" title="ๅๆถๅคไบ active ็ถๆ็ไธๆธธไธๅกไผ่ฏไธ้๏ผ0 ่กจ็คบไธ้ใ"> | |
| </div> | |
| </div> | |
| <div class="muted">่ฏดๆ๏ผๆ ๅฏ็จ่็น=็ด่ฟ๏ผๅฏ็จ 1 ไธช่็น=ๅบๅฎไปฃ็๏ผๅฏ็จๅคไธช่็น=ๅจๆไปฃ็ใๅนถ่กๆฑ ๅคงๅฐๆงๅถๆฏ่ฝฎ่ฏทๆฑๆฑ ๅ ๅๆถ่ฟ่ก็ไธๆธธๅฐ่ฏๆฐ๏ผๆดปๅจไธๅกไผ่ฏไธ้ๆงๅถๅๆถ active ็ไธๆธธไผ่ฏๆฐ๏ผ0 ่กจ็คบไธ้๏ผ่ถ ่ฟ้ๅถ็ๆฐ่ฏทๆฑไผ็ญๅพ ๏ผ่ฏทๆฑๆฑ ้ฆๅ ่ถ ๆถๅช้ๅถ่ฏทๆฑๆฑ ้ๅบ winner ๅ็็ญๅพ ๆถ้ด๏ผๅกซ 0 ่กจ็คบไบค็ปๅบๅฑ็ฝ็ป่ถ ๆถ๏ผwinner ๅ้กฟ่ถ ๆถ็จไบ้ฆๅ ๅ raw chunk ้ฟๆถ้ดไธๅๅฐ่พพๆถ็ดๆฅๅคๅฎ 504๏ผไธ้้ๅ ถๅฎๅ้๏ผๅ่็นๅ ้จ้่ฏๅกซ 0 ่กจ็คบ 0 ๆฌก้่ฏใ</div> | |
| </div> | |
| <div class="actions"> | |
| <button class="btn btn-primary" onclick="saveParallelPool()">Save</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="s-hd"><div><div class="s-title">๐ก๏ธ ่ฏทๆฑ้ฒๆค</div><div class="s-sub">Anti-tracking ไธ token ๆงๅถใ็ซๅณ็ๆ๏ผๆ ้้ๅฏใ</div></div></div> | |
| <div class="s-body"> | |
| <div style="display:flex;flex-direction:column;gap:10px"> | |
| <div class="toggle-row"> | |
| <div class="toggle-info"> | |
| <div class="t-title">๐ก๏ธ ้ฒๆ ่ฎฐๆณจๅ ฅ (anti_tracking)</div> | |
| <div class="t-sub">ๅจ็ณป็ปๆไปค้กถ้จๆณจๅ ฅ้ๆบๅญ็ฌฆ๏ผ้ฒๆญข Google ๅฏนไผ่ฏ่ฟ่ก่กไธบๆ ่ฎฐ</div> | |
| </div> | |
| <label class="tog"><input type="checkbox" id="anti_tracking"><span class="tog-track"><span class="tog-thumb"></span></span></label> | |
| </div> | |
| <div class="toggle-row"> | |
| <div class="toggle-info"> | |
| <div class="t-title">๐ซ ไธขๅผ max_tokens (drop_max_tokens)</div> | |
| <div class="t-sub">่ฝฌๅ่ฏทๆฑๆถ็งป้ค max_tokens ๅญๆฎต๏ผ้ฟๅ Vertex ๅๅบ่ขซๆๅคๆชๆญ</div> | |
| </div> | |
| <label class="tog"><input type="checkbox" id="drop_max_tokens"><span class="tog-track"><span class="tog-thumb"></span></span></label> | |
| </div> | |
| <div class="toggle-row"> | |
| <div class="toggle-info"> | |
| <div class="t-title">๐ข Anti-429 ้ๆบๆฐๆณจๅ ฅ</div> | |
| <div class="t-sub">ๆฏๆฌก่ฏทๆฑ่ชๅจๆๅ ฅ 100 ไฝ้ๆบๆฐ๏ผ้ไฝ่ขซ็ผๅญๅฝไธญ/้ๆต็ๆฆ็</div> | |
| </div> | |
| <label class="tog"><input type="checkbox" id="anti429_enabled"><span class="tog-track"><span class="tog-thumb"></span></span></label> | |
| </div> | |
| <div class="field" style="padding:0 2px"> | |
| <label>Anti-429 ๆๅ ฅไฝ็ฝฎ</label> | |
| <select id="anti429_target"> | |
| <option value="system">System๏ผ็ณป็ปๆถๆฏๆๅ้ข๏ผ</option> | |
| <option value="user">User๏ผ็ฌฌไธๆก็จๆทๆถๆฏๆๅ้ข๏ผ</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="actions"> | |
| <button class="btn btn-primary" onclick="saveProtection()">Save</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div><!-- /server --> | |
| <!-- โโโ API KEYS โโโ --> | |
| <div class="panel" id="panel-keys"> | |
| <div class="section"> | |
| <div class="s-hd"><div><div class="s-title">๐ API Keys</div><div class="s-sub">่ฐ็จ /v1/chat/completions ็่ฎฟ้ฎๅฏ้ฅใไฟฎๆน็ซๅณ็ๆใ</div></div></div> | |
| <div class="s-body"> | |
| <div class="grid"> | |
| <div class="field"><label>ๅ็งฐ</label><input type="text" id="key_name" placeholder="e.g. my-client"></div> | |
| <div class="field"><label>ๅฏ้ฅ</label><input type="text" id="key_value" placeholder="sk-123456"></div> | |
| <div class="field full"><label>ๅคๆณจ (ๅฏ้)</label><input type="text" id="key_desc"></div> | |
| </div> | |
| <div class="actions"> | |
| <button class="btn btn-primary" onclick="addKey()">๏ผ Add key</button> | |
| <span class="fhint">ๅๅไผ่ฆ็</span> | |
| </div> | |
| <div class="sub-list" id="keysList" style="margin-top:18px"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- โโโ MODELS โโโ --> | |
| <div class="panel" id="panel-models"> | |
| <div class="section"> | |
| <div class="s-hd"><div><div class="s-title">๐ค Models</div><div class="s-sub">็ฎก็ models.json ไธญ็ๅฏ็จๆจกๅๅ่กจใไฟฎๆน็ซๅณ็ๆใ</div></div></div> | |
| <div class="s-body"> | |
| <div class="grid"> | |
| <div class="field"><label>ๆทปๅ ๆจกๅ ID</label><input type="text" id="new_model_id" autocomplete="off" data-lpignore="true" data-form-type="other" placeholder="gemini-2.5-pro"></div> | |
| <div style="display:flex;align-items:flex-end"><button class="btn btn-primary" onclick="addModel()">๏ผ Add</button></div> | |
| </div> | |
| <table class="table" style="margin-top:18px"> | |
| <thead><tr><th>Model ID</th><th style="width:80px"></th></tr></thead> | |
| <tbody id="modelsTbody"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="s-hd"><div><div class="s-title">๐ Alias map</div><div class="s-sub">ๅซๅ โ ๅฎ้ ๆจกๅ ID ็ๆ ๅฐใ</div></div></div> | |
| <div class="s-body"> | |
| <div class="grid"> | |
| <div class="field"><label>ๅซๅ๏ผๅฎขๆท็ซฏไผ ๅ ฅ๏ผ</label><input type="text" id="alias_from" placeholder="gpt-4o"></div> | |
| <div class="field"><label>ๆ ๅฐๅฐ็ๅฎ้ ๆจกๅ ID</label><input type="text" id="alias_to" placeholder="gemini-2.5-pro"></div> | |
| </div> | |
| <div class="actions"><button class="btn btn-primary" onclick="addAlias()">๏ผ Add alias</button></div> | |
| <table class="table" style="margin-top:18px"> | |
| <thead><tr><th>ๅซๅ</th><th></th><th>ๅฎ้ ๆจกๅ</th><th style="width:80px"></th></tr></thead> | |
| <tbody id="aliasTbody"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- โโโ SUBSCRIPTION โโโ --> | |
| <div class="panel" id="panel-proxy"> | |
| <div class="section"> | |
| <div class="s-hd"> | |
| <div><div class="s-title">๐ฅ ่็นๅฏผๅ ฅ</div><div class="s-sub">่ฎข้ ๅฏผๅ ฅใๅชๅๆฟๅฏผๅ ฅใๆไปถๅฏผๅ ฅไธๆๅจๅ่็นๅฏผๅ ฅ็ปไธๅจๆญคๅๆขๆพ็คบใ</div></div> | |
| <span id="coreBadge" class="pill-warn" style="display:none">core not ready</span> | |
| </div> | |
| <div class="s-body"> | |
| <div class="subtabs"> | |
| <button class="subtab active" data-import-tab="subscription">่ฎข้ ๅฏผๅ ฅ</button> | |
| <button class="subtab" data-import-tab="clipboard">ๅชๅๆฟๅฏผๅ ฅ</button> | |
| <button class="subtab" data-import-tab="file">ๆไปถๅฏผๅ ฅ</button> | |
| <button class="subtab" data-import-tab="manual">ๆทปๅ ๅไธช่็น</button> | |
| </div> | |
| <div class="import-pane active" id="import-subscription"> | |
| <div class="import-stack"> | |
| <div class="import-block"> | |
| <div class="field"> | |
| <label>่ฎข้ ้พๆฅ่พๅ ฅ</label> | |
| <textarea id="sub_urls" placeholder="ๆฏ่กไธไธช๏ผๆ็ดๆฅ็ฒ่ดดๅคไธช่ฎข้ ้พๆฅใ็ณป็ปไผ่ชๅจไป้พๆฅไธญๆๅๅ็งฐ๏ผๆ ๆณๆๅๆถไฝฟ็จๅบๅทใ"></textarea> | |
| <div class="fhint">ๆฏๆไธๆฌก็ฒ่ดดๅคไธช http(s) ่ฎข้ ้พๆฅ๏ผๅ็งฐๅฏไธบ็ฉบ๏ผไผ่ชๅจ็จๅๅใ่ทฏๅพใtoken/id ็ญ็ๆใ</div> | |
| </div> | |
| </div> | |
| <div class="import-block"> | |
| <div class="collection-title"><div class="s-title">ๆไฝๆ้ฎ</div><div id="subSelectCount" class="fhint">้ๆฉ่ฎข้ ๅๆๅ่็น</div></div> | |
| <div class="actions"> | |
| <button class="btn btn-primary" onclick="addSubscriptionsBatch()">๏ผ ๆทปๅ ่ฎข้ </button> | |
| <button class="btn" onclick="document.getElementById('sub_urls').value=''">ๆธ ็ฉบ่พๅ ฅ</button> | |
| <button class="btn" onclick="selectAllSubscriptions(true)">ๅ จ้</button> | |
| <button class="btn" onclick="selectAllSubscriptions(false)">ๅๆถๅ จ้</button> | |
| <button class="btn btn-danger" onclick="deleteSelectedSubscriptions()">ๅ ้ค้ไธญ่ฎข้ </button> | |
| <button class="btn btn-primary" id="fetchBtn" onclick="fetchSubscriptions('selected')" disabled>ๆๅ้ไธญ่ฎข้ (0)</button> | |
| </div> | |
| <div id="subSummary" class="fhint" style="margin-top:10px"></div> | |
| </div> | |
| <div class="import-block"> | |
| <div class="collection-title"><div class="s-title">่ฎข้ ้พๆฅๅ่กจ</div><div class="fhint">ๆฏๆ Ctrl+็นๅป่ทณ่ทๅค้ใShift+็นๅป่ฟ็ปญๅค้</div></div> | |
| <div class="sub-list" id="subList"></div> | |
| <div id="subSelectBar" style="display:none"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="import-pane" id="import-clipboard"> | |
| <div class="soft-card" style="margin-top:0"> | |
| <div class="s-title" style="font-size:13px">๐ ๅชๅๆฟ็ฒ่ดดๅฏผๅ ฅ</div> | |
| <div class="s-sub">ๆฏๆ URI ๅ่กจใbase64 ่ฎข้ ๅ ๅฎนใClash/Mihomo YAMLใJSON/ๆฅๅฟไธญๆททๆ็ๅ่ฎฎ้พๆฅใ</div> | |
| <textarea id="nodePasteText" placeholder="็ฒ่ดด vmess:// / vless:// / trojan:// / ss:// / hysteria2:// / tuic:// / socks5:// / http:// ๆ Clash YAML..."></textarea> | |
| <div class="toolbar"> | |
| <button class="btn btn-sm" onclick="pasteFromClipboard()">่ฏปๅๅชๅๆฟ</button> | |
| <button class="btn btn-sm btn-primary" onclick="importNodeText()">ๅฏผๅ ฅ็ฒ่ดด่็น</button> | |
| <button class="btn btn-sm" onclick="document.getElementById('nodePasteText').value=''">ๆธ ็ฉบ</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="import-pane" id="import-file"> | |
| <div class="soft-card" style="margin-top:0"> | |
| <div class="s-title" style="font-size:13px">๐ ๆไปถๅฏผๅ ฅ</div> | |
| <div class="s-sub">็ปไธๆฏๆ .txt / .yaml / .yml / .json / .conf / .list / ไปปๆๆๆฌๆไปถไธญ็ๅ็งๅ่ฎฎ่็นใ</div> | |
| <div class="file-picker" style="margin-top:12px"> | |
| <input class="hidden-file" type="file" id="nodeImportFile" accept=".txt,.yaml,.yml,.json,.conf,.list,.csv,.log,*/*" onchange="updateFileName()"> | |
| <button class="btn btn-sm" onclick="document.getElementById('nodeImportFile').click()">้ๆฉๆไปถ</button> | |
| <span class="file-name" id="nodeFileName">ๆช้ๆฉๆไปถ</span> | |
| </div> | |
| <div class="toolbar"> | |
| <button class="btn btn-sm btn-primary" onclick="importNodeFile()">ๅฏผๅ ฅๆไปถ</button> | |
| <span class="fhint">ๅฏผๅ ฅไผๅๅนถๅฐ็ปไธ่็น้ๅ๏ผไธๅ่ฆ็ๅทฒๆ่็นใ</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="import-pane" id="import-manual"> | |
| <div class="single-node-card"> | |
| <div class="collection-title"> | |
| <div><div class="s-title" style="font-size:13px">โ ๆทปๅ ๅไธช่็น</div><div class="s-sub">็ฒ่ดดไธไธช่็น่ฟๆฅ๏ผๆฏๆๆๆๅทฒ่งฃๆๅ่ฎฎ๏ผๆทปๅ ๅ่ฟๅ ฅ็ปไธ่็น้ๅใ</div></div> | |
| </div> | |
| <div class="grid" style="margin-top:12px"> | |
| <div class="field"><label>่็นๅ็งฐ</label><input type="text" id="manualNodeName" placeholder="ๅฏ้๏ผ็็ฉบไฝฟ็จ่็นๅ ๅ็งฐ"></div> | |
| <div class="field"><label>่็น่ฟๆฅ</label><input type="text" id="manualNodeUri" placeholder="vmess:// / vless:// / trojan:// / ss:// / hysteria2:// / tuic:// / socks5:// / http:// ..."></div> | |
| </div> | |
| <div class="toolbar"> | |
| <button class="btn btn-sm btn-primary" onclick="addManualNode()">ๆทปๅ ๅฐ็ปไธ่็น้ๅ</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="s-hd"><div><div class="s-title">๐งฉ ็ปไธ่็น้ๅ</div><div class="s-sub">ๆๆๅฏผๅ ฅๆฅๆบไผๅๅนถไธบ็ปไธๅ้้ๅ๏ผ้ป่ฎคๆชๅฏ็จใ</div></div></div> | |
| <div class="s-body"> | |
| <div class="collection-grid"> | |
| <div class="collection-card accent"> | |
| <div class="collection-title"> | |
| <div><div class="s-title">้ๅไฟกๆฏ</div><div class="s-sub">็ฎ่ฆ็ถๆไธๅฝๅ็ญ้็ปๆใ</div></div> | |
| <span id="nodeModeBadge" class="pill-warn">็ด่ฟ</span> | |
| </div> | |
| <div id="poolCount" class="muted">่ฎข้ /ๆไปถ/ๅชๅๆฟๅฏผๅ ฅๅ่ชๅจไฟๅญไธบๅ้่็น</div> | |
| </div> | |
| <div class="collection-card"> | |
| <div class="collection-title"><div class="s-title">็ญ้ๆ็ดข</div></div> | |
| <div class="filter-row"> | |
| <input type="text" id="nodeSearch" placeholder="ๆๅ็งฐ / ๅฐๅ / ๆฅๆบๆ็ดข" oninput="renderFilteredNodes()"> | |
| <select id="nodeSourceFilter" onchange="renderFilteredNodes()"><option value="">ๅ จ้จๆฅๆบ</option></select> | |
| <select id="nodeTypeFilter" onchange="renderFilteredNodes()"><option value="">ๅ จ้จๅ่ฎฎ</option></select> | |
| </div> | |
| </div> | |
| <div class="collection-card" id="poolBar"> | |
| <div class="collection-title"><div class="s-title">ๆน้ๆไฝ</div><div class="fhint">้ๅฏนๅฝๅ็ญ้ / ๆ็ดข็ปๆ็ๆ</div></div> | |
| <div class="toolbar" style="margin-top:0"> | |
| <button class="btn btn-sm" onclick="selectAllUnifiedNodes(true)">ๅ จ้</button> | |
| <button class="btn btn-sm" onclick="selectAllUnifiedNodes(false)">ๅๆถๅ จ้</button> | |
| <button class="btn btn-sm btn-danger" onclick="deleteSelectedNodes()">ๅ ้ค้ไธญ</button> | |
| <button class="btn btn-sm btn-primary" onclick="setSelectedNodesEnabled(true)">ๅฏ็จ้ไธญ</button> | |
| <button class="btn btn-sm" onclick="setSelectedNodesEnabled(false)">็ฆ็จ้ไธญ</button> | |
| </div> | |
| </div> | |
| <div class="collection-card"> | |
| <div class="collection-title"><div class="s-title">่็นๅ่กจ</div><div class="fhint">ๆฏๆ Ctrl+็นๅป่ทณ่ทๅค้ใShift+็นๅป่ฟ็ปญๅค้</div></div> | |
| <div class="node-list" id="nodeList"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div><!-- /proxy --> | |
| </div><!-- /main --> | |
| </div><!-- /app --> | |
| <div class="toast-wrap" id="toastWrap"></div> | |
| <script> | |
| // โโ Core โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| const TOKEN_KEY = 'vp_admin_token'; | |
| let token = localStorage.getItem(TOKEN_KEY) || ''; | |
| let _allNodes = []; // last fetched nodes array | |
| let _filteredNodes = []; | |
| let _selectedNodeUris = new Set(); | |
| let _subscriptions = []; // configured subscriptions | |
| let _selectedSubIds = new Set(); | |
| let _lastNodeClickIndex = null; | |
| let _lastSubClickIndex = null; | |
| function esc(s) { | |
| return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]); | |
| } | |
| function showToast(msg, type='info') { | |
| const wrap = document.getElementById('toastWrap'); | |
| const el = document.createElement('div'); | |
| el.className = 'toast ' + type; | |
| el.textContent = msg; | |
| wrap.appendChild(el); | |
| setTimeout(()=>{ el.style.opacity='0'; el.style.transition='opacity .25s'; }, 2800); | |
| setTimeout(()=>el.remove(), 3100); | |
| } | |
| async function api(method, path, body) { | |
| const opts = { method, headers: { 'Content-Type': 'application/json' } }; | |
| if (token) opts.headers['Authorization'] = 'Bearer ' + token; | |
| if (body !== undefined) opts.body = JSON.stringify(body); | |
| const res = await fetch(apiPath(path), opts); | |
| if (res.status === 401) { token=''; localStorage.removeItem(TOKEN_KEY); showLogin(); throw new Error('Unauthorized'); } | |
| const data = await res.json().catch(()=>({})); | |
| if (!res.ok) throw new Error(data.detail || data.message || ('HTTP ' + res.status)); | |
| return data; | |
| } | |
| function apiBase() { | |
| const p = window.location.pathname; | |
| const marker = '/admin'; | |
| const idx = p.lastIndexOf(marker); | |
| return idx >= 0 ? p.slice(0, idx) : ''; | |
| } | |
| function apiPath(path) { | |
| if (/^https?:\/\//i.test(path)) return path; | |
| return apiBase() + (path.startsWith('/') ? path : '/' + path); | |
| } | |
| function showLogin() { | |
| document.getElementById('loginView').style.display = 'flex'; | |
| document.getElementById('appView').style.display = 'none'; | |
| } | |
| function showApp() { | |
| document.getElementById('loginView').style.display = 'none'; | |
| document.getElementById('appView').style.display = 'block'; | |
| loadAll(); | |
| } | |
| document.getElementById('loginForm').addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const pwInput = document.getElementById('pw'); | |
| const pw = pwInput.value.trim(); | |
| const errEl = document.getElementById('loginErr'); | |
| errEl.style.display = 'none'; | |
| localStorage.removeItem(TOKEN_KEY); | |
| token = ''; | |
| try { | |
| const r = await fetch(apiPath('/api/admin/login'), { method:'POST', headers:{'Content-Type':'application/json'}, cache:'no-store', body: JSON.stringify({password:pw}) }); | |
| const j = await r.json().catch(()=>({})); | |
| if (!r.ok) throw new Error(j.detail||'Login failed'); | |
| token = j.token; localStorage.setItem(TOKEN_KEY, token); | |
| pwInput.value = ''; | |
| showApp(); | |
| } catch(err) { errEl.textContent=err.message; errEl.style.display='block'; } | |
| }); | |
| async function logout() { | |
| try { await api('POST','/api/admin/logout'); } catch(e){} | |
| token=''; localStorage.removeItem(TOKEN_KEY); showLogin(); | |
| } | |
| document.querySelectorAll('.tab').forEach(t => { | |
| t.addEventListener('click', () => { | |
| document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active')); | |
| document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active')); | |
| t.classList.add('active'); | |
| document.getElementById('panel-'+t.dataset.tab).classList.add('active'); | |
| }); | |
| }); | |
| document.querySelectorAll('.subtab').forEach(t => { | |
| t.addEventListener('click', () => { | |
| document.querySelectorAll('.subtab').forEach(x=>x.classList.remove('active')); | |
| document.querySelectorAll('.import-pane').forEach(x=>x.classList.remove('active')); | |
| t.classList.add('active'); | |
| document.getElementById('import-'+t.dataset.importTab).classList.add('active'); | |
| }); | |
| }); | |
| async function loadAll() { | |
| try { | |
| await loadSettings(); | |
| await loadKeys(); | |
| await loadModels(); | |
| await loadSubscriptions(); | |
| await loadUnifiedNodes(); | |
| await loadProxyStatus(); | |
| } catch(e) {} | |
| } | |
| // โโ Settings โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async function loadSettings() { | |
| const s = await api('GET','/api/admin/settings'); | |
| document.getElementById('port_api').value = s.port_api; | |
| document.getElementById('debug').checked = s.debug; | |
| document.getElementById('portBadge').textContent = 'Port ' + s.port_api; | |
| document.getElementById('anti429_enabled').checked = !!s.anti429_enabled; | |
| document.getElementById('anti429_target').value = s.anti429_target || 'system'; | |
| document.getElementById('parallel_pool_size').value = s.parallel_pool_size || 10; | |
| document.getElementById('parallel_pool_max_size').value = s.parallel_pool_max_size || 20; | |
| document.getElementById('candidate_queue_length').value = s.candidate_queue_length || 100; | |
| document.getElementById('candidate_queue_rounds').value = s.candidate_queue_rounds || 0; | |
| document.getElementById('node_retry_count').value = s.node_retry_count ?? 1; | |
| document.getElementById('request_pool_deadline_seconds').value = s.request_pool_deadline_seconds || 0; | |
| document.getElementById('stream_winner_stall_timeout_seconds').value = s.stream_winner_stall_timeout_seconds || 0; | |
| document.getElementById('parallel_worker_base_port').value = s.parallel_worker_base_port || 12080; | |
| document.getElementById('parallel_worker_port_span').value = s.parallel_worker_port_span || 2000; | |
| document.getElementById('business_session_concurrency_limit').value = s.business_session_concurrency_limit || 10; | |
| document.getElementById('anti_tracking').checked = !!s.anti_tracking; | |
| document.getElementById('drop_max_tokens').checked = !!s.drop_max_tokens; | |
| document.getElementById('pwLockedWarn').style.display = s.admin_password_env_locked ? 'block' : 'none'; | |
| document.getElementById('newPw').disabled = !!s.admin_password_env_locked; | |
| } | |
| async function saveSettings() { | |
| const body = { | |
| port_api: parseInt(document.getElementById('port_api').value,10), | |
| debug: document.getElementById('debug').checked, | |
| }; | |
| try { | |
| const r = await api('PUT','/api/admin/settings',body); | |
| const pwEl = document.getElementById('newPw'); | |
| const pw = pwEl.value.trim(); | |
| if (pw && !pwEl.disabled) { | |
| if (pw.length < 6) { showToast('ๅฏ็ ่ณๅฐ 6 ไฝ','err'); return; } | |
| await api('PUT','/api/admin/settings',{ admin_password: pw }); | |
| pwEl.value = ''; | |
| } | |
| showToast('ๅทฒไฟๅญ','ok'); | |
| document.getElementById('saveTip').textContent = (r.notes&&r.notes.length) ? r.notes.join(' ยท ') : ''; | |
| document.getElementById('portBadge').textContent = 'Port ' + body.port_api; | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function saveProtection() { | |
| try { | |
| await api('PUT','/api/admin/settings', { | |
| anti_tracking: document.getElementById('anti_tracking').checked, | |
| drop_max_tokens: document.getElementById('drop_max_tokens').checked, | |
| anti429_enabled: document.getElementById('anti429_enabled').checked, | |
| anti429_target: document.getElementById('anti429_target').value, | |
| }); | |
| showToast('ๅทฒไฟๅญ','ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function saveParallelPool() { | |
| try { | |
| await api('PUT','/api/admin/settings', { | |
| parallel_pool_size: parseInt(document.getElementById('parallel_pool_size').value,10), | |
| parallel_pool_max_size: parseInt(document.getElementById('parallel_pool_max_size').value,10), | |
| candidate_queue_length: parseInt(document.getElementById('candidate_queue_length').value,10), | |
| candidate_queue_rounds: parseInt(document.getElementById('candidate_queue_rounds').value,10), | |
| node_retry_count: parseInt(document.getElementById('node_retry_count').value,10), | |
| request_pool_deadline_seconds: parseFloat(document.getElementById('request_pool_deadline_seconds').value), | |
| stream_winner_stall_timeout_seconds: parseFloat(document.getElementById('stream_winner_stall_timeout_seconds').value), | |
| parallel_worker_base_port: parseInt(document.getElementById('parallel_worker_base_port').value,10), | |
| parallel_worker_port_span: parseInt(document.getElementById('parallel_worker_port_span').value,10), | |
| business_session_concurrency_limit: parseInt(document.getElementById('business_session_concurrency_limit').value,10), | |
| }); | |
| showToast('ๅทฒไฟๅญ','ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| // โโ API Keys โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| function maskKey(k) { return (!k||k.length<8)?k:(k.slice(0,6)+'โฆ'+k.slice(-4)); } | |
| async function copyKey(key) { | |
| try { | |
| if (navigator.clipboard && window.isSecureContext) { | |
| await navigator.clipboard.writeText(key); | |
| } else { | |
| let textArea = document.createElement("textarea"); | |
| textArea.value = key; | |
| textArea.style.position = "fixed"; | |
| textArea.style.left = "-999999px"; | |
| document.body.appendChild(textArea); | |
| textArea.focus(); | |
| textArea.select(); | |
| document.execCommand('copy'); | |
| textArea.remove(); | |
| } | |
| showToast('ๅทฒๅคๅถๅฐๅช่ดดๆฟ','ok'); | |
| } catch(e) { | |
| showToast('ๅคๅถๅคฑ่ดฅ','err'); | |
| } | |
| } | |
| async function loadKeys() { | |
| const data = await api('GET','/api/admin/keys'); | |
| const container = document.getElementById('keysList'); | |
| container.innerHTML = ''; | |
| if (!data.keys.length) { | |
| container.innerHTML = '<div class="empty">่ฟๆฒกๆๅฏ้ฅใ</div>'; | |
| return; | |
| } | |
| data.keys.forEach(k => { | |
| const el = document.createElement('div'); | |
| el.className = 'sub-item'; | |
| el.innerHTML = ` | |
| <div class="sub-main"> | |
| <div class="sub-name mono-cell">${esc(k.name)}</div> | |
| <div class="sub-url masked mono-cell" title="็นๅปๆพ็คบ" data-full="${esc(k.key)}" onclick="toggleReveal(this)">${esc(maskKey(k.key))}</div> | |
| <div class="sub-meta">${esc(k.description||'ๆ ๅคๆณจ')}</div> | |
| </div> | |
| <div class="sub-actions"> | |
| <button class="btn btn-sm" data-key="${esc(k.key)}" onclick="copyKey(this.dataset.key)">ๅคๅถ</button> | |
| <button class="btn btn-sm btn-danger" data-name="${esc(k.name)}" onclick="deleteKey(this.dataset.name)">ๅ ้ค</button> | |
| </div>`; | |
| container.appendChild(el); | |
| }); | |
| } | |
| function toggleReveal(el) { | |
| const f = el.dataset.full; | |
| if (el.dataset.shown==='1') { el.textContent=maskKey(f); el.dataset.shown='0'; } | |
| else { el.textContent=f; el.dataset.shown='1'; } | |
| } | |
| async function addKey() { | |
| const name = document.getElementById('key_name').value.trim(); | |
| const key = document.getElementById('key_value').value.trim(); | |
| const description = document.getElementById('key_desc').value.trim(); | |
| if (!name||!key) { showToast('name / key ไธ่ฝไธบ็ฉบ','err'); return; } | |
| try { | |
| await api('POST','/api/admin/keys',{name,key,description}); | |
| document.getElementById('key_name').value=''; | |
| document.getElementById('key_value').value=''; | |
| document.getElementById('key_desc').value=''; | |
| await loadKeys(); showToast('ๅทฒๆทปๅ ','ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function deleteKey(name) { | |
| if (!confirm('ๅ ้คๅฏ้ฅ '+name+' ?')) return; | |
| try { | |
| await api('DELETE','/api/admin/keys/'+encodeURIComponent(name)); | |
| await loadKeys(); showToast('ๅทฒๅ ้ค','ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| // โโ Models โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| let _modelsCache = {models:[],alias_map:{}}; | |
| async function loadModels() { | |
| const data = await api('GET','/api/admin/models'); | |
| _modelsCache = data; | |
| renderModels(data.models, data.alias_map); | |
| } | |
| function renderModels(models, aliasMap) { | |
| const tbody = document.getElementById('modelsTbody'); | |
| tbody.innerHTML = ''; | |
| if (!models.length) { tbody.innerHTML='<tr><td colspan="2" class="empty">่ฟๆฒกๆๆจกๅ</td></tr>'; } | |
| else models.forEach(m => { | |
| const tr = document.createElement('tr'); | |
| tr.innerHTML = `<td class="mono-cell" style="color:var(--ac)">${esc(m)}</td> | |
| <td><button class="btn btn-sm btn-danger" onclick="deleteModel('${esc(m)}')">ๅ ้ค</button></td>`; | |
| tbody.appendChild(tr); | |
| }); | |
| const atbody = document.getElementById('aliasTbody'); | |
| atbody.innerHTML = ''; | |
| const entries = Object.entries(aliasMap||{}); | |
| if (!entries.length) { atbody.innerHTML='<tr><td colspan="4" class="empty">่ฟๆฒกๆๅซๅๆ ๅฐ</td></tr>'; } | |
| else entries.forEach(([from,to]) => { | |
| const tr = document.createElement('tr'); | |
| tr.innerHTML = `<td class="mono-cell" style="color:var(--ac)">${esc(from)}</td> | |
| <td style="color:var(--tx3);font-size:16px">โ</td> | |
| <td class="mono-cell">${esc(to)}</td> | |
| <td><button class="btn btn-sm btn-danger" onclick="deleteAlias('${esc(from)}')">ๅ ้ค</button></td>`; | |
| atbody.appendChild(tr); | |
| }); | |
| } | |
| async function addModel() { | |
| const id = document.getElementById('new_model_id').value.trim(); | |
| if (!id) { showToast('่ฏท่พๅ ฅๆจกๅ ID','err'); return; } | |
| const models = [...new Set([..._modelsCache.models, id])]; | |
| try { await api('PUT','/api/admin/models',{models}); document.getElementById('new_model_id').value=''; await loadModels(); showToast('ๅทฒๆทปๅ ๆจกๅ','ok'); } | |
| catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function deleteModel(id) { | |
| if (!confirm('ๅ ้คๆจกๅ '+id+' ?')) return; | |
| const models = _modelsCache.models.filter(m=>m!==id); | |
| try { await api('PUT','/api/admin/models',{models}); await loadModels(); showToast('ๅทฒๅ ้ค','ok'); } | |
| catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function addAlias() { | |
| const from = document.getElementById('alias_from').value.trim(); | |
| const to = document.getElementById('alias_to').value.trim(); | |
| if (!from||!to) { showToast('ๅซๅๅ็ฎๆ ๅไธ่ฝไธบ็ฉบ','err'); return; } | |
| const alias_map = {..._modelsCache.alias_map,[from]:to}; | |
| try { | |
| await api('PUT','/api/admin/models',{alias_map}); | |
| document.getElementById('alias_from').value=''; document.getElementById('alias_to').value=''; | |
| await loadModels(); showToast('ๅซๅๅทฒๆทปๅ ','ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function deleteAlias(from) { | |
| if (!confirm('ๅ ้คๅซๅ '+from+' ?')) return; | |
| const alias_map = {..._modelsCache.alias_map}; | |
| delete alias_map[from]; | |
| try { await api('PUT','/api/admin/models',{alias_map}); await loadModels(); showToast('ๅทฒๅ ้ค','ok'); } | |
| catch(e) { showToast(e.message,'err'); } | |
| } | |
| // โโ Unified Nodes โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async function loadNodePool() { | |
| return loadUnifiedNodes(); | |
| } | |
| async function loadUnifiedNodes() { | |
| try { | |
| const r = await api('GET','/api/admin/nodes'); | |
| _allNodes = r.nodes || _allNodes || []; | |
| const existingUris = new Set(_allNodes.map(n => n.raw_uri)); | |
| _selectedNodeUris = new Set([..._selectedNodeUris].filter(uri => existingUris.has(uri))); | |
| refreshNodeFilterOptions(); | |
| renderFilteredNodes(); | |
| updatePoolUI(); | |
| } catch(e) {} | |
| } | |
| function updatePoolUI() { | |
| const badge = document.getElementById('poolBadge'); | |
| if (_allNodes.length > 0) { | |
| badge.textContent = `nodes ${_allNodes.length}`; | |
| badge.style.display = 'inline-block'; | |
| } else { | |
| badge.style.display = 'none'; | |
| } | |
| refreshPoolCount(); | |
| syncRenderedNodeSelectionState(); | |
| } | |
| function syncRenderedNodeSelectionState() { | |
| const listEl = document.getElementById('nodeList'); | |
| if (!listEl) return; | |
| listEl.querySelectorAll('.node').forEach(div => { | |
| const uri = div.dataset.uri; | |
| const selected = _selectedNodeUris.has(uri); | |
| div.classList.toggle('selected', selected); | |
| const cb = div.querySelector('.node-cb'); | |
| if (cb) cb.checked = selected; | |
| }); | |
| } | |
| function syncRenderedNodeEnabledState(targetUris, enabled) { | |
| const listEl = document.getElementById('nodeList'); | |
| if (!listEl) return; | |
| listEl.querySelectorAll('.node').forEach(div => { | |
| if (!targetUris.has(div.dataset.uri)) return; | |
| const badges = div.querySelector('.node-badges'); | |
| if (badges) badges.innerHTML = enabled ? '<span class="nbadge nbadge-pool">ๅทฒๅฏ็จ</span>' : '<span class="nbadge nbadge-off">ๆชๅฏ็จ</span>'; | |
| }); | |
| } | |
| function refreshPoolCount() { | |
| const countEl = document.getElementById('poolCount'); | |
| const modeEl = document.getElementById('nodeModeBadge'); | |
| const count = _allNodes.length; | |
| const enabled = _allNodes.filter(n => !!n.enabled).length; | |
| const shown = _filteredNodes.length || (!_allNodes.length ? 0 : getFilteredNodes().length); | |
| let mode = '็ด่ฟ'; | |
| if (enabled === 1) mode = 'ๅบๅฎไปฃ็'; | |
| else if (enabled > 1) mode = 'ๅจๆไปฃ็'; | |
| if (modeEl) modeEl.textContent = mode; | |
| if (countEl) countEl.textContent = count ? `็ปไธ่็น้ๅ๏ผ${count} ไธช๏ผๅฏ็จ ${enabled} ไธช๏ผ${mode}๏ผ๏ผๅฝๅๆพ็คบ ${shown} ไธช๏ผๅทฒ้ๆฉ ${_selectedNodeUris.size} ไธช` : '่ฎข้ /ๆไปถ/ๅชๅๆฟๅฏผๅ ฅๅไฟๅญไธบ่็น้ ็ฝฎ๏ผ้ป่ฎคไธๅฏ็จ'; | |
| } | |
| function nodeSourceLabel(n) { | |
| return n.subscription_name || n.source || 'ๆช็ฅๆฅๆบ'; | |
| } | |
| function refreshNodeFilterOptions() { | |
| const sourceSel = document.getElementById('nodeSourceFilter'); | |
| const typeSel = document.getElementById('nodeTypeFilter'); | |
| if (!sourceSel || !typeSel) return; | |
| const currentSource = sourceSel.value; | |
| const currentType = typeSel.value; | |
| const sources = [...new Set(_allNodes.map(nodeSourceLabel).filter(Boolean))].sort(); | |
| const types = [...new Set(_allNodes.map(n => _tmap[n.type] || n.type || '').filter(Boolean))].sort(); | |
| sourceSel.innerHTML = '<option value="">ๅ จ้จๆฅๆบ</option>' + sources.map(s=>`<option value="${esc(s)}">${esc(s)}</option>`).join(''); | |
| typeSel.innerHTML = '<option value="">ๅ จ้จๅ่ฎฎ</option>' + types.map(t=>`<option value="${esc(t)}">${esc(t)}</option>`).join(''); | |
| sourceSel.value = sources.includes(currentSource) ? currentSource : ''; | |
| typeSel.value = types.includes(currentType) ? currentType : ''; | |
| } | |
| function getFilteredNodes() { | |
| const q = (document.getElementById('nodeSearch')?.value || '').trim().toLowerCase(); | |
| const source = document.getElementById('nodeSourceFilter')?.value || ''; | |
| const type = document.getElementById('nodeTypeFilter')?.value || ''; | |
| return _allNodes.filter(n => { | |
| const displayType = _tmap[n.type] || n.type || ''; | |
| const src = nodeSourceLabel(n); | |
| const hay = `${n.name||''} ${n.server||''} ${n.port||''} ${src} ${displayType} ${n.source||''}`.toLowerCase(); | |
| return (!q || hay.includes(q)) && (!source || src === source) && (!type || displayType === type); | |
| }); | |
| } | |
| function renderFilteredNodes() { | |
| _filteredNodes = getFilteredNodes(); | |
| renderNodes(_filteredNodes); | |
| refreshPoolCount(); | |
| } | |
| // โโ Subscription โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| async function loadSubscriptions() { | |
| try { | |
| const r = await api('GET','/api/admin/subscriptions'); | |
| _subscriptions = r.subscriptions || []; | |
| _selectedSubIds = new Set([..._selectedSubIds].filter(id => _subscriptions.some(s => s.id === id))); | |
| renderSubscriptions(); | |
| } catch(e) {} | |
| } | |
| async function loadSubscriptionsOnly() { | |
| const r = await api('GET','/api/admin/subscriptions'); | |
| _subscriptions = r.subscriptions || []; | |
| _selectedSubIds = new Set([..._selectedSubIds].filter(id => _subscriptions.some(s => s.id === id))); | |
| renderSubscriptions(); | |
| } | |
| function renderSubscriptions() { | |
| const listEl = document.getElementById('subList'); | |
| const bar = document.getElementById('subSelectBar'); | |
| listEl.innerHTML = ''; | |
| if (!_subscriptions.length) { | |
| listEl.innerHTML = '<div class="empty">่ฟๆฒกๆ่ฎข้ ใๆทปๅ ่ฎข้ ๅๅฏๅ้ใๅค้ๆๅ จ้ๆๅ่็นใ</div>'; | |
| bar.style.display = 'none'; | |
| refreshSubscriptionSelection(); | |
| return; | |
| } | |
| bar.style.display = 'flex'; | |
| _subscriptions.forEach(s => { | |
| const idx = _subscriptions.indexOf(s); | |
| const checked = _selectedSubIds.has(s.id); | |
| const updated = s.updated_at ? new Date(s.updated_at * 1000).toLocaleString() : 'ๆชๆๅ'; | |
| const div = document.createElement('div'); | |
| div.className = 'sub-item' + (checked ? ' selected' : ''); | |
| div.innerHTML = ` | |
| <input type="checkbox" class="sub-cb" data-index="${idx}" data-id="${esc(s.id)}" ${checked?'checked':''} onclick="handleSubCheckboxClick(event, this)" title="้ๆฉๆญค่ฎข้ ๅไธๆฌๆฌกๆๅ"> | |
| <div class="sub-main"> | |
| <div class="sub-name">${esc(s.name || '่ฎข้ ')}</div> | |
| <div class="sub-url">${esc(s.url)}</div> | |
| <div class="sub-meta">nodes ${esc(s.node_count || 0)} ยท ${esc(updated)}</div> | |
| </div> | |
| <div class="sub-actions"> | |
| <button class="btn btn-sm" onclick="editSubscription('${esc(s.id)}')">็ผ่พ</button> | |
| <button class="btn btn-sm btn-danger" onclick="deleteSubscription('${esc(s.id)}')">ๅ ้ค</button> | |
| </div>`; | |
| listEl.appendChild(div); | |
| }); | |
| refreshSubscriptionSelection(); | |
| } | |
| function refreshSubscriptionSelection() { | |
| const count = _selectedSubIds.size; | |
| const btn = document.getElementById('fetchBtn'); | |
| const countEl = document.getElementById('subSelectCount'); | |
| if (btn) { | |
| btn.textContent = `ๆๅ้ไธญ่ฎข้ (${count})`; | |
| btn.disabled = count < 1; | |
| } | |
| if (countEl) countEl.textContent = count ? `ๅทฒ้ๆฉ ${count} ไธช่ฎข้ ` : '้ๆฉ่ฎข้ ๅๆๅ่็น'; | |
| } | |
| function handleSubCheckboxClick(evt, cb) { | |
| const idx = parseInt(cb.dataset.index, 10); | |
| const targetChecked = cb.checked; | |
| if (evt.shiftKey && _lastSubClickIndex !== null) { | |
| const start = Math.min(_lastSubClickIndex, idx); | |
| const end = Math.max(_lastSubClickIndex, idx); | |
| for (let i = start; i <= end; i++) { | |
| const sub = _subscriptions[i]; | |
| if (sub && sub.id) { | |
| if (targetChecked) _selectedSubIds.add(sub.id); | |
| else _selectedSubIds.delete(sub.id); | |
| } | |
| } | |
| _lastSubClickIndex = idx; | |
| renderSubscriptions(); | |
| return; | |
| } | |
| toggleSubSelect(cb, false); | |
| _lastSubClickIndex = idx; | |
| renderSubscriptions(); | |
| } | |
| function toggleSubSelect(cb, refresh=true) { | |
| const id = cb.dataset.id; | |
| if (cb.checked) _selectedSubIds.add(id); | |
| else _selectedSubIds.delete(id); | |
| if (refresh) refreshSubscriptionSelection(); | |
| } | |
| function selectAllSubscriptions(checked) { | |
| _selectedSubIds.clear(); | |
| if (checked) _subscriptions.forEach(s=>_selectedSubIds.add(s.id)); | |
| _lastSubClickIndex = null; | |
| renderSubscriptions(); | |
| } | |
| async function addSubscription() { | |
| const url = document.getElementById('sub_urls').value.trim(); | |
| if (!url) { showToast('่ฏท่พๅ ฅ่ฎข้ ๅฐๅ','err'); return; } | |
| try { | |
| const r = await api('POST','/api/admin/subscriptions',{name:'',url,enabled:true}); | |
| _selectedSubIds.add(r.subscription.id); | |
| document.getElementById('sub_urls').value = ''; | |
| await loadSubscriptionsOnly(); | |
| showToast('่ฎข้ ๅทฒๆทปๅ ','ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function addSubscriptionsBatch() { | |
| const text = document.getElementById('sub_urls').value.trim(); | |
| if (!text) { showToast('่ฏท็ฒ่ดด่ณๅฐไธไธช่ฎข้ ้พๆฅ','err'); return; } | |
| try { | |
| const r = await api('POST','/api/admin/subscriptions/batch',{text}); | |
| _subscriptions = r.subscriptions || []; | |
| _selectedSubIds = new Set((r.added_items || []).map(s => s.id)); | |
| document.getElementById('sub_urls').value = ''; | |
| renderSubscriptions(); | |
| showToast(`ๅทฒๆทปๅ ${r.added} ไธช่ฎข้ ๏ผๆดๆฐ ${r.updated} ไธช`,'ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function pasteSubscriptionsFromClipboard() { | |
| try { | |
| const text = await navigator.clipboard.readText(); | |
| document.getElementById('sub_urls').value = text; | |
| showToast('ๅทฒ่ฏปๅๅชๅๆฟ','ok'); | |
| } catch(e) { showToast('ๆต่งๅจ้ปๆญข่ฏปๅๅชๅๆฟ๏ผ่ฏทๆๅจ Ctrl+V ็ฒ่ดด','err'); } | |
| } | |
| async function editSubscription(id) { | |
| const sub = _subscriptions.find(s=>s.id===id); | |
| if (!sub) return; | |
| const name = prompt('่ฎข้ ๅ็งฐ', sub.name || ''); | |
| if (name === null) return; | |
| const url = prompt('่ฎข้ ๅฐๅ', sub.url || ''); | |
| if (url === null) return; | |
| try { | |
| await api('PUT','/api/admin/subscriptions/'+encodeURIComponent(id),{name,url}); | |
| await loadSubscriptionsOnly(); | |
| showToast('่ฎข้ ๅทฒๆดๆฐ','ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function deleteSubscription(id) { | |
| const sub = _subscriptions.find(s=>s.id===id); | |
| if (!confirm('ๅ ้ค่ฎข้ '+((sub&&sub.name)||id)+' ?')) return; | |
| try { | |
| await api('DELETE','/api/admin/subscriptions/'+encodeURIComponent(id)); | |
| _selectedSubIds.delete(id); | |
| await loadSubscriptionsOnly(); | |
| showToast('่ฎข้ ๅทฒๅ ้ค','ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function deleteSelectedSubscriptions() { | |
| if (!_selectedSubIds.size) { showToast('่ฏท่ณๅฐ้ๆฉไธไธช่ฎข้ ','err'); return; } | |
| if (!confirm(`ๅ ้ค้ไธญ็ ${_selectedSubIds.size} ไธช่ฎข้ ๏ผ`)) return; | |
| try { | |
| const r = await api('POST','/api/admin/subscriptions/delete',{ids:[..._selectedSubIds]}); | |
| _subscriptions = r.subscriptions || []; | |
| _selectedSubIds.clear(); | |
| renderSubscriptions(); | |
| showToast(`ๅทฒๅ ้ค ${r.deleted} ไธช่ฎข้ `,'ok'); | |
| } catch(e) { showToast(e.message,'err'); await loadSubscriptionsOnly(); } | |
| } | |
| async function fetchSubscription(opts={}) { | |
| const url = document.getElementById('sub_urls').value.trim(); | |
| if (url) { | |
| try { | |
| const r = await api('POST','/api/admin/subscriptions',{url,enabled:true}); | |
| _selectedSubIds = new Set([r.subscription.id]); | |
| await loadSubscriptionsOnly(); | |
| return fetchSubscriptions('selected', opts); | |
| } catch(e) { if (!opts.silent) showToast(e.message,'err'); return; } | |
| } | |
| return fetchSubscriptions('selected', opts); | |
| } | |
| async function fetchSubscriptions(mode='selected', opts={}) { | |
| if (mode === 'selected' && !_selectedSubIds.size) { if (!opts.silent) showToast('่ฏท่ณๅฐ้ๆฉไธไธช่ฎข้ ','err'); return; } | |
| const btn = document.getElementById('fetchBtn'); | |
| const listEl = document.getElementById('nodeList'); | |
| if (btn) { btn.disabled=true; btn.textContent='ๆๅไธญ...'; } | |
| listEl.innerHTML=''; document.getElementById('subSummary').textContent=''; | |
| try { | |
| const data = await api('POST','/api/admin/subscriptions/fetch',{mode,ids:[..._selectedSubIds]}); | |
| const warn = data.errors && data.errors.length ? `๏ผ${data.errors.length} ไธช่ฎข้ ๅคฑ่ดฅ` : ''; | |
| const mergeInfo = data.added !== undefined ? `๏ผๆฐๅข ${data.added}๏ผๆดๆฐ ${data.updated}` : ''; | |
| document.getElementById('subSummary').textContent = `ๅ ฑ ${data.total} ไธช่็น ยท ${data.subscription_count} ไธช่ฎข้ ${mergeInfo}${warn}`; | |
| _allNodes = data.nodes||[]; | |
| if (!_allNodes.length) { listEl.innerHTML='<div class="empty">ๆฒกๆ่งฃๆๅฐไปปไฝ่็น</div>'; return; } | |
| refreshNodeFilterOptions(); | |
| renderFilteredNodes(); | |
| updatePoolUI(); | |
| await loadSubscriptionsOnly(); | |
| } catch(e) { | |
| if (!opts.silent) showToast(e.message,'err'); | |
| listEl.innerHTML='<div class="empty">'+esc(e.message)+'</div>'; | |
| } finally { if (btn) { btn.disabled=false; refreshSubscriptionSelection(); } } | |
| } | |
| async function pasteFromClipboard() { | |
| try { | |
| const text = await navigator.clipboard.readText(); | |
| document.getElementById('nodePasteText').value = text; | |
| showToast('ๅทฒไปๅชๅๆฟ่ฏปๅ','ok'); | |
| } catch(e) { showToast('ๆต่งๅจ้ปๆญข่ฏปๅๅชๅๆฟ๏ผ่ฏทๆๅจ Ctrl+V ็ฒ่ดด','err'); } | |
| } | |
| async function importNodeText() { | |
| const text = document.getElementById('nodePasteText').value.trim(); | |
| if (!text) { showToast('่ฏทๅ ็ฒ่ดด่็นๅ ๅฎน','err'); return; } | |
| try { | |
| const data = await api('POST','/api/admin/nodes/import-text',{text,source_name:'clipboard'}); | |
| _allNodes = data.nodes || []; | |
| _selectedNodeUris.clear(); | |
| refreshNodeFilterOptions(); | |
| renderFilteredNodes(); | |
| updatePoolUI(); | |
| document.getElementById('subSummary').textContent = `ๅชๅๆฟ่งฃๆ ${data.total} ไธช๏ผๆฐๅข ${data.added} ไธช๏ผๆดๆฐ ${data.updated} ไธช`; | |
| showToast(`ๅทฒๅฏผๅ ฅ ${data.total} ไธช่็น`,'ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| function updateFileName() { | |
| const input = document.getElementById('nodeImportFile'); | |
| const file = input && input.files && input.files[0]; | |
| document.getElementById('nodeFileName').textContent = file ? file.name : 'ๆช้ๆฉๆไปถ'; | |
| } | |
| async function importNodeFile() { | |
| const input = document.getElementById('nodeImportFile'); | |
| const file = input && input.files && input.files[0]; | |
| if (!file) { showToast('่ฏท้ๆฉ้ ็ฝฎๆไปถ','err'); return; } | |
| const fd = new FormData(); | |
| fd.append('file', file); | |
| try { | |
| const r = await fetch(apiPath('/api/admin/nodes/import'), { | |
| method: 'POST', | |
| headers: { 'Authorization': 'Bearer ' + token }, | |
| body: fd, | |
| }); | |
| const data = await r.json().catch(()=>({})); | |
| if (!r.ok) throw new Error(data.detail || 'ๅฏผๅ ฅๅคฑ่ดฅ'); | |
| _allNodes = data.nodes || []; | |
| _selectedNodeUris.clear(); | |
| document.getElementById('subSummary').textContent = `ๆไปถ่งฃๆ ${data.total} ไธช๏ผๆฐๅข ${data.added} ไธช๏ผๆดๆฐ ${data.updated} ไธช`; | |
| refreshNodeFilterOptions(); | |
| renderFilteredNodes(); | |
| updatePoolUI(); | |
| input.value = ''; | |
| updateFileName(); | |
| showToast(`ๅทฒๅฏผๅ ฅ ${data.total} ไธช่็น`,'ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| const _tmap = { | |
| A:atob('VkxFU1M='), B:atob('Vk1lc3M='), C:atob('VHJvamFu'), | |
| D:atob('U1M='), E:atob('U1NS'), F:atob('SHlzdGVyaWEy'), | |
| H:atob('QW55VExT'), I:atob('VFVJQw=='), J:atob('SHkx'), | |
| }; | |
| function renderNodes(nodes) { | |
| const listEl = document.getElementById('nodeList'); | |
| listEl.innerHTML = ''; | |
| if (!nodes.length) { | |
| listEl.innerHTML = '<div class="empty">็ปไธ่็น้ๅไธบ็ฉบใๅฏไป่ฎข้ ใๅชๅๆฟๆๆไปถๅฏผๅ ฅ่็นใ</div>'; | |
| return; | |
| } | |
| const frag = document.createDocumentFragment(); | |
| nodes.forEach((n,i) => { | |
| const isEnabled = !!n.enabled; | |
| const isSelected = _selectedNodeUris.has(n.raw_uri); | |
| const displayType = _tmap[n.type]||n.type; | |
| const div = document.createElement('div'); | |
| div.className = 'node' + (isSelected?' selected':''); | |
| div.dataset.index = String(i); | |
| div.dataset.uri = n.raw_uri; | |
| div.innerHTML = ` | |
| <input type="checkbox" class="node-cb" data-index="${i}" data-uri="${esc(n.raw_uri)}" ${isSelected?'checked':''} onclick="handleNodeCheckboxClick(event, this)"> | |
| <div class="node-info"> | |
| <div class="node-name">${esc(n.name||'(unnamed)')}</div> | |
| <div class="node-meta"><span class="node-type">${esc(displayType)}</span><span>${esc(n.server)}:${n.port}</span>${n.subscription_name ? `<span>${esc(n.subscription_name)}</span>` : ''}<span>${esc(n.source||'')}</span></div> | |
| </div> | |
| <div class="node-badges"> | |
| ${isEnabled ? '<span class="nbadge nbadge-pool">ๅทฒๅฏ็จ</span>' : '<span class="nbadge nbadge-off">ๆชๅฏ็จ</span>'} | |
| </div> | |
| <div class="node-actions"> | |
| <button class="btn btn-sm" data-uri="${esc(n.raw_uri)}" onclick="editUnifiedNode(this)">็ผ่พ</button> | |
| <button class="btn btn-sm btn-danger" data-uri="${esc(n.raw_uri)}" onclick="deleteOneNode(this)">ๅ ้ค</button> | |
| </div>`; | |
| frag.appendChild(div); | |
| }); | |
| listEl.appendChild(frag); | |
| } | |
| async function addManualNode() { | |
| const name = document.getElementById('manualNodeName').value.trim(); | |
| const raw_uri = document.getElementById('manualNodeUri').value.trim(); | |
| if (!raw_uri) { showToast('่ฏท่พๅ ฅ่็น่ฟๆฅ','err'); return; } | |
| try { | |
| const data = await api('POST','/api/admin/nodes',{name,raw_uri}); | |
| _allNodes = data.nodes || []; | |
| document.getElementById('manualNodeName').value = ''; | |
| document.getElementById('manualNodeUri').value = ''; | |
| refreshNodeFilterOptions(); | |
| renderFilteredNodes(); | |
| updatePoolUI(); | |
| showToast(data.added ? '่็นๅทฒๆทปๅ ' : '่็นๅทฒๆดๆฐ','ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| function handleNodeCheckboxClick(evt, cb) { | |
| const idx = parseInt(cb.dataset.index, 10); | |
| const targetChecked = cb.checked; | |
| if (evt.shiftKey && _lastNodeClickIndex !== null) { | |
| const start = Math.min(_lastNodeClickIndex, idx); | |
| const end = Math.max(_lastNodeClickIndex, idx); | |
| for (let i = start; i <= end; i++) { | |
| const node = _filteredNodes[i]; | |
| if (node && node.raw_uri) { | |
| if (targetChecked) _selectedNodeUris.add(node.raw_uri); | |
| else _selectedNodeUris.delete(node.raw_uri); | |
| } | |
| } | |
| } else { | |
| toggleUnifiedNodeSelect(cb, false); | |
| } | |
| _lastNodeClickIndex = idx; | |
| updatePoolUI(); | |
| } | |
| function toggleUnifiedNodeSelect(cb, refresh=true) { | |
| const uri = cb.dataset.uri; | |
| if (cb.checked) _selectedNodeUris.add(uri); | |
| else _selectedNodeUris.delete(uri); | |
| if (refresh) updatePoolUI(); | |
| } | |
| function selectAllUnifiedNodes(checked) { | |
| _selectedNodeUris.clear(); | |
| const list = _filteredNodes.length || !_allNodes.length ? _filteredNodes : getFilteredNodes(); | |
| if (checked) list.forEach(n=>_selectedNodeUris.add(n.raw_uri)); | |
| _lastNodeClickIndex = null; | |
| updatePoolUI(); | |
| } | |
| async function setSelectedNodesEnabled(enabled) { | |
| if (!_selectedNodeUris.size) { showToast('่ฏทๅ ้ๆฉ่็น','err'); return; } | |
| const selected = new Set(_selectedNodeUris); | |
| try { | |
| const data = await api('POST','/api/admin/nodes/enabled',{raw_uris:[...selected], enabled}); | |
| if (data.matched !== selected.size) { | |
| await loadUnifiedNodes(); | |
| } else { | |
| _allNodes = _allNodes.map(n => selected.has(n.raw_uri) ? {...n, enabled} : n); | |
| syncRenderedNodeEnabledState(selected, enabled); | |
| updatePoolUI(); | |
| } | |
| const verb = enabled ? 'ๅฏ็จ' : '็ฆ็จ'; | |
| showToast(`ๅทฒ${verb} ${data.updated} ไธช่็น`,'ok'); | |
| await loadProxyStatus(); | |
| } catch(e) { showToast(e.message,'err'); await loadUnifiedNodes(); } | |
| } | |
| async function editUnifiedNode(btn) { | |
| const uri = btn.dataset.uri; | |
| const node = _allNodes.find(n => n.raw_uri === uri); | |
| if (!node) return; | |
| const name = prompt('่็นๅ็งฐ', node.name || ''); | |
| if (name === null) return; | |
| const raw_uri = prompt('่็น URI', node.raw_uri || ''); | |
| if (raw_uri === null) return; | |
| try { | |
| const data = await api('PUT','/api/admin/nodes',{ | |
| original_raw_uri: uri, | |
| name, | |
| raw_uri, | |
| subscription_name: node.subscription_name || node.source || 'manual' | |
| }); | |
| _allNodes = data.nodes || _allNodes; | |
| _selectedNodeUris.delete(uri); | |
| refreshNodeFilterOptions(); | |
| renderFilteredNodes(); | |
| updatePoolUI(); | |
| showToast('่็นๅทฒๆดๆฐ','ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function deleteOneNode(btn) { | |
| const uri = btn.dataset.uri; | |
| const node = _allNodes.find(n => n.raw_uri === uri); | |
| if (!confirm('ๅ ้ค่็น '+((node&&node.name)||uri.slice(0,40))+' ?')) return; | |
| await deleteNodesByUris([uri]); | |
| } | |
| async function deleteSelectedNodes() { | |
| if (!_selectedNodeUris.size) { showToast('่ฏทๅ ้ๆฉ่็น','err'); return; } | |
| if (!confirm(`ๅ ้ค้ไธญ็ ${_selectedNodeUris.size} ไธช่็น๏ผ`)) return; | |
| await deleteNodesByUris([..._selectedNodeUris]); | |
| } | |
| async function deleteNodesByUris(raw_uris) { | |
| try { | |
| const targets = new Set(raw_uris); | |
| const data = await api('POST','/api/admin/nodes/delete',{raw_uris, return_nodes:false}); | |
| if (data.deleted !== targets.size) { | |
| await loadUnifiedNodes(); | |
| } else if (data.deleted > 0) { | |
| _allNodes = _allNodes.filter(n => !targets.has(n.raw_uri)); | |
| } | |
| raw_uris.forEach(uri => _selectedNodeUris.delete(uri)); | |
| refreshNodeFilterOptions(); | |
| renderFilteredNodes(); | |
| updatePoolUI(); | |
| showToast(`ๅทฒๅ ้ค ${data.deleted} ไธช่็น`,'ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| async function clearUnifiedNodes() { | |
| if (!_allNodes.length) return; | |
| if (!confirm(`ๆธ ็ฉบๅ จ้จ ${_allNodes.length} ไธช็ปไธ่็น๏ผ`)) return; | |
| try { | |
| const data = await api('DELETE','/api/admin/nodes/all'); | |
| _allNodes = []; | |
| _selectedNodeUris.clear(); | |
| refreshNodeFilterOptions(); | |
| renderFilteredNodes(); | |
| updatePoolUI(); | |
| showToast(`ๅทฒๆธ ็ฉบ ${data.deleted} ไธช่็น`,'ok'); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| function refreshNodeHighlight() { | |
| if (!_allNodes.length) return; | |
| syncRenderedNodeSelectionState(); | |
| } | |
| async function loadProxyStatus() { | |
| try { | |
| const s = await api('GET','/api/admin/proxy-status'); | |
| const bar=document.getElementById('activeBar'), | |
| lbl=document.getElementById('activeLabel'), | |
| nm=document.getElementById('activeName'), | |
| via=document.getElementById('activeVia'), | |
| stopBtn=document.getElementById('stopBtn'), | |
| coreBadge=document.getElementById('coreBadge'); | |
| coreBadge.style.display = s.binary_available ? 'none' : 'inline-block'; | |
| const enabled = _allNodes.filter(n => !!n.enabled); | |
| if (enabled.length > 0) { | |
| bar.classList.remove('off'); | |
| lbl.textContent = enabled.length === 1 ? 'ๅบๅฎไปฃ็' : 'ๅจๆไปฃ็'; | |
| nm.textContent = enabled.length === 1 ? (enabled[0].name || enabled[0].raw_uri.slice(0,60)) : `ๅทฒๅฏ็จ ${enabled.length} ไธช่็น`; | |
| via.textContent = enabled.length === 1 ? '' : 'ยท ไผ่ฏๅ้ๆฑ '; | |
| stopBtn.style.display='inline-block'; | |
| } else { | |
| bar.classList.add('off'); lbl.textContent='ๆชๅฏ็จ่็น ยท ็ด่ฟ'; | |
| nm.textContent=''; via.textContent=''; stopBtn.style.display='none'; | |
| } | |
| // unified node indicator in active bar | |
| const badge = document.getElementById('poolBadge'); | |
| if (_allNodes.length>0) { | |
| badge.textContent = `nodes ${_allNodes.length}`; | |
| badge.style.display='inline-block'; | |
| } else { badge.style.display='none'; } | |
| } catch(e) {} | |
| } | |
| async function stopProxy() { | |
| if (!confirm('ๅๆญขๅฝๅไปฃ็๏ผๅๅฐ็ด่ฟ๏ผ')) return; | |
| try { | |
| await api('POST','/api/admin/stop-proxy'); | |
| showToast('ๅทฒๅๆญขไปฃ็','ok'); | |
| await loadUnifiedNodes(); | |
| await loadProxyStatus(); refreshNodeHighlight(); | |
| } catch(e) { showToast(e.message,'err'); } | |
| } | |
| // โโ Init โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| (async()=>{ | |
| if (location.pathname.endsWith('/admin')) { | |
| history.replaceState(null, '', location.pathname + '?t=' + Date.now()); | |
| } | |
| if (token) { | |
| try { await api('GET','/api/admin/settings'); showApp(); return; } catch(e){} | |
| } | |
| showLogin(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |