frpc.html 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>frpc 管理 - PlateRecApp</title>
  7. <style>
  8. *{margin:0;padding:0;box-sizing:border-box}
  9. body{background:#0f172a;color:#e2e8f0;font-family:'Segoe UI',system-ui,sans-serif;min-height:100vh}
  10. .header{background:#1e293b;padding:16px 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #334155}
  11. .header h1{font-size:20px;font-weight:600;color:#f8fafc}
  12. .nav{display:flex;gap:16px;align-items:center}
  13. .nav a{color:#60a5fa;text-decoration:none;font-size:14px;padding:6px 12px;border-radius:6px;transition:background .2s}
  14. .nav a:hover,.nav a.active{background:#334155}
  15. .container{max-width:1200px;margin:0 auto;padding:20px}
  16. .tabs{display:flex;gap:8px;margin-bottom:20px}
  17. .tab{padding:8px 16px;background:#1e293b;border:1px solid #334155;border-radius:8px;cursor:pointer;color:#94a3b8;font-size:14px;transition:all .2s}
  18. .tab.active{background:#3b82f6;color:#fff;border-color:#3b82f6}
  19. .panel{display:none}.panel.active{display:block}
  20. .card{background:#1e293b;border-radius:12px;padding:20px;border:1px solid #334155;margin-bottom:16px}
  21. .card-title{font-size:15px;font-weight:600;margin-bottom:12px;color:#f8fafc}
  22. .status-dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}
  23. .status-dot.online{background:#4ade80}.status-dot.offline{background:#f87171}
  24. table{width:100%;border-collapse:collapse;font-size:14px}
  25. th{text-align:left;padding:8px 12px;color:#94a3b8;border-bottom:1px solid #334155;font-weight:500}
  26. td{padding:8px 12px;border-bottom:1px solid #1e293b}
  27. .btn{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:14px;font-weight:500;transition:all .2s}
  28. .btn-primary{background:#3b82f6;color:#fff}.btn-primary:hover{background:#2563eb}
  29. .btn-danger{background:#dc2626;color:#fff}.btn-danger:hover{background:#b91c1c}
  30. .btn-success{background:#16a34a;color:#fff}.btn-success:hover{background:#15803d}
  31. .btn-group{display:flex;gap:8px;margin-top:12px}
  32. textarea{width:100%;background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:8px;padding:12px;font-family:'Fira Code',monospace;font-size:13px;resize:vertical;min-height:400px;line-height:1.5}
  33. textarea:focus{outline:none;border-color:#3b82f6}
  34. .ssh-frame{width:100%;height:500px;border:1px solid #334155;border-radius:8px}
  35. .msg{padding:10px 14px;border-radius:8px;margin-top:10px;font-size:14px;display:none}
  36. .msg.success{display:block;background:#14532d;color:#86efac;border:1px solid #16a34a}
  37. .msg.error{display:block;background:#7f1d1d;color:#fca5a5;border:1px solid #dc2626}
  38. .ssh-link{display:inline-flex;align-items:center;gap:8px;padding:10px 20px;background:#1e293b;border:1px solid #334155;border-radius:8px;color:#60a5fa;text-decoration:none;font-size:14px;transition:all .2s}
  39. .ssh-link:hover{background:#334155;border-color:#60a5fa}
  40. @media (max-width: 768px) {
  41. .header { flex-direction: column; gap: 8px; padding: 12px 16px; }
  42. .header h1 { font-size: 16px; }
  43. .nav { flex-wrap: wrap; gap: 8px; justify-content: center; }
  44. .nav a { font-size: 12px; padding: 4px 8px; }
  45. .container { padding: 12px; }
  46. .tabs { flex-wrap: wrap; }
  47. .tab { flex: 1; min-width: 80px; text-align: center; font-size: 13px; padding: 6px 10px; }
  48. .card { padding: 14px; }
  49. table { font-size: 12px; display: block; overflow-x: auto; white-space: nowrap; }
  50. th, td { padding: 6px 8px; }
  51. .btn-group { flex-wrap: wrap; }
  52. .btn { flex: 1; min-width: 100px; text-align: center; }
  53. textarea { min-height: 200px; font-size: 12px; }
  54. .ssh-frame { height: 350px; }
  55. }
  56. @media (max-width: 480px) {
  57. .card-title { font-size: 13px; }
  58. .btn { font-size: 12px; padding: 6px 12px; }
  59. }
  60. </style>
  61. </head>
  62. <body>
  63. <div class="header">
  64. <h1>🔧 frpc 管理</h1>
  65. <div class="nav">
  66. <a href="/">监控台</a>
  67. <a href="/video">视频</a>
  68. <a href="/config">配置</a>
  69. <a href="/system">系统</a>
  70. <a href="/frpc" class="active">frpc</a>
  71. <a href="/ssh-keys">SSH</a>
  72. <a href="/logs">日志</a>
  73. <a href="/help">帮助</a>
  74. </div>
  75. </div>
  76. <div class="container">
  77. <div class="tabs">
  78. <div class="tab active" onclick="switchTab('status')">隧道状态</div>
  79. <div class="tab" onclick="switchTab('config')">配置编辑</div>
  80. <div class="tab" onclick="switchTab('ssh')">SSH 终端</div>
  81. </div>
  82. <!-- 隧道状态 -->
  83. <div id="panel-status" class="panel active">
  84. <div class="card">
  85. <div class="card-title">
  86. <span class="status-dot" id="svc-status"></span>
  87. frpc 服务状态
  88. <button class="btn btn-primary" style="float:right" onclick="refreshStatus()">🔄 刷新</button>
  89. </div>
  90. <table>
  91. <thead><tr><th>名称</th><th>类型</th><th>本地地址</th><th>远程地址</th><th>状态</th></tr></thead>
  92. <tbody id="tunnel-list"><tr><td colspan="5" style="color:#64748b">加载中...</td></tr></tbody>
  93. </table>
  94. </div>
  95. <div class="btn-group">
  96. <button class="btn btn-danger" onclick="restartFrpc()">⚡ 重启 frpc</button>
  97. </div>
  98. <div id="status-msg" class="msg"></div>
  99. </div>
  100. <!-- 配置编辑 -->
  101. <div id="panel-config" class="panel">
  102. <div class="card">
  103. <div class="card-title">frpc.toml 配置文件
  104. <button class="btn btn-primary" style="float:right" onclick="saveConfig()">💾 保存配置</button>
  105. </div>
  106. <textarea id="config-editor" spellcheck="false"></textarea>
  107. </div>
  108. <div class="btn-group">
  109. <button class="btn btn-success" onclick="saveAndRestart()">💾 保存并重启</button>
  110. <button class="btn btn-primary" onclick="loadConfig()">🔄 重新加载</button>
  111. </div>
  112. <div id="config-msg" class="msg"></div>
  113. </div>
  114. <!-- SSH 终端 -->
  115. <div id="panel-ssh" class="panel">
  116. <div class="card">
  117. <div class="card-title">Web SSH 终端 (ttyd)</div>
  118. <p style="color:#94a3b8;font-size:14px;margin-bottom:12px">通过 ttyd 提供的 Web SSH 终端直接连接到服务器。</p>
  119. <a class="ssh-link" id="ssh-external-link" href="#" target="_blank">
  120. 🖥️ 在新窗口打开 SSH 终端
  121. </a>
  122. <div style="margin-top:16px">
  123. <iframe id="ssh-frame" class="ssh-frame" src="" style="display:none"></iframe>
  124. <button class="btn btn-primary" style="margin-top:8px" onclick="toggleSSH()">🔌 连接 SSH 终端</button>
  125. </div>
  126. </div>
  127. </div>
  128. </div>
  129. <script>
  130. function getCookie(n){var m=document.cookie.match(new RegExp("(^| )"+n+"=([^;]+)"));return m?m[2]:"";}function authFetch(u,o){o=o||{};var h=o.headers||{};if(!(h instanceof Headers)){var nh=new Headers();for(var k in h)nh.set(k,h[k]);h=nh;}if(!h.has("X-CSRF-Token")){var t=getCookie("csrf_token");if(t)h.set("X-CSRF-Token",t);}o.headers=h;return fetch(u,o).then(function(r){if(r.status===401){window.location.href="/login?redirect="+encodeURIComponent(window.location.pathname);return new Promise(function(){});}return r;});}
  131. let sshConnected = false;
  132. function switchTab(name){
  133. document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',['status','config','ssh'][i]===name));
  134. document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
  135. document.getElementById('panel-'+name).classList.add('active');
  136. if(name==='config'&&!document.getElementById('config-editor').value) loadConfig();
  137. if(name==='status') refreshStatus();
  138. }
  139. function showMsg(id,text,ok){
  140. let el=document.getElementById(id);
  141. el.className='msg '+(ok?'success':'error');
  142. el.textContent=text;
  143. setTimeout(()=>el.className='msg',5000);
  144. }
  145. function refreshStatus(){
  146. authFetch('/api/frpc/status').then(r=>r.json()).then(d=>{
  147. let dot=document.getElementById('svc-status');
  148. dot.className='status-dot '+(d.running?'online':'offline');
  149. let tbody=document.getElementById('tunnel-list');
  150. if(!d.tunnels||d.tunnels.length===0){
  151. tbody.innerHTML='<tr><td colspan="5" style="color:#64748b">'+(d.error||'无隧道')+'</td></tr>';
  152. return;
  153. }
  154. tbody.innerHTML=d.tunnels.map(t=>'<tr><td>'+t.name+'</td><td>'+t.type+'</td><td>'+t.local_addr+'</td><td>'+t.remote_addr+'</td><td><span class="status-dot '+(t.status==='online'?'online':'offline')+'"></span>'+t.status+'</td></tr>').join('');
  155. }).catch(e=>console.error(e));
  156. }
  157. function loadConfig(){
  158. authFetch('/api/frpc/config').then(r=>r.text()).then(t=>{
  159. document.getElementById('config-editor').value=t;
  160. });
  161. }
  162. function saveConfig(){
  163. let content=document.getElementById('config-editor').value;
  164. authFetch('/api/frpc/config',{method:'POST',body:content,headers:{'Content-Type':'text/plain'}})
  165. .then(r=>r.json()).then(d=>{
  166. showMsg('config-msg',d.message||d.error,d.success);
  167. });
  168. }
  169. function saveAndRestart(){
  170. saveConfig();
  171. setTimeout(restartFrpc,500);
  172. }
  173. function restartFrpc(){
  174. if(!confirm('确定要重启 frpc 服务吗?配置变更后重启会生效。')) return;
  175. authFetch('/api/frpc/restart',{method:'POST'}).then(r=>r.json()).then(d=>{
  176. showMsg('status-msg',d.message||d.error,d.success);
  177. setTimeout(refreshStatus,2000);
  178. });
  179. }
  180. function toggleSSH(){
  181. let frame=document.getElementById('ssh-frame');
  182. if(!sshConnected){
  183. let proto=location.protocol==='https:'?'wss':'ws';
  184. let host=location.hostname;
  185. let port=location.port;
  186. // ttyd runs on separate port, use external URL
  187. frame.src='http://'+host+':40964';
  188. frame.style.display='block';
  189. sshConnected=true;
  190. }else{
  191. frame.style.display='none';
  192. frame.src='';
  193. sshConnected=false;
  194. }
  195. }
  196. // Init
  197. refreshStatus();
  198. document.getElementById('ssh-external-link').href='http://'+location.hostname+':40964';
  199. </script>
  200. </body>
  201. </html>