Initial commit

This commit is contained in:
Richard Jolley committed 2026-01-14 00:54:08 +00:00
commit c909d539bc
7178 files changed
+756624

No files matched your search

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,476 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Home Automation</title>
<link rel="stylesheet" href="/assets/css/style.css">
<!-- MQTT.js (browser) -->
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
</head>
<body>
<header>
<h1>My Home Automation</h1>
<div class="sub">MQTT control panel (LAN/VPN)</div>
</header>
<div id="top-menu"></div>
<script>
(async function loadMenu(){
try {
const r = await fetch('/assets/page-elements/nav.html');
const html = await r.text();
const mount = document.getElementById('top-menu');
// Insert the HTML
mount.outerHTML = html;
// Now highlight the current page link
const nav = document.querySelector('.top-menu');
if (!nav) return;
// Normalize both sides (strip trailing index.html and trailing slash)
const normalize = (u) => {
const url = new URL(u, location.origin);
let p = url.pathname.replace(/index\.html$/i,'');
if (p.length > 1 && p.endsWith('/')) p = p.slice(0,-1);
return url.origin + p;
};
const here = normalize(location.href);
nav.querySelectorAll('a').forEach(a => {
const target = normalize(a.href);
// exact match OR “same origin + same base path” cases
const isExact = target === here;
const isRootMatch = (
target === normalize(location.origin + '/') &&
(here === normalize(location.origin + '/') || here === normalize(location.href))
);
if (isExact || isRootMatch) {
a.classList.add('is-active');
a.setAttribute('aria-current', 'page');
}
});
} catch(e) {
console.warn('Top menu load failed:', e);
}
})();
</script>
<div class="container">
<!-- Connection Card -->
<div class="card" id="conn-card">
<h2>MQTT Connection</h2>
<div class="stack">
<div class="row">
<div class="pill">
<strong>Status:</strong>
<span id="conn-status" class="mono">disconnected</span>
<span id="auto-badge" class="auto-chip hidden">AUTO</span>
</div>
<div class="spacer"></div>
<button class="btn" id="connect-btn">Connect</button>
<button class="btn ghost" id="disconnect-btn" disabled>Disconnect</button>
</div>
<div class="row" style="gap:16px; align-items:flex-end; flex-wrap:wrap;">
<div style="min-width:220px; flex:1;">
<div class="field">
<label>Broker WebSocket URL <span class="mono notice">wss://automation.richardjolley.co.uk/mqtt</span></label>
<input id="broker-url" type="text" value="wss://automation.richardjolley.co.uk/mqtt" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
</div>
</div>
<div style="min-width:160px;">
<div class="field">
<label>Username</label>
<input id="broker-user" type="text" value="17ChurchWalk" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
</div>
</div>
<div style="min-width:220px;">
<div class="field">
<label>Password</label>
<input id="broker-pass" type="password" value="PantomimeFrequentedHouse" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
</div>
</div>
</div>
<div class="row" style="align-items:center; gap:10px;">
<input type="checkbox" id="auto-connect" checked /> <label for="auto-connect">Auto-connect on load</label>
</div>
<div id="conn-hint" class="notice muted"></div>
<div id="conn-error" class="notice error"></div>
<div class="notice">Credentials are pre-filled for convenience and stored locally in your browser (localStorage). For stronger security, consider a tiny server-side MQTT proxy so the page never sees raw creds.</div>
</div>
</div>
<div class="grid">
<!-- Link to Control Panel (existing) -->
<div class="card">
<h2>Control</h2>
<div class="row">
<a class="btn" href="https://control.richardjolley.co.uk">Open Control Panel</a>
<span class="chip">LAN/VPN only</span>
</div>
<p class="notice">VPN and service management.</p>
</div>
<!-- Office Light Card (Shelly color bulb) -->
<div class="card" id="office-light">
<div class="row" style="justify-content:space-between; align-items:center; gap:10px;">
<h2 style="margin:0;">Office Light</h2>
<div class="row">
<div id="ol-color" class="color-box" title="Current color"></div>
<button id="ol-toggle" class="btn">Toggle</button>
</div>
</div>
<div class="notice mono" style="margin-top:6px;">Topic base: <span id="ol-base-topic">shellies/office-bulb/color/0</span></div>
<div class="field">
<label>Mode</label>
<div class="seg" id="ol-mode">
<button data-mode="color" class="active" type="button">Color</button>
<button data-mode="white" type="button">White</button>
</div>
</div>
<div id="ol-color-controls">
<div class="field">
<label>Brightness (gain) <span class="mono" id="ol-bright-val">100</span></label>
<input type="range" id="ol-bright" min="0" max="100" value="100" />
</div>
<div class="row" style="gap:16px;">
<div class="field" style="flex:1;">
<label>Red <span class="mono" id="ol-r-val">255</span></label>
<input type="range" id="ol-r" min="0" max="255" value="255" />
</div>
<div class="field" style="flex:1;">
<label>Green <span class="mono" id="ol-g-val">255</span></label>
<input type="range" id="ol-g" min="0" max="255" value="255" />
</div>
<div class="field" style="flex:1;">
<label>Blue <span class="mono" id="ol-b-val">255</span></label>
<input type="range" id="ol-b" min="0" max="255" value="255" />
</div>
</div>
<div class="field">
<label>White channel (RGBW) <span class="mono" id="ol-w-val">0</span></label>
<input type="range" id="ol-w" min="0" max="255" value="0" />
</div>
<div class="field">
<label>Effect (0=off) <span class="mono" id="ol-effect-val">0</span></label>
<input type="range" id="ol-effect" min="0" max="10" value="0" />
</div>
</div>
<div id="ol-white-controls" class="hidden">
<div class="field">
<label>Brightness <span class="mono" id="ol-wbright-val">100</span></label>
<input type="range" id="ol-wbright" min="0" max="100" value="100" />
</div>
<div class="field">
<label>Color Temperature (K) <span class="mono" id="ol-temp-val">3000</span></label>
<input type="range" id="ol-temp" min="2700" max="6500" step="10" value="3000" />
</div>
<div class="field">
<label>Effect (0=off) <span class="mono" id="ol-effectw-val">0</span></label>
<input type="range" id="ol-effectw" min="0" max="10" value="0" />
</div>
</div>
<div class="notice">Shelly expects updates on <span class="mono">{base}/set</span> as JSON; on/off on <span class="mono">{base}/command</span>. We also listen to <span class="mono">{base}/status</span>.</div>
<div id="ol-feedback" class="notice"></div>
</div>
<div class="card" id="office-plug-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Office Plug Lamp</h2>
<div class="row off" id="office-plug-state">
<span class="status-dot"></span>
<span class="mono" id="office-plug-text">off</span>
</div>
</div>
<div class="notice mono" style="margin-top:6px;">Prefix: <span>office-plug-lamp</span> · RPC topic: <span>office-plug-lamp/rpc</span></div>
<div class="row" style="margin-top:10px; gap:8px;">
<button class="btn small" id="office-plug-on">On</button>
<button class="btn small" id="office-plug-off">Off</button>
<button class="btn small" id="office-plug-toggle">Toggle</button>
</div>
<div class="notice" id="office-plug-fb"></div>
</div>
<div class="card" id="living-plug-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Living Room Plug Lamp</h2>
<div class="row off" id="living-plug-state">
<span class="status-dot"></span>
<span class="mono" id="living-plug-text">off</span>
</div>
</div>
<div class="notice mono" style="margin-top:6px;">Prefix: <span>livingroom-plug-lamp</span> · RPC topic: <span>livingroom-plug-lamp/rpc</span></div>
<div class="row" style="margin-top:10px; gap:8px;">
<button class="btn small" id="living-plug-on">On</button>
<button class="btn small" id="living-plug-off">Off</button>
<button class="btn small" id="living-plug-toggle">Toggle</button>
</div>
<div class="notice" id="living-plug-fb"></div>
</div>
</div>
</div>
<script>
const qs = s => document.querySelector(s);
const byId = id => document.getElementById(id);
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
const debounce = (fn, ms=200) => { let t; return (...a) => { clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; };
const storage = {
get() {
return {
url: localStorage.getItem('mqtt_url') || 'wss://automation.richardjolley.co.uk/mqtt',
user: localStorage.getItem('mqtt_user') || '17ChurchWalk',
pass: localStorage.getItem('mqtt_pass') || 'PantomimeFrequentedHouse',
auto: localStorage.getItem('mqtt_auto') !== '0'
};
},
set({url,user,pass,auto}) {
localStorage.setItem('mqtt_url', url);
localStorage.setItem('mqtt_user', user || '');
localStorage.setItem('mqtt_pass', pass || '');
localStorage.setItem('mqtt_auto', auto ? '1':'0');
}
};
const elConnStatus = byId('conn-status');
const elConnect = byId('connect-btn');
const elDisconnect = byId('disconnect-btn');
const elUrl = byId('broker-url');
const elUser = byId('broker-user');
const elPass = byId('broker-pass');
const elAuto = byId('auto-connect');
const elAutoBadge = byId('auto-badge');
const elHint = byId('conn-hint');
const elErr = byId('conn-error');
const devices = [
{ kind:'bulb', name:'Office Light', base:'shellies/office-bulb/color/0',
ids:{ toggle:'ol-toggle', color:'ol-color', modeSeg:'ol-mode', R:'ol-r', G:'ol-g', B:'ol-b', W:'ol-w', Rv:'ol-r-val', Gv:'ol-g-val', Bv:'ol-b-val', Wv:'ol-w-val', bright:'ol-bright', brightv:'ol-bright-val', effect:'ol-effect', effectv:'ol-effect-val', wbright:'ol-wbright', wbrightv:'ol-wbright-val', temp:'ol-temp', tempv:'ol-temp-val', effectw:'ol-effectw', effectwv:'ol-effectw-val', colorControls:'ol-color-controls', whiteControls:'ol-white-controls', feedback:'ol-feedback' },
state:{ on:false, mode:'color', r:255,g:255,b:255,w:0,gain:100,effect:0, brightness:100,temp:3000,effectw:0 }
},
{ kind:'plug', prefix:'office-plug-lamp', switchId:0, els:{ stateWrap:'office-plug-state', stateText:'office-plug-text', on:'office-plug-on', off:'office-plug-off', toggle:'office-plug-toggle', fb:'office-plug-fb' }, state:{ on:false } },
{ kind:'plug', prefix:'livingroom-plug-lamp', switchId:0, els:{ stateWrap:'living-plug-state', stateText:'living-plug-text', on:'living-plug-on', off:'living-plug-off', toggle:'living-plug-toggle', fb:'living-plug-fb' }, state:{ on:false } }
];
(function initConnForm(){
const {url,user,pass,auto} = storage.get();
elUrl.value = url; elUser.value = user; elPass.value = pass; elAuto.checked = auto;
elAutoBadge.classList.toggle('hidden', !auto);
// Show helpful hint if https + ws
if (location.protocol === 'https:' && url.startsWith('ws://')) {
elHint.innerHTML = 'Mixed content blocked: this page is https but the broker is ws://. Use <code class="inline">wss://automation.richardjolley.co.uk/mqtt</code>.';
} else {
elHint.textContent = '';
}
})();
let client = null;
function setConnStatus(text, ok=false) {
elConnStatus.textContent = text;
elConnStatus.className = 'mono ' + (ok ? 'success' : (text==='disconnected'?'':'warn'));
elConnect.disabled = !!ok;
elDisconnect.disabled = !ok;
}
function showError(msg){ elErr.textContent = msg || ''; }
function connectMQTT(){
showError('');
const url = elUrl.value.trim();
const username = elUser.value.trim() || undefined;
const password = elPass.value || undefined;
storage.set({url, user: username||'', pass: password||'', auto: elAuto.checked});
if (typeof mqtt === 'undefined') {
showError('MQTT library failed to load. Check network/CSP and the CDN.');
return;
}
if (location.protocol === 'https:' && url.startsWith('ws://')) {
showError('Mixed content blocked: use wss://automation.richardjolley.co.uk/mqtt');
setConnStatus('blocked');
return;
}
try { if (client) { client.end(true); client = null; } } catch(e) {}
setConnStatus('connecting…');
client = mqtt.connect(url, { username, password, reconnectPeriod: 2000 });
client.on('connect', () => {
setConnStatus('connected', true);
devices.forEach(d => {
if (d.kind === 'bulb') {
try { client.subscribe(`${d.base}/status`); } catch(e){}
try { client.subscribe(`${d.base}`); } catch(e){}
} else if (d.kind === 'plug') {
try { client.subscribe(`${d.prefix}/events/rpc`); } catch(e){}
}
});
requestStatuses();
});
client.on('reconnect', () => setConnStatus('reconnecting…'));
client.on('close', () => setConnStatus('disconnected'));
client.on('error', (err) => {
setConnStatus('error');
showError('Connection error: ' + (err && err.message ? err.message : String(err)));
console.error(err);
});
client.on('message', (topic, payloadBuf) => {
const payload = payloadBuf.toString();
const bulb = devices[0];
if (topic === bulb.base) {
bulb.state.on = (payload.trim().toLowerCase() === 'on');
updateBulbUI(bulb);
return;
}
if (topic === `${bulb.base}/status`) {
try {
const js = JSON.parse(payload);
if (typeof js.ison === 'boolean') bulb.state.on = js.ison;
if (js.mode === 'color' || js.mode === 'white') bulb.state.mode = js.mode;
if (Number.isFinite(js.red)) bulb.state.r = clamp(js.red,0,255);
if (Number.isFinite(js.green)) bulb.state.g = clamp(js.green,0,255);
if (Number.isFinite(js.blue)) bulb.state.b = clamp(js.blue,0,255);
if (Number.isFinite(js.white)) bulb.state.w = clamp(js.white,0,255);
if (Number.isFinite(js.gain)) bulb.state.gain = clamp(js.gain,0,100);
if (Number.isFinite(js.effect)) bulb.state.effect = clamp(js.effect,0,10);
if (Number.isFinite(js.brightness)) bulb.state.brightness = clamp(js.brightness,0,100);
if (Number.isFinite(js.temp)) bulb.state.temp = clamp(js.temp,2700,6500);
updateBulbUI(bulb);
} catch(e) { console.warn('Bad JSON on status', e, payload); }
return;
}
const plug = devices.find(d => d.kind==='plug' && topic === `${d.prefix}/events/rpc`);
if (plug) {
try {
const js = JSON.parse(payload);
if (js.method === 'NotifyStatus' && js.params && js.params[`switch:${plug.switchId}`]) {
const out = js.params[`switch:${plug.switchId}`].output;
if (typeof out === 'boolean') { plug.state.on = out; updatePlugUI(plug); }
}
} catch(e) { console.warn('Bad RPC JSON', e, payload); }
return;
}
});
}
function requestStatuses(){
devices.filter(d=>d.kind==='plug').forEach((plug,i) => {
const req = { id: 100+i, src: 'ui', method: 'Shelly.GetStatus' };
publishRPC(plug, req);
});
}
function publish(dev, subPath, message) {
if (!client || client.disconnected) return false;
const topic = `${dev.base}${subPath ? '/' + subPath : ''}`;
try { client.publish(topic, message, { qos:0, retain:false }); return true; } catch(e) { console.error(e); return false; }
}
function publishRPC(plug, obj){
if (!client || client.disconnected) return false;
const topic = `${plug.prefix}/rpc`;
try { client.publish(topic, JSON.stringify(obj), { qos:0, retain:false }); return true; } catch(e) { console.error(e); return false; }
}
function updateBulbUI(dev){
const ids = dev.ids;
const seg = byId(ids.modeSeg);
seg.querySelectorAll('button').forEach(btn => btn.classList.toggle('active', btn.dataset.mode === dev.state.mode));
byId(ids.colorControls).classList.toggle('hidden', dev.state.mode !== 'color');
byId(ids.whiteControls).classList.toggle('hidden', dev.state.mode !== 'white');
byId(ids.color).style.background = `rgb(${dev.state.r} ${dev.state.g} ${dev.state.b})`;
byId(ids.toggle).textContent = dev.state.on ? 'Turn Off' : 'Turn On';
byId(ids.R).value = dev.state.r; byId(ids.Rv).textContent = dev.state.r;
byId(ids.G).value = dev.state.g; byId(ids.Gv).textContent = dev.state.g;
byId(ids.B).value = dev.state.b; byId(ids.Bv).textContent = dev.state.b;
byId(ids.W).value = dev.state.w; byId(ids.Wv).textContent = dev.state.w;
byId(ids.bright).value = dev.state.gain; byId(ids.brightv).textContent = dev.state.gain;
byId(ids.effect).value = dev.state.effect; byId(ids.effectv).textContent = dev.state.effect;
byId(ids.wbright).value = dev.state.brightness; byId(ids.wbrightv).textContent = dev.state.brightness;
byId(ids.temp).value = dev.state.temp; byId(ids.tempv).textContent = dev.state.temp;
byId(ids.effectw).value = dev.state.effectw; byId(ids.effectwv).textContent = dev.state.effectw;
}
function sendBulbUpdate(dev){
const s = dev.state; let body;
if (s.mode === 'color') { body = { mode:'color', red:s.r, green:s.g, blue:s.b, white:s.w, gain:s.gain, effect:s.effect, turn:'on' }; }
else { body = { mode:'white', brightness:s.brightness, temp:s.temp, effect:s.effectw, turn:'on' }; }
const ok = publish(dev, 'set', JSON.stringify(body));
byId(dev.ids.feedback).textContent = ok ? `Set → ${JSON.stringify(body)}` : 'Not connected';
}
function toggleBulbPower(dev){
const target = dev.state.on ? 'off' : 'on';
const ok = publish(dev, 'command', target);
byId(dev.ids.feedback).textContent = ok ? `Power ${target}` : 'Not connected';
}
function updatePlugUI(plug){
const wrap = byId(plug.els.stateWrap); const text = byId(plug.els.stateText);
wrap.classList.toggle('on', plug.state.on); wrap.classList.toggle('off', !plug.state.on);
text.textContent = plug.state.on ? 'on' : 'off';
}
function plugOn(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Set', params:{ id: plug.switchId, on:true } }); byId(plug.els.fb).textContent = ok ? 'Switch.Set → on' : 'Not connected'; }
function plugOff(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Set', params:{ id: plug.switchId, on:false } }); byId(plug.els.fb).textContent = ok ? 'Switch.Set → off' : 'Not connected'; }
function plugToggle(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Toggle', params:{ id: plug.switchId } }); byId(plug.els.fb).textContent = ok ? 'Switch.Toggle' : 'Not connected'; }
(function initOfficeLight(){
const d = devices[0]; const ids = d.ids; const apply = debounce(() => sendBulbUpdate(d), 180);
byId(ids.modeSeg).querySelectorAll('button').forEach(btn => { btn.addEventListener('click', () => { d.state.mode = btn.dataset.mode; updateBulbUI(d); apply(); }); });
byId(ids.R).addEventListener('input', e => { d.state.r = +e.target.value; byId(ids.Rv).textContent = d.state.r; updateBulbUI(d); apply(); });
byId(ids.G).addEventListener('input', e => { d.state.g = +e.target.value; byId(ids.Gv).textContent = d.state.g; updateBulbUI(d); apply(); });
byId(ids.B).addEventListener('input', e => { d.state.b = +e.target.value; byId(ids.Bv).textContent = d.state.b; updateBulbUI(d); apply(); });
byId(ids.W).addEventListener('input', e => { d.state.w = +e.target.value; byId(ids.Wv).textContent = d.state.w; updateBulbUI(d); apply(); });
byId(ids.bright).addEventListener('input', e => { d.state.gain = +e.target.value; byId(ids.brightv).textContent = d.state.gain; updateBulbUI(d); apply(); });
byId(ids.effect).addEventListener('input', e => { d.state.effect = +e.target.value; byId(ids.effectv).textContent = d.state.effect; updateBulbUI(d); apply(); });
byId(ids.wbright).addEventListener('input', e => { d.state.brightness = +e.target.value; byId(ids.wbrightv).textContent = d.state.brightness; updateBulbUI(d); apply(); });
byId(ids.temp).addEventListener('input', e => { d.state.temp = +e.target.value; byId(ids.tempv).textContent = d.state.temp; updateBulbUI(d); apply(); });
byId(ids.effectw).addEventListener('input', e => { d.state.effectw = +e.target.value; byId(ids.effectwv).textContent = d.state.effectw; updateBulbUI(d); apply(); });
byId(ids.toggle).addEventListener('click', () => { toggleBulbPower(d); }); updateBulbUI(d);
})();
(function initPlugs(){
[{id:'office'}, {id:'living'}].forEach(k=>{}); // noop placeholder to keep structure tidy
devices.filter(d=>d.kind==='plug').forEach(plug => {
byId(plug.els.on).addEventListener('click', ()=>plugOn(plug));
byId(plug.els.off).addEventListener('click', ()=>plugOff(plug));
byId(plug.els.toggle).addEventListener('click', ()=>plugToggle(plug));
updatePlugUI(plug);
});
})();
elConnect.addEventListener('click', connectMQTT);
elDisconnect.addEventListener('click', () => { if (client) { try { client.end(true); } catch(e){} } setConnStatus('disconnected'); });
window.addEventListener('load', () => {
const {auto} = storage.get();
elAuto.checked = auto; elAutoBadge.classList.toggle('hidden', !auto);
if (auto) { try { connectMQTT(); } catch (e) { console.warn('Auto-connect failed', e); showError('Auto-connect failed: ' + e.message); } }
});
elAuto.addEventListener('change', () => {
storage.set({ url: elUrl.value, user: elUser.value, pass: elPass.value, auto: elAuto.checked });
elAutoBadge.classList.toggle('hidden', !elAuto.checked);
});
</script>
</body>
</html>
@@ -0,0 +1,432 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Home Automation</title>
<link rel="stylesheet" href="/assets/css/style.css">
<!-- MQTT.js (browser) -->
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
</head>
<body>
<header>
<h1>My Home Automation</h1>
<div class="sub">MQTT control panel (LAN/VPN)</div>
</header>
<div class="container">
<!-- Connection Card -->
<div class="card" id="conn-card">
<h2>MQTT Connection</h2>
<div class="stack">
<div class="row">
<div class="pill">
<strong>Status:</strong>
<span id="conn-status" class="mono">disconnected</span>
<span id="auto-badge" class="auto-chip hidden">AUTO</span>
</div>
<div class="spacer"></div>
<button class="btn" id="connect-btn">Connect</button>
<button class="btn ghost" id="disconnect-btn" disabled>Disconnect</button>
</div>
<div class="row" style="gap:16px; align-items:flex-end; flex-wrap:wrap;">
<div style="min-width:220px; flex:1;">
<div class="field">
<label>Broker WebSocket URL <span class="mono notice">ws://192.168.1.237:9001</span></label>
<input id="broker-url" type="text" value="ws://192.168.1.237:9001" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
</div>
</div>
<div style="min-width:160px;">
<div class="field">
<label>Username</label>
<input id="broker-user" type="text" value="17ChurchWalk" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
</div>
</div>
<div style="min-width:220px;">
<div class="field">
<label>Password</label>
<input id="broker-pass" type="password" value="PantomimeFrequentedHouse" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
</div>
</div>
</div>
<div class="row" style="align-items:center; gap:10px;">
<input type="checkbox" id="auto-connect" checked /> <label for="auto-connect">Auto-connect on load</label>
</div>
<div id="conn-hint" class="notice muted"></div>
<div id="conn-error" class="notice error"></div>
<div class="notice">Credentials are pre-filled for convenience and stored locally in your browser (localStorage). For stronger security, consider a tiny server-side MQTT proxy so the page never sees raw creds.</div>
</div>
</div>
<div class="grid">
<!-- Link to Control Panel (existing) -->
<div class="card">
<h2>Control</h2>
<div class="row">
<a class="btn" href="http://control.richardjolley.co.uk">Open Control Panel</a>
<span class="chip">LAN/VPN only</span>
</div>
<p class="notice">VPN and service management.</p>
</div>
<!-- Office Light Card (Shelly color bulb) -->
<div class="card" id="office-light">
<div class="row" style="justify-content:space-between; align-items:center; gap:10px;">
<h2 style="margin:0;">Office Light</h2>
<div class="row">
<div id="ol-color" class="color-box" title="Current color"></div>
<button id="ol-toggle" class="btn">Toggle</button>
</div>
</div>
<div class="notice mono" style="margin-top:6px;">Topic base: <span id="ol-base-topic">shellies/office-bulb/color/0</span></div>
<div class="field">
<label>Mode</label>
<div class="seg" id="ol-mode">
<button data-mode="color" class="active" type="button">Color</button>
<button data-mode="white" type="button">White</button>
</div>
</div>
<div id="ol-color-controls">
<div class="field">
<label>Brightness (gain) <span class="mono" id="ol-bright-val">100</span></label>
<input type="range" id="ol-bright" min="0" max="100" value="100" />
</div>
<div class="row" style="gap:16px;">
<div class="field" style="flex:1;">
<label>Red <span class="mono" id="ol-r-val">255</span></label>
<input type="range" id="ol-r" min="0" max="255" value="255" />
</div>
<div class="field" style="flex:1;">
<label>Green <span class="mono" id="ol-g-val">255</span></label>
<input type="range" id="ol-g" min="0" max="255" value="255" />
</div>
<div class="field" style="flex:1;">
<label>Blue <span class="mono" id="ol-b-val">255</span></label>
<input type="range" id="ol-b" min="0" max="255" value="255" />
</div>
</div>
<div class="field">
<label>White channel (RGBW) <span class="mono" id="ol-w-val">0</span></label>
<input type="range" id="ol-w" min="0" max="255" value="0" />
</div>
<div class="field">
<label>Effect (0=off) <span class="mono" id="ol-effect-val">0</span></label>
<input type="range" id="ol-effect" min="0" max="10" value="0" />
</div>
</div>
<div id="ol-white-controls" class="hidden">
<div class="field">
<label>Brightness <span class="mono" id="ol-wbright-val">100</span></label>
<input type="range" id="ol-wbright" min="0" max="100" value="100" />
</div>
<div class="field">
<label>Color Temperature (K) <span class="mono" id="ol-temp-val">3000</span></label>
<input type="range" id="ol-temp" min="2700" max="6500" step="10" value="3000" />
</div>
<div class="field">
<label>Effect (0=off) <span class="mono" id="ol-effectw-val">0</span></label>
<input type="range" id="ol-effectw" min="0" max="10" value="0" />
</div>
</div>
<div class="notice">Shelly expects updates on <span class="mono">{base}/set</span> as JSON; on/off on <span class="mono">{base}/command</span>. We also listen to <span class="mono">{base}/status</span>.</div>
<div id="ol-feedback" class="notice"></div>
</div>
<div class="card" id="office-plug-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Office Plug Lamp</h2>
<div class="row off" id="office-plug-state">
<span class="status-dot"></span>
<span class="mono" id="office-plug-text">off</span>
</div>
</div>
<div class="notice mono" style="margin-top:6px;">Prefix: <span>office-plug-lamp</span> · RPC topic: <span>office-plug-lamp/rpc</span></div>
<div class="row" style="margin-top:10px; gap:8px;">
<button class="btn small" id="office-plug-on">On</button>
<button class="btn small" id="office-plug-off">Off</button>
<button class="btn small" id="office-plug-toggle">Toggle</button>
</div>
<div class="notice" id="office-plug-fb"></div>
</div>
<div class="card" id="living-plug-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Living Room Plug Lamp</h2>
<div class="row off" id="living-plug-state">
<span class="status-dot"></span>
<span class="mono" id="living-plug-text">off</span>
</div>
</div>
<div class="notice mono" style="margin-top:6px;">Prefix: <span>livingroom-plug-lamp</span> · RPC topic: <span>livingroom-plug-lamp/rpc</span></div>
<div class="row" style="margin-top:10px; gap:8px;">
<button class="btn small" id="living-plug-on">On</button>
<button class="btn small" id="living-plug-off">Off</button>
<button class="btn small" id="living-plug-toggle">Toggle</button>
</div>
<div class="notice" id="living-plug-fb"></div>
</div>
</div>
</div>
<script>
const qs = s => document.querySelector(s);
const byId = id => document.getElementById(id);
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
const debounce = (fn, ms=200) => { let t; return (...a) => { clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; };
const storage = {
get() {
return {
url: localStorage.getItem('mqtt_url') || 'ws://192.168.1.237:9001',
user: localStorage.getItem('mqtt_user') || '17ChurchWalk',
pass: localStorage.getItem('mqtt_pass') || 'PantomimeFrequentedHouse',
auto: localStorage.getItem('mqtt_auto') !== '0'
};
},
set({url,user,pass,auto}) {
localStorage.setItem('mqtt_url', url);
localStorage.setItem('mqtt_user', user || '');
localStorage.setItem('mqtt_pass', pass || '');
localStorage.setItem('mqtt_auto', auto ? '1':'0');
}
};
const elConnStatus = byId('conn-status');
const elConnect = byId('connect-btn');
const elDisconnect = byId('disconnect-btn');
const elUrl = byId('broker-url');
const elUser = byId('broker-user');
const elPass = byId('broker-pass');
const elAuto = byId('auto-connect');
const elAutoBadge = byId('auto-badge');
const elHint = byId('conn-hint');
const elErr = byId('conn-error');
const devices = [
{ kind:'bulb', name:'Office Light', base:'shellies/office-bulb/color/0',
ids:{ toggle:'ol-toggle', color:'ol-color', modeSeg:'ol-mode', R:'ol-r', G:'ol-g', B:'ol-b', W:'ol-w', Rv:'ol-r-val', Gv:'ol-g-val', Bv:'ol-b-val', Wv:'ol-w-val', bright:'ol-bright', brightv:'ol-bright-val', effect:'ol-effect', effectv:'ol-effect-val', wbright:'ol-wbright', wbrightv:'ol-wbright-val', temp:'ol-temp', tempv:'ol-temp-val', effectw:'ol-effectw', effectwv:'ol-effectw-val', colorControls:'ol-color-controls', whiteControls:'ol-white-controls', feedback:'ol-feedback' },
state:{ on:false, mode:'color', r:255,g:255,b:255,w:0,gain:100,effect:0, brightness:100,temp:3000,effectw:0 }
},
{ kind:'plug', prefix:'office-plug-lamp', switchId:0, els:{ stateWrap:'office-plug-state', stateText:'office-plug-text', on:'office-plug-on', off:'office-plug-off', toggle:'office-plug-toggle', fb:'office-plug-fb' }, state:{ on:false } },
{ kind:'plug', prefix:'livingroom-plug-lamp', switchId:0, els:{ stateWrap:'living-plug-state', stateText:'living-plug-text', on:'living-plug-on', off:'living-plug-off', toggle:'living-plug-toggle', fb:'living-plug-fb' }, state:{ on:false } }
];
(function initConnForm(){
const {url,user,pass,auto} = storage.get();
elUrl.value = url; elUser.value = user; elPass.value = pass; elAuto.checked = auto;
elAutoBadge.classList.toggle('hidden', !auto);
// Show helpful hint if https + ws
if (location.protocol === 'https:' && url.startsWith('ws://')) {
elHint.innerHTML = 'This page is served over <code class="inline">https</code> but the broker URL is <code class="inline">ws://</code>. Browsers block mixed content. Either open this page via <code class="inline">http://</code> on your LAN, or change the broker to <code class="inline">wss://</code> and enable a TLS WebSocket listener in Mosquitto.';
} else {
elHint.textContent = '';
}
})();
let client = null;
function setConnStatus(text, ok=false) {
elConnStatus.textContent = text;
elConnStatus.className = 'mono ' + (ok ? 'success' : (text==='disconnected'?'':'warn'));
elConnect.disabled = !!ok;
elDisconnect.disabled = !ok;
}
function showError(msg){ elErr.textContent = msg || ''; }
function connectMQTT(){
showError('');
const url = elUrl.value.trim();
const username = elUser.value.trim() || undefined;
const password = elPass.value || undefined;
storage.set({url, user: username||'', pass: password||'', auto: elAuto.checked});
if (typeof mqtt === 'undefined') {
showError('MQTT library failed to load. Check network/CSP and the CDN.');
return;
}
if (location.protocol === 'https:' && url.startsWith('ws://')) {
showError('Mixed content blocked: change broker URL to wss:// or open this page via http://');
setConnStatus('blocked');
return;
}
try { if (client) { client.end(true); client = null; } } catch(e) {}
setConnStatus('connecting…');
client = mqtt.connect(url, { username, password, reconnectPeriod: 2000 });
client.on('connect', () => {
setConnStatus('connected', true);
devices.forEach(d => {
if (d.kind === 'bulb') {
try { client.subscribe(`${d.base}/status`); } catch(e){}
try { client.subscribe(`${d.base}`); } catch(e){}
} else if (d.kind === 'plug') {
try { client.subscribe(`${d.prefix}/events/rpc`); } catch(e){}
}
});
requestStatuses();
});
client.on('reconnect', () => setConnStatus('reconnecting…'));
client.on('close', () => setConnStatus('disconnected'));
client.on('error', (err) => {
setConnStatus('error');
showError('Connection error: ' + (err && err.message ? err.message : String(err)));
console.error(err);
});
client.on('message', (topic, payloadBuf) => {
const payload = payloadBuf.toString();
const bulb = devices[0];
if (topic === bulb.base) {
bulb.state.on = (payload.trim().toLowerCase() === 'on');
updateBulbUI(bulb);
return;
}
if (topic === `${bulb.base}/status`) {
try {
const js = JSON.parse(payload);
if (typeof js.ison === 'boolean') bulb.state.on = js.ison;
if (js.mode === 'color' || js.mode === 'white') bulb.state.mode = js.mode;
if (Number.isFinite(js.red)) bulb.state.r = clamp(js.red,0,255);
if (Number.isFinite(js.green)) bulb.state.g = clamp(js.green,0,255);
if (Number.isFinite(js.blue)) bulb.state.b = clamp(js.blue,0,255);
if (Number.isFinite(js.white)) bulb.state.w = clamp(js.white,0,255);
if (Number.isFinite(js.gain)) bulb.state.gain = clamp(js.gain,0,100);
if (Number.isFinite(js.effect)) bulb.state.effect = clamp(js.effect,0,10);
if (Number.isFinite(js.brightness)) bulb.state.brightness = clamp(js.brightness,0,100);
if (Number.isFinite(js.temp)) bulb.state.temp = clamp(js.temp,2700,6500);
updateBulbUI(bulb);
} catch(e) { console.warn('Bad JSON on status', e, payload); }
return;
}
const plug = devices.find(d => d.kind==='plug' && topic === `${d.prefix}/events/rpc`);
if (plug) {
try {
const js = JSON.parse(payload);
if (js.method === 'NotifyStatus' && js.params && js.params[`switch:${plug.switchId}`]) {
const out = js.params[`switch:${plug.switchId}`].output;
if (typeof out === 'boolean') { plug.state.on = out; updatePlugUI(plug); }
}
} catch(e) { console.warn('Bad RPC JSON', e, payload); }
return;
}
});
}
function requestStatuses(){
devices.filter(d=>d.kind==='plug').forEach((plug,i) => {
const req = { id: 100+i, src: 'ui', method: 'Shelly.GetStatus' };
publishRPC(plug, req);
});
}
function publish(dev, subPath, message) {
if (!client || client.disconnected) return false;
const topic = `${dev.base}${subPath ? '/' + subPath : ''}`;
try { client.publish(topic, message, { qos:0, retain:false }); return true; } catch(e) { console.error(e); return false; }
}
function publishRPC(plug, obj){
if (!client || client.disconnected) return false;
const topic = `${plug.prefix}/rpc`;
try { client.publish(topic, JSON.stringify(obj), { qos:0, retain:false }); return true; } catch(e) { console.error(e); return false; }
}
function updateBulbUI(dev){
const ids = dev.ids;
const seg = byId(ids.modeSeg);
seg.querySelectorAll('button').forEach(btn => btn.classList.toggle('active', btn.dataset.mode === dev.state.mode));
byId(ids.colorControls).classList.toggle('hidden', dev.state.mode !== 'color');
byId(ids.whiteControls).classList.toggle('hidden', dev.state.mode !== 'white');
byId(ids.color).style.background = `rgb(${dev.state.r} ${dev.state.g} ${dev.state.b})`;
byId(ids.toggle).textContent = dev.state.on ? 'Turn Off' : 'Turn On';
byId(ids.R).value = dev.state.r; byId(ids.Rv).textContent = dev.state.r;
byId(ids.G).value = dev.state.g; byId(ids.Gv).textContent = dev.state.g;
byId(ids.B).value = dev.state.b; byId(ids.Bv).textContent = dev.state.b;
byId(ids.W).value = dev.state.w; byId(ids.Wv).textContent = dev.state.w;
byId(ids.bright).value = dev.state.gain; byId(ids.brightv).textContent = dev.state.gain;
byId(ids.effect).value = dev.state.effect; byId(ids.effectv).textContent = dev.state.effect;
byId(ids.wbright).value = dev.state.brightness; byId(ids.wbrightv).textContent = dev.state.brightness;
byId(ids.temp).value = dev.state.temp; byId(ids.tempv).textContent = dev.state.temp;
byId(ids.effectw).value = dev.state.effectw; byId(ids.effectwv).textContent = dev.state.effectw;
}
function sendBulbUpdate(dev){
const s = dev.state; let body;
if (s.mode === 'color') { body = { mode:'color', red:s.r, green:s.g, blue:s.b, white:s.w, gain:s.gain, effect:s.effect, turn:'on' }; }
else { body = { mode:'white', brightness:s.brightness, temp:s.temp, effect:s.effectw, turn:'on' }; }
const ok = publish(dev, 'set', JSON.stringify(body));
byId(dev.ids.feedback).textContent = ok ? `Set → ${JSON.stringify(body)}` : 'Not connected';
}
function toggleBulbPower(dev){
const target = dev.state.on ? 'off' : 'on';
const ok = publish(dev, 'command', target);
byId(dev.ids.feedback).textContent = ok ? `Power ${target}` : 'Not connected';
}
function updatePlugUI(plug){
const wrap = byId(plug.els.stateWrap); const text = byId(plug.els.stateText);
wrap.classList.toggle('on', plug.state.on); wrap.classList.toggle('off', !plug.state.on);
text.textContent = plug.state.on ? 'on' : 'off';
}
function plugOn(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Set', params:{ id: plug.switchId, on:true } }); byId(plug.els.fb).textContent = ok ? 'Switch.Set → on' : 'Not connected'; }
function plugOff(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Set', params:{ id: plug.switchId, on:false } }); byId(plug.els.fb).textContent = ok ? 'Switch.Set → off' : 'Not connected'; }
function plugToggle(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Toggle', params:{ id: plug.switchId } }); byId(plug.els.fb).textContent = ok ? 'Switch.Toggle' : 'Not connected'; }
(function initOfficeLight(){
const d = devices[0]; const ids = d.ids; const apply = debounce(() => sendBulbUpdate(d), 180);
byId(ids.modeSeg).querySelectorAll('button').forEach(btn => { btn.addEventListener('click', () => { d.state.mode = btn.dataset.mode; updateBulbUI(d); apply(); }); });
byId(ids.R).addEventListener('input', e => { d.state.r = +e.target.value; byId(ids.Rv).textContent = d.state.r; updateBulbUI(d); apply(); });
byId(ids.G).addEventListener('input', e => { d.state.g = +e.target.value; byId(ids.Gv).textContent = d.state.g; updateBulbUI(d); apply(); });
byId(ids.B).addEventListener('input', e => { d.state.b = +e.target.value; byId(ids.Bv).textContent = d.state.b; updateBulbUI(d); apply(); });
byId(ids.W).addEventListener('input', e => { d.state.w = +e.target.value; byId(ids.Wv).textContent = d.state.w; updateBulbUI(d); apply(); });
byId(ids.bright).addEventListener('input', e => { d.state.gain = +e.target.value; byId(ids.brightv).textContent = d.state.gain; updateBulbUI(d); apply(); });
byId(ids.effect).addEventListener('input', e => { d.state.effect = +e.target.value; byId(ids.effectv).textContent = d.state.effect; updateBulbUI(d); apply(); });
byId(ids.wbright).addEventListener('input', e => { d.state.brightness = +e.target.value; byId(ids.wbrightv).textContent = d.state.brightness; updateBulbUI(d); apply(); });
byId(ids.temp).addEventListener('input', e => { d.state.temp = +e.target.value; byId(ids.tempv).textContent = d.state.temp; updateBulbUI(d); apply(); });
byId(ids.effectw).addEventListener('input', e => { d.state.effectw = +e.target.value; byId(ids.effectwv).textContent = d.state.effectw; updateBulbUI(d); apply(); });
byId(ids.toggle).addEventListener('click', () => { toggleBulbPower(d); }); updateBulbUI(d);
})();
(function initPlugs(){
[{id:'office'}, {id:'living'}].forEach(k=>{}); // noop placeholder to keep structure tidy
devices.filter(d=>d.kind==='plug').forEach(plug => {
byId(plug.els.on).addEventListener('click', ()=>plugOn(plug));
byId(plug.els.off).addEventListener('click', ()=>plugOff(plug));
byId(plug.els.toggle).addEventListener('click', ()=>plugToggle(plug));
updatePlugUI(plug);
});
})();
elConnect.addEventListener('click', connectMQTT);
elDisconnect.addEventListener('click', () => { if (client) { try { client.end(true); } catch(e){} } setConnStatus('disconnected'); });
window.addEventListener('load', () => {
const {auto} = storage.get();
elAuto.checked = auto; elAutoBadge.classList.toggle('hidden', !auto);
if (auto) { try { connectMQTT(); } catch (e) { console.warn('Auto-connect failed', e); showError('Auto-connect failed: ' + e.message); } }
});
elAuto.addEventListener('change', () => {
storage.set({ url: elUrl.value, user: elUser.value, pass: elPass.value, auto: elAuto.checked });
elAutoBadge.classList.toggle('hidden', !elAuto.checked);
});
</script>
</body>
</html>
@@ -0,0 +1,287 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Server Dashboard</title>
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<header>
<h1>My Home Server Dashboard</h1>
<div class="sub">Server & network controls (LAN/VPN)</div>
</header>
<div id="top-menu"></div>
<script>
(async function loadMenu(){
try {
const r = await fetch('/assets/page-elements/nav.html');
const html = await r.text();
const mount = document.getElementById('top-menu');
// Insert the HTML
mount.outerHTML = html;
// Now highlight the current page link
const nav = document.querySelector('.top-menu');
if (!nav) return;
// Normalize both sides (strip trailing index.html and trailing slash)
const normalize = (u) => {
const url = new URL(u, location.origin);
let p = url.pathname.replace(/index\.html$/i,'');
if (p.length > 1 && p.endsWith('/')) p = p.slice(0,-1);
return url.origin + p;
};
const here = normalize(location.href);
nav.querySelectorAll('a').forEach(a => {
const target = normalize(a.href);
// exact match OR “same origin + same base path” cases
const isExact = target === here;
const isRootMatch = (
target === normalize(location.origin + '/') &&
(here === normalize(location.origin + '/') || here === normalize(location.href))
);
if (isExact || isRootMatch) {
a.classList.add('is-active');
a.setAttribute('aria-current', 'page');
}
});
} catch(e) {
console.warn('Top menu load failed:', e);
}
})();
</script>
<div class="container">
<div class="grid">
<!-- Home Automation link -->
<div class="card" id="home-automation">
<h2>Home Automation</h2>
<div class="row">
<a class="btn" href="http://automation.richardjolley.co.uk" target="_blank">Open Home Automation</a>
<span class="chip">LAN/VPN only</span>
</div>
<p class="notice">MQTT device controls.</p>
</div>
<!-- Jellyfin (optional; guarded in JS) -->
<div class="card" id="jellyfin-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Jellyfin</h2>
<div class="pill" id="jellyfin-pill"><span class="status-dot"></span><span id="jellyfin-status" class="label">Checking…</span></div>
</div>
<div class="row section">
<a class="btn" href="http://jellyfin.richardjolley.co.uk" target="_blank">Open Jellyfin</a>
<button class="btn ghost small" onclick="restartService('jellyfin')">Restart</button>
</div>
<div class="feedback" id="jellyfin-feedback"></div>
</div>
<!-- Pi-hole -->
<div class="card" id="pihole-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Pi-hole</h2>
<div class="pill" id="pihole-FTL-pill"><span class="status-dot"></span><span id="pihole-FTL-status" class="label">Checking…</span></div>
</div>
<div class="row section">
<a class="btn" href="http://pihole.richardjolley.co.uk/admin" target="_blank">Open Pi-hole</a>
<button class="btn ghost small" onclick="restartService('pihole-FTL')">Restart</button>
</div>
<div class="feedback" id="pihole-FTL-feedback"></div>
</div>
<!-- Nextcloud (Apache) -->
<div class="card" id="nextcloud-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Nextcloud</h2>
<div class="pill" id="apache2-pill"><span class="status-dot"></span><span id="apache2-status" class="label">Checking…</span></div>
</div>
<div class="row section">
<a class="btn" href="http://cloud.richardjolley.co.uk" target="_blank">Open Nextcloud</a>
<button class="btn ghost small" onclick="restartService('apache2')">Restart Apache</button>
</div>
<div class="feedback" id="apache2-feedback"></div>
</div>
<!-- File Browser -->
<div class="card" id="filebrowser-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">File Browser</h2>
<div class="pill" id="filebrowser-pill"><span class="status-dot"></span><span id="filebrowser-status" class="label">Checking…</span></div>
</div>
<div class="row section">
<a class="btn" href="http://filebrowser.richardjolley.co.uk" target="_blank">Open File Browser</a>
<button class="btn ghost small" onclick="restartService('filebrowser')">Restart</button>
</div>
<div class="feedback" id="filebrowser-feedback"></div>
</div>
<!-- WireGuard Tunnels -->
<div class="card" id="wireguard-tunnels-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">WireGuard Tunnels</h2>
<span class="notice">Bring interfaces up/down</span>
</div>
<div id="wg-tunnels" class="section"></div>
<div class="feedback ok" id="wg-tunnel-feedback"></div>
</div>
<!-- VPN (Surfshark) -->
<div class="card" id="vpn-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">VPN (Surfshark)</h2>
<span class="notice">OpenVPN via CGI</span>
</div>
<div class="section">
<label class="label" for="vpn-location">Location</label>
<select id="vpn-location" style="padding:8px; border:1px solid #e5e7eb; border-radius:8px; min-width:180px;">
<option value="uk-lon">London</option>
<option value="uk-man">Manchester</option>
<option value="uk-gla">Glasgow</option>
</select>
</div>
<div class="row" style="margin-top:8px; gap:8px;">
<button class="btn small" onclick="connectVPN()">Connect</button>
<button class="btn ghost small" onclick="disconnectVPN()">Disconnect</button>
<button class="btn ghost small" onclick="checkVPNStatus()">Check IP</button>
</div>
<div class="feedback ok" id="vpn-feedback"></div>
</div>
</div>
</div>
<script>
function setStatusPill(idPrefix, statusText) {
const pill = document.getElementById(idPrefix + '-pill');
const label = document.getElementById(idPrefix + '-status');
if (!label || !pill) return; // guard if card not present
label.textContent = statusText || 'unknown';
pill.classList.remove('ok','bad');
if (/active|running|online|enabled/i.test(statusText)) pill.classList.add('ok');
else if (/inactive|failed|stopped|error/i.test(statusText)) pill.classList.add('bad');
}
function fetchStatus(serviceName, elementId) {
fetch(`/cgi-bin/control_service_status.cgi?service=${serviceName}`)
.then(r => r.json())
.then(data => {
const statusText = data.status || 'unknown';
const el = document.getElementById(elementId);
if (el) el.textContent = statusText; // keep API-compatible
setStatusPill(elementId.replace(/-status$/, ''), statusText);
})
.catch(() => {
const el = document.getElementById(elementId);
if (el) el.textContent = 'error';
setStatusPill(elementId.replace(/-status$/, ''), 'error');
});
}
function showFeedback(id, msg, ok=true){
const el = document.getElementById(id);
if (!el) return;
el.textContent = msg;
el.classList.toggle('ok', ok);
el.classList.toggle('err', !ok);
el.style.display = 'block';
setTimeout(() => { el.style.transition = 'opacity 1s'; el.style.opacity = '0';
setTimeout(()=>{ el.style.display='none'; el.style.opacity='1'; }, 1000);
}, 5000);
}
function restartService(serviceName) {
fetch('/cgi-bin/control_service_restart.cgi', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `service=${encodeURIComponent(serviceName)}`
})
.then(r => r.text())
.then(() => {
showFeedback(`${serviceName}-feedback`, `Restarted ${serviceName} successfully`, true);
fetchStatus(serviceName, `${serviceName}-status`);
})
.catch(() => showFeedback(`${serviceName}-feedback`, `Error restarting ${serviceName}`, false));
}
// WireGuard helpers
function checkTunnelStatus(tunnel) {
fetch(`/cgi-bin/control_service_status.cgi?service=wg-quick@${tunnel}`)
.then(r => r.json())
.then(data => setStatusPill(`${tunnel}`, data.status || 'unknown'))
.catch(() => setStatusPill(`${tunnel}`, 'error'));
}
function manageTunnel(tunnel, action) {
fetch('/cgi-bin/control_wireguard_manage.cgi', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `tunnel=${encodeURIComponent(tunnel)}&action=${encodeURIComponent(action)}`
})
.then(r => r.text())
.then(text => { showFeedback('wg-tunnel-feedback', `Tunnel ${tunnel} ${action}ed: ${text}`, true); checkTunnelStatus(tunnel); })
.catch(() => showFeedback('wg-tunnel-feedback', `Failed to ${action} ${tunnel}`, false));
}
function loadWireguardTunnels() {
fetch('/cgi-bin/control_wireguard_list.cgi')
.then(r => r.json())
.then(tunnels => {
const container = document.getElementById('wg-tunnels');
container.innerHTML = '';
tunnels.forEach(({ name, description }) => {
const row = document.createElement('div');
row.className = 'row';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
row.style.marginBottom = '10px';
row.innerHTML = `
<div class="row" style="gap:8px;">
<strong>${name}</strong>
<span class="notice">${description || ''}</span>
</div>
<div class="row">
<div class="pill" id="${name}-pill"><span class="status-dot"></span><span id="${name}-status" class="label">Checking…</span></div>
<button class="btn ghost small" onclick="manageTunnel('${name}','up')">Up</button>
<button class="btn ghost small" onclick="manageTunnel('${name}','down')">Down</button>
</div>`;
container.appendChild(row);
checkTunnelStatus(name);
});
});
}
// VPN (Surfshark)
function connectVPN(){
const location = document.getElementById('vpn-location').value;
fetch('/cgi-bin/vpn-connect.cgi', {
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:`location=${encodeURIComponent(location)}`
})
.then(r=>r.text())
.then(()=> showFeedback('vpn-feedback', `VPN connected to ${location}`, true))
.catch(()=> showFeedback('vpn-feedback', 'Failed to connect VPN.', false));
}
function disconnectVPN(){
fetch('/cgi-bin/vpn-disconnect.cgi', { method:'POST' })
.then(()=> showFeedback('vpn-feedback', 'VPN disconnected.', true))
.catch(()=> showFeedback('vpn-feedback', 'Failed to disconnect VPN.', false));
}
function checkVPNStatus(){
fetch('/cgi-bin/vpn-status.cgi')
.then(r=>r.text())
.then(text=> showFeedback('vpn-feedback', `VPN status: ${text}`, true))
.catch(()=> showFeedback('vpn-feedback', 'Failed to check VPN status.', false));
}
// Initial status checks (guarded if cards exist)
['jellyfin','pihole-FTL','apache2','filebrowser'].forEach(svc => {
const el = document.getElementById(`${svc}-status`);
if (el) fetchStatus(svc, `${svc}-status`);
});
loadWireguardTunnels();
</script>
</body>
</html>
@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Server Dashboard</title>
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<header>
<h1>My Home Server Dashboard</h1>
<div class="sub">Server & network controls (LAN/VPN)</div>
</header>
<div class="container">
<div class="grid">
<!-- Home Automation link -->
<div class="card" id="home-automation">
<h2>Home Automation</h2>
<div class="row">
<a class="btn" href="http://automation.richardjolley.co.uk" target="_blank">Open Home Automation</a>
<span class="chip">LAN/VPN only</span>
</div>
<p class="notice">MQTT device controls.</p>
</div>
<!-- Jellyfin (optional; guarded in JS) -->
<div class="card" id="jellyfin-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Jellyfin</h2>
<div class="pill" id="jellyfin-pill"><span class="status-dot"></span><span id="jellyfin-status" class="label">Checking…</span></div>
</div>
<div class="row section">
<a class="btn" href="http://jellyfin.richardjolley.co.uk" target="_blank">Open Jellyfin</a>
<button class="btn ghost small" onclick="restartService('jellyfin')">Restart</button>
</div>
<div class="feedback" id="jellyfin-feedback"></div>
</div>
<!-- Pi-hole -->
<div class="card" id="pihole-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Pi-hole</h2>
<div class="pill" id="pihole-FTL-pill"><span class="status-dot"></span><span id="pihole-FTL-status" class="label">Checking…</span></div>
</div>
<div class="row section">
<a class="btn" href="http://pihole.richardjolley.co.uk/admin" target="_blank">Open Pi-hole</a>
<button class="btn ghost small" onclick="restartService('pihole-FTL')">Restart</button>
</div>
<div class="feedback" id="pihole-FTL-feedback"></div>
</div>
<!-- Nextcloud (Apache) -->
<div class="card" id="nextcloud-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Nextcloud</h2>
<div class="pill" id="apache2-pill"><span class="status-dot"></span><span id="apache2-status" class="label">Checking…</span></div>
</div>
<div class="row section">
<a class="btn" href="http://cloud.richardjolley.co.uk" target="_blank">Open Nextcloud</a>
<button class="btn ghost small" onclick="restartService('apache2')">Restart Apache</button>
</div>
<div class="feedback" id="apache2-feedback"></div>
</div>
<!-- File Browser -->
<div class="card" id="filebrowser-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">File Browser</h2>
<div class="pill" id="filebrowser-pill"><span class="status-dot"></span><span id="filebrowser-status" class="label">Checking…</span></div>
</div>
<div class="row section">
<a class="btn" href="http://filebrowser.richardjolley.co.uk" target="_blank">Open File Browser</a>
<button class="btn ghost small" onclick="restartService('filebrowser')">Restart</button>
</div>
<div class="feedback" id="filebrowser-feedback"></div>
</div>
<!-- WireGuard Tunnels -->
<div class="card" id="wireguard-tunnels-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">WireGuard Tunnels</h2>
<span class="notice">Bring interfaces up/down</span>
</div>
<div id="wg-tunnels" class="section"></div>
<div class="feedback ok" id="wg-tunnel-feedback"></div>
</div>
<!-- VPN (Surfshark) -->
<div class="card" id="vpn-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">VPN (Surfshark)</h2>
<span class="notice">OpenVPN via CGI</span>
</div>
<div class="section">
<label class="label" for="vpn-location">Location</label>
<select id="vpn-location" style="padding:8px; border:1px solid #e5e7eb; border-radius:8px; min-width:180px;">
<option value="uk-lon">London</option>
<option value="uk-man">Manchester</option>
<option value="uk-gla">Glasgow</option>
</select>
</div>
<div class="row" style="margin-top:8px; gap:8px;">
<button class="btn small" onclick="connectVPN()">Connect</button>
<button class="btn ghost small" onclick="disconnectVPN()">Disconnect</button>
<button class="btn ghost small" onclick="checkVPNStatus()">Check IP</button>
</div>
<div class="feedback ok" id="vpn-feedback"></div>
</div>
</div>
</div>
<script>
function setStatusPill(idPrefix, statusText) {
const pill = document.getElementById(idPrefix + '-pill');
const label = document.getElementById(idPrefix + '-status');
if (!label || !pill) return; // guard if card not present
label.textContent = statusText || 'unknown';
pill.classList.remove('ok','bad');
if (/active|running|online|enabled/i.test(statusText)) pill.classList.add('ok');
else if (/inactive|failed|stopped|error/i.test(statusText)) pill.classList.add('bad');
}
function fetchStatus(serviceName, elementId) {
fetch(`/cgi-bin/control_service_status.cgi?service=${serviceName}`)
.then(r => r.json())
.then(data => {
const statusText = data.status || 'unknown';
const el = document.getElementById(elementId);
if (el) el.textContent = statusText; // keep API-compatible
setStatusPill(elementId.replace(/-status$/, ''), statusText);
})
.catch(() => {
const el = document.getElementById(elementId);
if (el) el.textContent = 'error';
setStatusPill(elementId.replace(/-status$/, ''), 'error');
});
}
function showFeedback(id, msg, ok=true){
const el = document.getElementById(id);
if (!el) return;
el.textContent = msg;
el.classList.toggle('ok', ok);
el.classList.toggle('err', !ok);
el.style.display = 'block';
setTimeout(() => { el.style.transition = 'opacity 1s'; el.style.opacity = '0';
setTimeout(()=>{ el.style.display='none'; el.style.opacity='1'; }, 1000);
}, 5000);
}
function restartService(serviceName) {
fetch('/cgi-bin/control_service_restart.cgi', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `service=${encodeURIComponent(serviceName)}`
})
.then(r => r.text())
.then(() => {
showFeedback(`${serviceName}-feedback`, `Restarted ${serviceName} successfully`, true);
fetchStatus(serviceName, `${serviceName}-status`);
})
.catch(() => showFeedback(`${serviceName}-feedback`, `Error restarting ${serviceName}`, false));
}
// WireGuard helpers
function checkTunnelStatus(tunnel) {
fetch(`/cgi-bin/control_service_status.cgi?service=wg-quick@${tunnel}`)
.then(r => r.json())
.then(data => setStatusPill(`${tunnel}`, data.status || 'unknown'))
.catch(() => setStatusPill(`${tunnel}`, 'error'));
}
function manageTunnel(tunnel, action) {
fetch('/cgi-bin/control_wireguard_manage.cgi', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `tunnel=${encodeURIComponent(tunnel)}&action=${encodeURIComponent(action)}`
})
.then(r => r.text())
.then(text => { showFeedback('wg-tunnel-feedback', `Tunnel ${tunnel} ${action}ed: ${text}`, true); checkTunnelStatus(tunnel); })
.catch(() => showFeedback('wg-tunnel-feedback', `Failed to ${action} ${tunnel}`, false));
}
function loadWireguardTunnels() {
fetch('/cgi-bin/control_wireguard_list.cgi')
.then(r => r.json())
.then(tunnels => {
const container = document.getElementById('wg-tunnels');
container.innerHTML = '';
tunnels.forEach(({ name, description }) => {
const row = document.createElement('div');
row.className = 'row';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
row.style.marginBottom = '10px';
row.innerHTML = `
<div class="row" style="gap:8px;">
<strong>${name}</strong>
<span class="notice">${description || ''}</span>
</div>
<div class="row">
<div class="pill" id="${name}-pill"><span class="status-dot"></span><span id="${name}-status" class="label">Checking…</span></div>
<button class="btn ghost small" onclick="manageTunnel('${name}','up')">Up</button>
<button class="btn ghost small" onclick="manageTunnel('${name}','down')">Down</button>
</div>`;
container.appendChild(row);
checkTunnelStatus(name);
});
});
}
// VPN (Surfshark)
function connectVPN(){
const location = document.getElementById('vpn-location').value;
fetch('/cgi-bin/vpn-connect.cgi', {
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:`location=${encodeURIComponent(location)}`
})
.then(r=>r.text())
.then(()=> showFeedback('vpn-feedback', `VPN connected to ${location}`, true))
.catch(()=> showFeedback('vpn-feedback', 'Failed to connect VPN.', false));
}
function disconnectVPN(){
fetch('/cgi-bin/vpn-disconnect.cgi', { method:'POST' })
.then(()=> showFeedback('vpn-feedback', 'VPN disconnected.', true))
.catch(()=> showFeedback('vpn-feedback', 'Failed to disconnect VPN.', false));
}
function checkVPNStatus(){
fetch('/cgi-bin/vpn-status.cgi')
.then(r=>r.text())
.then(text=> showFeedback('vpn-feedback', `VPN status: ${text}`, true))
.catch(()=> showFeedback('vpn-feedback', 'Failed to check VPN status.', false));
}
// Initial status checks (guarded if cards exist)
['jellyfin','pihole-FTL','apache2','filebrowser'].forEach(svc => {
const el = document.getElementById(`${svc}-status`);
if (el) fetchStatus(svc, `${svc}-status`);
});
loadWireguardTunnels();
</script>
</body>
</html>
@@ -0,0 +1,140 @@
//This is code for the modification of rules
(function(){
const APPLY_URL = 'https://control.richardjolley.co.uk/cgi-bin/automation-rules-apply.cgi';
const GET_URL = 'https://control.richardjolley.co.uk/cgi-bin/automation-rules-get.cgi';
const el = id => document.getElementById(id);
const modeEl = el('mode');
const nameEl = el('rule-name');
const idEl = el('match-id');
const stEl = el('match-state');
const cdEl = el('cooldown');
const typeEl = el('action-type');
const topicEl= el('action-topic');
const payloadEl = el('action-payload');
const deviceEl = el('action-device');
const statusEl = el('rule-status');
function setStatus(msg, ok=true) {
statusEl.textContent = msg || '';
statusEl.style.color = ok ? 'inherit' : 'crimson';
}
function showActionFields() {
const t = typeEl.value;
document.querySelectorAll('#rule-editor-card .action-row').forEach(row => {
const forList = (row.getAttribute('data-for') || '').split(/\s+/);
row.style.display = forList.includes(t) ? '' : 'none';
});
}
typeEl.addEventListener('change', showActionFields);
showActionFields();
// Presets
document.querySelectorAll('#rule-editor-card .preset').forEach(btn => {
btn.addEventListener('click', () => {
const p = btn.getAttribute('data-preset');
// Common defaults
nameEl.value = '';
idEl.value = 0;
stEl.value = '1';
cdEl.value = 200;
if (p === 'plug-toggle') {
nameEl.value = 's2_toggle_plug_on_press';
idEl.value = 1;
typeEl.value = 'mqtt_publish';
topicEl.value = 'office-plug-lamp/rpc';
payloadEl.value = JSON.stringify({ method: "Switch.Toggle", params: { id: 0 } }, null, 2);
} else if (p === 'bulb-toggle-legacy') {
nameEl.value = 's1_toggle_legacy_bulb';
idEl.value = 0;
typeEl.value = 'mqtt_publish_toggle_legacy';
deviceEl.value = 'office-bulb';
} else if (p === 'bulb-brightness-up') {
nameEl.value = 's1_brightness_up';
idEl.value = 0;
typeEl.value = 'mqtt_publish_json';
// Adjust to your device’s topic. For legacy Gen1 bulbs, often: shellies/<dev>/color/0/set
topicEl.value = 'shellies/office-bulb/color/0/set';
payloadEl.value = JSON.stringify({ brightness_change: +10 }, null, 2);
} else if (p === 'bulb-brightness-down') {
nameEl.value = 's1_brightness_down';
idEl.value = 0;
typeEl.value = 'mqtt_publish_json';
topicEl.value = 'shellies/office-bulb/color/0/set';
payloadEl.value = JSON.stringify({ brightness_change: -10 }, null, 2);
}
showActionFields();
});
});
// Submit
el('rule-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
setStatus('Applying…');
// Build rule
const rule = {
name: String(nameEl.value || '').trim(),
match: { method: "NotifyInput", params: { id: Number(idEl.value), state: Number(stEl.value) } },
actions: [],
};
const cd = Number(cdEl.value);
if (!isNaN(cd) && cd > 0) rule.cooldown_ms = cd;
const t = typeEl.value;
if (t === 'mqtt_publish' || t === 'mqtt_publish_json') {
const topic = String(topicEl.value || '').trim();
if (!topic) { setStatus('Topic is required for mqtt_publish*', false); return; }
let payload = payloadEl.value.trim();
let payloadVal = null;
// For mqtt_publish_json we require valid JSON
if (t === 'mqtt_publish_json') {
try { payloadVal = payload ? JSON.parse(payload) : null; }
catch (e) { setStatus('Payload must be valid JSON', false); return; }
rule.actions.push({ type: t, topic, payload: payloadVal });
} else {
// allow raw strings OR JSON (we won’t parse)
try {
payloadVal = payload ? JSON.parse(payload) : null;
} catch { payloadVal = payload; } // keep as string
rule.actions.push({ type: t, topic, payload: payloadVal });
}
} else if (t === 'mqtt_publish_toggle_legacy') {
const device = String(deviceEl.value || '').trim();
if (!device) { setStatus('Legacy device name is required', false); return; }
rule.actions.push({ type: t, device });
} else {
setStatus('Unsupported action type', false);
return;
}
const payloadOut = (modeEl.value === 'replace')
? { mode: 'replace', rules: [ rule ] } // start simple: replace with one rule
: { mode: 'append', rule };
try {
const res = await fetch(APPLY_URL, {
method: 'POST',
cache: 'no-store',
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payloadOut)
});
const ct = res.headers.get('content-type') || '';
const text = await res.text();
if (!ct.includes('application/json')) throw new Error(`Expected JSON, got ${ct}: ${text}`);
const data = JSON.parse(text);
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`);
setStatus(`Applied ✔ (backup: ${data.backup})`);
// Refresh the read-only card if you kept my function name; else call your own reload
try { if (typeof loadBridgeRules === 'function') await loadBridgeRules(); } catch {}
} catch (e) {
setStatus(`Failed: ${e.message}`, false);
console.error(e);
}
});
})();
@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Services</title>
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<header>
<h1>My Services</h1>
<div class="sub">Apps running on the home server</div>
</header>
<div id="top-menu"></div>
<script>
(async function loadMenu(){
try {
const r = await fetch('/assets/page-elements/nav.html');
const html = await r.text();
const mount = document.getElementById('top-menu');
// Insert the HTML
mount.outerHTML = html;
// Now highlight the current page link
const nav = document.querySelector('.top-menu');
if (!nav) return;
// Normalize both sides (strip trailing index.html and trailing slash)
const normalize = (u) => {
const url = new URL(u, location.origin);
let p = url.pathname.replace(/index\.html$/i,'');
if (p.length > 1 && p.endsWith('/')) p = p.slice(0,-1);
return url.origin + p;
};
const here = normalize(location.href);
nav.querySelectorAll('a').forEach(a => {
const target = normalize(a.href);
// exact match OR “same origin + same base path” cases
const isExact = target === here;
const isRootMatch = (
target === normalize(location.origin + '/') &&
(here === normalize(location.origin + '/') || here === normalize(location.href))
);
if (isExact || isRootMatch) {
a.classList.add('is-active');
a.setAttribute('aria-current', 'page');
}
});
} catch(e) {
console.warn('Top menu load failed:', e);
}
})();
</script>
<div class="container">
<div class="grid">
<!-- Jellyfin -->
<div class="card" id="jellyfin-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Jellyfin</h2>
<div class="pill" id="jellyfin-pill">
<span class="status-dot"></span>
<span id="jellyfin-status" class="label">Checking…</span>
</div>
</div>
<div class="row section">
<a class="btn" href="http://richflix.co.uk" target="_blank">Open Jellyfin</a>
<span class="notice">Media server</span>
</div>
</div>
<!-- Audiobooks -->
<div class="card" id="audiobookshelf-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Audiobooks</h2>
<div class="pill" id="audiobookshelf-pill">
<span class="status-dot"></span>
<span id="audiobookshelf-status" class="label">Checking…</span>
</div>
</div>
<div class="row section">
<a class="btn" href="http://audiobooks.richardjolley.co.uk" target="_blank">Open Audiobooks</a>
<span class="notice">Audiobook server</span>
</div>
</div>
<!-- Nextcloud (Apache) -->
<div class="card" id="nextcloud-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">Nextcloud</h2>
<div class="pill" id="apache2-pill">
<span class="status-dot"></span>
<span id="apache2-status" class="label">Checking…</span>
</div>
</div>
<div class="row section">
<a class="btn" href="http://cloud.richardjolley.co.uk" target="_blank">Open Nextcloud</a>
<span class="notice">Files & collaboration</span>
</div>
</div>
<!-- File Browser -->
<div class="card" id="filebrowser-card">
<div class="row" style="justify-content:space-between; align-items:center;">
<h2 style="margin:0;">File Browser</h2>
<div class="pill" id="filebrowser-pill">
<span class="status-dot"></span>
<span id="filebrowser-status" class="label">Checking…</span>
</div>
</div>
<div class="row section">
<a class="btn" href="http://filebrowser.richardjolley.co.uk" target="_blank">Open File Browser</a>
<span class="notice">Web file manager</span>
</div>
</div>
</div>
</div>
<script>
// Same pill logic as control page, trimmed to read-only.
function setStatusPill(idPrefix, statusText) {
const pill = document.getElementById(idPrefix + '-pill');
const label = document.getElementById(idPrefix + '-status');
if (!label || !pill) return;
label.textContent = statusText || 'unknown';
pill.classList.remove('ok','bad');
if (/active|running|online|enabled/i.test(statusText)) pill.classList.add('ok');
else if (/inactive|failed|stopped|error/i.test(statusText)) pill.classList.add('bad');
}
function fetchStatus(serviceName, elementId) {
fetch(`/cgi-bin/control_service_status.cgi?service=${serviceName}`)
.then(r => r.json())
.then(data => {
const statusText = data.status || 'unknown';
const el = document.getElementById(elementId);
if (el) el.textContent = statusText;
setStatusPill(elementId.replace(/-status$/, ''), statusText);
})
.catch(() => {
const el = document.getElementById(elementId);
if (el) el.textContent = 'error';
setStatusPill(elementId.replace(/-status$/, ''), 'error');
});
}
// Initial checks (read-only)
['jellyfin', 'audiobookshelf','apache2','filebrowser'].forEach(svc => {
const el = document.getElementById(`${svc}-status`);
if (el) fetchStatus(svc, `${svc}-status`);
});
</script>
</body>
</html>
@@ -0,0 +1,96 @@
:root { --card-bg:#fff; --muted:#6b7280; }
* { box-sizing:border-box; }
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
margin:0; background:#f3f4f6; color:#111827;
}
/* Header */
header { padding:24px 16px 8px; text-align:center; }
h1 { margin:0 0 6px; font-size: clamp(20px, 3vw, 32px); }
.sub { color: var(--muted); font-size: 14px; }
/* Layout */
.container { max-width: 1200px; margin: 0 auto; padding: 16px; }
.grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:16px; }
/* Card */
.card { min-width: 0; background: var(--card-bg); border-radius:14px; box-shadow: 0 6px 20px rgba(0,0,0,.06); padding:18px; margin-bottom: 20px; }
.card h2 { margin:0 0 12px; font-size:18px; }
/* Flex helpers */
.row { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.spacer { flex:1; }
/* Buttons */
.btn { appearance:none; border:0; border-radius:10px; padding:8px 12px; background:#111827; color:#fff; cursor:pointer; font-weight:600; }
.btn.ghost { background:#e5e7eb; color:#111827; }
.btn.small { padding:6px 10px; font-size:12px; }
/* Pills & chips */
.pill { border-radius:999px; padding:6px 10px; background:#f3f4f6; color:#111827; display:inline-flex; align-items:center; gap:8px; }
.status-dot { width:10px; height:10px; border-radius:50%; background:#9CA3AF; display:inline-block; }
.ok .status-dot { background:#10B981; }
.bad .status-dot { background:#EF4444; }
.chip { font-size:12px; padding:4px 8px; border-radius:999px; background:#eef2ff; color:#3730a3; }
/* Inputs & notices */
.notice { color: var(--muted); font-size:12px; }
input[type="text"], input[type="password"], select {
padding:8px; border:1px solid #e5e7eb; border-radius:8px;
}
/* Utility */
.hidden { display:none; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
/* Optional: color/preview box used on automation page */
.color-box { width:36px; height:24px; border-radius:6px; border:1px solid #e5e7eb; }
/* Segmented control (automation page) */
.seg { display:inline-flex; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden; }
.seg button { background:#fff; color:#111; border:0; padding:6px 10px; }
.seg button.active { background:#111827; color:#fff; }
.top-menu {
background:#111827;
padding:10px 20px;
}
.top-menu ul {
list-style:none;
margin:0;
padding:0;
display:flex;
gap:20px;
}
.top-menu a {
color:white;
text-decoration:none;
font-weight:600;
font-size:14px;
}
.top-menu a:hover {
text-decoration:underline;
}
/* Top menu wrapper */
.top-menu { padding: 8px 0; }
/* Center the buttons and add even spacing */
.top-menu__row { justify-content: center; gap: 8px; }
/* Active page: make the ghost button look “primary” */
.btn.ghost.is-active {
background: #111827;
color: #fff;
outline: 2px solid #111827; /* subtle emphasis */
}
/* Keyboard focus ring (accessible) */
.top-menu a.btn:focus-visible {
outline: 2px solid #111827;
outline-offset: 2px;
}
@@ -0,0 +1,9 @@
<nav class="top-menu" aria-label="Site">
<div class="container">
<div class="row top-menu__row">
<a class="btn ghost small" href="http://control.richardjolley.co.uk/">Control</a>
<a class="btn ghost small" href="http://automation.richardjolley.co.uk/">Automation</a>
<a class="btn ghost small" href="http://services.richardjolley.co.uk/">Services</a>
</div>
</div>
</nav>