#!/bin/sh # eWOL — enhanced Wake-On-LAN UI for OpenWrt (vanilla uhttpd) or GL.iNet devices # # Features: # - Dark/light UI (switch, remembers preference, default dark) # - Font size zoom +/- buttons (remembers preference) # - Simple Lua CGI API using LuCI libs # - Self-contained in /www/ewol ; single symlink into /www/cgi-bin # # Optional: # - Set ALLOW_PUBLIC=0 to restrict to LAN only (basic check). # - You can tweak LAN_IF and PING_TIMEOUT if needed. # (GL.iNet devices usually have br-lan as default, but please double-check yours first.) set -eu EWOL_DIR="/www/ewol" CGI_LINK="/www/cgi-bin/ewol-ctl" LAN_IF="br-lan" PING_TIMEOUT=1 ALLOW_PUBLIC=0 mkdir -p "$EWOL_DIR" # --------------- # index.html # --------------- cat > "$EWOL_DIR/index.html" << 'EOF' eWOL: enhanced Wake-On-LAN

eWOL - enhanced Wake-On-LAN

Idle
Hostname MAC IP Status Action
EOF # --------------------------- # style.css # --------------------------- cat > "$EWOL_DIR/style.css" << 'EOF' :root { --bg: #0b0c10; --fg: #e5e7eb; --muted:#9ca3af; --card:#111827; --border:#1f2937; --accent:#60a5fa; --ok:#22c55e; --err:#ef4444; --base-font-size: 14px; } @media (prefers-color-scheme: light){ :root { --bg:#f7fafc; --fg:#111827; --muted:#6b7280; --card:#ffffff; --border:#e5e7eb; --accent:#2563eb; --ok:#16a34a; --err:#dc2626; } } [data-theme="light"]:root { --bg:#f7fafc; --fg:#111827; --muted:#6b7280; --card:#ffffff; --border:#e5e7eb; --accent:#2563eb; --ok:#16a34a; --err:#dc2626; } [data-theme="dark"]:root { --bg:#0b0c10; --fg:#e5e7eb; --muted:#9ca3af; --card:#111827; --border:#1f2937; --accent:#60a5fa; --ok:#22c55e; --err:#ef4444; } * { box-sizing: border-box } body { margin:0; font-size: var(--base-font-size, 14px); line-height: 1.45; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, sans-serif; background:var(--bg); color:var(--fg); } header { position: sticky; top:0; z-index:10; backdrop-filter: blur(6px); background: color-mix(in oklab, var(--bg) 88%, transparent); border-bottom:1px solid var(--border); } .wrap { max-width: 980px; margin: 0 auto; padding: 16px; } h1 { margin:0; font-size: 20px; } .muted { color: var(--muted); } .row { display:flex; gap:12px; align-items:center; justify-content:space-between; flex-wrap:wrap; } button, .btn { cursor:pointer; border:1px solid var(--border); background:var(--card); color:var(--fg); padding:6px 10px; border-radius:10px; } button:hover { border-color: var(--accent); } .pill { font-size:12px; padding:3px 8px; border-radius:999px; border:1px solid var(--border); } .ok { color: var(--ok); } .err { color: var(--err); } main { padding:16px; } .card { background:var(--card); border:1px solid var(--border); border-radius:14px; padding: 12px; } table { width:100%; border-collapse: collapse; } th, td { padding: 10px; border-bottom:1px solid var(--border); text-align:left; } th { font-weight:600; user-select:none; } th.sortable { cursor:pointer; } tr:hover td { background: color-mix(in oklab, var(--card) 92%, var(--accent) 8% ); } .actions { display:flex; gap:8px; } .center { text-align:center; } .footer { color: var(--muted); font-size:12px; padding:12px 0 20px } .toggle { display:inline-flex; align-items:center; gap:8px } img.status-icon { height:18px;vertical-align:middle;margin-right:3px; } .font-resize-btn { font-size:16px; min-width:32px; } EOF # --------------- # script.js # --------------- cat > "$EWOL_DIR/script.js" << 'EOF' 'use strict'; (function(){ const api = '/cgi-bin/ewol-ctl'; const $ = (sel, el=document)=>el.querySelector(sel); // Status icons for eWOL, GL.iNet default LuCI resources const ICONS = { Online: '/luci-static/resources/icons/port_up.png', Offline: '/luci-static/resources/icons/port_down.png', Loading: '/luci-static/resources/icons/loading.gif', Sent: '/luci-static/resources/icons/port_up.png', Error: '/luci-static/resources/icons/signal-none.png' }; const state = { rows: [], sortKey:'name', sortDir:1, statusMap: {} }; // --- FONT SIZE LOGIC --- const FONT_MIN = 10, FONT_MAX = 22, FONT_STEP = 2, FONT_DEFAULT = 14; function setFontSize(size) { size = Math.max(FONT_MIN, Math.min(FONT_MAX, size)); document.documentElement.style.setProperty('--base-font-size', size + 'px'); try { localStorage.setItem('ewol-font-size', size); } catch(e){} } function loadFontSize() { let size = FONT_DEFAULT; try { size = parseInt(localStorage.getItem('ewol-font-size')) || FONT_DEFAULT; } catch(e){} setFontSize(size); } function handleFontPlus() { let size = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--base-font-size')) || FONT_DEFAULT; setFontSize(size + FONT_STEP); } function handleFontMinus() { let size = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--base-font-size')) || FONT_DEFAULT; setFontSize(size - FONT_STEP); } function setStatus(text, cls){ const pill = $('#status'); pill.textContent = text; pill.className = 'pill ' + (cls||''); } function showError(msg){ const box = $('#err'); box.style.display = 'block'; box.textContent = msg; setStatus('Error', 'err'); } function sortRows(){ const k = state.sortKey, d = state.sortDir; state.rows.sort((a,b)=>{ const x = a[k] ?? '', y = b[k] ?? ''; return (String(x).localeCompare(String(y), undefined, {numeric:true})) * d; }); } function render(){ sortRows(); const tb = $('#grid tbody'); tb.innerHTML = ''; for(const r of state.rows){ const statusText = state.statusMap[r.name] || ''; let statusIcon = ''; let label = ''; let statusClass = ''; if(statusText === 'Online') { statusIcon = ICONS.Online; label = 'Online'; statusClass = 'ok'; } else if(statusText === 'Offline') { statusIcon = ICONS.Offline; label = 'Offline'; statusClass = 'err'; } else if(statusText === 'Loading') { statusIcon = ICONS.Loading; label = ''; statusClass = ''; } else if(statusText === 'Error') { statusIcon = ICONS.Error; label = 'Error'; statusClass = 'err'; } else if(statusText === 'Sent') { statusIcon = ICONS.Sent; label = 'Sent!'; statusClass = 'ok'; } // For Sent, show ASCII green check and "Sent!", we don't have any usable resource on GL.iNet devices const iconHtml = (label === 'Sent!') ? ' ' : (statusIcon ? `` : ''); const tr = document.createElement('tr'); tr.innerHTML = ` ${r.name||''} ${r.mac||''} ${r.ip||''} ${iconHtml}${label} `; tb.appendChild(tr); } } async function call(cmd, params={}){ const url = new URL(location.origin + api); url.searchParams.set('cmd', cmd); Object.entries(params).forEach(([k,v])=>url.searchParams.set(k,v)); const res = await fetch(url, { cache:'no-store' }); if(!res.ok) throw new Error('HTTP '+res.status); const data = await res.json(); if(!data.ok) throw new Error(data.err||'Unknown error'); return data.data; } async function loadHosts(){ try{ setStatus('Loading…'); const data = await call('hl'); state.rows = data; state.statusMap = {}; render(); setStatus('Ready'); }catch(e){ showError('Failed to load hosts: '+e.message); } } async function wake(name){ state.statusMap[name] = 'Sent'; render(); setStatus('Waking…'); try{ await call('hw', { host:name }); setStatus('Magic packet sent', 'ok'); }catch(e){ state.statusMap[name] = 'Error'; render(); showError('Wake failed: '+e.message); } } async function ping(name){ state.statusMap[name] = 'Loading'; render(); setStatus('Checking…'); try{ const data = await call('ping', { host:name }); const online = !!data.online; state.statusMap[name] = online ? 'Online' : 'Offline'; render(); setStatus('Ready', online ? 'ok' : 'err'); }catch(e){ state.statusMap[name] = 'Error'; render(); showError('Ping failed: '+e.message); } } function handleClick(ev){ const btn = ev.target.closest('button[data-act]'); if(!btn) return; const host = btn.dataset.host; if(btn.dataset.act==='wake') wake(host); if(btn.dataset.act==='ping') ping(host); } // --- THEME LOGIC --- function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme); $('#themeToggle').checked = theme === 'dark'; try { localStorage.setItem('theme', theme); } catch(e){} } function getSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } function loadTheme() { let theme = 'dark'; try { theme = localStorage.getItem('theme') || getSystemTheme(); } catch(e){ theme = getSystemTheme(); } applyTheme(theme); } function handleThemeToggle() { const theme = $('#themeToggle').checked ? 'dark' : 'light'; applyTheme(theme); } function handleSort(ev){ const th = ev.target.closest('th.sortable'); if(!th) return; const key = th.dataset.key; if(key){ if(state.sortKey === key){ state.sortDir = -state.sortDir; }else{ state.sortKey = key; state.sortDir = 1; } render(); } } function init(){ $('#grid').addEventListener('click', handleClick); $('#grid thead').addEventListener('click', handleSort); $('#refresh').addEventListener('click', loadHosts); // Theme logic $('#themeToggle').addEventListener('change', handleThemeToggle); loadTheme(); // Font size logic $('#fontPlus').addEventListener('click', handleFontPlus); $('#fontMinus').addEventListener('click', handleFontMinus); loadFontSize(); loadHosts(); } window.addEventListener('DOMContentLoaded', init); })(); EOF # --------------------------- # api.lua (CGI) -- fix ping for all OpenWrt # --------------------------- cat > "$EWOL_DIR/api.lua" << 'EOF' #!/usr/bin/lua -- eWOL CGI API local json = require('luci.jsonc') local nixio = require('nixio') local sys = require('luci.sys') local ip = require('luci.ip') local DEVICES_JSON = '/www/ewol/devices.json' local LAN_IF = 'br-lan' local PING_TIMEOUT = 1 local ALLOW_PUBLIC = 0 -- Set 1 to allow WAN/public local function in_lan(addr) local a = ip.IPv4(addr) local dev = ip.IPv4(ip.getaddr(LAN_IF) or '0.0.0.0/0') return a and dev and a:is4() and dev:contains(a) end local function enforce_origin() if ALLOW_PUBLIC == 1 then return true end local ra = nixio.getenv('REMOTE_ADDR') or '' if ra == '' then return false end if in_lan(ra) then return true end return false end local function wol_send(mac) return sys.call(string.format("etherwake -D -i %s %q >/dev/null 2>&1", LAN_IF, mac)) == 0 end -- Use system ping for best compatibility, not luci.sys.ping local function ping_host(ipaddr) return os.execute("ping -c 1 -w 1 " .. ipaddr .. " >/dev/null 2>&1") == 0 end local function load_devices() local f = io.open(DEVICES_JSON, 'r') if not f then return nil end local data = f:read('*a') f:close() return json.parse(data) end local function json_out(tbl) io.write('Status: 200 OK\r\n') io.write('Content-Type: application/json\r\n\r\n') io.write(json.stringify(tbl)) end local function main() local args = require('luci.http').urldecode_params(nixio.getenv('QUERY_STRING') or '') local ret = { ok=false } if not enforce_origin() then ret.err = 'Forbidden: not from LAN'; return json_out(ret) end local devices = load_devices() if not devices then ret.err = 'Missing devices.json'; return json_out(ret) end local cmd = args.cmd or 'hl' if cmd == 'hl' then local rows = {} for name, info in pairs(devices) do rows[#rows+1] = { name=name, mac=info.mac, ip=info.ip } end ret.ok = true; ret.data = rows elseif cmd == 'hw' then local h = devices[args.host] if not h then ret.err = 'Unknown host' else if wol_send(h.mac) then ret.ok = true; ret.data = { sent=true } else ret.err = 'WoL command failed' end end elseif cmd == 'ping' then local h = devices[args.host] if not h or not h.ip then ret.err = 'Unknown host or missing IP' else local online = ping_host(h.ip) ret.ok = true; ret.data = { online = online } end else ret.err = 'Unknown command' end return json_out(ret) end main() EOF # --------------------------- # devices.json sample file # --------------------------- if [ ! -f "$EWOL_DIR/devices.json" ]; then cat > "$EWOL_DIR/devices.json" << 'EOF' { "PC1": { "mac": "11:11:11:11:11:11", "ip": "192.168.1.101" }, "PC2": { "mac": "22:22:22:22:22:22", "ip": "192.168.1.102" }, "PC3": { "mac": "33:33:33:33:33:33", "ip": "192.168.1.103" } } EOF fi # --------------------------- # permissions + cgi link # --------------------------- chmod 644 "$EWOL_DIR/index.html" chmod 644 "$EWOL_DIR/script.js" chmod 644 "$EWOL_DIR/style.css" chmod 644 "$EWOL_DIR/devices.json" chmod 755 "$EWOL_DIR/api.lua" [ -L "$CGI_LINK" ] || ln -s "$EWOL_DIR/api.lua" "$CGI_LINK" cat </ewol/index.html Optional settings: If you attempt to open http:///ewol and it gives 403, set 'option index_page "index.html"' in /etc/config/uhttpd and restart uhttpd with /etc/init.d/uhttpd restart ---------------------------------------------------------------------------- Optional security: Default denies access from WAN. To allow public access, export EWOL_ALLOW_PUBLIC=1 in uhttpd env or set ALLOW_PUBLIC=1 in api.lua. ---------------------------------------------------------------------------- EOF