diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4b4241..b6962f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 0.3.4 — Modern Web UI
+
+- Master- und Agent-WebUI vollständig modernisiert, weiterhin ohne Framework oder Build-Schritt.
+- Responsive Sidebar-Navigation mit Mobile-Menü und Scroll-Tracking.
+- Dark-/Light-Theme mit lokaler Browser-Präferenz.
+- Neue Dashboard-Karten, Panel-Hierarchie, Status-Badges, moderne Formulare/Switches und Tabellen.
+- Live-Bereiche und Editoren bleiben weiterhin getrennt; automatische Refreshes überschreiben keine Eingaben.
+- Verbesserte Darstellung für Farms, Broker, Published Resources, Director, Alerts, Audit und lokale Agent-Ansichten.
+- Keine neue Runtime-Abhängigkeit: HTML/CSS/Vanilla-JavaScript bleiben direkt in den Go-Binaries eingebettet.
+
## 0.3.3
- Fix: Master-WebUI definiert und aktualisiert `agentCache`, bevor die Farm-Mitglieder berechnet werden.
diff --git a/README.md b/README.md
index 37be3a2..11e0c8b 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,17 @@
SessionGuard is a Go-based **RDS control plane** for Windows Remote Desktop Session Hosts. It is designed to complement Apache Guacamole: Guacamole remains the HTML5/RDP gateway, while SessionGuard provides Citrix-like broker, Director, policy, profile-lifecycle and operations functions.
-**Current development version: 0.3.3 (Broker & Director production candidate)**
+**Current development version: 0.3.4 (Broker & Director production candidate)**
> SessionGuard is not an ICA/HDX implementation and does not replace the Windows RDS runtime. It deliberately reuses standard RDP/WTS, Guacamole and PocketID/OIDC.
+## 0.3.4 Modern Web UI
+
+- Master and Agent consoles use a responsive enterprise-style sidebar and dashboard layout.
+- Dark/light theme switching is implemented with plain CSS and Vanilla JavaScript.
+- Modern status badges, cards, forms, switches and tables without Node.js, npm, Vue, React or a frontend build step.
+- The existing refresh-safe editor behavior remains intact: live telemetry can update without replacing forms currently being edited.
+
## 0.3.3 Farm UI regression fix
The master UI now maintains an explicit `agentCache` from the dashboard response before rendering farm membership. This fixes `ReferenceError: agentCache is not defined` introduced by the v0.3.2 farm-count display correction.
@@ -288,4 +295,4 @@ The current source tree contains unit tests for configuration, templates, profil
## Production-candidate status
-The design intentionally fails closed around destructive profile operations and broker farm boundaries. Nevertheless, v0.3.3 should be introduced as a canary before broad production rollout. In particular, validate native WTS behavior, SMB failure/recovery, PostgreSQL backup/restore, Guacamole extension loading and your exact PocketID group claims in your environment.
+The design intentionally fails closed around destructive profile operations and broker farm boundaries. Nevertheless, v0.3.4 should be introduced as a canary before broad production rollout. In particular, validate native WTS behavior, SMB failure/recovery, PostgreSQL backup/restore, Guacamole extension loading and your exact PocketID group claims in your environment.
diff --git a/internal/agent/ui.go b/internal/agent/ui.go
index 8b80cce..1c677f7 100644
--- a/internal/agent/ui.go
+++ b/internal/agent/ui.go
@@ -1,18 +1,70 @@
package agent
-const agentHTML = `
SessionGuard Agent
SessionGuard Agent
Lokaler Terminalserver
Aktiv
–
Getrennt
–
Sitzungen
–
Profil-Jobs
–
Cleanup
–
Master
–
Sitzungen
Profil-Pipeline
Aktivitäts- & Audit-Log
Lokale Policy
`
+const agentHTML = `SessionGuard Agent
+
+
Lokaler Agent
Lokaler Terminalserver
Live · 5s
+
Local Control
Terminalserver-Status
Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.
Aktiv▶
–
GetrenntⅡ
–
Sitzungen◎
–
Profil-Jobs↕
–
Cleanup⌫
–
Master⇄
–
+
RDS
Sitzungen
Aktive und getrennte Benutzer sowie administrative Aktionen.
+
Profile
Profil-Pipeline
Backup, Restore, Retry und Cleanup-Status pro Benutzer.
+
Telemetry
Aktivitäts- & Audit-Log
Dry-Run, Profilaktionen, Fehler und lokale Ereignisse.
+
Configuration
Lokale Policy
Fallback-Konfiguration für Profile, Sessions, Cleanup und Templates.
+
`
const agentJS = `
let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false;
const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+
+function initShell(){
+ let saved='';try{saved=localStorage.getItem('sessionguard-theme')||''}catch(e){}
+ let theme=saved||((window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches)?'light':'dark');
+ function applyTheme(t){document.documentElement.dataset.theme=t;try{localStorage.setItem('sessionguard-theme',t)}catch(e){};let label=t==='light'?'☾ Dunkles Theme':'☀ Helles Theme';let b=$('themeToggle');if(b)b.textContent=label;let bt=$('themeTop');if(bt)bt.textContent=t==='light'?'☾':'☀'}
+ function toggleTheme(){theme=document.documentElement.dataset.theme==='light'?'dark':'light';applyTheme(theme)}
+ applyTheme(theme);let b=$('themeToggle'),bt=$('themeTop');if(b)b.onclick=toggleTheme;if(bt)bt.onclick=toggleTheme;
+ let mt=$('menuToggle'),ov=$('mobileOverlay');if(mt)mt.onclick=()=>document.body.classList.toggle('nav-open');if(ov)ov.onclick=()=>document.body.classList.remove('nav-open');
+ document.querySelectorAll('.nav-link').forEach(a=>a.addEventListener('click',()=>{document.body.classList.remove('nav-open');document.querySelectorAll('.nav-link').forEach(x=>x.classList.remove('active'));a.classList.add('active')}));
+ if('IntersectionObserver' in window){let links=[...document.querySelectorAll('.nav-link[href^="#"]')],map=new Map(links.map(a=>[a.getAttribute('href').slice(1),a]));let io=new IntersectionObserver(entries=>{let best=entries.filter(e=>e.isIntersecting).sort((a,b)=>b.intersectionRatio-a.intersectionRatio)[0];if(!best)return;let a=map.get(best.target.id);if(a){links.forEach(x=>x.classList.remove('active'));a.classList.add('active')}},{rootMargin:'-18% 0px -65% 0px',threshold:[0,.15,.4]});map.forEach((_,id)=>{let el=document.getElementById(id);if(el)io.observe(el)})}
+}
async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}function when(v){if(!v)return '–';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'–':d.toLocaleString('de-DE')}function dur(v){if(!v)return '–';let s=Math.max(0,Math.floor((Date.now()-new Date(v).getTime())/1000));return Math.floor(s/3600)+'h '+Math.floor(s%3600/60)+'m'}
function renderEvents(events){let rows=(events||[]).slice().reverse().slice(0,200);$('events').innerHTML=rows.length?'
Zeit
Typ
Benutzer
Meldung
'+rows.map(x=>'
'+esc(when(x.time))+'
'+esc(x.level||'info')+'
'+esc(x.user||'–')+'
'+esc(x.message||'')+'
').join('')+'
':'
Noch keine Ereignisse.
'}
function renderProfiles(s){let jobs=s.profile_jobs||[],status=Object.values(s.profile_status||{});let html='';if(jobs.length)html+='
'}
async function sessionAction(id,action){let body={action};if(action==='message'){let m=prompt('Nachricht an die Sitzung:');if(!m)return;body.message=m;body.title='SessionGuard'}if(action==='logoff'&&!confirm('Sitzung '+id+' wirklich abmelden? Die Profil-Pipeline startet nach dem Sitzungsende.'))return;try{await api('/api/v1/sessions/'+id+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});setTimeout(refresh,400)}catch(e){alert(e.message)}}
function renderSessions(s){let p=s.policy||{},control=p.sessions&&p.sessions.control_enabled,ss=s.sessions||[];$('sessions').innerHTML='
'}
-async function refresh(){try{let d=await api('/api/v1/status'),s=d.snapshot||{};lastSnapshot=s;let ss=s.sessions||[];$('host').textContent=(s.server&&s.server.hostname)||'Lokaler Terminalserver';$('active').textContent=ss.filter(x=>x.state==='Active').length;$('disc').textContent=ss.filter(x=>x.state==='Disconnected').length;$('total').textContent=ss.filter(x=>x.user).length;$('pending').textContent=(s.pending_cleanup||[]).length;$('profileJobs').textContent=(s.profile_jobs||[]).length;$('master').innerHTML=d.master_error?'Offline':'Verbunden';renderSessions(s);renderProfiles(s);renderEvents(s.events||[])}catch(e){$('master').innerHTML=''+esc(e.message)+''}}
+async function refresh(){try{let d=await api('/api/v1/status'),s=d.snapshot||{};lastSnapshot=s;let ss=s.sessions||[];$('host').textContent=(s.server&&s.server.hostname)||'Lokaler Terminalserver';$('active').textContent=ss.filter(x=>x.state==='Active').length;$('disc').textContent=ss.filter(x=>x.state==='Disconnected').length;$('total').textContent=ss.filter(x=>x.user).length;$('pending').textContent=(s.pending_cleanup||[]).length;$('profileJobs').textContent=(s.profile_jobs||[]).length;$('master').innerHTML=d.master_error?'Offline':'Verbunden';renderSessions(s);renderProfiles(s);renderEvents(s.events||[])}catch(e){$('master').innerHTML=''+esc(e.message)+''}}
function templateDefault(){return{id:'neues-template',kind:'file',target:'Desktop\\Beispiel.txt',source:'',content:'',content_base64:'',url:'',shortcut:{target:'',arguments:'',working_directory:'',icon_location:'',description:''},overwrite:true}}function templateSpecific(t){let k=(t.kind||'file').toLowerCase();if(k==='directory')return'
Keine weiteren Angaben.
';if(k==='url')return'';if(k==='shortcut'){let s=t.shortcut||{};return''}return''}
function renderTemplates(){let h=$('templateList');if(!h)return;h.innerHTML=policyTemplates.length?policyTemplates.map((t,i)=>'
'+esc(t.id||('Template '+(i+1)))+'
'+templateSpecific(t)+'
').join(''):'
Keine Templates konfiguriert.
'}
function collectTemplates(){return[...document.querySelectorAll('.template-card')].map(c=>{let g=n=>{let e=c.querySelector('[data-field="'+n+'"]');return e?e.value:''},k=g('kind')||'file',t={id:g('id').trim(),kind:k,target:g('target').trim(),overwrite:!!c.querySelector('[data-field="overwrite"]:checked')};if(k==='file'){t.source=g('source').trim();t.content=g('content');t.content_base64=g('content_base64').trim()}else if(k==='url')t.url=g('url').trim();else if(k==='shortcut')t.shortcut={target:g('shortcut.target').trim(),arguments:g('shortcut.arguments'),working_directory:g('shortcut.working_directory').trim(),icon_location:g('shortcut.icon_location').trim(),description:g('shortcut.description')};return t})}
@@ -22,4 +74,4 @@ function policyHTML(p){let c=p.cleanup||{},pr=p.profiles||{},sp=p.sessions||{};r
function updatePolicyDirty(){let e=$('policyDirtyState');if(e)e.textContent=policyDirty?'Ungespeicherte Änderungen':''}function markPolicyDirty(){policyDirty=true;updatePolicyDirty()}async function reloadPolicy(){if(policyDirty&&!confirm('Ungespeicherte Änderungen verwerfen und Policy neu laden?'))return;policyDirty=false;await loadPolicy(true)}
async function loadPolicy(force){if(policyDirty&&!force)return;try{let p=await api('/api/v1/policy');policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('policy').innerHTML=policyHTML(p);renderTemplates();renderProfileFolders();policyDirty=false;policyLoaded=true;updatePolicyDirty()}catch(e){if(!policyLoaded)$('policy').textContent=e.message}}
async function savePolicy(){try{policyTemplates=collectTemplates();profileFolders=collectProfileFolders();let p={profiles:{enabled:$('profEnabled').checked,store_root:$('storeRoot').value.trim(),backup_on_logoff:$('backupOnLogoff').checked,restore_on_logon:$('restoreOnLogon').checked,backup_delay_seconds:+$('backupDelay').value,retry_seconds:+$('profRetry').value,restore_window_seconds:+$('restoreWindow').value,keep_versions:+$('keepVersions').value,exclude_users:lines('profUsers'),exclude_sids:lines('profSids'),folders:profileFolders},sessions:{control_enabled:$('controlEnabled').checked,disconnected_logoff_enabled:$('autoLogoff').checked,disconnected_timeout_seconds:+$('discTimeout').value,exclude_users:lines('sessUsers'),exclude_sids:lines('sessSids')},cleanup:{enabled:$('enabled').checked,grace_seconds:+$('grace').value,poll_seconds:+$('poll').value,retry_seconds:+$('retry').value,dry_run:$('dry').checked,exclude_users:lines('users'),exclude_sids:lines('sids'),allowed_profile_roots:lines('roots')},templates:policyTemplates};await api('/api/v1/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});policyDirty=false;await loadPolicy(true);refresh()}catch(e){alert(e.message)}}
-$('sessions').addEventListener('click',e=>{let b=e.target.closest('button[data-session-action]');if(b)sessionAction(+b.dataset.session,b.dataset.sessionAction)});$('policy').addEventListener('click',e=>{let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-policy')savePolicy();if(a==='reload-policy')reloadPolicy();if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates();markPolicyDirty()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates();markPolicyDirty()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders();markPolicyDirty()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders();markPolicyDirty()}});$('policy').addEventListener('input',e=>{if(e.target.matches('input,textarea,select'))markPolicyDirty()});$('policy').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates();markPolicyDirty()}});refresh();loadPolicy(true);setInterval(refresh,5000);`
+$('sessions').addEventListener('click',e=>{let b=e.target.closest('button[data-session-action]');if(b)sessionAction(+b.dataset.session,b.dataset.sessionAction)});$('policy').addEventListener('click',e=>{let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-policy')savePolicy();if(a==='reload-policy')reloadPolicy();if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates();markPolicyDirty()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates();markPolicyDirty()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders();markPolicyDirty()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders();markPolicyDirty()}});$('policy').addEventListener('input',e=>{if(e.target.matches('input,textarea,select'))markPolicyDirty()});$('policy').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates();markPolicyDirty()}});initShell();refresh();loadPolicy(true);setInterval(refresh,5000);`
diff --git a/internal/master/ui.go b/internal/master/ui.go
index ba3422d..e32f24e 100644
--- a/internal/master/ui.go
+++ b/internal/master/ui.go
@@ -1,12 +1,72 @@
package master
-const masterHTML = `SessionGuard Master
SG
SessionGuard
Master Console · v0.3.3 · Broker & Director
Server
–
Online
–
Aktiv
–
Getrennt
–
Profil-Jobs
–
Cleanup
–
Farms
–
Alerts
–
Terminalserver
Server auswählen
Links einen Agent auswählen.
Farms & Broker
Published Resources
Session-Historie
Policy-Historie & Rollback
Alerts
Audit-Log
`
+const masterHTML = `SessionGuard Master
+
+
+
Master Console
Broker, Director, Profile & Policy Management
Live · 5s
+
Control Plane
Infrastruktur auf einen Blick
Live-Status der RDS-Farm, Profile und Broker-Ressourcen.
+
Server▣
–
Online●
–
Aktiv▶
–
GetrenntⅡ
–
Profil-Jobs↕
–
Cleanup⌫
–
Farms⌘
–
Alerts!
–
+
Director
Terminalserver
Server auswählen, Sitzungen steuern und Health-Details prüfen.
Serverliste
Online, Sessions, Health und Jobs
Server auswählen
Live-Details und Konfiguration
Links einen Agent auswählen.
+
Broker
Farms & Broker
Hosts gruppieren, Tags definieren und aktive Leases nachvollziehen.
+
Delivery
Published Resources
Desktops und RemoteApps mit Guacamole-Verbindungen und Farms verknüpfen.
+
Historie
Session-Historie
Logon, Reconnect, Disconnect und Profilereignisse nachvollziehen.
+
Governance
Policy-Historie & Rollback
Revisionen vergleichen und frühere Sollstände wieder aktivieren.
+
Monitoring
Alerts
Aktive und gelöste Betriebswarnungen.
+
Compliance
Audit-Log
Administrative Änderungen und Aktionen revisionsfreundlich nachvollziehen.
'}}catch(e){toast(e.message)}}
async function selectAgent(id,quiet,preserve){let same=selected===id;selected=id;try{current=await api('/api/v1/agents/'+encodeURIComponent(id));renderDetail(current,!!preserve&&same)}catch(e){if(!quiet)toast(e.message)}}
function renderPolicyHistory(){let rows=(policyHistory||[]).slice().reverse().slice(0,250);$('policyHistory').innerHTML=rows.length?'
Zeit
Ziel
Revision
Akteur
'+rows.map(x=>'
'+when(x.created_at)+'
'+esc(x.target)+'
'+esc(x.revision)+'
'+esc(x.actor||'–')+'
').join('')+'
':'
Noch keine Policy-Versionen.
'}
@@ -19,7 +79,7 @@ function profileHTML(a){let s=a.snapshot||{},jobs=s.profile_jobs||[],st=Object.v
function processesHTML(a){let ps=((a.snapshot||{}).processes||[]).filter(p=>p.session_id!==0).sort((x,y)=>y.memory_bytes-x.memory_bytes).slice(0,300);return ps.length?'
PID
Session
Prozess
RAM
'+ps.map(p=>'
'+p.pid+'
'+p.session_id+'
'+esc(p.name)+'
'+bytes(p.memory_bytes)+'
').join('')+'
':'
Keine Session-Prozesse erfasst.
'}
function telemetryHTML(a){let rows=Object.values(((a.snapshot||{}).telemetry)||{}).sort((x,y)=>new Date(y.first_seen_at)-new Date(x.first_seen_at)).slice(0,100);return rows.length?'
Session
Benutzer
Logon erkannt
Restore
Bis Ready
'+rows.map(x=>'
'+x.session_id+'
'+esc(x.user||x.sid||'–')+'
'+when(x.logon_at||x.first_seen_at)+'
'+((x.restore_duration_ms||0)/1000).toFixed(2)+' s
'+((x.observed_logon_ms||0)/1000).toFixed(2)+' s
').join('')+'
':'
Noch keine Logon-Telemetrie.
'}
function renderHistory(rows){rows=(rows||[]).slice().reverse();$('history').innerHTML=rows.length?'