Update
Some checks failed
release-tag / release-image (push) Failing after 1m32s
release-main / release-images (push) Failing after 1m32s

This commit is contained in:
2026-08-22 16:25:16 +02:00
parent 7972ed7e38
commit c2ffc72451
12 changed files with 2351 additions and 25 deletions

View File

@@ -1,5 +1,16 @@
# Changelog
## 0.3.1 - Web UI editor stability
- Fixed Master live refresh replacing Farm, Resource and server-control forms every five seconds.
- Split live server metrics from editable server-control fields.
- Farm and Resource editors are mounted once; only their data tables and broker leases refresh.
- Added explicit reset/reload actions instead of implicit form replacement.
- Added Agent policy dirty-state protection and visible "Ungespeicherte Änderungen" state.
- Added explicit Agent policy reload with confirmation before discarding unsaved edits.
- Added `Cache-Control: no-store, max-age=0` for the Web UI HTML and JavaScript.
- Added browser-level regression verification covering a full live-refresh cycle.
## 0.3.0 - Broker & Director production candidate
### Broker and farm control

View File

@@ -1,11 +1,11 @@
FROM golang:1.26-bookworm AS build
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/sessionguard-master ./cmd/master
FROM alpine:3.24
FROM alpine:3.20
RUN apk add --no-cache ca-certificates && addgroup -S sessionguard && adduser -S -G sessionguard sessionguard && mkdir -p /var/lib/sessionguard /etc/sessionguard && chown -R sessionguard:sessionguard /var/lib/sessionguard /etc/sessionguard
COPY --from=build /out/sessionguard-master /usr/local/bin/sessionguard-master
USER sessionguard

View File

@@ -2,10 +2,18 @@
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.0 (Broker & Director production candidate)**
**Current development version: 0.3.1 (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.1 UI stability fix
- Live dashboard refresh no longer recreates editable Master forms.
- Server live metrics and server-control inputs are rendered independently.
- Farm and Published Resource editors remain mounted while their live tables refresh.
- Agent policy edits are protected by a dirty-state guard and an explicit reload action.
- HTML and `app.js` are served with `Cache-Control: no-store` to prevent stale UI code after upgrades.
## 0.3.0 at a glance
### Smart broker and farms
@@ -275,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.0 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.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.

View File

@@ -48,9 +48,9 @@ Traefik labels unchanged.
git describe --tags --always | sed 's/^v//'
```
A commit tagged `v0.3.0` therefore publishes `0.3.0`; later commits are named
like `0.3.0-1-g0123456` until the next tag.
A commit tagged `v0.3.1` therefore publishes `0.3.1`; later commits are named
like `0.3.1-1-g0123456` until the next tag.
The extension Dockerfile no longer hardcodes `sessionguard-guacamole-0.3.0.jar`.
The extension Dockerfile no longer hardcodes `sessionguard-guacamole-0.3.1.jar`.
Maven may therefore change the project version without requiring a Dockerfile
change.

View File

@@ -2,7 +2,7 @@
# Build context must point at the SessionGuard repository root.
services:
guacamole:
image: sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.3.0}
image: sessionguard-guacamole:${SESSIONGUARD_VERSION:-0.3.1}
build:
context: ../..
dockerfile: deploy/guacamole/Dockerfile.guacamole

2301
docs/INSTALLATION.md Normal file

File diff suppressed because it is too large Load Diff

2
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/example/sessionguard
go 1.23.0
go 1.26.0
require (
github.com/coreos/go-oidc/v3 v3.14.1

View File

@@ -5,7 +5,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>info.hilden.sessionguard</groupId>
<artifactId>sessionguard-guacamole</artifactId>
<version>0.3.0</version>
<version>0.3.1</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>11</maven.compiler.release>

View File

@@ -25,7 +25,7 @@ import (
"github.com/example/sessionguard/internal/windowsx"
)
const Version = "0.3.0"
const Version = "0.3.1"
type App struct {
cfg config.Agent
@@ -981,6 +981,7 @@ func (a *App) serveHTTP(ctx context.Context) error {
}
mux.HandleFunc("GET /app.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, max-age=0")
_, _ = fmt.Fprint(w, agentJS)
})
mux.Handle("GET /", secure(http.HandlerFunc(a.agentPage)))
@@ -1104,5 +1105,6 @@ func securityHeaders(next http.Handler) http.Handler {
}
func (a *App) agentPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, max-age=0")
_, _ = fmt.Fprint(w, agentHTML)
}

View File

@@ -2,10 +2,10 @@ package agent
const agentHTML = `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SessionGuard Agent</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:1450px;margin:auto;padding:28px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.brand{font-size:20px;font-weight:800}.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}.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}.good{color:#6fe39a}.bad{color:#ff8d8d}.warning{color:#ffd166}.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:90px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.check{display:flex;align-items:center;gap:8px}.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}.log{max-height:360px;overflow:auto}.event-error{color:#ffaaaa}.event-warning{color:#ffd166}.event-dry-run{color:#ffd166}.empty{padding:18px;color:#8190aa}.section{border-top:1px solid #2a3d5d;padding-top:14px;margin-top:4px}.section-title{font-weight:800;margin-bottom:10px}.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}@media(max-width:1050px){.grid{grid-template-columns:repeat(3,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><div class="brand">SessionGuard Agent</div><div class="muted" id="host">Lokaler Terminalserver</div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div><div class="grid"><div class="card"><div class="muted">Aktiv</div><div class="value" id="active"></div></div><div class="card"><div class="muted">Getrennt</div><div class="value" id="disc"></div></div><div class="card"><div class="muted">Sitzungen</div><div class="value" id="total"></div></div><div class="card"><div class="muted">Profil-Jobs</div><div class="value" id="profileJobs"></div></div><div class="card"><div class="muted">Cleanup</div><div class="value" id="pending"></div></div><div class="card"><div class="muted">Master</div><div class="value" style="font-size:15px" id="master"></div></div></div><section class="panel"><h2>Sitzungen</h2><div id="sessions"></div></section><section class="panel"><h2>Profil-Pipeline</h2><div id="profiles"></div></section><section class="panel"><h2>Aktivitäts- &amp; Audit-Log</h2><div id="events"></div></section><section class="panel"><h2>Lokale Policy</h2><div id="policy"></div></section></div><script src="/app.js"></script></body></html>`
</style></head><body><div class="wrap"><div class="top"><div><div class="brand">SessionGuard Agent</div><div class="muted" id="host">Lokaler Terminalserver</div></div><form action="/logout" method="post"><button class="secondary">Abmelden</button></form></div><div class="grid"><div class="card"><div class="muted">Aktiv</div><div class="value" id="active"></div></div><div class="card"><div class="muted">Getrennt</div><div class="value" id="disc"></div></div><div class="card"><div class="muted">Sitzungen</div><div class="value" id="total"></div></div><div class="card"><div class="muted">Profil-Jobs</div><div class="value" id="profileJobs"></div></div><div class="card"><div class="muted">Cleanup</div><div class="value" id="pending"></div></div><div class="card"><div class="muted">Master</div><div class="value" style="font-size:15px" id="master"></div></div></div><section class="panel"><h2>Sitzungen</h2><div id="sessions"></div></section><section class="panel"><h2>Profil-Pipeline</h2><div id="profiles"></div></section><section class="panel"><h2>Aktivitäts- &amp; Audit-Log</h2><div id="events"></div></section><section class="panel"><h2>Lokale Policy</h2><div id="policy"></div></section></div><script src="/app.js?v=0.3.1"></script></body></html>`
const agentJS = `
let policyTemplates=[],profileFolders=[],lastSnapshot=null;
let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false;
const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
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?'<div class="log"><table class="table"><thead><tr><th>Zeit</th><th>Typ</th><th>Benutzer</th><th>Meldung</th></tr></thead><tbody>'+rows.map(x=>'<tr class="event-'+esc(x.level||'info')+'"><td>'+esc(when(x.time))+'</td><td>'+esc(x.level||'info')+'</td><td>'+esc(x.user||'')+'</td><td>'+esc(x.message||'')+'</td></tr>').join('')+'</tbody></table></div>':'<div class="empty">Noch keine Ereignisse.</div>'}
@@ -18,7 +18,8 @@ function renderTemplates(){let h=$('templateList');if(!h)return;h.innerHTML=poli
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})}
function renderProfileFolders(){let h=$('profileFolderList');if(!h)return;h.innerHTML=profileFolders.length?profileFolders.map((f,i)=>'<div class="item profile-folder"><div class="item-head"><strong>'+esc(f.path||('Ordner '+(i+1)))+'</strong><button type="button" class="danger" data-action="remove-profile-folder" data-index="'+i+'">Löschen</button></div><div class="two"><label>Pfad relativ zum Profil<input data-field="path" value="'+esc(f.path||'')+'" placeholder="AppData\\Roaming\\Hersteller"></label><label>Ausschlüsse (eine Zeile; * oder Pfad/**)<textarea data-field="exclude">'+esc((f.exclude_globs||[]).join('\n'))+'</textarea></label></div></div>').join(''):'<div class="empty">Keine Profilordner konfiguriert.</div>'}
function collectProfileFolders(){return[...document.querySelectorAll('.profile-folder')].map(c=>({path:c.querySelector('[data-field="path"]').value.trim(),exclude_globs:c.querySelector('[data-field="exclude"]').value.split('\n').map(x=>x.trim()).filter(Boolean)})).filter(x=>x.path)}
function policyHTML(p){let c=p.cleanup||{},pr=p.profiles||{},sp=p.sessions||{};return'<div class="form"><div class="section-title">Profil-Sicherung & Wiederherstellung</div><div class="check"><input id="profEnabled" type="checkbox" '+(pr.enabled?'checked':'')+'><label>Profil-Synchronisation aktiv</label></div><div class="two"><label>Profil-Store (UNC oder lokaler Pfad)<input id="storeRoot" value="'+esc(pr.store_root||'')+'" placeholder="\\\\fileserver\\SessionGuardProfiles"></label><label>Backup-Verzögerung (s)<input id="backupDelay" type="number" min="1" value="'+(pr.backup_delay_seconds||5)+'"></label><label>Retry (s)<input id="profRetry" type="number" min="1" value="'+(pr.retry_seconds||60)+'"></label><label>Restore-Fenster (s)<input id="restoreWindow" type="number" min="10" value="'+(pr.restore_window_seconds||120)+'"></label><label>Historische Versionen<input id="keepVersions" type="number" min="0" max="50" value="'+(pr.keep_versions??2)+'"></label></div><div class="check"><input id="backupOnLogoff" type="checkbox" '+(pr.backup_on_logoff?'checked':'')+'><label>Nach Logoff sichern</label></div><div class="check"><input id="restoreOnLogon" type="checkbox" '+(pr.restore_on_logon?'checked':'')+'><label>Bei Logon wiederherstellen</label></div><label>Von Profil-Sync ausgeschlossene Benutzer<textarea id="profUsers">'+esc((pr.exclude_users||[]).join('\n'))+'</textarea></label><label>Von Profil-Sync ausgeschlossene SIDs<textarea id="profSids">'+esc((pr.exclude_sids||[]).join('\n'))+'</textarea></label><div class="toolbar"><strong>Zu sichernde Profilordner</strong><button type="button" class="secondary" data-action="add-profile-folder">Ordner hinzufügen</button></div><div id="profileFolderList" class="list"></div><div class="note">Gesichert werden nur konfigurierte Teilbäume. Reparse-Points/Symlinks werden nicht verfolgt. Cleanup beginnt nach einem Logoff erst nach erfolgreichem Backup.</div><div class="section"><div class="section-title">Sitzungsrichtlinie</div><div class="check"><input id="controlEnabled" type="checkbox" '+(sp.control_enabled?'checked':'')+'><label>Administrative Sitzungssteuerung erlauben</label></div><div class="check"><input id="autoLogoff" type="checkbox" '+(sp.disconnected_logoff_enabled?'checked':'')+'><label>Getrennte Sitzungen automatisch abmelden</label></div><label>Timeout für getrennte Sitzung (s; min. 60)<input id="discTimeout" type="number" min="60" value="'+(sp.disconnected_timeout_seconds||3600)+'"></label><label>Vom Auto-Logoff ausgeschlossene Benutzer<textarea id="sessUsers">'+esc((sp.exclude_users||[]).join('\n'))+'</textarea></label><label>Vom Auto-Logoff ausgeschlossene SIDs<textarea id="sessSids">'+esc((sp.exclude_sids||[]).join('\n'))+'</textarea></label></div><div class="section"><div class="section-title">Profil-Cleanup</div><div class="cols"><label>Grace (s)<input id="grace" type="number" min="1" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" min="2" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" min="1" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'><label>Cleanup aktiv</label></div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'><label>Cleanup Dry-Run</label></div><label>Cleanup: Benutzer ausschließen<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>Cleanup: SIDs ausschließen<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Erlaubte Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label></div><div class="section"><div class="toolbar"><strong>Templates</strong><button type="button" class="secondary" data-action="add-template">Template hinzufügen</button></div><div id="templateList" class="list"></div></div><div><button type="button" data-action="save-policy">Lokal speichern</button></div></div>'}
async function loadPolicy(){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()}catch(e){$('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)});await loadPolicy();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==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('policy').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});refresh();loadPolicy();setInterval(refresh,5000);`
function policyHTML(p){let c=p.cleanup||{},pr=p.profiles||{},sp=p.sessions||{};return'<div class="form"><div class="section-title">Profil-Sicherung & Wiederherstellung</div><div class="check"><input id="profEnabled" type="checkbox" '+(pr.enabled?'checked':'')+'><label>Profil-Synchronisation aktiv</label></div><div class="two"><label>Profil-Store (UNC oder lokaler Pfad)<input id="storeRoot" value="'+esc(pr.store_root||'')+'" placeholder="\\\\fileserver\\SessionGuardProfiles"></label><label>Backup-Verzögerung (s)<input id="backupDelay" type="number" min="1" value="'+(pr.backup_delay_seconds||5)+'"></label><label>Retry (s)<input id="profRetry" type="number" min="1" value="'+(pr.retry_seconds||60)+'"></label><label>Restore-Fenster (s)<input id="restoreWindow" type="number" min="10" value="'+(pr.restore_window_seconds||120)+'"></label><label>Historische Versionen<input id="keepVersions" type="number" min="0" max="50" value="'+(pr.keep_versions??2)+'"></label></div><div class="check"><input id="backupOnLogoff" type="checkbox" '+(pr.backup_on_logoff?'checked':'')+'><label>Nach Logoff sichern</label></div><div class="check"><input id="restoreOnLogon" type="checkbox" '+(pr.restore_on_logon?'checked':'')+'><label>Bei Logon wiederherstellen</label></div><label>Von Profil-Sync ausgeschlossene Benutzer<textarea id="profUsers">'+esc((pr.exclude_users||[]).join('\n'))+'</textarea></label><label>Von Profil-Sync ausgeschlossene SIDs<textarea id="profSids">'+esc((pr.exclude_sids||[]).join('\n'))+'</textarea></label><div class="toolbar"><strong>Zu sichernde Profilordner</strong><button type="button" class="secondary" data-action="add-profile-folder">Ordner hinzufügen</button></div><div id="profileFolderList" class="list"></div><div class="note">Gesichert werden nur konfigurierte Teilbäume. Reparse-Points/Symlinks werden nicht verfolgt. Cleanup beginnt nach einem Logoff erst nach erfolgreichem Backup.</div><div class="section"><div class="section-title">Sitzungsrichtlinie</div><div class="check"><input id="controlEnabled" type="checkbox" '+(sp.control_enabled?'checked':'')+'><label>Administrative Sitzungssteuerung erlauben</label></div><div class="check"><input id="autoLogoff" type="checkbox" '+(sp.disconnected_logoff_enabled?'checked':'')+'><label>Getrennte Sitzungen automatisch abmelden</label></div><label>Timeout für getrennte Sitzung (s; min. 60)<input id="discTimeout" type="number" min="60" value="'+(sp.disconnected_timeout_seconds||3600)+'"></label><label>Vom Auto-Logoff ausgeschlossene Benutzer<textarea id="sessUsers">'+esc((sp.exclude_users||[]).join('\n'))+'</textarea></label><label>Vom Auto-Logoff ausgeschlossene SIDs<textarea id="sessSids">'+esc((sp.exclude_sids||[]).join('\n'))+'</textarea></label></div><div class="section"><div class="section-title">Profil-Cleanup</div><div class="cols"><label>Grace (s)<input id="grace" type="number" min="1" value="'+(c.grace_seconds||600)+'"></label><label>Polling (s)<input id="poll" type="number" min="2" value="'+(c.poll_seconds||10)+'"></label><label>Retry (s)<input id="retry" type="number" min="1" value="'+(c.retry_seconds||60)+'"></label></div><div class="check"><input id="enabled" type="checkbox" '+(c.enabled?'checked':'')+'><label>Cleanup aktiv</label></div><div class="check"><input id="dry" type="checkbox" '+(c.dry_run?'checked':'')+'><label>Cleanup Dry-Run</label></div><label>Cleanup: Benutzer ausschließen<textarea id="users">'+esc((c.exclude_users||[]).join('\n'))+'</textarea></label><label>Cleanup: SIDs ausschließen<textarea id="sids">'+esc((c.exclude_sids||[]).join('\n'))+'</textarea></label><label>Erlaubte Profil-Roots<textarea id="roots">'+esc((c.allowed_profile_roots||[]).join('\n'))+'</textarea></label></div><div class="section"><div class="toolbar"><strong>Templates</strong><button type="button" class="secondary" data-action="add-template">Template hinzufügen</button></div><div id="templateList" class="list"></div></div><div class="actions"><button type="button" data-action="save-policy">Lokal speichern</button><button type="button" class="secondary" data-action="reload-policy">Editor neu laden</button><span id="policyDirtyState" class="warning"></span></div></div>'}
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);`

View File

@@ -23,7 +23,7 @@ import (
"github.com/example/sessionguard/internal/model"
)
const Version = "0.3.0"
const Version = "0.3.1"
type App struct {
cfg config.Master
@@ -58,6 +58,7 @@ func (a *App) Run(ctx context.Context) error {
mux.HandleFunc("POST /api/v1/broker/tokens", a.brokerTokens)
mux.HandleFunc("GET /app.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, max-age=0")
_, _ = fmt.Fprint(w, masterJS)
})
mux.Handle("GET /", a.auth.Require(http.HandlerFunc(a.masterPage)))
@@ -1481,6 +1482,7 @@ func requestActor(r *http.Request) string {
func (a *App) masterPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, max-age=0")
_, _ = fmt.Fprint(w, masterHTML)
}
func randomToken(n int) string {

View File

@@ -2,16 +2,17 @@ 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 · 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"></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.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>`
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)}
function renderAgentTable(a){$('agents').innerHTML='<table class="table"><thead><tr><th>Status</th><th>Server</th><th>Mode</th><th>Health</th><th>Sitzungen</th><th>Jobs</th></tr></thead><tbody>'+a.map(x=>'<tr class="row '+(x.id===selected?'selected':'')+'" data-id="'+esc(x.id)+'"><td><i class="dot '+(x.online?'':'off')+'"></i>'+(x.online?'Online':'Offline')+'</td><td><strong>'+esc(x.name)+'</strong><br><span class="muted">'+esc(x.snapshot.agent_version||'')+'</span></td><td>'+esc(x.maintenance_mode||'online')+'</td><td>'+esc((x.snapshot.health||{}).score??'')+'</td><td>'+x.active_sessions+' aktiv / '+x.total_sessions+'</td><td>'+((x.snapshot.profile_jobs||[]).length)+' P / '+((x.snapshot.pending_cleanup||[]).length)+' C</td></tr>').join('')+'</tbody></table>';document.querySelectorAll('.row').forEach(r=>r.onclick=()=>selectAgent(r.dataset.id,false,false))}
async function refresh(){try{let d=await api('/api/v1/dashboard'),a=d.agents||[];$('mServers').textContent=a.length;$('mOnline').textContent=a.filter(x=>x.online).length;$('mActive').textContent=a.reduce((n,x)=>n+x.active_sessions,0);$('mDisc').textContent=a.reduce((n,x)=>n+(x.disconnected_sessions||0),0);$('mProfile').textContent=a.reduce((n,x)=>n+(x.snapshot.profile_jobs||[]).length,0);$('mCleanup').textContent=a.reduce((n,x)=>n+(x.snapshot.pending_cleanup||[]).length,0);$('mFarms').textContent=d.farms||0;$('mAlerts').textContent=d.active_alerts||0;renderAgentTable(a);if(selected)await selectAgent(selected,true,true);let fr=await api('/api/v1/farms');farmCache=fr.farms||[];renderFarms();let rr=await api('/api/v1/resources');resourceCache=rr.resources||[];renderResources();let lr=await api('/api/v1/leases');brokerLeases=lr.leases||[];renderFarms();let hi=await api('/api/v1/history?limit=500');renderHistory(hi.history||[]);try{let ph=await api('/api/v1/policy/history');policyHistory=ph.history||[];renderPolicyHistory()}catch(e){$('policyHistory').innerHTML='<div class="empty">Policy-Historie erfordert Policy-Admin/Admin-Rolle.</div>'}let al=await api('/api/v1/alerts');renderAlerts(al.alerts||[]);if(!me)me=await api('/api/v1/me');try{let au=await api('/api/v1/audit');renderAudit(au.audit||[])}catch(e){$('audit').innerHTML='<div class="empty">Audit-Log erfordert Auditor/Admin-Rolle.</div>'}}catch(e){toast(e.message)}}
async function refresh(){try{let d=await api('/api/v1/dashboard'),a=d.agents||[];$('mServers').textContent=a.length;$('mOnline').textContent=a.filter(x=>x.online).length;$('mActive').textContent=a.reduce((n,x)=>n+x.active_sessions,0);$('mDisc').textContent=a.reduce((n,x)=>n+(x.disconnected_sessions||0),0);$('mProfile').textContent=a.reduce((n,x)=>n+(x.snapshot.profile_jobs||[]).length,0);$('mCleanup').textContent=a.reduce((n,x)=>n+(x.snapshot.pending_cleanup||[]).length,0);$('mFarms').textContent=d.farms||0;$('mAlerts').textContent=d.active_alerts||0;renderAgentTable(a);if(selected)await selectAgent(selected,true,true);let fr=await api('/api/v1/farms');farmCache=fr.farms||[];let rr=await api('/api/v1/resources');resourceCache=rr.resources||[];let lr=await api('/api/v1/leases');brokerLeases=lr.leases||[];renderFarms();renderResources();let hi=await api('/api/v1/history?limit=500');renderHistory(hi.history||[]);try{let ph=await api('/api/v1/policy/history');policyHistory=ph.history||[];renderPolicyHistory()}catch(e){$('policyHistory').innerHTML='<div class="empty">Policy-Historie erfordert Policy-Admin/Admin-Rolle.</div>'}let al=await api('/api/v1/alerts');renderAlerts(al.alerts||[]);if(!me)me=await api('/api/v1/me');try{let au=await api('/api/v1/audit');renderAudit(au.audit||[])}catch(e){$('audit').innerHTML='<div class="empty">Audit-Log erfordert Auditor/Admin-Rolle.</div>'}}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?'<table class="table"><thead><tr><th>Zeit</th><th>Ziel</th><th>Revision</th><th>Akteur</th><th></th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+when(x.created_at)+'</td><td>'+esc(x.target)+'</td><td><code>'+esc(x.revision)+'</code></td><td>'+esc(x.actor||'')+'</td><td><button class="secondary" data-global-action="rollback-policy" data-target="'+esc(x.target)+'" data-revision="'+esc(x.revision)+'">Rollback</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Policy-Versionen.</div>'}
function renderAudit(rows){rows=(rows||[]).slice().reverse().slice(0,250);$('audit').innerHTML=rows.length?'<table class="table"><thead><tr><th>Zeit</th><th>Akteur</th><th>Aktion</th><th>Ziel</th><th>Ergebnis</th></tr></thead><tbody>'+rows.map(x=>'<tr><td>'+esc(when(x.time))+'</td><td>'+esc(x.actor)+'</td><td>'+esc(x.action)+'</td><td>'+esc(x.target||'')+'</td><td class="'+(x.result==='error'?'bad':x.result==='success'?'good':'')+'">'+esc(x.result)+(x.details?'<br><span class="muted">'+esc(x.details)+'</span>':'')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Noch keine Audit-Einträge.</div>'}
function overview(a){let s=a.snapshot||{},v=s.server||{},h=s.health||{},mem=v.memory_total?((v.memory_total-v.memory_available)*100/v.memory_total):0,tags=Object.entries(a.tags||{}).map(([k,v])=>k+'='+v).join('\n');return'<div class="form"><div class="cols"><div><span class="muted">OS</span><br><strong>'+esc(v.os||'')+'</strong></div><div><span class="muted">Health</span><br><strong class="'+((h.score||0)>=70?'good':'bad')+'">'+esc(h.score??'')+'/100</strong></div><div><span class="muted">CPU</span><br><strong>'+Number(v.cpu_percent||0).toFixed(1)+' %</strong></div><div><span class="muted">RAM</span><br><strong>'+mem.toFixed(1)+' % · '+bytes(v.memory_available)+' frei</strong></div><div><span class="muted">Systemdisk</span><br><strong>'+bytes(v.disk_free)+' frei</strong></div><div><span class="muted">Heartbeat</span><br><strong>'+when(a.last_seen)+'</strong></div></div><div class="note">RDP: '+(h.rdp_listener_ok?'✓':'✗')+' · Profile Store: '+(h.profile_store_ok?'✓':'✗')+' · Broker Score: '+esc(Math.round((h.score||0)*10-(v.cpu_percent||0)*2))+' · Pending Commands: '+(a.pending_commands||[]).length+'</div><div class="two"><label>Maintenance Mode<select id="agentMode"><option value="online" '+((a.maintenance_mode||'online')==='online'?'selected':'')+'>Online</option><option value="drain" '+(a.maintenance_mode==='drain'?'selected':'')+'>Drain</option><option value="maintenance" '+(a.maintenance_mode==='maintenance'?'selected':'')+'>Maintenance</option></select></label><label>Farm IDs (eine pro Zeile)<textarea id="agentFarms">'+esc((a.farm_ids||[]).join('\n'))+'</textarea></label><label>Tags key=value<textarea id="agentTags">'+esc(tags)+'</textarea></label><div><div class="check"><input id="restartDrained" type="checkbox" '+(a.restart_when_drained?'checked':'')+'><label>Neu starten sobald leer</label></div><div class="actions"><button type="button" data-action="save-control">Serversteuerung speichern</button><button type="button" class="secondary" data-action="broadcast">Nachricht an alle</button><button type="button" class="danger" data-action="logoff-disconnected">Getrennte abmelden</button></div></div></div></div>'}
function overviewStats(a){let s=a.snapshot||{},v=s.server||{},h=s.health||{},mem=v.memory_total?((v.memory_total-v.memory_available)*100/v.memory_total):0;return'<div class="form"><div class="cols"><div><span class="muted">OS</span><br><strong>'+esc(v.os||'')+'</strong></div><div><span class="muted">Health</span><br><strong class="'+((h.score||0)>=70?'good':'bad')+'">'+esc(h.score??'')+'/100</strong></div><div><span class="muted">CPU</span><br><strong>'+Number(v.cpu_percent||0).toFixed(1)+' %</strong></div><div><span class="muted">RAM</span><br><strong>'+mem.toFixed(1)+' % · '+bytes(v.memory_available)+' frei</strong></div><div><span class="muted">Systemdisk</span><br><strong>'+bytes(v.disk_free)+' frei</strong></div><div><span class="muted">Heartbeat</span><br><strong>'+when(a.last_seen)+'</strong></div></div><div class="note">RDP: '+(h.rdp_listener_ok?'✓':'✗')+' · Profile Store: '+(h.profile_store_ok?'✓':'✗')+' · Broker Score: '+esc(Math.round((h.score||0)*10-(v.cpu_percent||0)*2))+' · Pending Commands: '+(a.pending_commands||[]).length+'</div></div>'}
function controlHTML(a){let tags=Object.entries(a.tags||{}).map(([k,v])=>k+'='+v).join('\n');return'<div class="form"><div class="two"><label>Maintenance Mode<select id="agentMode"><option value="online" '+((a.maintenance_mode||'online')==='online'?'selected':'')+'>Online</option><option value="drain" '+(a.maintenance_mode==='drain'?'selected':'')+'>Drain</option><option value="maintenance" '+(a.maintenance_mode==='maintenance'?'selected':'')+'>Maintenance</option></select></label><label>Farm IDs (eine pro Zeile)<textarea id="agentFarms">'+esc((a.farm_ids||[]).join('\n'))+'</textarea></label><label>Tags key=value<textarea id="agentTags">'+esc(tags)+'</textarea></label><div><div class="check"><input id="restartDrained" type="checkbox" '+(a.restart_when_drained?'checked':'')+'><label>Neu starten sobald leer</label></div><div class="actions"><button type="button" data-action="save-control">Serversteuerung speichern</button><button type="button" class="secondary" data-action="broadcast">Nachricht an alle</button><button type="button" class="danger" data-action="logoff-disconnected">Getrennte abmelden</button><button type="button" class="secondary" data-action="reload-control">Steuerung neu laden</button></div></div></div></div>'}
async function sessionAction(id,action){if(!selected)return;let body={action};if(action==='message'){let m=prompt('Nachricht an Sitzung '+id+':');if(!m)return;body.message=m;body.title='SessionGuard'}if(action==='logoff'&&!confirm('Sitzung '+id+' wirklich abmelden? Die Profilsicherung startet nach dem Sitzungsende.'))return;try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/sessions/'+id+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Sitzungsaktion an Agent gesendet');setTimeout(()=>selectAgent(selected,true,true),1000)}catch(e){toast(e.message)}}
function sessionsHTML(a){let s=a.snapshot||{},ss=s.sessions||[],p=(a.desired_policy&&a.desired_policy.revision)?a.desired_policy:s.policy,control=p&&p.sessions&&p.sessions.control_enabled;return ss.length?'<table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Logon / Idle</th><th>Client</th><th>Aktionen</th></tr></thead><tbody>'+ss.map(x=>'<tr><td>'+x.id+'</td><td>'+esc((x.domain?x.domain+'\\':'')+x.user)+'</td><td>'+esc(x.state)+'</td><td>'+esc(when(x.logon_at))+'<br><span class="muted">Idle '+Math.round((x.idle_seconds||0)/60)+' min</span></td><td>'+esc(x.client_name||'')+'<br><span class="muted">'+esc(x.client_address||'')+'</span></td><td>'+(control&&x.user?'<div class="actions"><button class="secondary" data-session="'+x.id+'" data-session-action="message">Nachricht</button><button class="secondary" data-session="'+x.id+'" data-session-action="disconnect">Trennen</button><button class="danger" data-session="'+x.id+'" data-session-action="logoff">Abmelden</button></div>':'')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine Benutzersitzungen.</div>'}
function profileHTML(a){let s=a.snapshot||{},jobs=s.profile_jobs||[],st=Object.values(s.profile_status||{});let h=jobs.length?'<table class="table"><thead><tr><th>Operation</th><th>Benutzer</th><th>Fällig</th><th>Fehler</th></tr></thead><tbody>'+jobs.map(j=>'<tr><td>'+esc(j.operation)+'</td><td>'+esc(j.user)+'</td><td>'+when(j.due_at)+'</td><td>'+esc(j.last_error||'')+'</td></tr>').join('')+'</tbody></table>':'';if(st.length)h+='<table class="table"><thead><tr><th>Benutzer</th><th>Backup</th><th>Restore</th><th>Fehler</th></tr></thead><tbody>'+st.map(x=>'<tr><td>'+esc(x.user||x.sid)+'</td><td>'+when(x.last_backup_at)+'</td><td>'+when(x.last_restore_at)+'</td><td>'+esc(x.last_backup_error||x.last_restore_error||'')+'</td></tr>').join('')+'</tbody></table>';return h||'<div class="empty">Keine Profil-Jobs/Statusdaten.</div>'}
@@ -19,11 +20,11 @@ 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 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>':'';$('farms').innerHTML='<div 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><button type="button" data-global-action="create-farm">Farm anlegen</button></div>'+(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 renderResources(){let opts=farmCache.map(f=>'<option value="'+esc(f.id)+'">'+esc(f.name)+'</option>').join('');$('resources').innerHTML='<div 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">'+opts+'</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><button type="button" data-global-action="create-resource">Resource anlegen</button></div>'+(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 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 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:[]}}
function renderDetail(a,preserve){$('detailTitle').textContent=a.name||'Server';if(!preserve||editorAgent!==a.id||!$('overview')){let p=activePolicy(a);policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('detail').className='';$('detail').innerHTML='<div id="overview"></div><div class="section-title">Sitzungen</div><div id="sessionList" class="content"></div><div class="section-title">Logon Performance</div><div id="telemetryList" class="content"></div><div class="section-title">Prozesse</div><div id="processList" class="content"></div><div class="section-title">Profil-Pipeline</div><div id="profileList" class="content"></div><div class="section-title">Agent-Ereignisse</div><div id="eventList" class="content"></div><div class="section-title">Policy</div><div id="policyEditor">'+policyForm(p,a)+'</div>';editorAgent=a.id;renderTemplates();renderProfileFolders()}$('overview').innerHTML=overview(a);$('sessionList').innerHTML=sessionsHTML(a);$('telemetryList').innerHTML=telemetryHTML(a);$('processList').innerHTML=processesHTML(a);$('profileList').innerHTML=profileHTML(a);$('eventList').innerHTML=eventsHTML(a)}
function renderDetail(a,preserve){$('detailTitle').textContent=a.name||'Server';if(!preserve||editorAgent!==a.id||!$('overviewStats')){let p=activePolicy(a);policyTemplates=JSON.parse(JSON.stringify(p.templates||[]));profileFolders=JSON.parse(JSON.stringify((p.profiles&&p.profiles.folders)||[]));$('detail').className='';$('detail').innerHTML='<div id="overviewStats"></div><div class="section-title">Serversteuerung</div><div id="controlEditor"></div><div class="section-title">Sitzungen</div><div id="sessionList" class="content"></div><div class="section-title">Logon Performance</div><div id="telemetryList" class="content"></div><div class="section-title">Prozesse</div><div id="processList" class="content"></div><div class="section-title">Profil-Pipeline</div><div id="profileList" class="content"></div><div class="section-title">Agent-Ereignisse</div><div id="eventList" class="content"></div><div class="section-title">Policy</div><div id="policyEditor">'+policyForm(p,a)+'</div>';$('controlEditor').innerHTML=controlHTML(a);editorAgent=a.id;renderTemplates();renderProfileFolders()}$('overviewStats').innerHTML=overviewStats(a);$('sessionList').innerHTML=sessionsHTML(a);$('telemetryList').innerHTML=telemetryHTML(a);$('processList').innerHTML=processesHTML(a);$('profileList').innerHTML=profileHTML(a);$('eventList').innerHTML=eventsHTML(a)}
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'<div class="muted">Keine weiteren Angaben.</div>';if(k==='url')return'<label>URL<input data-field="url" value="'+esc(t.url||'')+'"></label>';if(k==='shortcut'){let s=t.shortcut||{};return'<div class="two"><label>Zielprogramm<input data-field="shortcut.target" value="'+esc(s.target||'')+'"></label><label>Argumente<input data-field="shortcut.arguments" value="'+esc(s.arguments||'')+'"></label><label>Arbeitsverzeichnis<input data-field="shortcut.working_directory" value="'+esc(s.working_directory||'')+'"></label><label>Icon<input data-field="shortcut.icon_location" value="'+esc(s.icon_location||'')+'"></label></div><label>Beschreibung<input data-field="shortcut.description" value="'+esc(s.description||'')+'"></label>'}return'<label>Quelldatei / UNC-Pfad<input data-field="source" value="'+esc(t.source||'')+'"></label><div class="two"><label>Inline-Inhalt<textarea data-field="content">'+esc(t.content||'')+'</textarea></label><label>Inline Base64<textarea data-field="content_base64">'+esc(t.content_base64||'')+'</textarea></label></div>'}
function renderTemplates(){let h=$('templateList');if(!h)return;h.innerHTML=policyTemplates.length?policyTemplates.map((t,i)=>'<div class="item template-card"><div class="item-head"><strong>'+esc(t.id||('Template '+(i+1)))+'</strong><button type="button" class="danger" data-action="remove-template" data-index="'+i+'">Löschen</button></div><div class="cols"><label>ID<input data-field="id" value="'+esc(t.id||'')+'"></label><label>Typ<select data-field="kind" data-action="template-kind"><option value="file" '+((t.kind||'file')==='file'?'selected':'')+'>Datei</option><option value="directory" '+(t.kind==='directory'?'selected':'')+'>Ordner</option><option value="url" '+(t.kind==='url'?'selected':'')+'>URL</option><option value="shortcut" '+(t.kind==='shortcut'?'selected':'')+'>.lnk</option></select></label><label>Ziel<input data-field="target" value="'+esc(t.target||'')+'"></label></div><div class="check"><input data-field="overwrite" type="checkbox" '+(t.overwrite?'checked':'')+'><label>Aktualisieren/überschreiben</label></div>'+templateSpecific(t)+'</div>').join(''):'<div class="empty">Keine Templates.</div>'}
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})}
@@ -34,5 +35,5 @@ async function bulkSessionAction(action,scope){if(!selected)return;let body={act
async function saveAgentControl(){if(!selected)return;let tags={};($('agentTags').value||'').split('\n').map(x=>x.trim()).filter(Boolean).forEach(x=>{let i=x.indexOf('=');if(i>0)tags[x.slice(0,i).trim()]=x.slice(i+1).trim()});let body={mode:$('agentMode').value,restart_when_drained:$('restartDrained').checked,tags,farm_ids:$('agentFarms').value.split('\n').map(x=>x.trim()).filter(Boolean)};try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/control',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Serversteuerung gespeichert');await selectAgent(selected,true,false)}catch(e){toast(e.message)}}
async function killProcess(pid){if(!selected||!confirm('Prozess '+pid+' wirklich beenden?'))return;try{await api('/api/v1/agents/'+encodeURIComponent(selected)+'/processes/'+pid+'/kill',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});toast('Prozess-Beenden an Agent gesendet')}catch(e){toast(e.message)}}
async function savePolicy(all){if(!selected)return;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(all?'/api/v1/policy/all':'/api/v1/agents/'+encodeURIComponent(selected)+'/policy',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});toast(all?'Policy auf alle Server angewendet':'Policy gespeichert');await selectAgent(selected,true,false)}catch(e){toast(e.message)}}
$('detail').addEventListener('click',e=>{let sb=e.target.closest('button[data-session-action]');if(sb){sessionAction(+sb.dataset.session,sb.dataset.sessionAction);return}let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-control')saveAgentControl();if(a==='broadcast')bulkSessionAction('message','all');if(a==='logoff-disconnected')bulkSessionAction('logoff','disconnected');if(a==='kill-process')killProcess(+b.dataset.process);if(a==='save-one')savePolicy(false);if(a==='save-all')savePolicy(true);if(a==='reload-policy')selectAgent(selected,false,false);if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('detail').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});document.addEventListener('click',async e=>{let b=e.target.closest('button[data-global-action]');if(!b)return;let a=b.dataset.globalAction;try{if(a==='create-farm'){let name=$('farmName').value.trim();if(!name)return;await api('/api/v1/farms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description:$('farmDesc').value.trim(),enabled:true,required_tags:parseTags($('farmTags').value)})});toast('Farm angelegt');await refresh()}if(a==='delete-farm'){if(!confirm('Farm löschen?'))return;await api('/api/v1/farms/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Farm gelöscht');await refresh()}if(a==='create-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;await api('/api/v1/resources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,kind:$('resKind').value,farm_id,guacamole_connection_id:$('resConnID').value.trim(),guacamole_connection_name:$('resConnName').value.trim(),remote_app:$('resRemoteApp').value.trim(),remote_app_dir:$('resRemoteDir').value.trim(),remote_app_args:$('resRemoteArgs').value.trim(),enabled:true})});toast('Resource angelegt');await refresh()}if(a==='delete-resource'){if(!confirm('Resource löschen?'))return;await api('/api/v1/resources/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Resource gelöscht');await refresh()}if(a==='rollback-policy'){if(!confirm('Policy auf Revision '+b.dataset.revision+' zurückrollen?'))return;let target=b.dataset.target,rev=encodeURIComponent(b.dataset.revision),url;if(target==='global')url='/api/v1/policy/global/rollback/'+rev;else if(target.startsWith('agent:'))url='/api/v1/agents/'+encodeURIComponent(target.slice(6))+'/policy/rollback/'+rev;else if(target.startsWith('farm:'))url='/api/v1/farms/'+encodeURIComponent(target.slice(5))+'/policy/rollback/'+rev;else throw new Error('Unbekanntes Policy-Ziel');await api(url,{method:'POST'});toast('Rollback eingeplant');editorAgent=null;await refresh()}}catch(err){toast(err.message)}});
$('detail').addEventListener('click',e=>{let sb=e.target.closest('button[data-session-action]');if(sb){sessionAction(+sb.dataset.session,sb.dataset.sessionAction);return}let b=e.target.closest('button[data-action]');if(!b)return;let a=b.dataset.action;if(a==='save-control')saveAgentControl();if(a==='reload-control'&&current&&$('controlEditor'))$('controlEditor').innerHTML=controlHTML(current);if(a==='broadcast')bulkSessionAction('message','all');if(a==='logoff-disconnected')bulkSessionAction('logoff','disconnected');if(a==='kill-process')killProcess(+b.dataset.process);if(a==='save-one')savePolicy(false);if(a==='save-all')savePolicy(true);if(a==='reload-policy')selectAgent(selected,false,false);if(a==='add-template'){policyTemplates=collectTemplates();policyTemplates.push(templateDefault());renderTemplates()}if(a==='remove-template'){policyTemplates=collectTemplates();policyTemplates.splice(+b.dataset.index,1);renderTemplates()}if(a==='add-profile-folder'){profileFolders=collectProfileFolders();profileFolders.push({path:'AppData\\Roaming\\Hersteller',exclude_globs:['Cache/**']});renderProfileFolders()}if(a==='remove-profile-folder'){profileFolders=collectProfileFolders();profileFolders.splice(+b.dataset.index,1);renderProfileFolders()}});$('detail').addEventListener('change',e=>{if(e.target.matches('select[data-action="template-kind"]')){policyTemplates=collectTemplates();renderTemplates()}});document.addEventListener('click',async e=>{let b=e.target.closest('button[data-global-action]');if(!b)return;let a=b.dataset.globalAction;try{if(a==='clear-farm-form'){$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';return}if(a==='create-farm'){let name=$('farmName').value.trim();if(!name)return;await api('/api/v1/farms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description:$('farmDesc').value.trim(),enabled:true,required_tags:parseTags($('farmTags').value)})});$('farmName').value='';$('farmDesc').value='';$('farmTags').value='';toast('Farm angelegt');await refresh()}if(a==='delete-farm'){if(!confirm('Farm löschen?'))return;await api('/api/v1/farms/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Farm gelöscht');await refresh()}if(a==='clear-resource-form'){$('resName').value='';$('resKind').value='desktop';$('resConnID').value='';$('resConnName').value='';$('resRemoteApp').value='';$('resRemoteDir').value='';$('resRemoteArgs').value='';return}if(a==='create-resource'){let name=$('resName').value.trim(),farm_id=$('resFarm').value;if(!name||!farm_id)return;await api('/api/v1/resources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,kind:$('resKind').value,farm_id,guacamole_connection_id:$('resConnID').value.trim(),guacamole_connection_name:$('resConnName').value.trim(),remote_app:$('resRemoteApp').value.trim(),remote_app_dir:$('resRemoteDir').value.trim(),remote_app_args:$('resRemoteArgs').value.trim(),enabled:true})});$('resName').value='';$('resConnID').value='';$('resConnName').value='';$('resRemoteApp').value='';$('resRemoteDir').value='';$('resRemoteArgs').value='';toast('Resource angelegt');await refresh()}if(a==='delete-resource'){if(!confirm('Resource löschen?'))return;await api('/api/v1/resources/'+encodeURIComponent(b.dataset.id),{method:'DELETE'});toast('Resource gelöscht');await refresh()}if(a==='rollback-policy'){if(!confirm('Policy auf Revision '+b.dataset.revision+' zurückrollen?'))return;let target=b.dataset.target,rev=encodeURIComponent(b.dataset.revision),url;if(target==='global')url='/api/v1/policy/global/rollback/'+rev;else if(target.startsWith('agent:'))url='/api/v1/agents/'+encodeURIComponent(target.slice(6))+'/policy/rollback/'+rev;else if(target.startsWith('farm:'))url='/api/v1/farms/'+encodeURIComponent(target.slice(5))+'/policy/rollback/'+rev;else throw new Error('Unbekanntes Policy-Ziel');await api(url,{method:'POST'});toast('Rollback eingeplant');editorAgent=null;await refresh()}}catch(err){toast(err.message)}});
refresh();setInterval(refresh,5000);`