meshcentral-mobile-app/meshcentral-mobile.html

1185 lines
53 KiB
HTML
Raw Permalink 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="#0f172a">
<meta name="color-scheme" content="dark">
<!-- iOS Home Screen Icons (inline SVG-based) -->
<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='%230f172a'/><rect x='30' y='30' width='120' height='120' rx='24' stroke='%2338bdf8' stroke-width='6' fill='none'/><circle cx='90' cy='78' r='18' stroke='%2338bdf8' stroke-width='5' fill='none'/><path d='M60 132c0-16.569 13.431-30 30-30s30 13.431 30 30' stroke='%2338bdf8' 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='%230f172a'/><rect x='5' y='5' width='22' height='22' rx='5' stroke='%2338bdf8' stroke-width='2' fill='none'/><circle cx='16' cy='13' r='4' stroke='%2338bdf8' stroke-width='1.5' fill='none'/><path d='M10 24c0-3.314 2.686-6 6-6s6 2.686 6 6' stroke='%2338bdf8' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>">
<!-- PWA Manifest (inline) -->
<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%230f172a%22%2C%22theme_color%22%3A%22%230f172a%22%2C%22description%22%3A%22Mobile%20client%20for%20MeshCentral%22%2C%22icons%22%3A%5B%7B%22src%22%3A%22data%3Aimage%2Fsvg%2Bxml%2C%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%20512%20512'%3E%3Crect%20width%3D'512'%20height%3D'512'%20rx%3D'100'%20fill%3D'%25230f172a'%2F%3E%3Crect%20x%3D'80'%20y%3D'80'%20width%3D'352'%20height%3D'352'%20rx%3D'60'%20stroke%3D'%252338bdf8'%20stroke-width%3D'16'%20fill%3D'none'%2F%3E%3Ccircle%20cx%3D'256'%20cy%3D'220'%20r%3D'50'%20stroke%3D'%252338bdf8'%20stroke-width%3D'14'%20fill%3D'none'%2F%3E%3Cpath%20d%3D'M170%20380c0-47.5%2038.5-86%2086-86s86%2038.5%2086%2086'%20stroke%3D'%252338bdf8'%20stroke-width%3D'14'%20fill%3D'none'%20stroke-linecap%3D'round'%2F%3E%3C%2Fsvg%3E%22%2C%22sizes%22%3A%22512x512%22%2C%22type%22%3A%22image%2Fsvg%2Bxml%22%7D%5D%7D">
<title>MeshCtrl</title>
<style>
/* ========== RESET & BASE ========== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f172a; --bg-card: #1e293b; --bg-input: #334155;
--bg-hover: #334155; --bg-active: #475569;
--text: #f1f5f9; --text-dim: #94a3b8; --text-muted: #64748b;
--accent: #38bdf8; --accent-dim: #0ea5e9; --accent-bg: rgba(56,189,248,0.1);
--green: #4ade80; --green-bg: rgba(74,222,128,0.12);
--red: #f87171; --red-bg: rgba(248,113,113,0.12);
--orange: #fb923c; --orange-bg: rgba(251,146,60,0.12);
--yellow: #fbbf24; --yellow-bg: rgba(251,191,36,0.12);
--border: #334155; --radius: 10px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--nav-h: 56px;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
}
html, body { height: 100%; background: var(--bg); color: var(--text); overflow: hidden; -webkit-text-size-adjust: 100%; }
body { display: flex; flex-direction: column; padding-top: var(--safe-top); padding-bottom: var(--safe-bottom); }
input, button, textarea, select { font: inherit; color: inherit; }
a { color: var(--accent); text-decoration: none; }
/* ========== SCROLLABLE ========== */
.scroll { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-y: contain; }
/* ========== HEADER ========== */
.header {
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
background: var(--bg); border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 100;
}
.header h1 { font-size: 18px; font-weight: 700; flex: 1; }
.header-btn {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
}
.header-btn:active { background: var(--bg-hover); }
.header-btn svg { width: 18px; height: 18px; stroke: var(--text-dim); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* ========== CONNECTION DOT ========== */
.conn-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; flex-shrink: 0; }
.conn-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
.conn-dot.offline { background: var(--red); }
.conn-dot.connecting { background: var(--yellow); animation: pulse 1.5s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* ========== BOTTOM NAV ========== */
.nav {
display: flex; background: var(--bg-card); border-top: 1px solid var(--border);
padding-bottom: var(--safe-bottom); height: calc(var(--nav-h) + var(--safe-bottom));
}
.nav-item {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 2px; padding: 6px 0; cursor: pointer; color: var(--text-muted);
transition: color 0.15s; -webkit-tap-highlight-color: transparent; border: none; background: none;
}
.nav-item.active { color: var(--accent); }
.nav-item svg { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.nav-item span { font-size: 10px; font-weight: 600; letter-spacing: 0.3px; }
/* ========== LOGIN SCREEN ========== */
.login-screen {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px;
}
.login-logo { width: 64px; height: 64px; margin-bottom: 16px; }
.login-logo svg { width: 100%; height: 100%; }
.login-title { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
.login-sub { font-size: 14px; color: var(--text-dim); margin-bottom: 32px; }
.login-form { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field label { font-size: 12px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
.field input {
background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius);
padding: 12px 14px; font-size: 16px; outline: none; transition: border-color 0.15s;
}
.field input:focus { border-color: var(--accent); }
.field input::placeholder { color: var(--text-muted); }
.btn {
padding: 14px; border-radius: var(--radius); font-size: 15px; font-weight: 600;
border: none; cursor: pointer; transition: all 0.15s; text-align: center;
}
.btn-primary { background: var(--accent); color: #0f172a; }
.btn-primary:active { background: var(--accent-dim); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary { background: var(--bg-card); border: 1px solid var(--border); color: var(--text); }
.btn-secondary:active { background: var(--bg-hover); }
.btn-danger { background: var(--red-bg); color: var(--red); border: 1px solid rgba(248,113,113,0.3); }
.btn-danger:active { opacity: 0.7; }
.login-error { background: var(--red-bg); color: var(--red); padding: 10px 12px; border-radius: var(--radius); font-size: 13px; text-align: center; }
.login-saved { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 360px; margin-bottom: 16px; }
.saved-server {
display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer;
}
.saved-server:active { background: var(--bg-hover); }
.saved-server .info { flex: 1; }
.saved-server .info .url { font-size: 14px; font-weight: 600; }
.saved-server .info .user { font-size: 12px; color: var(--text-dim); }
.saved-server .remove-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 6px; border: none; background: var(--red-bg); cursor: pointer; }
.saved-server .remove-btn svg { width: 14px; height: 14px; stroke: var(--red); fill: none; stroke-width: 2; }
/* ========== DASHBOARD ========== */
.dashboard { padding: 16px; }
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; }
.stat-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px; text-align: center;
}
.stat-val { font-size: 28px; font-weight: 700; line-height: 1.1; }
.stat-val.green { color: var(--green); }
.stat-val.red { color: var(--red); }
.stat-label { font-size: 11px; color: var(--text-dim); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.section-title { font-size: 13px; font-weight: 700; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
/* ========== SEARCH BAR ========== */
.search-bar {
display: flex; align-items: center; gap: 8px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: var(--radius); padding: 0 12px; margin-bottom: 12px;
}
.search-bar svg { width: 16px; height: 16px; stroke: var(--text-muted); fill: none; stroke-width: 2; flex-shrink: 0; }
.search-bar input { flex: 1; background: none; border: none; padding: 10px 0; font-size: 15px; outline: none; }
.search-bar input::placeholder { color: var(--text-muted); }
/* ========== DEVICE LIST ========== */
.device-list { display: flex; flex-direction: column; gap: 6px; padding-bottom: 20px; }
.device-card {
display: flex; align-items: center; gap: 12px; padding: 14px;
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background 0.1s;
}
.device-card:active { background: var(--bg-hover); }
.device-icon {
width: 40px; height: 40px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.device-icon svg { width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 2; }
.device-icon.windows { background: var(--accent-bg); color: var(--accent); }
.device-icon.linux { background: var(--orange-bg); color: var(--orange); }
.device-icon.mac { background: var(--green-bg); color: var(--green); }
.device-icon.other { background: rgba(148,163,184,0.12); color: var(--text-dim); }
.device-info { flex: 1; min-width: 0; }
.device-name { font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.device-meta { font-size: 12px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.device-status { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.status-badge {
font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 20px;
}
.status-badge.online { background: var(--green-bg); color: var(--green); }
.status-badge.offline { background: rgba(100,116,139,0.2); color: var(--text-muted); }
/* ========== DEVICE DETAIL ========== */
.detail-screen { display: flex; flex-direction: column; height: 100%; }
.detail-header {
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
background: var(--bg); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 50;
}
.back-btn {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
background: none; border: none; cursor: pointer;
}
.back-btn svg { width: 20px; height: 20px; stroke: var(--accent); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.detail-title { flex: 1; font-size: 17px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.detail-body { padding: 16px; }
.detail-status-banner {
display: flex; align-items: center; gap: 10px; padding: 14px;
border-radius: var(--radius); margin-bottom: 16px;
}
.detail-status-banner.online { background: var(--green-bg); border: 1px solid rgba(74,222,128,0.25); }
.detail-status-banner.offline { background: rgba(100,116,139,0.12); border: 1px solid var(--border); }
.detail-status-banner .status-text { font-size: 14px; font-weight: 600; }
.detail-status-banner.online .status-text { color: var(--green); }
.detail-status-banner.offline .status-text { color: var(--text-muted); }
.detail-status-banner .status-sub { font-size: 12px; color: var(--text-dim); }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 20px; }
.info-item {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px;
}
.info-item.full { grid-column: 1 / -1; }
.info-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px; }
.info-value { font-size: 14px; font-weight: 500; word-break: break-all; }
/* ========== POWER ACTIONS ========== */
.actions-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 20px; }
.action-btn {
display: flex; align-items: center; gap: 10px; padding: 14px;
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
cursor: pointer; -webkit-tap-highlight-color: transparent; transition: all 0.1s;
}
.action-btn:active { background: var(--bg-hover); transform: scale(0.98); }
.action-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.action-btn svg { width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.action-btn .action-label { font-size: 13px; font-weight: 600; }
.action-btn.danger { border-color: rgba(248,113,113,0.3); }
.action-btn.danger svg { stroke: var(--red); }
/* ========== TERMINAL ========== */
.terminal-screen { display: flex; flex-direction: column; height: 100%; }
.terminal-toolbar {
display: flex; align-items: center; gap: 8px; padding: 8px 16px;
background: var(--bg); border-bottom: 1px solid var(--border);
}
.terminal-select {
flex: 1; background: var(--bg-input); border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px; font-size: 13px; color: var(--text);
appearance: none; 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='%2394a3b8' fill='none' stroke-width='1.5'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 10px center;
}
.terminal-container {
flex: 1; background: #0c0c0c; font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
font-size: 13px; line-height: 1.4; padding: 10px; overflow-y: auto; -webkit-overflow-scrolling: touch;
white-space: pre-wrap; word-break: break-all; color: #d4d4d4;
}
.terminal-input-row {
display: flex; gap: 8px; padding: 8px 12px; background: #0c0c0c; border-top: 1px solid #333;
}
.terminal-input-row input {
flex: 1; background: #1a1a1a; border: 1px solid #444; border-radius: 6px;
padding: 10px 12px; font-family: monospace; font-size: 14px; color: #d4d4d4; outline: none;
}
.terminal-input-row input:focus { border-color: var(--accent); }
.terminal-input-row button {
padding: 10px 16px; background: var(--accent); color: #0f172a; border: none;
border-radius: 6px; font-weight: 600; font-size: 13px; cursor: pointer;
}
/* ========== GROUPS VIEW ========== */
.group-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
margin-bottom: 10px; overflow: hidden;
}
.group-header {
display: flex; align-items: center; gap: 10px; padding: 14px;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.group-header:active { background: var(--bg-hover); }
.group-header svg { width: 16px; height: 16px; stroke: var(--text-dim); fill: none; stroke-width: 2; transition: transform 0.2s; }
.group-header.expanded svg { transform: rotate(90deg); }
.group-name { flex: 1; font-size: 15px; font-weight: 600; }
.group-count { font-size: 12px; color: var(--text-dim); background: var(--bg-input); padding: 2px 8px; border-radius: 10px; }
.group-devices { border-top: 1px solid var(--border); }
/* ========== SETTINGS ========== */
.settings-section { margin-bottom: 24px; }
.settings-item {
display: flex; align-items: center; gap: 12px; padding: 14px;
background: var(--bg-card); border: 1px solid var(--border);
}
.settings-item:first-child { border-radius: var(--radius) var(--radius) 0 0; }
.settings-item:last-child { border-radius: 0 0 var(--radius) var(--radius); }
.settings-item:only-child { border-radius: var(--radius); }
.settings-item + .settings-item { border-top: none; }
.settings-item svg { width: 20px; height: 20px; stroke: var(--text-dim); fill: none; stroke-width: 2; flex-shrink: 0; }
.settings-item .label { flex: 1; font-size: 15px; }
.settings-item .val { font-size: 13px; color: var(--text-dim); }
/* ========== TOAST ========== */
.toast {
position: fixed; bottom: calc(var(--nav-h) + var(--safe-bottom) + 12px); left: 16px; right: 16px;
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 12px 16px; font-size: 14px; z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
transform: translateY(120%); transition: transform 0.3s ease;
}
.toast.show { transform: translateY(0); }
.toast.success { border-color: rgba(74,222,128,0.4); }
.toast.error { border-color: rgba(248,113,113,0.4); }
/* ========== CONFIRM DIALOG ========== */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 300; display: flex; align-items: flex-end; justify-content: center; padding: 16px; }
.confirm-dialog { background: var(--bg-card); border-radius: 16px; width: 100%; max-width: 400px; padding: 24px; margin-bottom: var(--safe-bottom); }
.confirm-dialog h3 { font-size: 17px; margin-bottom: 8px; }
.confirm-dialog p { font-size: 14px; color: var(--text-dim); margin-bottom: 20px; }
.confirm-dialog .actions { display: flex; gap: 10px; }
.confirm-dialog .actions .btn { flex: 1; }
/* ========== EMPTY STATE ========== */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
.empty-state svg { width: 48px; height: 48px; stroke: var(--text-muted); fill: none; stroke-width: 1.5; margin-bottom: 16px; }
.empty-state h3 { font-size: 16px; margin-bottom: 4px; }
.empty-state p { font-size: 13px; color: var(--text-dim); }
/* ========== LOADING ========== */
.spinner { width: 24px; height: 24px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-overlay { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; }
.loading-overlay p { font-size: 14px; color: var(--text-dim); }
/* ========== UTILITIES ========== */
.hidden { display: none !important; }
.flex-col { display: flex; flex-direction: column; }
.flex-1 { flex: 1; }
</style>
</head>
<body>
<!-- ========== APP ROOT ========== -->
<div id="app" class="flex-col" style="height:100%"></div>
<!-- ========== TOAST ========== -->
<div id="toast" class="toast"></div>
<!-- ========== CONFIRM DIALOG ========== -->
<div id="confirm-overlay" class="overlay hidden" onclick="if(event.target===this)closeConfirm()">
<div class="confirm-dialog">
<h3 id="confirm-title"></h3>
<p id="confirm-msg"></p>
<div class="actions">
<button class="btn btn-secondary" onclick="closeConfirm()">Cancel</button>
<button class="btn btn-danger" id="confirm-action" onclick="confirmAction()">Confirm</button>
</div>
</div>
</div>
<script>
// ============================================================
// MeshCtrl Mobile PWA — MeshCentral WebSocket Client
// ============================================================
// ---------- State ----------
const S = {
view: 'login', // login | main
tab: 'dashboard', // dashboard | devices | terminal | settings
ws: null,
connected: false,
connecting: false,
serverUrl: 'https://mesh.wilddragon.net',
username: '',
password: '',
loginToken: '',
authMode: 'credentials', // credentials | token
devices: [],
meshes: {},
selectedDevice: null,
searchQuery: '',
expandedGroups: {},
terminalNodeId: null,
terminalOutput: '',
terminalWs: null,
};
// ---------- Saved servers (in-memory, persisted via IndexedDB if available) ----------
let savedServers = [];
try {
const raw = window.indexedDB ? null : null; // We'll use a simpler approach
} catch(e) {}
function loadSaved() {
try { savedServers = JSON.parse(localStorage.getItem('mc_servers') || '[]'); } catch(e) { savedServers = []; }
}
function saveSaved() {
try { localStorage.setItem('mc_servers', JSON.stringify(savedServers)); } catch(e) {}
}
loadSaved();
// ---------- Icons (SVG strings) ----------
const IC = {
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" fill="currentColor" stroke="none"/><circle cx="4" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="4" cy="18" r="1" 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>',
chevLeft: '<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>',
chevRight: '<svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></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>',
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>',
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>',
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>',
wifiOff: '<svg viewBox="0 0 24 24"><line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.56 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><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>',
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>',
hdd: '<svg viewBox="0 0 24 24"><line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/><circle cx="6" cy="16" r="1" fill="currentColor" stroke="none"/><circle cx="10" cy="16" r="1" fill="currentColor" stroke="none"/></svg>',
zap: '<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
wake: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>',
sleep: '<svg viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></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>',
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>',
folder: '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>',
shield: '<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></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>',
};
// ---------- Helpers ----------
const $ = (id) => document.getElementById(id);
function toast(msg, type = 'info') {
const t = $('toast');
t.textContent = msg;
t.className = 'toast show ' + type;
clearTimeout(t._t);
t._t = setTimeout(() => t.className = 'toast', 3000);
}
let _confirmCb = null;
function showConfirm(title, msg, btnText, cb) {
$('confirm-title').textContent = title;
$('confirm-msg').textContent = msg;
$('confirm-action').textContent = btnText;
_confirmCb = cb;
$('confirm-overlay').classList.remove('hidden');
}
function closeConfirm() { $('confirm-overlay').classList.add('hidden'); _confirmCb = null; }
function confirmAction() { if (_confirmCb) _confirmCb(); closeConfirm(); }
function getOsClass(device) {
if (!device || !device.osdesc) return 'other';
const os = device.osdesc.toLowerCase();
if (os.includes('windows')) return 'windows';
if (os.includes('linux') || os.includes('ubuntu') || os.includes('debian') || os.includes('centos') || os.includes('fedora')) return 'linux';
if (os.includes('mac') || os.includes('darwin') || os.includes('apple')) return 'mac';
return 'other';
}
function getOsIcon(device) {
const cls = getOsClass(device);
if (cls === 'windows' || cls === 'mac') return IC.monitor;
if (cls === 'linux') return IC.server;
return IC.cpu;
}
function isOnline(device) {
return device && (device.conn & 1) !== 0;
}
function lastSeenText(device) {
if (!device.lastconnect) return 'Never connected';
const d = new Date(device.lastconnect * 1000);
const now = new Date();
const diff = (now - d) / 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';
}
// ---------- WebSocket ----------
function wsConnect() {
if (S.ws) { try { S.ws.close(); } catch(e) {} }
S.connecting = true;
S.connected = false;
render();
let url = S.serverUrl.replace(/\/$/, '');
url = url.replace(/^http/, 'ws') + '/control.ashx';
if (S.authMode === 'credentials' && S.username && S.password) {
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('Failed to connect: ' + e.message, 'error');
render();
return;
}
S.ws.onopen = () => {
S.connecting = false;
S.connected = true;
S.view = 'main';
// Save server
const existing = savedServers.find(s => s.url === S.serverUrl && s.username === S.username);
if (!existing) {
savedServers.push({ url: S.serverUrl, username: S.username || '(token)' });
saveSaved();
}
toast('Connected to MeshCentral', 'success');
// Request data
wsSend({ action: 'meshes' });
wsSend({ action: 'nodes' });
render();
};
S.ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
handleMsg(msg);
} catch(e) {}
};
S.ws.onerror = () => {
S.connecting = false;
toast('WebSocket error', 'error');
render();
};
S.ws.onclose = (ev) => {
S.connected = false;
S.connecting = false;
if (S.view === 'main') {
toast('Disconnected from server', 'error');
} else if (ev.code === 1000 || ev.reason) {
toast('Connection closed: ' + (ev.reason || 'Authentication failed'), 'error');
}
render();
};
}
function wsSend(obj) {
if (S.ws && S.ws.readyState === WebSocket.OPEN) {
S.ws.send(JSON.stringify(obj));
}
}
function handleMsg(msg) {
if (!msg.action) {
// Could be server info or event
if (msg.meshes) {
S.meshes = {};
for (const m of msg.meshes) {
S.meshes[m._id] = m;
}
render();
}
return;
}
switch(msg.action) {
case 'meshes':
if (msg.meshes) {
S.meshes = {};
for (const m of msg.meshes) {
S.meshes[m._id] = m;
}
}
render();
break;
case 'nodes':
if (msg.nodes) {
S.devices = [];
for (const meshId in msg.nodes) {
for (const node of msg.nodes[meshId]) {
node._meshId = meshId;
S.devices.push(node);
}
}
S.devices.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}
render();
break;
case 'changenode':
if (msg.node) {
const idx = S.devices.findIndex(d => d._id === msg.node._id);
if (idx >= 0) {
Object.assign(S.devices[idx], msg.node);
} else {
S.devices.push(msg.node);
}
render();
}
break;
case 'nodeconnect':
if (msg.nodeid) {
const dev = S.devices.find(d => d._id === msg.nodeid);
if (dev) {
if (msg.conn !== undefined) dev.conn = msg.conn;
render();
}
}
break;
case 'event':
// Various events
if (msg.event && msg.event.nodeid) {
const dev = S.devices.find(d => d._id === msg.event.nodeid);
if (dev && msg.event.conn !== undefined) {
dev.conn = msg.event.conn;
render();
}
}
break;
case 'close':
toast(msg.msg || 'Connection closed by server', 'error');
break;
case 'msg':
if (msg.type === 'console' && msg.value) {
S.terminalOutput += msg.value;
render();
}
break;
}
}
function disconnect() {
if (S.ws) { try { S.ws.close(); } catch(e) {} S.ws = null; }
if (S.terminalWs) { try { S.terminalWs.close(); } catch(e) {} S.terminalWs = null; }
S.connected = false;
S.connecting = false;
S.devices = [];
S.meshes = {};
S.selectedDevice = null;
S.view = 'login';
S.tab = 'dashboard';
render();
}
// ---------- Device Power Actions ----------
function devicePower(nodeId, powerAction) {
wsSend({ action: 'poweraction', nodeids: [nodeId], actiontype: powerAction });
const labels = { 2: 'Shutdown', 3: 'Reboot', 4: 'Sleep', 100: 'Wake-on-LAN' };
toast((labels[powerAction] || 'Power') + ' command sent', 'success');
}
function sendConsoleCmd(nodeId, cmd) {
wsSend({ action: 'msg', type: 'console', nodeid: nodeId, value: cmd });
}
// ---------- Render Engine ----------
function render() {
const app = $('app');
if (S.view === 'login') {
app.innerHTML = renderLogin();
} else {
if (S.selectedDevice) {
app.innerHTML = renderDeviceDetail();
} else {
app.innerHTML = renderMain();
}
}
bindEvents();
}
function renderLogin() {
const savedHtml = savedServers.map((s, i) => `
<div class="saved-server" data-saved="${i}">
<div class="info">
<div class="url">${escHtml(s.url.replace(/^https?:\/\//, ''))}</div>
<div class="user">${escHtml(s.username)}</div>
</div>
<button class="remove-btn" data-remove="${i}" onclick="event.stopPropagation(); removeSaved(${i})">${IC.x}</button>
</div>
`).join('');
return `
<div class="login-screen">
<div class="login-logo">
<svg viewBox="0 0 64 64" fill="none"><rect x="8" y="8" width="48" height="48" rx="12" stroke="${'var(--accent)'}" stroke-width="3"/><circle cx="32" cy="28" r="8" stroke="var(--accent)" stroke-width="2.5"/><path d="M20 48c0-6.627 5.373-12 12-12s12 5.373 12 12" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round"/></svg>
</div>
<div class="login-title">MeshCtrl</div>
<div class="login-sub">Mobile client for MeshCentral</div>
${savedServers.length > 0 ? `
<div class="section-title" style="width:100%;max-width:360px;">Recent Servers</div>
<div class="login-saved">${savedHtml}</div>
<div class="section-title" style="width:100%;max-width:360px;margin-top:8px;">New Connection</div>
` : ''}
<div class="login-form" id="login-form">
<div class="field">
<label>Server URL</label>
<input id="inp-url" type="url" placeholder="https://mesh.example.com" value="${escHtml(S.serverUrl)}" autocapitalize="none" autocorrect="off">
</div>
<div style="display:flex;gap:8px;margin-bottom:4px;">
<button class="btn btn-secondary" style="flex:1;font-size:13px;padding:8px;${S.authMode==='credentials'?'background:var(--accent-bg);border-color:var(--accent);color:var(--accent)':''}" onclick="S.authMode='credentials';render()">Credentials</button>
<button class="btn btn-secondary" style="flex:1;font-size:13px;padding:8px;${S.authMode==='token'?'background:var(--accent-bg);border-color:var(--accent);color:var(--accent)':''}" onclick="S.authMode='token';render()">Login Token</button>
</div>
${S.authMode === 'credentials' ? `
<div class="field">
<label>Username</label>
<input id="inp-user" type="text" placeholder="admin" value="${escHtml(S.username)}" autocapitalize="none" autocorrect="off">
</div>
<div class="field">
<label>Password</label>
<input id="inp-pass" type="password" placeholder="••••••••" value="${escHtml(S.password)}">
</div>
` : `
<div class="field">
<label>Login Token</label>
<input id="inp-token" type="text" placeholder="Paste your login token" value="${escHtml(S.loginToken)}" autocapitalize="none" autocorrect="off">
</div>
`}
<button class="btn btn-primary" id="btn-connect" ${S.connecting ? 'disabled' : ''}>
${S.connecting ? '<div class="spinner" style="width:18px;height:18px;margin:0 auto;border-width:2px;"></div>' : 'Connect'}
</button>
</div>
</div>
`;
}
function renderMain() {
const onlineCount = S.devices.filter(d => isOnline(d)).length;
const offlineCount = S.devices.length - onlineCount;
return `
<div class="header">
<span class="conn-dot ${S.connected ? 'online' : S.connecting ? 'connecting' : 'offline'}"></span>
<h1>MeshCtrl</h1>
<button class="header-btn" onclick="refreshDevices()" title="Refresh">${IC.refresh}</button>
</div>
<div class="scroll flex-1" id="main-scroll">
${S.tab === 'dashboard' ? renderDashboard(onlineCount, offlineCount) : ''}
${S.tab === 'devices' ? renderDevices() : ''}
${S.tab === 'terminal' ? renderTerminal() : ''}
${S.tab === 'settings' ? renderSettings() : ''}
</div>
<nav class="nav">
<button class="nav-item ${S.tab==='dashboard'?'active':''}" onclick="switchTab('dashboard')">
${IC.grid}<span>Dashboard</span>
</button>
<button class="nav-item ${S.tab==='devices'?'active':''}" onclick="switchTab('devices')">
${IC.monitor}<span>Devices</span>
</button>
<button class="nav-item ${S.tab==='terminal'?'active':''}" onclick="switchTab('terminal')">
${IC.terminal}<span>Terminal</span>
</button>
<button class="nav-item ${S.tab==='settings'?'active':''}" onclick="switchTab('settings')">
${IC.settings}<span>Settings</span>
</button>
</nav>
`;
}
function renderDashboard(onlineCount, offlineCount) {
const recentOnline = S.devices.filter(d => isOnline(d)).slice(0, 5);
const recentOffline = S.devices.filter(d => !isOnline(d)).slice(0, 3);
return `
<div class="dashboard">
<div class="stats-row">
<div class="stat-card">
<div class="stat-val">${S.devices.length}</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-card">
<div class="stat-val green">${onlineCount}</div>
<div class="stat-label">Online</div>
</div>
<div class="stat-card">
<div class="stat-val red">${offlineCount}</div>
<div class="stat-label">Offline</div>
</div>
</div>
${Object.keys(S.meshes).length > 0 ? `
<div class="section-title">Device Groups</div>
${Object.values(S.meshes).map(mesh => {
const meshDevices = S.devices.filter(d => d.meshid === mesh._id);
const meshOnline = meshDevices.filter(d => isOnline(d)).length;
return `
<div class="group-card">
<div class="group-header ${S.expandedGroups[mesh._id]?'expanded':''}" onclick="toggleGroup('${mesh._id}')">
${IC.chevRight}
<div class="group-name">${escHtml(mesh.name || 'Unnamed Group')}</div>
<div class="group-count">${meshOnline}/${meshDevices.length}</div>
</div>
${S.expandedGroups[mesh._id] ? `
<div class="group-devices">
${meshDevices.length === 0 ? '<div style="padding:12px;font-size:13px;color:var(--text-muted);">No devices in this group</div>' :
meshDevices.map(d => renderDeviceRow(d)).join('')}
</div>
` : ''}
</div>
`;
}).join('')}
` : ''}
${recentOnline.length > 0 ? `
<div class="section-title" style="margin-top:16px;">Online Devices</div>
<div class="device-list">
${recentOnline.map(d => renderDeviceRow(d)).join('')}
${onlineCount > 5 ? `<div style="text-align:center;padding:8px;font-size:13px;color:var(--text-dim);">+ ${onlineCount - 5} more online</div>` : ''}
</div>
` : ''}
${S.devices.length === 0 && !S.connecting ? `
<div class="empty-state">
${IC.wifiOff}
<h3>No devices found</h3>
<p>Devices will appear here once agents connect to your MeshCentral server.</p>
</div>
` : ''}
</div>
`;
}
function renderDeviceRow(device) {
const online = isOnline(device);
const osClass = getOsClass(device);
return `
<div class="device-card" onclick="selectDevice('${device._id}')">
<div class="device-icon ${osClass}">${getOsIcon(device)}</div>
<div class="device-info">
<div class="device-name">${escHtml(device.name || 'Unknown')}</div>
<div class="device-meta">${escHtml(device.osdesc || 'Unknown OS')}</div>
</div>
<div class="device-status">
<span class="status-badge ${online ? 'online' : 'offline'}">${online ? 'Online' : 'Offline'}</span>
</div>
</div>
`;
}
function renderDevices() {
const q = S.searchQuery.toLowerCase();
let filtered = S.devices;
if (q) {
filtered = S.devices.filter(d =>
(d.name || '').toLowerCase().includes(q) ||
(d.osdesc || '').toLowerCase().includes(q) ||
(d.host || '').toLowerCase().includes(q)
);
}
// Sort: online first, then alphabetical
filtered.sort((a, b) => {
const aOn = isOnline(a) ? 0 : 1;
const bOn = isOnline(b) ? 0 : 1;
if (aOn !== bOn) return aOn - bOn;
return (a.name || '').localeCompare(b.name || '');
});
return `
<div style="padding:12px 16px 0;">
<div class="search-bar">
${IC.search}
<input id="search-input" type="text" placeholder="Search devices..." value="${escHtml(S.searchQuery)}" oninput="S.searchQuery=this.value;render();refocusSearch();">
</div>
</div>
<div style="padding:0 16px 16px;">
${filtered.length === 0 ? `
<div class="empty-state">
${IC.search}
<h3>No devices found</h3>
<p>${q ? 'Try a different search term' : 'No devices connected to this server'}</p>
</div>
` : `
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px;">${filtered.length} device${filtered.length !== 1 ? 's' : ''}</div>
<div class="device-list">
${filtered.map(d => renderDeviceRow(d)).join('')}
</div>
`}
</div>
`;
}
function renderDeviceDetail() {
const d = S.selectedDevice;
if (!d) return '';
const online = isOnline(d);
const meshName = S.meshes[d.meshid] ? S.meshes[d.meshid].name : 'Unknown Group';
// Extract IP addresses
let ipAddr = 'Unknown';
if (d.ip) ipAddr = d.ip;
else if (d.host) ipAddr = d.host;
return `
<div class="detail-screen">
<div class="detail-header">
<button class="back-btn" onclick="S.selectedDevice=null;render();">${IC.chevLeft}</button>
<div class="detail-title">${escHtml(d.name || 'Unknown')}</div>
<button class="header-btn" onclick="refreshDevices()" title="Refresh">${IC.refresh}</button>
</div>
<div class="scroll flex-1">
<div class="detail-body">
<div class="detail-status-banner ${online ? 'online' : 'offline'}">
<span class="conn-dot ${online ? 'online' : 'offline'}"></span>
<div>
<div class="status-text">${online ? 'Online' : 'Offline'}</div>
<div class="status-sub">${online ? 'Connected and responding' : 'Last seen: ' + lastSeenText(d)}</div>
</div>
</div>
<div class="section-title">System Information</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Operating System</div>
<div class="info-value">${escHtml(d.osdesc || 'Unknown')}</div>
</div>
<div class="info-item">
<div class="info-label">Device Group</div>
<div class="info-value">${escHtml(meshName)}</div>
</div>
<div class="info-item">
<div class="info-label">IP Address</div>
<div class="info-value">${escHtml(ipAddr)}</div>
</div>
<div class="info-item">
<div class="info-label">Agent ID</div>
<div class="info-value" style="font-size:11px;">${escHtml((d._id || '').replace('node//', '').substring(0, 24))}...</div>
</div>
${d.agent ? `
<div class="info-item full">
<div class="info-label">Agent Version</div>
<div class="info-value">v${d.agent.ver || 'Unknown'}${d.agent.core ? ' (Core: ' + d.agent.core + ')' : ''}</div>
</div>
` : ''}
</div>
<div class="section-title">Power Actions</div>
<div class="actions-grid">
<button class="action-btn" onclick="confirmPower('${d._id}', 100, 'Wake on LAN', 'Send a Wake-on-LAN packet to this device?')" ${!online ? '' : ''}>
${IC.wake}
<span class="action-label">Wake</span>
</button>
<button class="action-btn" onclick="confirmPower('${d._id}', 4, 'Sleep', 'Put this device to sleep?')" ${!online ? 'disabled' : ''}>
${IC.sleep}
<span class="action-label">Sleep</span>
</button>
<button class="action-btn danger" onclick="confirmPower('${d._id}', 3, 'Reboot', 'Are you sure you want to reboot this device?')" ${!online ? 'disabled' : ''}>
${IC.refresh}
<span class="action-label">Reboot</span>
</button>
<button class="action-btn danger" onclick="confirmPower('${d._id}', 2, 'Shutdown', 'Are you sure you want to shut down this device?')" ${!online ? 'disabled' : ''}>
${IC.power}
<span class="action-label">Shutdown</span>
</button>
</div>
${online ? `
<div class="section-title">Remote Access</div>
<button class="action-btn" style="width:100%;margin-bottom:10px;" onclick="openTerminalFor('${d._id}', '${escHtml(d.name || 'Device')}')">
${IC.terminal}
<span class="action-label">Open Terminal</span>
</button>
<button class="action-btn" style="width:100%;margin-bottom:10px;" onclick="openDesktopLink('${d._id}')">
${IC.monitor}
<span class="action-label">Open Remote Desktop (in MeshCentral)</span>
</button>
` : ''}
</div>
</div>
</div>
`;
}
function renderTerminal() {
const onlineDevices = S.devices.filter(d => isOnline(d));
if (onlineDevices.length === 0) {
return `
<div class="empty-state" style="flex:1;">
${IC.terminal}
<h3>No online devices</h3>
<p>Terminal requires at least one online device.</p>
</div>
`;
}
return `
<div style="display:flex;flex-direction:column;flex:1;">
<div class="terminal-toolbar">
<select class="terminal-select" id="term-device" onchange="S.terminalNodeId=this.value;S.terminalOutput='';render();">
<option value="">Select a device...</option>
${onlineDevices.map(d => `<option value="${d._id}" ${S.terminalNodeId === d._id ? 'selected' : ''}>${escHtml(d.name || 'Unknown')}</option>`).join('')}
</select>
</div>
<div class="terminal-container" id="term-output">${escHtml(S.terminalOutput) || '<span style="color:#666;">Select a device and type a command below.\nCommands are sent via the MeshCentral agent console.</span>'}</div>
<div class="terminal-input-row">
<input id="term-input" type="text" placeholder="${S.terminalNodeId ? 'Type a command...' : 'Select a device first'}" ${!S.terminalNodeId ? 'disabled' : ''} autocapitalize="none" autocorrect="off" spellcheck="false">
<button onclick="sendTermCmd()" ${!S.terminalNodeId ? 'disabled' : ''}>Send</button>
</div>
</div>
`;
}
function renderSettings() {
const serverHost = S.serverUrl.replace(/^https?:\/\//, '');
return `
<div style="padding:16px;">
<div class="section-title">Connection</div>
<div class="settings-section">
<div class="settings-item">
${IC.wifi}
<span class="label">Server</span>
<span class="val">${escHtml(serverHost)}</span>
</div>
<div class="settings-item">
${IC.shield}
<span class="label">Status</span>
<span class="val" style="color:${S.connected ? 'var(--green)' : 'var(--red)'};">${S.connected ? 'Connected' : 'Disconnected'}</span>
</div>
<div class="settings-item">
${IC.key}
<span class="label">Auth Mode</span>
<span class="val">${S.authMode === 'credentials' ? 'Credentials' : 'Token'}</span>
</div>
</div>
<div class="section-title">Actions</div>
<div class="settings-section">
<div class="settings-item" style="cursor:pointer;" onclick="refreshDevices()">
${IC.refresh}
<span class="label">Refresh Devices</span>
${IC.chevRight}
</div>
<div class="settings-item" style="cursor:pointer;" onclick="openMeshCentral()">
${IC.monitor}
<span class="label">Open MeshCentral Web UI</span>
${IC.chevRight}
</div>
</div>
<div class="section-title">Account</div>
<div class="settings-section">
<div class="settings-item" style="cursor:pointer;" onclick="confirmDisconnect()">
${IC.logout}
<span class="label" style="color:var(--red);">Disconnect</span>
</div>
</div>
<div style="text-align:center;padding:24px 0;color:var(--text-muted);font-size:12px;">
MeshCtrl Mobile v1.0<br>
Built for MeshCentral
</div>
</div>
`;
}
// ---------- Event Binding ----------
function bindEvents() {
// Login form
const btnConnect = $('btn-connect');
if (btnConnect) {
btnConnect.onclick = () => {
S.serverUrl = ($('inp-url')?.value || '').trim();
if (S.authMode === 'credentials') {
S.username = ($('inp-user')?.value || '').trim();
S.password = ($('inp-pass')?.value || '').trim();
} else {
S.loginToken = ($('inp-token')?.value || '').trim();
}
if (!S.serverUrl) { toast('Please enter a server URL', 'error'); return; }
wsConnect();
};
}
// Terminal input
const termInput = $('term-input');
if (termInput) {
termInput.onkeydown = (e) => {
if (e.key === 'Enter') sendTermCmd();
};
}
// Auto-scroll terminal
const termOut = $('term-output');
if (termOut) termOut.scrollTop = termOut.scrollHeight;
// Saved server clicks
document.querySelectorAll('[data-saved]').forEach(el => {
el.onclick = (e) => {
if (e.target.closest('.remove-btn')) return;
const idx = parseInt(el.dataset.saved);
const s = savedServers[idx];
if (s) {
S.serverUrl = s.url;
S.username = s.username !== '(token)' ? s.username : '';
render();
}
};
});
}
// ---------- Actions ----------
function switchTab(tab) { S.tab = tab; render(); }
function selectDevice(id) { S.selectedDevice = S.devices.find(d => d._id === id); render(); }
function toggleGroup(id) { S.expandedGroups[id] = !S.expandedGroups[id]; render(); }
function refreshDevices() { wsSend({ action: 'meshes' }); wsSend({ action: 'nodes' }); toast('Refreshing...'); }
function removeSaved(idx) {
savedServers.splice(idx, 1);
saveSaved();
render();
}
function confirmPower(nodeId, action, label, msg) {
showConfirm(label, msg, label, () => devicePower(nodeId, action));
}
function confirmDisconnect() {
showConfirm('Disconnect', 'Are you sure you want to disconnect from the server?', 'Disconnect', disconnect);
}
function openTerminalFor(nodeId, name) {
S.terminalNodeId = nodeId;
S.terminalOutput = '';
S.tab = 'terminal';
S.selectedDevice = null;
render();
}
function openDesktopLink(nodeId) {
// Open in MeshCentral web UI for full remote desktop
const url = S.serverUrl + '/?node=' + encodeURIComponent(nodeId);
window.open(url, '_blank');
}
function openMeshCentral() {
window.open(S.serverUrl, '_blank');
}
function sendTermCmd() {
const input = $('term-input');
if (!input || !input.value.trim() || !S.terminalNodeId) return;
const cmd = input.value.trim();
S.terminalOutput += '\n> ' + cmd + '\n';
sendConsoleCmd(S.terminalNodeId, cmd);
input.value = '';
render();
// Re-focus input after render
setTimeout(() => { const i = $('term-input'); if (i) i.focus(); }, 50);
}
function refocusSearch() {
setTimeout(() => {
const i = $('search-input');
if (i) { i.focus(); i.setSelectionRange(i.value.length, i.value.length); }
}, 10);
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ---------- Service Worker (inline, for PWA installability) ----------
if ('serviceWorker' in navigator) {
const swCode = `self.addEventListener('install', e => 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).then(res => {
if (res.ok && e.request.method === 'GET') {
const c = res.clone();
caches.open('meshctrl-v1').then(cache => cache.put(e.request, c));
}
return res;
}).catch(() => caches.match(e.request))
)
);
});`;
const blob = new Blob([swCode], { type: 'application/javascript' });
const swUrl = URL.createObjectURL(blob);
navigator.serviceWorker.register(swUrl).catch(() => {});
}
// ---------- Pull-to-refresh (Dashboard) ----------
let _pullStart = 0;
document.addEventListener('touchstart', e => {
const scroll = document.getElementById('main-scroll');
if (scroll && scroll.scrollTop === 0) _pullStart = e.touches[0].clientY;
else _pullStart = 0;
}, { passive: true });
document.addEventListener('touchend', e => {
if (_pullStart && e.changedTouches[0].clientY - _pullStart > 80) {
if (S.connected && S.view === 'main') refreshDevices();
}
_pullStart = 0;
}, { passive: true });
// ---------- Init ----------
render();
</script>
</body>
</html>