2026-03-31 15:29:54 -04:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" >
< meta name = "apple-mobile-web-app-capable" content = "yes" >
< meta name = "apple-mobile-web-app-status-bar-style" content = "black-translucent" >
< meta name = "apple-mobile-web-app-title" content = "MeshCtrl" >
< meta name = "mobile-web-app-capable" content = "yes" >
2026-05-15 23:46:42 -04:00
< meta name = "theme-color" content = "#080d14" >
2026-03-31 15:29:54 -04:00
< meta name = "color-scheme" content = "dark" >
2026-05-15 23:46:42 -04:00
< link rel = "apple-touch-icon" href = "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 180'><rect width='180' height='180' rx='40' fill='%23080d14'/><rect x='28' y='28' width='124' height='124' rx='26' stroke='%2300d4ff' stroke-width='6' fill='none'/><circle cx='90' cy='76' r='20' stroke='%2300d4ff' stroke-width='5' fill='none'/><path d='M58 136c0-17.673 14.327-32 32-32s32 14.327 32 32' stroke='%2300d4ff' stroke-width='5' fill='none' stroke-linecap='round'/></svg>" >
< link rel = "icon" type = "image/svg+xml" href = "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23080d14'/><rect x='4' y='4' width='24' height='24' rx='5' stroke='%2300d4ff' stroke-width='2' fill='none'/><circle cx='16' cy='13' r='4' stroke='%2300d4ff' stroke-width='1.5' fill='none'/><path d='M10 24c0-3.314 2.686-6 6-6s6 2.686 6 6' stroke='%2300d4ff' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>" >
< link rel = "manifest" href = "data:application/manifest+json,%7B%22name%22%3A%22MeshCtrl%20Mobile%22%2C%22short_name%22%3A%22MeshCtrl%22%2C%22start_url%22%3A%22.%22%2C%22display%22%3A%22standalone%22%2C%22background_color%22%3A%22%23080d14%22%2C%22theme_color%22%3A%22%23080d14%22%7D" >
2026-03-31 15:29:54 -04:00
< title > MeshCtrl< / title >
< style >
2026-05-15 23:46:42 -04:00
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
:root{
--bg:#080d14;--bg1:#0d1520;--bg2:#121e2e;--bg3:#1a2840;--bg4:#223050;
--line:rgba(255,255,255,0.07);--line2:rgba(255,255,255,0.12);
--t0:#f0f6ff;--t1:#8da3bc;--t2:#5a7a99;--t3:#3a5470;
--cyan:#00d4ff;--cyan2:#00a8cc;--cyan-bg:rgba(0,212,255,0.1);--cyan-bdr:rgba(0,212,255,0.25);
--green:#00e676;--green-bg:rgba(0,230,118,0.1);
--red:#ff5370;--red-bg:rgba(255,83,112,0.12);--red-bdr:rgba(255,83,112,0.3);
--amber:#ffb300;--amber-bg:rgba(255,179,0,0.1);
--r:12px;--r-sm:8px;--nav-h:64px;--hdr-h:54px;
--safe-t:env(safe-area-inset-top,0px);--safe-b:env(safe-area-inset-bottom,0px);
font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',system-ui,sans-serif;
-webkit-font-smoothing:antialiased;
}
html,body{height:100%;background:var(--bg);color:var(--t0);overflow:hidden}
body{display:flex;flex-direction:column;padding-top:var(--safe-t);padding-bottom:var(--safe-b)}
input,button,textarea,select{font:inherit;color:inherit;-webkit-appearance:none}
button{background:none;border:none;cursor:pointer}
.scroll{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior-y:contain}
.scroll::-webkit-scrollbar{display:none}
.hdr{display:flex;align-items:center;gap:8px;padding:0 16px;height:var(--hdr-h);background:rgba(8,13,20,0.92);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:100;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
.hdr-title{font-size:17px;font-weight:700;flex:1;letter-spacing:-0.3px}
.hdr-btn{width:40px;height:40px;display:flex;align-items:center;justify-content:center;border-radius:10px;background:var(--bg2);border:1px solid var(--line2);transition:background 0.1s;flex-shrink:0}
.hdr-btn:active{background:var(--bg3)}
.hdr-btn svg{width:18px;height:18px;stroke:var(--t1);fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.dot.on{background:var(--green);box-shadow:0 0 8px var(--green)}
.dot.off{background:var(--t3)}
.dot.ing{background:var(--amber);animation:blink 1.2s ease infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0.3}}
.nav{display:flex;background:var(--bg1);border-top:1px solid var(--line);padding-bottom:var(--safe-b);height:calc(var(--nav-h) + var(--safe-b))}
.nav-btn{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;padding:6px 0;color:var(--t3);transition:color 0.15s}
.nav-btn.active{color:var(--cyan)}
.nav-btn svg{width:24px;height:24px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.nav-btn span{font-size:10px;font-weight:600;letter-spacing:0.2px}
.login-wrap{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px 20px}
.login-mark{margin-bottom:18px}
.login-mark svg{width:60px;height:60px}
.login-h1{font-size:26px;font-weight:700;letter-spacing:-0.5px;margin-bottom:4px}
.login-sub{font-size:14px;color:var(--t1);margin-bottom:28px}
.login-form{width:100%;max-width:380px;display:flex;flex-direction:column;gap:10px}
.saved-list{width:100%;max-width:380px;margin-bottom:16px}
.saved-item{display:flex;align-items:center;gap:12px;padding:14px 16px;background:var(--bg2);border:1px solid var(--line);border-radius:var(--r);cursor:pointer;margin-bottom:8px;transition:background 0.1s}
.saved-item:active{background:var(--bg3)}
.saved-url{font-size:14px;font-weight:600}
.saved-user{font-size:12px;color:var(--t2);margin-top:2px}
.saved-del{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:var(--red-bg);border-radius:8px;flex-shrink:0}
.saved-del svg{width:14px;height:14px;stroke:var(--red);fill:none;stroke-width:2}
.field{display:flex;flex-direction:column;gap:5px}
.field label{font-size:11px;font-weight:700;color:var(--t2);text-transform:uppercase;letter-spacing:0.6px;padding-left:2px}
.field input{background:var(--bg2);border:1.5px solid var(--line2);border-radius:var(--r-sm);padding:13px 14px;font-size:16px;outline:none;transition:border-color 0.15s;color:var(--t0)}
.field input:focus{border-color:var(--cyan)}
.field input::placeholder{color:var(--t3)}
.btn{padding:15px;border-radius:var(--r-sm);font-size:15px;font-weight:700;border:none;cursor:pointer;transition:all 0.12s;text-align:center;display:flex;align-items:center;justify-content:center;gap:8px}
.btn-primary{background:var(--cyan);color:#000}
.btn-primary:active{background:var(--cyan2)}
.btn-primary:disabled{opacity:0.45;cursor:not-allowed}
.btn-ghost{background:var(--bg2);border:1.5px solid var(--line2);color:var(--t0)}
.btn-ghost:active{background:var(--bg3)}
.btn-danger{background:var(--red-bg);color:var(--red);border:1.5px solid var(--red-bdr)}
.btn-danger:active{opacity:0.7}
.divider-text{display:flex;align-items:center;gap:10px;margin:6px 0}
.divider-text span{font-size:12px;color:var(--t3);flex-shrink:0}
.divider-text::before,.divider-text::after{content:'';flex:1;height:1px;background:var(--line)}
.search-bar{display:flex;align-items:center;gap:8px;background:var(--bg2);border:1.5px solid var(--line2);border-radius:var(--r-sm);padding:0 14px;margin-bottom:14px;transition:border-color 0.15s}
.search-bar:focus-within{border-color:var(--cyan)}
.search-bar svg{width:16px;height:16px;stroke:var(--t2);fill:none;stroke-width:2;flex-shrink:0}
.search-bar input{flex:1;background:none;border:none;padding:12px 0;font-size:15px;outline:none;color:var(--t0)}
.search-bar input::placeholder{color:var(--t3)}
.search-clear{width:20px;height:20px;background:var(--t3);border-radius:50%;display:flex;align-items:center;justify-content:center;flex-shrink:0;cursor:pointer}
.search-clear svg{width:10px;height:10px;stroke:#000;fill:none;stroke-width:2.5}
.chips{display:flex;gap:6px;margin-bottom:14px;overflow-x:auto;padding-bottom:2px}
.chips::-webkit-scrollbar{display:none}
.chip{padding:6px 12px;border-radius:20px;font-size:12px;font-weight:600;border:1.5px solid var(--line2);background:var(--bg2);color:var(--t2);white-space:nowrap;flex-shrink:0;transition:all 0.12s}
.chip.active{background:var(--cyan-bg);border-color:var(--cyan-bdr);color:var(--cyan)}
.device-card{display:flex;align-items:center;gap:12px;padding:14px 16px;background:var(--bg1);border:1px solid var(--line);border-radius:var(--r);cursor:pointer;transition:background 0.1s;margin-bottom:8px;position:relative;overflow:hidden}
.device-card:active{background:var(--bg2)}
.device-card.online{border-left:3px solid var(--green)}
.device-card.offline{border-left:3px solid var(--t3)}
.dev-icon{width:44px;height:44px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.dev-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2}
.dev-icon.win{background:rgba(0,120,215,0.15);color:#4da6ff}
.dev-icon.lin{background:rgba(255,140,0,0.12);color:#ffaa44}
.dev-icon.mac{background:rgba(0,200,130,0.12);color:#00c882}
.dev-icon.unk{background:var(--bg3);color:var(--t2)}
.dev-body{flex:1;min-width:0}
.dev-name{font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dev-meta{font-size:12px;color:var(--t2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dev-actions{display:flex;gap:6px;flex-shrink:0}
.dev-qbtn{width:36px;height:36px;display:flex;align-items:center;justify-content:center;border-radius:8px;background:var(--bg3);border:1px solid var(--line2);transition:all 0.1s}
.dev-qbtn:active{background:var(--bg4);transform:scale(0.92)}
.dev-qbtn svg{width:16px;height:16px;stroke:var(--t1);fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.dev-qbtn.warn svg{stroke:var(--amber)}
.dev-qbtn.danger svg{stroke:var(--red)}
.badge{font-size:11px;font-weight:700;padding:3px 9px;border-radius:20px;flex-shrink:0}
.badge.on{background:var(--green-bg);color:var(--green)}
.badge.off{background:rgba(90,120,153,0.15);color:var(--t2)}
.sec-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;padding:0 2px}
.sec-title{font-size:11px;font-weight:700;color:var(--t2);text-transform:uppercase;letter-spacing:0.8px}
.sec-link{font-size:12px;color:var(--cyan);font-weight:600}
.stats-row{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:20px}
.stat-card{background:var(--bg1);border:1px solid var(--line);border-radius:var(--r);padding:14px 12px;text-align:center}
.stat-n{font-size:30px;font-weight:800;line-height:1;letter-spacing:-1px}
.stat-n.green{color:var(--green)}
.stat-n.red{color:var(--red)}
.stat-n.blue{color:var(--cyan)}
.stat-lbl{font-size:11px;color:var(--t2);margin-top:4px;font-weight:500}
.detail-wrap{display:flex;flex-direction:column;height:100%}
.detail-hdr{display:flex;align-items:center;gap:10px;padding:0 16px;height:var(--hdr-h);background:rgba(8,13,20,0.92);border-bottom:1px solid var(--line);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);position:sticky;top:0;z-index:100}
.back-btn{width:40px;height:40px;display:flex;align-items:center;justify-content:center;background:none;border:none;cursor:pointer;margin-left:-8px}
.back-btn svg{width:22px;height:22px;stroke:var(--cyan);fill:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}
.detail-title{flex:1;font-size:17px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.status-banner{display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:var(--r);margin-bottom:18px}
.status-banner.on{background:var(--green-bg);border:1px solid rgba(0,230,118,0.2)}
.status-banner.off{background:var(--bg2);border:1px solid var(--line)}
.sb-text{font-size:14px;font-weight:700}
.on .sb-text{color:var(--green)}
.off .sb-text{color:var(--t2)}
.sb-sub{font-size:12px;color:var(--t2);margin-top:1px}
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:20px}
.info-cell{background:var(--bg1);border:1px solid var(--line);border-radius:var(--r-sm);padding:12px}
.info-cell.full{grid-column:1/-1}
.info-lbl{font-size:10px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:0.6px;margin-bottom:4px}
.info-val{font-size:13px;font-weight:500;word-break:break-all;line-height:1.4}
.power-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:20px}
.pow-btn{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:18px 12px;background:var(--bg1);border:1px solid var(--line);border-radius:var(--r);cursor:pointer;transition:all 0.12s}
.pow-btn:active{background:var(--bg3);transform:scale(0.96)}
.pow-btn:disabled{opacity:0.35;cursor:not-allowed;transform:none}
.pow-btn svg{width:26px;height:26px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.pow-btn span{font-size:13px;font-weight:600;color:var(--t1)}
.pow-btn.wake{color:var(--cyan)}
.pow-btn.sleep{color:var(--amber)}
.pow-btn.reboot{color:var(--amber)}
.pow-btn.shut{color:var(--red);border-color:var(--red-bdr)}
.pow-btn.term-btn{grid-column:1/-1;flex-direction:row;color:var(--cyan);padding:16px}
.pow-btn.term-btn span{font-size:14px}
.group-card{background:var(--bg1);border:1px solid var(--line);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
.group-hdr{display:flex;align-items:center;gap:10px;padding:14px 16px;cursor:pointer;transition:background 0.1s}
.group-hdr:active{background:var(--bg2)}
.group-hdr svg{width:16px;height:16px;stroke:var(--t2);fill:none;stroke-width:2;transition:transform 0.2s;flex-shrink:0}
.group-hdr.open svg.chev{transform:rotate(90deg)}
.group-hdr-name{flex:1;font-size:15px;font-weight:600}
.group-hdr-pills{display:flex;gap:5px}
.gpill{font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px}
.gpill.on{background:var(--green-bg);color:var(--green)}
.gpill.off{background:var(--bg3);color:var(--t2)}
.group-devices{border-top:1px solid var(--line)}
.group-dev-item{display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;border-bottom:1px solid var(--line);transition:background 0.1s}
.group-dev-item:last-child{border-bottom:none}
.group-dev-item:active{background:var(--bg2)}
.term-wrap{display:flex;flex-direction:column;flex:1;min-height:0}
.term-dev-sel{padding:10px 16px;border-bottom:1px solid var(--line)}
.term-dev-pick{width:100%;background:var(--bg2);border:1.5px solid var(--line2);border-radius:var(--r-sm);padding:12px 14px;font-size:14px;color:var(--t0);background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%235a7a99' fill='none' stroke-width='1.5'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;appearance:none}
.term-out{flex:1;background:#060a10;font-family:'SF Mono','Menlo','Monaco',Consolas,monospace;font-size:13px;line-height:1.5;padding:12px 14px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;color:#cdd9e5;-webkit-overflow-scrolling:touch}
.term-out::-webkit-scrollbar{width:3px}
.term-out::-webkit-scrollbar-track{background:transparent}
.term-out::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:3px}
.term-prompt{color:var(--cyan)}
.term-kb-bar{display:flex;gap:6px;padding:8px 12px;background:#090e16;border-top:1px solid rgba(255,255,255,0.06);overflow-x:auto}
.term-kb-bar::-webkit-scrollbar{display:none}
.kb-key{padding:6px 12px;background:#1a2540;border:1px solid rgba(255,255,255,0.1);border-radius:6px;font-family:monospace;font-size:12px;color:#8da3bc;white-space:nowrap;flex-shrink:0}
.kb-key:active{background:var(--bg4);color:var(--t0)}
.term-in-row{display:flex;gap:8px;padding:10px 12px;background:#060a10;border-top:1px solid rgba(255,255,255,0.08)}
.term-in{flex:1;background:#0d1520;border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:11px 14px;font-family:monospace;font-size:14px;color:#cdd9e5;outline:none}
.term-in:focus{border-color:var(--cyan)}
.term-send{padding:11px 18px;background:var(--cyan);color:#000;border-radius:8px;font-weight:700;font-size:13px;flex-shrink:0}
.term-send:active{background:var(--cyan2)}
.term-send:disabled{opacity:0.4}
.settings-wrap{padding:16px}
.settings-group{margin-bottom:24px;border-radius:var(--r);overflow:hidden;border:1px solid var(--line)}
.settings-row{display:flex;align-items:center;gap:12px;padding:15px 16px;background:var(--bg1);cursor:pointer;border-bottom:1px solid var(--line);transition:background 0.1s}
.settings-row:last-child{border-bottom:none}
.settings-row:active{background:var(--bg2)}
.settings-row svg{width:20px;height:20px;stroke:var(--t2);fill:none;stroke-width:2;flex-shrink:0}
.settings-row .lbl{flex:1;font-size:15px}
.settings-row .val{font-size:13px;color:var(--t2)}
.settings-row .val.on{color:var(--green)}
.settings-row .val.off{color:var(--red)}
.spinner{width:22px;height:22px;border:2.5px solid var(--line2);border-top-color:var(--cyan);border-radius:50%;animation:spin 0.75s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:52px 24px;text-align:center}
.empty svg{width:48px;height:48px;stroke:var(--t3);fill:none;stroke-width:1.5;margin-bottom:16px}
.empty h3{font-size:16px;font-weight:700;margin-bottom:6px}
.empty p{font-size:13px;color:var(--t2);line-height:1.5}
.toast{position:fixed;bottom:calc(var(--nav-h) + var(--safe-b) + 14px);left:16px;right:16px;padding:13px 16px;border-radius:var(--r);font-size:14px;font-weight:500;background:var(--bg2);border:1px solid var(--line2);box-shadow:0 10px 40px rgba(0,0,0,0.6);z-index:500;transform:translateY(120%);opacity:0;transition:transform 0.28s cubic-bezier(0.34,1.56,0.64,1),opacity 0.28s ease}
.toast.in{transform:translateY(0);opacity:1}
.toast.success{border-color:rgba(0,230,118,0.35)}
.toast.error{border-color:var(--red-bdr)}
.sheet-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.65);z-index:400;display:flex;align-items:flex-end;justify-content:center;opacity:0;transition:opacity 0.2s;pointer-events:none}
.sheet-overlay.open{opacity:1;pointer-events:all}
.sheet{background:var(--bg1);border-radius:20px 20px 0 0;width:100%;max-width:460px;padding:0 0 calc(16px + var(--safe-b));transform:translateY(100%);transition:transform 0.3s cubic-bezier(0.32,0.72,0,1)}
.sheet-overlay.open .sheet{transform:translateY(0)}
.sheet-handle{width:36px;height:4px;background:var(--t3);border-radius:2px;margin:12px auto 6px}
.sheet-title{font-size:15px;font-weight:700;text-align:center;padding:8px 16px 16px}
.sheet-msg{font-size:14px;color:var(--t1);text-align:center;padding:0 20px 18px;line-height:1.5}
.sheet-actions{padding:0 16px;display:flex;flex-direction:column;gap:8px}
.sheet-cancel{color:var(--t2);text-align:center;padding:14px;font-size:15px;font-weight:600;margin-top:4px}
.sheet-cancel:active{color:var(--t1)}
.pad{padding:16px}
.hidden{display:none!important}
2026-03-31 15:29:54 -04:00
< / style >
< / head >
< body >
2026-05-15 23:46:42 -04:00
< div id = "app" style = "height:100%;display:flex;flex-direction:column" > < / div >
2026-03-31 15:29:54 -04:00
< div id = "toast" class = "toast" > < / div >
2026-05-15 23:46:42 -04:00
< div id = "sheet-overlay" class = "sheet-overlay" onclick = "if(event.target===this)closeSheet()" >
< div class = "sheet" >
< div class = "sheet-handle" > < / div >
< div id = "sheet-title" class = "sheet-title" > < / div >
< div id = "sheet-msg" class = "sheet-msg" > < / div >
< div id = "sheet-actions" class = "sheet-actions" > < / div >
< button id = "sheet-cancel" class = "sheet-cancel" onclick = "closeSheet()" > Cancel< / button >
2026-03-31 15:29:54 -04:00
< / div >
< / div >
< script >
2026-05-15 23:46:42 -04:00
const S={view:'login',tab:'dashboard',ws:null,connected:false,connecting:false,serverUrl:'https://mesh.wilddragon.net',username:'',password:'',loginToken:'',authMode:'credentials',devices:[],meshes:{},selectedDevice:null,searchQuery:'',filterStatus:'all',expandedGroups:{},termNodeId:null,termLines:[],termHistory:[],termHistIdx:-1};
let savedServers=[];
function loadSaved(){try{savedServers=JSON.parse(localStorage.getItem('mc_srv2')||'[]');}catch(e){savedServers=[];}}
function saveSaved(){try{localStorage.setItem('mc_srv2',JSON.stringify(savedServers));}catch(e){}}
2026-03-31 15:29:54 -04:00
loadSaved();
2026-05-15 23:46:42 -04:00
const I={
monitor:`< svg viewBox = "0 0 24 24" > < rect x = "2" y = "3" width = "20" height = "14" rx = "2" / > < line x1 = "8" y1 = "21" x2 = "16" y2 = "21" / > < line x1 = "12" y1 = "17" x2 = "12" y2 = "21" / > < / svg > `,
server:`< svg viewBox = "0 0 24 24" > < rect x = "2" y = "2" width = "20" height = "8" rx = "2" / > < rect x = "2" y = "14" width = "20" height = "8" rx = "2" / > < circle cx = "6" cy = "6" r = "1" fill = "currentColor" stroke = "none" / > < circle cx = "6" cy = "18" r = "1" fill = "currentColor" stroke = "none" / > < / svg > `,
terminal:`< svg viewBox = "0 0 24 24" > < polyline points = "4 17 10 11 4 5" / > < line x1 = "12" y1 = "19" x2 = "20" y2 = "19" / > < / svg > `,
settings:`< svg viewBox = "0 0 24 24" > < circle cx = "12" cy = "12" r = "3" / > < path d = "M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" / > < / svg > `,
grid:`< svg viewBox = "0 0 24 24" > < rect x = "3" y = "3" width = "7" height = "7" rx = "1" / > < rect x = "14" y = "3" width = "7" height = "7" rx = "1" / > < rect x = "3" y = "14" width = "7" height = "7" rx = "1" / > < rect x = "14" y = "14" width = "7" height = "7" rx = "1" / > < / svg > `,
list:`< svg viewBox = "0 0 24 24" > < line x1 = "8" y1 = "6" x2 = "21" y2 = "6" / > < line x1 = "8" y1 = "12" x2 = "21" y2 = "12" / > < line x1 = "8" y1 = "18" x2 = "21" y2 = "18" / > < circle cx = "4" cy = "6" r = "1.2" fill = "currentColor" stroke = "none" / > < circle cx = "4" cy = "12" r = "1.2" fill = "currentColor" stroke = "none" / > < circle cx = "4" cy = "18" r = "1.2" fill = "currentColor" stroke = "none" / > < / svg > `,
search:`< svg viewBox = "0 0 24 24" > < circle cx = "11" cy = "11" r = "8" / > < line x1 = "21" y1 = "21" x2 = "16.65" y2 = "16.65" / > < / svg > `,
back:`< svg viewBox = "0 0 24 24" > < polyline points = "15 18 9 12 15 6" / > < / svg > `,
chev:`< svg class = "chev" viewBox = "0 0 24 24" > < polyline points = "9 6 15 12 9 18" / > < / svg > `,
refresh:`< svg viewBox = "0 0 24 24" > < polyline points = "23 4 23 10 17 10" / > < path d = "M20.49 15a9 9 0 1 1-2.12-9.36L23 10" / > < / svg > `,
power:`< svg viewBox = "0 0 24 24" > < path d = "M18.36 6.64a9 9 0 1 1-12.73 0" / > < line x1 = "12" y1 = "2" x2 = "12" y2 = "12" / > < / svg > `,
reboot:`< svg viewBox = "0 0 24 24" > < polyline points = "1 4 1 10 7 10" / > < path d = "M3.51 15a9 9 0 1 0 .49-4" / > < / svg > `,
wake:`< svg viewBox = "0 0 24 24" > < circle cx = "12" cy = "12" r = "4" / > < path d = "M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" / > < / svg > `,
moon:`< svg viewBox = "0 0 24 24" > < path d = "M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" / > < / svg > `,
cpu:`< svg viewBox = "0 0 24 24" > < rect x = "4" y = "4" width = "16" height = "16" rx = "2" / > < rect x = "9" y = "9" width = "6" height = "6" / > < line x1 = "9" y1 = "1" x2 = "9" y2 = "4" / > < line x1 = "15" y1 = "1" x2 = "15" y2 = "4" / > < line x1 = "9" y1 = "20" x2 = "9" y2 = "23" / > < line x1 = "15" y1 = "20" x2 = "15" y2 = "23" / > < line x1 = "20" y1 = "9" x2 = "23" y2 = "9" / > < line x1 = "20" y1 = "14" x2 = "23" y2 = "14" / > < line x1 = "1" y1 = "9" x2 = "4" y2 = "9" / > < line x1 = "1" y1 = "14" x2 = "4" y2 = "14" / > < / svg > `,
wifi:`< svg viewBox = "0 0 24 24" > < path d = "M5 12.55a11 11 0 0 1 14.08 0" / > < path d = "M1.42 9a16 16 0 0 1 21.16 0" / > < path d = "M8.53 16.11a6 6 0 0 1 6.95 0" / > < circle cx = "12" cy = "20" r = "1" fill = "currentColor" stroke = "none" / > < / svg > `,
logout:`< svg viewBox = "0 0 24 24" > < path d = "M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" / > < polyline points = "16 17 21 12 16 7" / > < line x1 = "21" y1 = "12" x2 = "9" y2 = "12" / > < / svg > `,
key:`< svg viewBox = "0 0 24 24" > < path d = "M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" / > < / svg > `,
x:`< svg viewBox = "0 0 24 24" > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < / svg > `,
globe:`< svg viewBox = "0 0 24 24" > < circle cx = "12" cy = "12" r = "10" / > < line x1 = "2" y1 = "12" x2 = "22" y2 = "12" / > < path d = "M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" / > < / svg > `,
2026-03-31 15:29:54 -04:00
};
2026-05-15 23:46:42 -04:00
const $id=id=>document.getElementById(id);
const esc=s=>!s?'':String(s).replace(/&/g,'& ').replace(/< /g,'< ').replace(/>/g,'> ').replace(/"/g,'" ');
const online=d=>d&&(d.conn&1)!==0;
const osClass=d=>{if(!d?.osdesc)return'unk';const o=d.osdesc.toLowerCase();return o.includes('windows')?'win':(o.includes('linux')||o.includes('ubuntu')||o.includes('debian'))?'lin':(o.includes('mac')||o.includes('darwin'))?'mac':'unk';};
const osIcon=d=>{const c=osClass(d);return c==='win'?I.monitor:c==='lin'?I.server:c==='mac'?I.monitor:I.cpu;};
const lastSeen=d=>{if(!d.lastconnect)return'Never';const diff=(Date.now()-d.lastconnect*1000)/1000;if(diff< 60 ) return ' Just now ' ; if ( diff < 3600 ) return Math . floor ( diff / 60 ) + ' m ago ' ; if ( diff < 86400 ) return Math . floor ( diff / 3600 ) + ' h ago ' ; return Math . floor ( diff / 86400 ) + ' d ago ' ; } ;
let _toastT;
function toast(msg,type='info'){const t=$id('toast');t.textContent=msg;t.className='toast '+type;requestAnimationFrame(()=>{t.classList.add('in');});clearTimeout(_toastT);_toastT=setTimeout(()=>t.classList.remove('in'),3000);}
let _sheetCb=null;
function openSheet(title,msg,actions){$id('sheet-title').textContent=title;$id('sheet-msg').textContent=msg;$id('sheet-actions').innerHTML=actions.map(a=>`< button class = "btn ${a.danger?'btn-danger':'btn-ghost'}" onclick = "sheetAction(${a.idx})" > ${a.label}< / button > `).join('');_sheetCb=actions;$id('sheet-overlay').classList.add('open');}
function closeSheet(){$id('sheet-overlay').classList.remove('open');_sheetCb=null;}
function sheetAction(idx){if(_sheetCb&&_sheetCb[idx]?.cb)_sheetCb[idx].cb(); closeSheet();}
function wsConnect(){
if(S.ws){try{S.ws.close();}catch(e){}}
S.connecting=true;S.connected=false;render();
let url=S.serverUrl.replace(/\/$/,'').replace(/^http/,'ws')+'/control.ashx';
if(S.authMode==='credentials'&&S.username)url+='?user='+encodeURIComponent(S.username)+'&pass='+encodeURIComponent(S.password);
else if(S.authMode==='token'&&S.loginToken)url+='?token='+encodeURIComponent(S.loginToken);
try{S.ws=new WebSocket(url);}catch(e){S.connecting=false;toast('Connection failed: '+e.message,'error');render();return;}
S.ws.onopen=()=>{S.connecting=false;S.connected=true;S.view='main';const key=S.serverUrl+'|'+(S.username||'(token)');if(!savedServers.find(s=>s.key===key)){savedServers.unshift({key,url:S.serverUrl,username:S.username||'(token)'});if(savedServers.length>5)savedServers.pop();saveSaved();}toast('Connected','success');wsSend({action:'meshes'});wsSend({action:'nodes'});render();};
S.ws.onmessage=ev=>{try{handleMsg(JSON.parse(ev.data));}catch(e){}};
S.ws.onerror=()=>{S.connecting=false;toast('WebSocket error','error');render();};
S.ws.onclose=()=>{S.connected=false;S.connecting=false;if(S.view==='main')toast('Disconnected','error');render();};
}
function wsSend(obj){if(S.ws?.readyState===WebSocket.OPEN)S.ws.send(JSON.stringify(obj));}
function handleMsg(msg){
if(!msg.action){if(msg.meshes){S.meshes={};msg.meshes.forEach(m=>S.meshes[m._id]=m);render();}return;}
switch(msg.action){
case'meshes':if(msg.meshes){S.meshes={};msg.meshes.forEach(m=>S.meshes[m._id]=m);}render();break;
case'nodes':if(msg.nodes){S.devices=[];for(const mid in msg.nodes)msg.nodes[mid].forEach(n=>{n._meshId=mid;S.devices.push(n);});S.devices.sort((a,b)=>(a.name||'').localeCompare(b.name||''));}render();break;
case'changenode':if(msg.node){const i=S.devices.findIndex(d=>d._id===msg.node._id);if(i>=0)Object.assign(S.devices[i],msg.node);else S.devices.push(msg.node);if(S.selectedDevice?._id===msg.node._id)Object.assign(S.selectedDevice,msg.node);render();}break;
case'nodeconnect':if(msg.nodeid){const d=S.devices.find(d=>d._id===msg.nodeid);if(d&&msg.conn!==undefined){d.conn=msg.conn; render();}}break;
case'event':if(msg.event?.nodeid){const d=S.devices.find(d=>d._id===msg.event.nodeid);if(d&&msg.event.conn!==undefined){d.conn=msg.event.conn; render();}}break;
case'msg':if(msg.type==='console'&&msg.value){S.termLines.push({type:'out',text:msg.value}); if(S.tab==='terminal')renderTermOutput();}break;
2026-03-31 15:29:54 -04:00
}
}
2026-05-15 23:46:42 -04:00
function disconnect(){if(S.ws){try{S.ws.close();}catch(e){}S.ws=null;}Object.assign(S,{connected:false,connecting:false,devices:[],meshes:{},selectedDevice:null,view:'login',tab:'dashboard',termLines:[],termHistory:[],termHistIdx:-1});render();}
const POWER_LABELS={2:'Shutdown',3:'Reboot',4:'Sleep',100:'Wake on LAN'};
function powerAction(nodeId,action){wsSend({action:'poweraction',nodeids:[nodeId],actiontype:action});toast(POWER_LABELS[action]+' sent','success');}
function render(){const app=$id('app');if(S.view==='login'){app.innerHTML=tLogin();}else if(S.selectedDevice){app.innerHTML=tDeviceDetail();}else{app.innerHTML=tMain();}bindEvents();}
function tLogin(){
const recentHtml=savedServers.map((s,i)=>`< div class = "saved-item" onclick = "loadSavedIdx(${i})" > < div style = "flex:1" > < div class = "saved-url" > ${esc(s.url.replace(/^https?:\/\//,''))}< / div > < div class = "saved-user" > ${esc(s.username)}< / div > < / div > < button class = "saved-del" onclick = "event.stopPropagation();removeSaved(${i})" > ${I.x}< / button > < / div > `).join('');
return`< div class = "login-wrap" > < div class = "login-mark" > < svg viewBox = "0 0 60 60" fill = "none" > < rect x = "4" y = "4" width = "52" height = "52" rx = "14" stroke = "#00d4ff" stroke-width = "3" / > < circle cx = "30" cy = "25" r = "9" stroke = "#00d4ff" stroke-width = "2.5" / > < path d = "M16 48c0-7.732 6.268-14 14-14s14 6.268 14 14" stroke = "#00d4ff" stroke-width = "2.5" stroke-linecap = "round" / > < / svg > < / div > < div class = "login-h1" > MeshCtrl< / div > < div class = "login-sub" > MeshCentral mobile client< / div > ${savedServers.length?`< div style = "width:100%;max-width:380px" > < div style = "font-size:11px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:0.7px;margin-bottom:8px;padding-left:2px" > Recent< / div > < div class = "saved-list" > ${recentHtml}< / div > < / div > < div class = "divider-text" style = "width:100%;max-width:380px" > < span > or new< / span > < / div > `:''}< div class = "login-form" > < div class = "field" > < label > Server URL< / label > < input id = "f-url" type = "url" placeholder = "https://mesh.example.com" value = "${esc(S.serverUrl)}" autocapitalize = "none" autocorrect = "off" spellcheck = "false" > < / div > < div style = "display:flex;gap:6px" > < button class = "btn btn-ghost" style = "flex:1;padding:10px;font-size:13px;${S.authMode==='credentials'?'background:var(--cyan-bg);border-color:var(--cyan-bdr);color:var(--cyan)':''}" onclick = "S.authMode='credentials';render()" > Password< / button > < button class = "btn btn-ghost" style = "flex:1;padding:10px;font-size:13px;${S.authMode==='token'?'background:var(--cyan-bg);border-color:var(--cyan-bdr);color:var(--cyan)':''}" onclick = "S.authMode='token';render()" > Token< / button > < / div > ${S.authMode==='credentials'?`< div class = "field" > < label > Username< / label > < input id = "f-user" type = "text" placeholder = "admin" value = "${esc(S.username)}" autocapitalize = "none" autocorrect = "off" > < / div > < div class = "field" > < label > Password< / label > < input id = "f-pass" type = "password" placeholder = "••••••••" value = "${esc(S.password)}" > < / div > `:`< div class = "field" > < label > Login Token< / label > < input id = "f-token" type = "text" placeholder = "Paste login token" value = "${esc(S.loginToken)}" autocapitalize = "none" autocorrect = "off" spellcheck = "false" > < / div > `}< button class = "btn btn-primary" id = "btn-connect" $ { S . connecting ? ' disabled ' : ' ' } style = "margin-top:4px" > ${S.connecting?'< div class = "spinner" style = "width:18px;height:18px;border-width:2px;border-color:rgba(0,0,0,0.2);border-top-color:#000" > < / div > ':'Connect'}< / button > < / div > < / div > `;
}
function tMain(){
let content;
if(S.tab==='dashboard')content=tDashboard();
else if(S.tab==='devices')content=tDevices();
else if(S.tab==='terminal')content=tTerminal();
else content=tSettings();
const dotCls=S.connected?'on':S.connecting?'ing':'off';
const isTerm=S.tab==='terminal';
return`< div class = "hdr" > < span class = "dot ${dotCls}" > < / span > < div > < div class = "hdr-title" > MeshCtrl< / div > < / div > < button class = "hdr-btn" onclick = "doRefresh()" style = "margin-left:auto" > ${I.refresh}< / button > < / div > < div class = "scroll" id = "main-scroll" style = "${isTerm?'display:none':''}" > ${isTerm?'':content}< / div > ${isTerm?`< div style = "flex:1;display:flex;flex-direction:column;min-height:0" > ${content}< / div > `:''}< nav class = "nav" > < button class = "nav-btn ${S.tab==='dashboard'?'active':''}" onclick = "setTab('dashboard')" > ${I.grid}< span > Dashboard< / span > < / button > < button class = "nav-btn ${S.tab==='devices'?'active':''}" onclick = "setTab('devices')" > ${I.list}< span > Devices< / span > < / button > < button class = "nav-btn ${S.tab==='terminal'?'active':''}" onclick = "setTab('terminal')" > ${I.terminal}< span > Terminal< / span > < / button > < button class = "nav-btn ${S.tab==='settings'?'active':''}" onclick = "setTab('settings')" > ${I.settings}< span > Settings< / span > < / button > < / nav > `;
}
function tDashboard(){
const onlineDevs=S.devices.filter(d=>online(d));
const meshList=Object.values(S.meshes);
return`< div class = "pad" style = "padding-bottom:20px" > < div class = "stats-row" > < div class = "stat-card" > < div class = "stat-n blue" > ${S.devices.length}< / div > < div class = "stat-lbl" > Total< / div > < / div > < div class = "stat-card" > < div class = "stat-n green" > ${onlineDevs.length}< / div > < div class = "stat-lbl" > Online< / div > < / div > < div class = "stat-card" > < div class = "stat-n red" > ${S.devices.length-onlineDevs.length}< / div > < div class = "stat-lbl" > Offline< / div > < / div > < / div > ${meshList.length>0?`< div class = "sec-hdr" > < div class = "sec-title" > Device Groups< / div > < span style = "font-size:12px;color:var(--t2)" > ${meshList.length} group${meshList.length!==1?'s':''}< / span > < / div > ${meshList.map(mesh=>{const mDevs=S.devices.filter(d=>d.meshid===mesh._id);const mOn=mDevs.filter(d=>online(d)).length;const expanded=S.expandedGroups[mesh._id];return`< div class = "group-card" > < div class = "group-hdr${expanded?' open':''}" onclick = "toggleGroup('${mesh._id}')" > < svg class = "chev" viewBox = "0 0 24 24" > < polyline points = "9 6 15 12 9 18" / > < / svg > < div class = "group-hdr-name" > ${esc(mesh.name||'Unnamed')}< / div > < div class = "group-hdr-pills" > ${mOn>0?`< span class = "gpill on" > ${mOn} on< / span > `:''} ${mDevs.length-mOn>0?`< span class = "gpill off" > ${mDevs.length-mOn} off< / span > `:''}< / div > < / div > ${expanded?`< div class = "group-devices" > ${mDevs.length===0?'< div style = "padding:12px 16px;font-size:13px;color:var(--t3)" > No devices< / div > ':mDevs.map(d=>`< div class = "group-dev-item" onclick = "selectDev('${d._id}')" > < div class = "dev-icon ${osClass(d)}" style = "width:32px;height:32px;border-radius:8px" > ${osIcon(d)}< / div > < div style = "flex:1;min-width:0" > < div class = "dev-name" style = "font-size:14px" > ${esc(d.name||'Unknown')}< / div > < div class = "dev-meta" > ${esc(d.osdesc||'')}< / div > < / div > < span class = "badge ${online(d)?'on':'off'}" > ${online(d)?'●\u00a0On':'Off'}< / span > < / div > `).join('')}< / div > `:''}< / div > `;}).join('')}`:''} ${onlineDevs.length>0?`< div class = "sec-hdr" style = "margin-top:16px" > < div class = "sec-title" > Online Now< / div > < button class = "sec-link" onclick = "setTab('devices')" > All →< / button > < / div > ${onlineDevs.slice(0,6).map(d=>tDeviceCard(d)).join('')}${onlineDevs.length>6?`< div style = "text-align:center;padding:8px;font-size:13px;color:var(--t2)" > +${onlineDevs.length-6} more< / div > `:''}`:S.devices.length===0?`< div class = "empty" > ${I.wifi}< h3 > No devices< / h3 > < p > Agents will appear here once connected.< / p > < / div > `:''}< / div > `;
}
function tDeviceCard(d){
const isOn=online(d);
return`< div class = "device-card ${isOn?'online':'offline'}" onclick = "selectDev('${d._id}')" > < div class = "dev-icon ${osClass(d)}" > ${osIcon(d)}< / div > < div class = "dev-body" > < div class = "dev-name" > ${esc(d.name||'Unknown')}< / div > < div class = "dev-meta" > ${esc(d.osdesc||'Unknown OS')}< / div > < / div > < div class = "dev-actions" onclick = "event.stopPropagation()" > ${isOn?`< button class = "dev-qbtn" title = "Terminal" onclick = "quickTerminal('${d._id}')" > ${I.terminal}< / button > < button class = "dev-qbtn warn" title = "Reboot" onclick = "quickReboot('${d._id}','${esc(d.name||'device')}')" > ${I.reboot}< / button > `:`< button class = "dev-qbtn" title = "Wake on LAN" onclick = "powerAction('${d._id}',100)" > ${I.wake}< / button > `}< / div > < / div > `;
}
function tDevices(){
const q=S.searchQuery.toLowerCase();
let list=S.devices;
if(S.filterStatus==='online')list=list.filter(d=>online(d));
else if(S.filterStatus==='offline')list=list.filter(d=>!online(d));
if(q)list=list.filter(d=>(d.name||'').toLowerCase().includes(q)||(d.osdesc||'').toLowerCase().includes(q)||(d.host||'').toLowerCase().includes(q));
list=[...list].sort((a,b)=>{const aOn=online(a)?0:1,bOn=online(b)?0:1;if(aOn!==bOn)return aOn-bOn;return(a.name||'').localeCompare(b.name||'');});
return`< div class = "pad" style = "padding-bottom:20px" > < div class = "search-bar" > ${I.search}< input id = "si" type = "text" placeholder = "Search devices…" value = "${esc(S.searchQuery)}" oninput = "S.searchQuery=this.value;renderDevicesOnly()" autocapitalize = "none" autocorrect = "off" > ${S.searchQuery?`< button class = "search-clear" onclick = "S.searchQuery='';render()" > ${I.x}< / button > `:''}< / div > < div class = "chips" > < button class = "chip${S.filterStatus==='all'?' active':''}" onclick = "S.filterStatus='all';render()" > All (${S.devices.length})< / button > < button class = "chip${S.filterStatus==='online'?' active':''}" onclick = "S.filterStatus='online';render()" > Online (${S.devices.filter(d=>online(d)).length})< / button > < button class = "chip${S.filterStatus==='offline'?' active':''}" onclick = "S.filterStatus='offline';render()" > Offline (${S.devices.filter(d=>!online(d)).length})< / button > < / div > < div id = "device-list-inner" > ${list.length===0?`< div class = "empty" > ${I.search}< h3 > No results< / h3 > < p > Try adjusting your search or filter.< / p > < / div > `:list.map(d=>tDeviceCard(d)).join('')}< / div > < / div > `;
}
function tDeviceDetail(){
const d=S.selectedDevice;if(!d)return'';
const isOn=online(d);
const meshName=S.meshes[d.meshid]?.name||'Unknown group';
const ipAddr=d.ip||d.host||'—';
return`< div class = "detail-wrap" > < div class = "detail-hdr" > < button class = "back-btn" onclick = "S.selectedDevice=null;render()" > ${I.back}< / button > < div style = "flex:1;min-width:0" > < div class = "detail-title" > ${esc(d.name||'Unknown')}< / div > < / div > < button class = "hdr-btn" onclick = "doRefresh()" style = "flex-shrink:0" > ${I.refresh}< / button > < / div > < div class = "scroll" > < div class = "pad" style = "padding-bottom:24px" > < div class = "status-banner ${isOn?'on':'off'}" > < span class = "dot ${isOn?'on':'off'}" > < / span > < div > < div class = "sb-text" > ${isOn?'Online':'Offline'}< / div > < div class = "sb-sub" > ${isOn?esc(ipAddr):'Last seen: '+lastSeen(d)}< / div > < / div > < / div > < div class = "sec-title" style = "margin-bottom:10px" > System Info< / div > < div class = "info-grid" > < div class = "info-cell" > < div class = "info-lbl" > OS< / div > < div class = "info-val" > ${esc(d.osdesc||'Unknown')}< / div > < / div > < div class = "info-cell" > < div class = "info-lbl" > Group< / div > < div class = "info-val" > ${esc(meshName)}< / div > < / div > < div class = "info-cell" > < div class = "info-lbl" > IP / Host< / div > < div class = "info-val" > ${esc(ipAddr)}< / div > < / div > < div class = "info-cell" > < div class = "info-lbl" > Agent< / div > < div class = "info-val" > ${d.agent?'v'+(d.agent.ver||'?'):'—'}< / div > < / div > < div class = "info-cell full" > < div class = "info-lbl" > Node ID< / div > < div class = "info-val" style = "font-size:11px;font-family:monospace" > ${esc(d._id||'')}< / div > < / div > < / div > < div class = "sec-title" style = "margin-bottom:10px" > Power< / div > < div class = "power-grid" > < button class = "pow-btn wake" onclick = "powerAction('${d._id}',100)" > ${I.wake}< span > Wake< / span > < / button > < button class = "pow-btn sleep" $ { ! isOn ? ' disabled ' : ' ' } onclick = "openSheet('Sleep','Put ${esc(d.name||'device')} to sleep?',[{idx:0,label:'Sleep',danger:false,cb:()=>powerAction('${d._id}',4)}])" > ${I.moon}< span > Sleep< / span > < / button > < button class = "pow-btn reboot" $ { ! isOn ? ' disabled ' : ' ' } onclick = "openSheet('Reboot','Reboot ${esc(d.name||'device')}?',[{idx:0,label:'Reboot',danger:true,cb:()=>powerAction('${d._id}',3)}])" > ${I.reboot}< span > Reboot< / span > < / button > < button class = "pow-btn shut" $ { ! isOn ? ' disabled ' : ' ' } onclick = "openSheet('Shutdown','Shut down ${esc(d.name||'device')}?',[{idx:0,label:'Shutdown',danger:true,cb:()=>powerAction('${d._id}',2)}])" > ${I.power}< span > Shutdown< / span > < / button > < / div > ${isOn?`< div class = "sec-title" style = "margin-bottom:10px" > Remote Access< / div > < button class = "pow-btn term-btn" onclick = "openTerminalFor('${d._id}')" > ${I.terminal}< span > Open Terminal< / span > < / button > < button class = "pow-btn term-btn" style = "margin-top:8px;color:var(--t1);border-color:var(--line)" onclick = "window.open(S.serverUrl+'/?node='+encodeURIComponent('${d._id}'),'_blank')" > ${I.globe}< span > MeshCentral Web UI< / span > < / button > `:''}< / div > < / div > < / div > `;
}
function tTerminal(){
const onlineDevs=S.devices.filter(d=>online(d));
return`< div class = "term-wrap" > < div class = "term-dev-sel" > < select class = "term-dev-pick" id = "term-sel" onchange = "S.termNodeId=this.value;S.termLines=[];renderTermOutput()" > < option value = "" > — Select online device —< / option > ${onlineDevs.map(d=>`< option value = "${d._id}" $ { S . termNodeId = ==d._id?'selected':''} > ${esc(d.name||'Unknown')}< / option > `).join('')}< / select > < / div > < div class = "term-out" id = "term-out" > ${S.termLines.length===0?'< span style = "color:#3a5470" > # Select an online device above\n# Commands sent via MeshCentral agent console< / span > ':S.termLines.map(l=>l.type==='cmd'?`< span class = "term-prompt" > $ ${esc(l.text)}< / span > `:`< span > ${esc(l.text)}< / span > `).join('\n')}< / div > < div class = "term-kb-bar" > < button class = "kb-key" onclick = "termKey('Ctrl+C')" > ^C< / button > < button class = "kb-key" onclick = "termKey('Ctrl+D')" > ^D< / button > < button class = "kb-key" onclick = "termHistNav(1)" > ▲ Prev< / button > < button class = "kb-key" onclick = "termHistNav(-1)" > ▼ Next< / button > < button class = "kb-key" onclick = "termInsert('ls -la')" > ls -la< / button > < button class = "kb-key" onclick = "termInsert('pwd')" > pwd< / button > < button class = "kb-key" onclick = "termInsert('df -h')" > df -h< / button > < button class = "kb-key" onclick = "termInsert('top')" > top< / button > < button class = "kb-key" onclick = "termInsert('docker ps')" > docker ps< / button > < button class = "kb-key" onclick = "termInsert('systemctl status')" > systemctl< / button > < / div > < div class = "term-in-row" > < input class = "term-in" id = "term-in" type = "text" placeholder = "${S.termNodeId?'Enter command…':'Select a device first'}" $ { ! S . termNodeId ? ' disabled ' : ' ' } autocapitalize = "none" autocorrect = "off" autocomplete = "off" spellcheck = "false" > < button class = "term-send" id = "term-send" $ { ! S . termNodeId ? ' disabled ' : ' ' } onclick = "sendTermCmd()" > ↵< / button > < / div > < / div > `;
}
function tSettings(){
const host=S.serverUrl.replace(/^https?:\/\//,'');
return`< div class = "settings-wrap" > < div class = "sec-title" style = "margin-bottom:8px" > Connection< / div > < div class = "settings-group" > < div class = "settings-row" style = "cursor:default" > ${I.wifi}< span class = "lbl" > Server< / span > < span class = "val" > ${esc(host)}< / span > < / div > < div class = "settings-row" style = "cursor:default" > ${I.key}< span class = "lbl" > Auth< / span > < span class = "val" > ${S.authMode==='credentials'?'Credentials':'Token'}< / span > < / div > < div class = "settings-row" style = "cursor:default" > ${I.wifi}< span class = "lbl" > Status< / span > < span class = "val ${S.connected?'on':'off'}" > ${S.connected?'Connected':'Disconnected'}< / span > < / div > < / div > < div class = "sec-title" style = "margin-bottom:8px" > Actions< / div > < div class = "settings-group" > < div class = "settings-row" onclick = "doRefresh()" > ${I.refresh}< span class = "lbl" > Refresh Devices< / span > < svg viewBox = "0 0 24 24" > < polyline points = "9 6 15 12 9 18" / > < / svg > < / div > < div class = "settings-row" onclick = "window.open(S.serverUrl,'_blank')" > ${I.globe}< span class = "lbl" > Open MeshCentral Web UI< / span > < svg viewBox = "0 0 24 24" > < polyline points = "9 6 15 12 9 18" / > < / svg > < / div > < / div > < div class = "sec-title" style = "margin-bottom:8px" > Session< / div > < div class = "settings-group" > < div class = "settings-row" onclick = "doDisconnect()" > ${I.logout}< span class = "lbl" style = "color:var(--red)" > Disconnect< / span > < / div > < / div > < div style = "text-align:center;padding:28px 0 8px;color:var(--t3);font-size:12px" > MeshCtrl Mobile v2.0< / div > < / div > `;
}
function renderTermOutput(){const el=$id('term-out');if(!el)return;if(S.termLines.length===0){el.innerHTML='< span style = "color:#3a5470" > # Select an online device above\n# Commands sent via MeshCentral agent console< / span > ';}else{el.innerHTML=S.termLines.map(l=>l.type==='cmd'?`< span class = "term-prompt" > $ ${esc(l.text)}< / span > `:`< span > ${esc(l.text)}< / span > `).join('\n');}el.scrollTop=el.scrollHeight;}
function renderDevicesOnly(){const inner=$id('device-list-inner');if(!inner){render();return;}const q=S.searchQuery.toLowerCase();let list=S.devices;if(S.filterStatus==='online')list=list.filter(d=>online(d));else if(S.filterStatus==='offline')list=list.filter(d=>!online(d));if(q)list=list.filter(d=>(d.name||'').toLowerCase().includes(q)||(d.osdesc||'').toLowerCase().includes(q)||(d.host||'').toLowerCase().includes(q));list=[...list].sort((a,b)=>{const aOn=online(a)?0:1,bOn=online(b)?0:1;if(aOn!==bOn)return aOn-bOn;return(a.name||'').localeCompare(b.name||'');});inner.innerHTML=list.length===0?`< div class = "empty" > ${I.search}< h3 > No results< / h3 > < p > Try adjusting your search or filter.< / p > < / div > `:list.map(d=>tDeviceCard(d)).join('');}
function bindEvents(){
const bc=$id('btn-connect');
if(bc)bc.onclick=()=>{S.serverUrl=($id('f-url')?.value||'').trim();if(S.authMode==='credentials'){S.username=($id('f-user')?.value||'').trim();S.password=($id('f-pass')?.value||'').trim();}else{S.loginToken=($id('f-token')?.value||'').trim();}if(!S.serverUrl){toast('Enter a server URL','error');return;}wsConnect();};
['f-url','f-user','f-pass','f-token'].forEach(id=>{const el=$id(id);if(el)el.onkeydown=e=>{if(e.key==='Enter')bc?.click();};});
const ti=$id('term-in');
if(ti){ti.onkeydown=e=>{if(e.key==='Enter')sendTermCmd();else if(e.key==='ArrowUp')termHistNav(1);else if(e.key==='ArrowDown')termHistNav(-1);};}
setTimeout(()=>{const to=$id('term-out');if(to)to.scrollTop=to.scrollHeight;},0);
}
function setTab(t){S.tab=t;S.selectedDevice=null;render();}
function selectDev(id){S.selectedDevice=S.devices.find(d=>d._id===id);render();}
function toggleGroup(id){S.expandedGroups[id]=!S.expandedGroups[id];render();}
function doRefresh(){wsSend({action:'meshes'});wsSend({action:'nodes'});toast('Refreshing…');}
function removeSaved(i){savedServers.splice(i,1);saveSaved();render();}
function loadSavedIdx(i){const s=savedServers[i];if(s){S.serverUrl=s.url;S.username=s.username!=='(token)'?s.username:'';render();}}
function quickTerminal(id){openTerminalFor(id);}
function quickReboot(id,name){openSheet('Reboot',`Reboot ${name}?`,[{idx:0,label:'Reboot',danger:true,cb:()=>powerAction(id,3)}]);}
function openTerminalFor(id){S.termNodeId=id;S.termLines=[];S.tab='terminal';S.selectedDevice=null;render();setTimeout(()=>$id('term-in')?.focus(),150);}
function doDisconnect(){openSheet('Disconnect','Disconnect from the server?',[{idx:0,label:'Disconnect',danger:true,cb:disconnect}]);}
function sendTermCmd(){const inp=$id('term-in');if(!inp?.value?.trim()||!S.termNodeId)return;const cmd=inp.value.trim();S.termHistory.unshift(cmd);if(S.termHistory.length>50)S.termHistory.pop();S.termHistIdx=-1;S.termLines.push({type:'cmd',text:cmd});wsSend({action:'msg',type:'console',nodeid:S.termNodeId,value:cmd});inp.value='';renderTermOutput();}
function termHistNav(dir){const inp=$id('term-in');if(!inp)return;S.termHistIdx=Math.max(-1,Math.min(S.termHistory.length-1,S.termHistIdx+dir));inp.value=S.termHistIdx>=0?S.termHistory[S.termHistIdx]:'';}
function termKey(k){const inp=$id('term-in');if(!inp||!S.termNodeId)return;if(k==='Ctrl+C'){S.termLines.push({type:'cmd',text:'^C'});wsSend({action:'msg',type:'console',nodeid:S.termNodeId,value:'\x03'});renderTermOutput();}else if(k==='Ctrl+D'){wsSend({action:'msg',type:'console',nodeid:S.termNodeId,value:'\x04'});}}
function termInsert(text){const inp=$id('term-in');if(!inp)return;inp.value=text;inp.focus();}
let _pt0=0;
document.addEventListener('touchstart',e=>{const s=$id('main-scroll');_pt0=(s&&s.scrollTop===0)?e.touches[0].clientY:0; },{passive:true});
document.addEventListener('touchend',e=>{if(_pt0&&e.changedTouches[0].clientY-_pt0>80&&S.connected&&S.view==='main')doRefresh(); _pt0=0;},{passive:true});
if('serviceWorker'in navigator){const sw=`self.addEventListener('install',()=>self.skipWaiting());self.addEventListener('activate',e=>e.waitUntil(clients.claim()));self.addEventListener('fetch',e=>{if(e.request.url.includes('control.ashx'))return;e.respondWith(caches.match(e.request).then(r=>r||fetch(e.request)));});`;const blob=new Blob([sw],{type:'application/javascript'});navigator.serviceWorker.register(URL.createObjectURL(blob)).catch(()=>{});}
2026-03-31 15:29:54 -04:00
render();
< / script >
< / body >
< / html >