meshcentral-mobile-app/meshcentral-mobile.html

378 lines
47 KiB
HTML
Raw Normal View History

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">
<meta name="theme-color" content="#080d14">
2026-03-31 15:29:54 -04:00
<meta name="color-scheme" content="dark">
<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>
*,*::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>
<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>
<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>
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();
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
};
const $id=id=>document.getElementById(id);
const esc=s=>!s?'':String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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
}
}
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>