From 429fcbd6a39f3b60a5117b3c9845b44f4284eb72 Mon Sep 17 00:00:00 2001 From: Dom Date: Sun, 24 Aug 2025 11:10:58 +0200 Subject: [PATCH] Create ewol-install.sh First version --- ewol-install.sh | 518 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 ewol-install.sh diff --git a/ewol-install.sh b/ewol-install.sh new file mode 100644 index 0000000..bc86188 --- /dev/null +++ b/ewol-install.sh @@ -0,0 +1,518 @@ +#!/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 + + + + +
+
+
+ +
+ + +
+ + + + + + + + + + + +
HostnameMACIPStatusAction
+
+ + +
+ + + + +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