Bugfix
All checks were successful
release-tag / release-image (push) Successful in 2m15s
release-main / release-images (push) Successful in 3m42s

This commit is contained in:
2026-08-22 18:40:47 +02:00
parent 3691493742
commit 8a61a17395
3 changed files with 8 additions and 3 deletions

View File

@@ -1,5 +1,10 @@
# Changelog
## 0.3.2
- Fixed Farm member count in the Master WebUI. The table now uses the same membership rules as the broker: explicit `farm.agent_ids`, agent-side `agent.farm_ids`, and `required_tags`.
- Farm member names are available as a tooltip on the member count.
## 0.3.1 - Web UI editor stability
- Fixed Master live refresh replacing Farm, Resource and server-control forms every five seconds.

View File

@@ -283,4 +283,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.1 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.2 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.

View File

@@ -2,7 +2,7 @@ package master
const masterHTML = `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SessionGuard Master</title><style>
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;color-scheme:dark;background:#0b1020;color:#e8edf7}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#0b1020,#111a31);min-height:100vh}.wrap{max-width:1600px;margin:auto;padding:28px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.brand{display:flex;gap:11px;align-items:center}.logo{width:42px;height:42px;border-radius:12px;background:#5b8cff;display:grid;place-items:center;font-weight:900}.muted{color:#95a3be}.grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px}.card,.panel{background:rgba(18,27,49,.88);border:1px solid #263654;border-radius:16px}.card{padding:16px}.value{font-size:24px;font-weight:800;margin-top:5px}.split{display:grid;grid-template-columns:minmax(350px,.9fr) minmax(650px,1.6fr);gap:16px}.panel{margin-top:16px;overflow:hidden}.panel h2{font-size:16px;margin:0;padding:15px 18px;border-bottom:1px solid #263654}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:10px 12px;border-bottom:1px solid #22314e;text-align:left;font-size:13px;vertical-align:top}.table th{color:#94a5c4}.row{cursor:pointer}.row:hover,.row.selected{background:#182642}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#5ee08b;margin-right:7px}.dot.off{background:#65728a}.form{padding:16px;display:grid;gap:13px}.cols{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.two{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}label{display:grid;gap:5px;font-size:13px;color:#c8d4e7}input,textarea,select{width:100%;background:#0d1629;color:#e8edf7;border:1px solid #314463;border-radius:9px;padding:9px}textarea{min-height:88px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.check{display:flex;gap:8px;align-items:center}.check input{width:auto}.check label{display:block}button{background:#5b8cff;color:white;border:0;border-radius:9px;padding:8px 11px;font-weight:650;cursor:pointer}button.secondary{background:#263654}button.danger{background:#743b46}.actions{display:flex;gap:5px;flex-wrap:wrap}.empty{padding:20px;color:#8190aa}.section-title{font-weight:800;padding:12px 16px;border-top:1px solid #263654;border-bottom:1px solid #263654}.content{max-height:320px;overflow:auto}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px}.list{display:grid;gap:10px}.item{border:1px solid #2b3d5c;background:#0f192c;border-radius:12px;padding:12px;display:grid;gap:9px}.item-head{display:flex;justify-content:space-between;align-items:center}.note{padding:10px;border:1px solid #334866;background:#101b31;border-radius:10px;color:#9fb0ca;font-size:12px}.good{color:#6fe39a}.bad{color:#ff8d8d}.warning,.event-warning,.event-dry-run{color:#ffd166}.event-error{color:#ffaaaa}.toast{position:fixed;right:22px;bottom:22px;background:#1f3155;border:1px solid #3d5785;padding:11px 14px;border-radius:10px;display:none;z-index:10}@media(max-width:1100px){.grid{grid-template-columns:repeat(3,1fr)}.split{grid-template-columns:1fr}.cols,.two{grid-template-columns:1fr}}@media(max-width:650px){.grid{grid-template-columns:repeat(2,1fr)}}
</style></head><body><div class="wrap"><div class="top"><div class="brand"><div class="logo">SG</div><div><strong>SessionGuard</strong><div class="muted">Master Console · v0.3.1 · Broker & Director</div></div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div><div class="grid"><div class="card"><div class="muted">Server</div><div class="value" id="mServers"></div></div><div class="card"><div class="muted">Online</div><div class="value" id="mOnline"></div></div><div class="card"><div class="muted">Aktiv</div><div class="value" id="mActive"></div></div><div class="card"><div class="muted">Getrennt</div><div class="value" id="mDisc"></div></div><div class="card"><div class="muted">Profil-Jobs</div><div class="value" id="mProfile"></div></div><div class="card"><div class="muted">Cleanup</div><div class="value" id="mCleanup"></div></div><div class="card"><div class="muted">Farms</div><div class="value" id="mFarms"></div></div><div class="card"><div class="muted">Alerts</div><div class="value" id="mAlerts"></div></div></div><div class="split"><section class="panel"><h2>Terminalserver</h2><div id="agents"></div></section><section class="panel"><h2 id="detailTitle">Server auswählen</h2><div id="detail" class="empty">Links einen Agent auswählen.</div></section></div><section class="panel"><h2>Farms & Broker</h2><div id="farms"></div></section><section class="panel"><h2>Published Resources</h2><div id="resources"></div></section><section class="panel"><h2>Session-Historie</h2><div id="history" class="content"></div></section><section class="panel"><h2>Policy-Historie & Rollback</h2><div id="policyHistory" class="content"></div></section><section class="panel"><h2>Alerts</h2><div id="alerts" class="content"></div></section><section class="panel"><h2>Audit-Log</h2><div id="audit" class="content"></div></section></div><div class="toast" id="toast"></div><script src="/app.js?v=0.3.1"></script></body></html>`
</style></head><body><div class="wrap"><div class="top"><div class="brand"><div class="logo">SG</div><div><strong>SessionGuard</strong><div class="muted">Master Console · v0.3.2 · Broker & Director</div></div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div><div class="grid"><div class="card"><div class="muted">Server</div><div class="value" id="mServers"></div></div><div class="card"><div class="muted">Online</div><div class="value" id="mOnline"></div></div><div class="card"><div class="muted">Aktiv</div><div class="value" id="mActive"></div></div><div class="card"><div class="muted">Getrennt</div><div class="value" id="mDisc"></div></div><div class="card"><div class="muted">Profil-Jobs</div><div class="value" id="mProfile"></div></div><div class="card"><div class="muted">Cleanup</div><div class="value" id="mCleanup"></div></div><div class="card"><div class="muted">Farms</div><div class="value" id="mFarms"></div></div><div class="card"><div class="muted">Alerts</div><div class="value" id="mAlerts"></div></div></div><div class="split"><section class="panel"><h2>Terminalserver</h2><div id="agents"></div></section><section class="panel"><h2 id="detailTitle">Server auswählen</h2><div id="detail" class="empty">Links einen Agent auswählen.</div></section></div><section class="panel"><h2>Farms & Broker</h2><div id="farms"></div></section><section class="panel"><h2>Published Resources</h2><div id="resources"></div></section><section class="panel"><h2>Session-Historie</h2><div id="history" class="content"></div></section><section class="panel"><h2>Policy-Historie & Rollback</h2><div id="policyHistory" class="content"></div></section><section class="panel"><h2>Alerts</h2><div id="alerts" class="content"></div></section><section class="panel"><h2>Audit-Log</h2><div id="audit" class="content"></div></section></div><div class="toast" id="toast"></div><script src="/app.js?v=0.3.2"></script></body></html>`
const masterJS = `
let selected=null,current=null,editorAgent=null,policyTemplates=[],profileFolders=[],farmCache=[],resourceCache=[],brokerLeases=[],policyHistory=[],me=null;const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));function when(v){if(!v)return'';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'':d.toLocaleString('de-DE')}function bytes(n){if(!n)return'';let u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i<u.length-1){n/=1024;i++}return n.toFixed(i>1?1:0)+' '+u[i]}function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',3000)}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)}
@@ -20,7 +20,7 @@ function processesHTML(a){let ps=((a.snapshot||{}).processes||[]).filter(p=>p.se
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?'<table class="table"><thead><tr><th>Session</th><th>Benutzer</th><th>Logon erkannt</th><th>Restore</th><th>Bis Ready</th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+x.session_id+'</td><td>'+esc(x.user||x.sid||'')+'</td><td>'+when(x.logon_at||x.first_seen_at)+'</td><td>'+((x.restore_duration_ms||0)/1000).toFixed(2)+' s</td><td>'+((x.observed_logon_ms||0)/1000).toFixed(2)+' s</td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Logon-Telemetrie.</div>'}
function renderHistory(rows){rows=(rows||[]).slice().reverse();$('history').innerHTML=rows.length?'<table class="table"><thead><tr><th>Zeit</th><th>Server</th><th>Benutzer</th><th>Ereignis</th><th>Client</th><th>Details</th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+when(x.time)+'</td><td>'+esc(x.hostname)+'</td><td>'+esc(x.user)+'</td><td>'+esc(x.event)+'</td><td>'+esc(x.client_name||'')+'</td><td>'+esc(x.details||'')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Session-Historie.</div>'}
function renderAlerts(rows){$('alerts').innerHTML=rows.length?'<table class="table"><thead><tr><th>Status</th><th>Server</th><th>Typ</th><th>Meldung</th><th>Zuletzt</th></tr></thead><tbody>'+rows.map(x=>'<tr><td class="'+(x.active?'bad':'good')+'">'+(x.active?'AKTIV':'gelöst')+'</td><td>'+esc(x.hostname||'')+'</td><td>'+esc(x.type)+'</td><td>'+esc(x.message)+'</td><td>'+when(x.last_seen_at)+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine Alerts.</div>'}
function parseTags(v){let o={};String(v||'').split('\n').map(x=>x.trim()).filter(Boolean).forEach(x=>{let i=x.indexOf('=');if(i>0)o[x.slice(0,i).trim()]=x.slice(i+1).trim()});return o}function renderFarms(){let root=$('farms');if(!root)return;if(!$('farmEditor'))root.innerHTML='<div id="farmEditor" class="form"><div class="cols"><label>Neue Farm<input id="farmName" placeholder="Office"></label><label>Beschreibung<input id="farmDesc"></label><label>Required Tags (key=value)<textarea id="farmTags" placeholder="role=office"></textarea></label></div><div class="actions"><button type="button" data-global-action="create-farm">Farm anlegen</button><button type="button" class="secondary" data-global-action="clear-farm-form">Eingaben leeren</button></div></div><div id="farmData"></div>';let lease=brokerLeases.length?'<h3>Aktive Broker-Leases</h3><table class="table"><thead><tr><th>Benutzer</th><th>Agent</th><th>Farm</th><th>Resource</th><th>Grund</th><th>Bis</th></tr></thead><tbody>'+brokerLeases.map(l=>'<tr><td>'+esc(l.user_key)+'</td><td>'+esc(l.agent_id)+'</td><td>'+esc(l.farm_id||'')+'</td><td>'+esc(l.resource_id||'')+'</td><td>'+esc(l.reason||'')+'</td><td>'+when(l.expires_at)+'</td></tr>').join('')+'</tbody></table>':'';$('farmData').innerHTML=(farmCache.length?'<table class="table"><thead><tr><th>Name</th><th>ID</th><th>Agenten</th><th>Required Tags</th><th></th></tr></thead><tbody>'+farmCache.map(f=>'<tr><td>'+esc(f.name)+'</td><td>'+esc(f.id)+'</td><td>'+esc((f.agent_ids||[]).length)+'</td><td>'+esc(Object.entries(f.required_tags||{}).map(([k,v])=>k+'='+v).join(', '))+'</td><td><button class="danger" data-global-action="delete-farm" data-id="'+esc(f.id)+'">Löschen</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Farms.</div>')+lease}
function parseTags(v){let o={};String(v||'').split('\n').map(x=>x.trim()).filter(Boolean).forEach(x=>{let i=x.indexOf('=');if(i>0)o[x.slice(0,i).trim()]=x.slice(i+1).trim()});return o}function agentMatchesFarm(a,f){if(!a||!f)return false;if((f.agent_ids||[]).includes(a.id)||(a.farm_ids||[]).includes(f.id))return true;let required=Object.entries(f.required_tags||{});return required.length>0&&required.every(([k,v])=>String((a.tags||{})[k]??'')===String(v))}function farmMembers(f){return agentCache.filter(a=>agentMatchesFarm(a,f))}function renderFarms(){let root=$('farms');if(!root)return;if(!$('farmEditor'))root.innerHTML='<div id="farmEditor" class="form"><div class="cols"><label>Neue Farm<input id="farmName" placeholder="Office"></label><label>Beschreibung<input id="farmDesc"></label><label>Required Tags (key=value)<textarea id="farmTags" placeholder="role=office"></textarea></label></div><div class="actions"><button type="button" data-global-action="create-farm">Farm anlegen</button><button type="button" class="secondary" data-global-action="clear-farm-form">Eingaben leeren</button></div></div><div id="farmData"></div>';let lease=brokerLeases.length?'<h3>Aktive Broker-Leases</h3><table class="table"><thead><tr><th>Benutzer</th><th>Agent</th><th>Farm</th><th>Resource</th><th>Grund</th><th>Bis</th></tr></thead><tbody>'+brokerLeases.map(l=>'<tr><td>'+esc(l.user_key)+'</td><td>'+esc(l.agent_id)+'</td><td>'+esc(l.farm_id||'')+'</td><td>'+esc(l.resource_id||'')+'</td><td>'+esc(l.reason||'')+'</td><td>'+when(l.expires_at)+'</td></tr>').join('')+'</tbody></table>':'';$('farmData').innerHTML=(farmCache.length?'<table class="table"><thead><tr><th>Name</th><th>ID</th><th>Agenten</th><th>Required Tags</th><th></th></tr></thead><tbody>'+farmCache.map(f=>'<tr><td>'+esc(f.name)+'</td><td>'+esc(f.id)+'</td><td title="'+esc(farmMembers(f).map(a=>a.name||a.id).join(', '))+'">'+farmMembers(f).length+'</td><td>'+esc(Object.entries(f.required_tags||{}).map(([k,v])=>k+'='+v).join(', '))+'</td><td><button class="danger" data-global-action="delete-farm" data-id="'+esc(f.id)+'">Löschen</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Farms.</div>')+lease}
function renderResources(){let root=$('resources');if(!root)return;if(!$('resourceEditor'))root.innerHTML='<div id="resourceEditor" class="form"><div class="cols"><label>Name<input id="resName" placeholder="Office Desktop"></label><label>Typ<select id="resKind"><option value="desktop">Desktop</option><option value="remoteapp">RemoteApp</option></select></label><label>Farm<select id="resFarm"></select></label><label>Guacamole Connection ID<input id="resConnID"></label><label>Guacamole Connection Name<input id="resConnName"></label><label>RemoteApp<input id="resRemoteApp" placeholder="||Sage"></label><label>RemoteApp Working Dir<input id="resRemoteDir"></label><label>RemoteApp Argumente<input id="resRemoteArgs"></label></div><div class="actions"><button type="button" data-global-action="create-resource">Resource anlegen</button><button type="button" class="secondary" data-global-action="clear-resource-form">Eingaben leeren</button></div></div><div id="resourceData"></div>';let sel=$('resFarm'),cur=sel?sel.value:'';if(sel&&document.activeElement!==sel){sel.innerHTML=farmCache.map(f=>'<option value="'+esc(f.id)+'">'+esc(f.name)+'</option>').join('');if(cur&&farmCache.some(f=>f.id===cur))sel.value=cur}$('resourceData').innerHTML=resourceCache.length?'<table class="table"><thead><tr><th>Name</th><th>Typ</th><th>Farm</th><th>Guacamole</th><th>RemoteApp</th><th></th></tr></thead><tbody>'+resourceCache.map(x=>'<tr><td>'+esc(x.name)+'</td><td>'+esc(x.kind)+'</td><td>'+esc(x.farm_id)+'</td><td>'+esc(x.guacamole_connection_id||x.guacamole_connection_name||'')+'</td><td>'+esc(x.remote_app||'')+'</td><td><button class="danger" data-global-action="delete-resource" data-id="'+esc(x.id)+'">Löschen</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Resources.</div>'}
function eventsHTML(a){let e=((a.snapshot||{}).events||[]).slice().reverse().slice(0,160);return e.length?'<table class="table"><thead><tr><th>Zeit</th><th>Typ</th><th>Benutzer</th><th>Meldung</th></tr></thead><tbody>'+e.map(x=>'<tr class="event-'+esc(x.level)+'"><td>'+when(x.time)+'</td><td>'+esc(x.level)+'</td><td>'+esc(x.user||'')+'</td><td>'+esc(x.message)+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine Ereignisse.</div>'}
function activePolicy(a){if(a.desired_policy&&a.desired_policy.revision)return a.desired_policy;if(a.snapshot&&a.snapshot.policy)return a.snapshot.policy;return{cleanup:{grace_seconds:600,poll_seconds:10,retry_seconds:60,dry_run:true,allowed_profile_roots:['C:\\Users']},profiles:{retry_seconds:60,keep_versions:2,folders:[]},sessions:{disconnected_timeout_seconds:3600},templates:[]}}