0.4.0
release-tag / release-image (push) Successful in 2m2s
release-main / release-images (push) Successful in 3m34s

This commit is contained in:
2026-08-22 23:48:15 +02:00
parent b466737549
commit e104e7289f
23 changed files with 1023 additions and 85 deletions
+125 -2
View File
@@ -25,7 +25,7 @@ import (
"github.com/example/sessionguard/internal/windowsx"
)
const Version = "0.3.3"
const Version = "0.4.0"
type App struct {
cfg config.Agent
@@ -85,6 +85,11 @@ func (a *App) worker(ctx context.Context) {
defer poll.Stop()
hb := time.NewTicker(time.Duration(max(3, a.cfg.HeartbeatSeconds)) * time.Second)
defer hb.Stop()
remoteApps := time.NewTicker(60 * time.Second)
defer remoteApps.Stop()
if a.remoteAppReconcileNeeded() {
a.syncRemoteApps()
}
for {
select {
case <-ctx.Done():
@@ -103,10 +108,20 @@ func (a *App) worker(ctx context.Context) {
poll.Reset(time.Duration(max(2, a.policy().Cleanup.PollSeconds)) * time.Second)
case <-hb.C:
a.sendHeartbeat(ctx)
case <-remoteApps.C:
if a.remoteAppReconcileNeeded() {
a.syncRemoteApps()
}
}
}
}
func (a *App) remoteAppReconcileNeeded() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return len(a.state.DesiredRemoteApps) > 0 || len(a.state.ManagedRemoteApps) > 0
}
func (a *App) tick(ctx context.Context) {
sessions, err := windowsx.Sessions()
if err != nil {
@@ -850,7 +865,7 @@ func (a *App) refreshSnapshot(server model.ServerInfo, sessions []model.Session,
for id, t := range a.state.Telemetry {
telemetry[id] = t
}
a.snapshot = model.AgentSnapshot{ProtocolVersion: model.ProtocolVersion, AgentID: a.state.AgentID, Server: server, Health: health, Sessions: sessions, Processes: processes, Telemetry: telemetry, PendingCleanup: pendingSlice(a.state.Pending), ProfileJobs: profileJobSlice(a.state.ProfileJobs), ProfileStatus: cloneProfileStatus(a.state.ProfileStatus), Events: eventSlice(a.state.Events), CommandResults: resultSlice(a.state.CommandResults), Policy: a.state.Policy, PolicyRevision: a.state.Policy.Revision, AgentVersion: Version, Time: now}
a.snapshot = model.AgentSnapshot{ProtocolVersion: model.ProtocolVersion, AgentID: a.state.AgentID, Server: server, Health: health, Sessions: sessions, Processes: processes, Telemetry: telemetry, PendingCleanup: pendingSlice(a.state.Pending), ProfileJobs: profileJobSlice(a.state.ProfileJobs), ProfileStatus: cloneProfileStatus(a.state.ProfileStatus), Events: eventSlice(a.state.Events), CommandResults: resultSlice(a.state.CommandResults), RemoteApps: append([]model.RemoteAppStatus(nil), a.state.RemoteAppStatus...), Policy: a.state.Policy, PolicyRevision: a.state.Policy.Revision, AgentVersion: Version, Time: now}
}
func (a *App) calculateHealth(server model.ServerInfo) model.HealthStatus {
@@ -936,6 +951,12 @@ func (a *App) sendHeartbeat(ctx context.Context) {
a.appendEventLocked("info", "policy_applied", "", fmt.Sprintf("Master-Policy %s angewendet", p.Revision))
}
}
remoteAppsChanged := !remoteAppSpecsEqual(a.state.DesiredRemoteApps, resp.DesiredRemoteApps)
if remoteAppsChanged {
a.state.DesiredRemoteApps = append([]model.RemoteAppSpec(nil), resp.DesiredRemoteApps...)
a.appendEventLocked("info", "remoteapp_desired_state", "", fmt.Sprintf("RemoteApp-Sollzustand aktualisiert: %d Apps", len(resp.DesiredRemoteApps)))
}
remoteAppSyncDue := remoteAppsChanged || a.state.LastRemoteAppSync.IsZero() || time.Since(a.state.LastRemoteAppSync) >= 60*time.Second
_ = a.store.save(a.state)
current := make([]model.Session, 0, len(a.state.LastSessions))
if changed {
@@ -944,6 +965,9 @@ func (a *App) sendHeartbeat(ctx context.Context) {
}
}
a.mu.Unlock()
if remoteAppSyncDue {
a.syncRemoteApps()
}
if changed {
for _, s := range current {
if s.SID != "" && s.User != "" {
@@ -954,6 +978,105 @@ func (a *App) sendHeartbeat(ctx context.Context) {
a.processCommands(resp.Commands)
}
func remoteAppSpecsEqual(a, b []model.RemoteAppSpec) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func (a *App) syncRemoteApps() {
a.mu.RLock()
desired := append([]model.RemoteAppSpec(nil), a.state.DesiredRemoteApps...)
previous := make(map[string]model.RemoteAppSpec, len(a.state.ManagedRemoteApps))
for alias, spec := range a.state.ManagedRemoteApps {
previous[alias] = spec
}
owned := make(map[string]bool, len(a.state.OwnedRemoteAppAliases))
for alias, isOwned := range a.state.OwnedRemoteAppAliases {
if isOwned {
owned[strings.ToLower(alias)] = true
}
}
a.mu.RUnlock()
wanted := make(map[string]model.RemoteAppSpec, len(desired))
for _, spec := range desired {
wanted[strings.ToLower(spec.Alias)] = spec
}
remove := make([]string, 0)
for alias := range previous {
key := strings.ToLower(alias)
if _, ok := wanted[key]; !ok && owned[key] {
remove = append(remove, alias)
}
}
sort.Strings(remove)
managedStatus, reconcileErr := windowsx.ReconcileRemoteApps(desired, remove)
if reconcileErr == nil {
for _, st := range managedStatus {
if st.Owned {
owned[strings.ToLower(st.Alias)] = true
}
}
for _, alias := range remove {
delete(owned, strings.ToLower(alias))
}
}
discovered, discoverErr := windowsx.DiscoverRemoteApps()
status := managedStatus
if discoverErr == nil {
status = windowsx.RemoteAppStatusByDesired(discovered, desired)
managedErrors := map[string]string{}
for _, st := range managedStatus {
if st.Error != "" {
managedErrors[strings.ToLower(st.Alias)] = st.Error
}
}
for i := range status {
key := strings.ToLower(status[i].Alias)
status[i].Owned = owned[key]
if errText := managedErrors[key]; errText != "" {
status[i].Error = errText
status[i].InSync = false
}
}
}
now := time.Now().UTC()
a.mu.Lock()
a.state.LastRemoteAppSync = now
if reconcileErr != nil {
a.appendEventLocked("error", "remoteapp_reconcile_error", "", reconcileErr.Error())
}
if discoverErr != nil {
a.appendEventLocked("error", "remoteapp_discovery_error", "", discoverErr.Error())
}
if reconcileErr == nil {
a.state.ManagedRemoteApps = map[string]model.RemoteAppSpec{}
for _, spec := range desired {
a.state.ManagedRemoteApps[spec.Alias] = spec
}
a.state.OwnedRemoteAppAliases = map[string]bool{}
for alias, isOwned := range owned {
if isOwned {
a.state.OwnedRemoteAppAliases[alias] = true
}
}
}
if status != nil {
a.state.RemoteAppStatus = status
a.snapshot.RemoteApps = append([]model.RemoteAppStatus(nil), status...)
}
_ = a.store.save(a.state)
a.mu.Unlock()
}
func (a *App) serveHTTP(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
+47 -26
View File
@@ -13,20 +13,25 @@ import (
)
type State struct {
AgentID string `json:"agent_id,omitempty"`
AgentToken string `json:"agent_token,omitempty"`
Policy model.Policy `json:"policy"`
LastSessions map[uint32]model.Session `json:"last_sessions,omitempty"`
Pending map[string]model.CleanupJob `json:"pending,omitempty"`
ProfileJobs map[string]model.ProfileJob `json:"profile_jobs,omitempty"`
ProfileStatus map[string]model.ProfileStatus `json:"profile_status,omitempty"`
DisconnectedSince map[uint32]time.Time `json:"disconnected_since,omitempty"`
AutoLogoffRequested map[uint32]time.Time `json:"auto_logoff_requested,omitempty"`
RestoredSessions map[uint32]bool `json:"restored_sessions,omitempty"`
ProcessedCommands map[string]time.Time `json:"processed_commands,omitempty"`
CommandResults []model.CommandResult `json:"command_results,omitempty"`
Events []model.AgentEvent `json:"events,omitempty"`
Telemetry map[uint32]model.SessionTelemetry `json:"telemetry,omitempty"`
AgentID string `json:"agent_id,omitempty"`
AgentToken string `json:"agent_token,omitempty"`
Policy model.Policy `json:"policy"`
LastSessions map[uint32]model.Session `json:"last_sessions,omitempty"`
Pending map[string]model.CleanupJob `json:"pending,omitempty"`
ProfileJobs map[string]model.ProfileJob `json:"profile_jobs,omitempty"`
ProfileStatus map[string]model.ProfileStatus `json:"profile_status,omitempty"`
DisconnectedSince map[uint32]time.Time `json:"disconnected_since,omitempty"`
AutoLogoffRequested map[uint32]time.Time `json:"auto_logoff_requested,omitempty"`
RestoredSessions map[uint32]bool `json:"restored_sessions,omitempty"`
ProcessedCommands map[string]time.Time `json:"processed_commands,omitempty"`
CommandResults []model.CommandResult `json:"command_results,omitempty"`
Events []model.AgentEvent `json:"events,omitempty"`
Telemetry map[uint32]model.SessionTelemetry `json:"telemetry,omitempty"`
DesiredRemoteApps []model.RemoteAppSpec `json:"desired_remote_apps,omitempty"`
ManagedRemoteApps map[string]model.RemoteAppSpec `json:"managed_remote_apps,omitempty"`
OwnedRemoteAppAliases map[string]bool `json:"owned_remote_app_aliases,omitempty"`
RemoteAppStatus []model.RemoteAppStatus `json:"remote_app_status,omitempty"`
LastRemoteAppSync time.Time `json:"last_remote_app_sync,omitempty"`
}
type stateStore struct {
@@ -36,18 +41,22 @@ type stateStore struct {
func loadState(path string, initial model.Policy) (State, error) {
s := State{
Policy: initial,
LastSessions: map[uint32]model.Session{},
Pending: map[string]model.CleanupJob{},
ProfileJobs: map[string]model.ProfileJob{},
ProfileStatus: map[string]model.ProfileStatus{},
DisconnectedSince: map[uint32]time.Time{},
AutoLogoffRequested: map[uint32]time.Time{},
RestoredSessions: map[uint32]bool{},
ProcessedCommands: map[string]time.Time{},
CommandResults: []model.CommandResult{},
Events: []model.AgentEvent{},
Telemetry: map[uint32]model.SessionTelemetry{},
Policy: initial,
LastSessions: map[uint32]model.Session{},
Pending: map[string]model.CleanupJob{},
ProfileJobs: map[string]model.ProfileJob{},
ProfileStatus: map[string]model.ProfileStatus{},
DisconnectedSince: map[uint32]time.Time{},
AutoLogoffRequested: map[uint32]time.Time{},
RestoredSessions: map[uint32]bool{},
ProcessedCommands: map[string]time.Time{},
CommandResults: []model.CommandResult{},
Events: []model.AgentEvent{},
Telemetry: map[uint32]model.SessionTelemetry{},
DesiredRemoteApps: []model.RemoteAppSpec{},
ManagedRemoteApps: map[string]model.RemoteAppSpec{},
OwnedRemoteAppAliases: map[string]bool{},
RemoteAppStatus: []model.RemoteAppStatus{},
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
@@ -89,6 +98,18 @@ func loadState(path string, initial model.Policy) (State, error) {
if s.Telemetry == nil {
s.Telemetry = map[uint32]model.SessionTelemetry{}
}
if s.DesiredRemoteApps == nil {
s.DesiredRemoteApps = []model.RemoteAppSpec{}
}
if s.ManagedRemoteApps == nil {
s.ManagedRemoteApps = map[string]model.RemoteAppSpec{}
}
if s.OwnedRemoteAppAliases == nil {
s.OwnedRemoteAppAliases = map[string]bool{}
}
if s.RemoteAppStatus == nil {
s.RemoteAppStatus = []model.RemoteAppStatus{}
}
if s.RestoredSessions == nil {
// Upgrade safety: do not restore into sessions that were already active before
// upgrading from a version that did not track per-session restore state.
+6 -4
View File
@@ -36,14 +36,15 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
@media(max-width:720px){.agent-metrics{grid-template-columns:repeat(2,1fr)}.app-shell{display:block}.sidebar{position:fixed;left:0;top:0;transform:translateX(-102%);width:min(290px,86vw);transition:transform .2s ease;box-shadow:var(--shadow)}body.nav-open .sidebar{transform:translateX(0)}body.nav-open .mobile-overlay{display:block;position:fixed;inset:0;background:rgba(0,0,0,.48);z-index:25}.menu-toggle{display:inline-flex}.topbar{height:60px}.live-pill{display:none}.page{padding:14px}.metrics{grid-template-columns:repeat(2,1fr)}.section-heading{align-items:flex-start;flex-direction:column}.table th,.table td{white-space:nowrap}.form{padding:13px}}
@media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}}
</style></head><body>
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.3.4</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#profiles-section"><span class="nav-icon">↕</span>Profil-Pipeline</a><a class="nav-link" href="#events-section"><span class="nav-icon">≋</span>Aktivitätslog</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.4.0</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#remoteapps-section"><span class="nav-icon">◇</span>RemoteApps</a><a class="nav-link" href="#profiles-section"><span class="nav-icon">↕</span>Profil-Pipeline</a><a class="nav-link" href="#events-section"><span class="nav-icon">≋</span>Aktivitätslog</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
<div class="app-main"><header class="topbar"><div class="topbar-left"><button class="secondary menu-toggle" id="menuToggle" type="button">☰</button><div><div class="topbar-title">Lokaler Agent</div><div class="topbar-sub" id="host">Lokaler Terminalserver</div></div></div><div class="topbar-actions"><span class="live-pill"><span class="live-dot"></span>Live · 5s</span><button class="secondary theme-top" id="themeTop" type="button">◐</button></div></header>
<main class="page"><section class="page-section" id="overview"><div class="section-heading"><div><div class="eyebrow">Local Control</div><h1>Terminalserver-Status</h1><div class="section-copy">Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.</div></div></div><div class="metrics agent-metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="active">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="disc">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Sitzungen</span><span class="metric-icon">◎</span></div><div class="value" id="total">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="profileJobs">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="pending">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Master</span><span class="metric-icon">⇄</span></div><div class="value" style="font-size:14px;margin-top:14px" id="master">–</div></div></div></section>
<main class="page"><section class="page-section" id="overview"><div class="section-heading"><div><div class="eyebrow">Local Control</div><h1>Terminalserver-Status</h1><div class="section-copy">Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.</div></div></div><div class="metrics agent-metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="active">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="disc">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Sitzungen</span><span class="metric-icon">◎</span></div><div class="value" id="total">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="profileJobs">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="pending">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">RemoteApps</span><span class="metric-icon">◇</span></div><div class="value" id="remoteAppCount">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Master</span><span class="metric-icon">⇄</span></div><div class="value" style="font-size:14px;margin-top:14px" id="master">–</div></div></div></section>
<section class="page-section" id="sessions-section"><div class="section-heading"><div><div class="eyebrow">RDS</div><h2>Sitzungen</h2><div class="section-copy">Aktive und getrennte Benutzer sowie administrative Aktionen.</div></div></div><section class="panel"><div id="sessions"></div></section></section>
<section class="page-section" id="remoteapps-section"><div class="section-heading"><div><div class="eyebrow">Delivery</div><h2>RemoteApps</h2><div class="section-copy">Vom Windows RemoteApp-Provider entdeckte Anwendungen und der vom Master verwaltete Sollzustand.</div></div></div><section class="panel"><div id="remoteapps"></div></section></section>
<section class="page-section" id="profiles-section"><div class="section-heading"><div><div class="eyebrow">Profile</div><h2>Profil-Pipeline</h2><div class="section-copy">Backup, Restore, Retry und Cleanup-Status pro Benutzer.</div></div></div><section class="panel"><div id="profiles"></div></section></section>
<section class="page-section" id="events-section"><div class="section-heading"><div><div class="eyebrow">Telemetry</div><h2>Aktivitäts- & Audit-Log</h2><div class="section-copy">Dry-Run, Profilaktionen, Fehler und lokale Ereignisse.</div></div></div><section class="panel"><div id="events"></div></section></section>
<section class="page-section" id="policy-section"><div class="section-heading"><div><div class="eyebrow">Configuration</div><h2>Lokale Policy</h2><div class="section-copy">Fallback-Konfiguration für Profile, Sessions, Cleanup und Templates.</div></div></div><section class="panel"><div id="policy"></div></section></section>
</main></div></div><script src="/app.js?v=0.3.4"></script></body></html>`
</main></div></div><script src="/app.js?v=0.4.0"></script></body></html>`
const agentJS = `
let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false;
@@ -61,10 +62,11 @@ function initShell(){
}
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>'}
function renderRemoteApps(s){let rows=s.remote_apps||[];$('remoteapps').innerHTML=rows.length?'<table class="table"><thead><tr><th>Status</th><th>Alias</th><th>Pfad</th><th>Verwaltung</th><th>Fehler</th></tr></thead><tbody>'+rows.map(x=>'<tr><td><span class="badge '+(x.published&&x.path_exists&&x.in_sync?'good':'bad')+'">'+(x.published&&x.path_exists&&x.in_sync?'Bereit':'Nicht bereit')+'</span></td><td><strong>||'+esc(x.alias)+'</strong><br><span class="muted">'+esc(x.display_name||x.resource_id||'–')+'</span></td><td>'+esc(x.path||'–')+'</td><td>'+(x.managed?'<span class="badge good">Master</span>'+(x.owned?' <span class="muted">(angelegt)</span>':' <span class="muted">(übernommen)</span>'):'<span class="badge neutral">lokal entdeckt</span>')+'</td><td>'+esc(x.error||'–')+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">Keine RemoteApps gefunden. Bei Agent-gesteuerten Apps erscheint der Status nach dem nächsten Master-Heartbeat.</div>'}
function renderProfiles(s){let jobs=s.profile_jobs||[],status=Object.values(s.profile_status||{});let html='';if(jobs.length)html+='<table class="table"><thead><tr><th>Job</th><th>Benutzer</th><th>Fällig</th><th>Versuche</th><th>Fehler</th></tr></thead><tbody>'+jobs.map(j=>'<tr><td>'+esc(j.operation)+'</td><td>'+esc(j.user)+'</td><td>'+esc(when(j.due_at))+'</td><td>'+j.attempts+'</td><td>'+esc(j.last_error||'–')+'</td></tr>').join('')+'</tbody></table>';if(status.length)html+='<table class="table"><thead><tr><th>Benutzer</th><th>Letztes Backup</th><th>Letzter Restore</th><th>Status</th></tr></thead><tbody>'+status.map(x=>'<tr><td>'+esc(x.user||x.sid)+'</td><td>'+esc(when(x.last_backup_at))+'</td><td>'+esc(when(x.last_restore_at))+'</td><td>'+esc(x.last_backup_error||x.last_restore_error||'OK')+'</td></tr>').join('')+'</tbody></table>';$('profiles').innerHTML=html||'<div class="empty">Keine Profil-Jobs oder -Historie.</div>'}
async function sessionAction(id,action){let body={action};if(action==='message'){let m=prompt('Nachricht an die Sitzung:');if(!m)return;body.message=m;body.title='SessionGuard'}if(action==='logoff'&&!confirm('Sitzung '+id+' wirklich abmelden? Die Profil-Pipeline startet nach dem Sitzungsende.'))return;try{await api('/api/v1/sessions/'+id+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});setTimeout(refresh,400)}catch(e){alert(e.message)}}
function renderSessions(s){let p=s.policy||{},control=p.sessions&&p.sessions.control_enabled,ss=s.sessions||[];$('sessions').innerHTML='<table class="table"><thead><tr><th>ID</th><th>Benutzer</th><th>Status</th><th>Getrennt seit</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(x.disconnected_since?when(x.disconnected_since)+' ('+dur(x.disconnected_since)+')':'–')+'</td><td>'+esc(x.client_name||'–')+'</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>'}
async function refresh(){try{let d=await api('/api/v1/status'),s=d.snapshot||{};lastSnapshot=s;let ss=s.sessions||[];$('host').textContent=(s.server&&s.server.hostname)||'Lokaler Terminalserver';$('active').textContent=ss.filter(x=>x.state==='Active').length;$('disc').textContent=ss.filter(x=>x.state==='Disconnected').length;$('total').textContent=ss.filter(x=>x.user).length;$('pending').textContent=(s.pending_cleanup||[]).length;$('profileJobs').textContent=(s.profile_jobs||[]).length;$('master').innerHTML=d.master_error?'<span class="status-badge status-danger">Offline</span>':'<span class="status-badge status-online">Verbunden</span>';renderSessions(s);renderProfiles(s);renderEvents(s.events||[])}catch(e){$('master').innerHTML='<span class="bad">'+esc(e.message)+'</span>'}}
async function refresh(){try{let d=await api('/api/v1/status'),s=d.snapshot||{};lastSnapshot=s;let ss=s.sessions||[];$('host').textContent=(s.server&&s.server.hostname)||'Lokaler Terminalserver';$('active').textContent=ss.filter(x=>x.state==='Active').length;$('disc').textContent=ss.filter(x=>x.state==='Disconnected').length;$('total').textContent=ss.filter(x=>x.user).length;$('pending').textContent=(s.pending_cleanup||[]).length;$('profileJobs').textContent=(s.profile_jobs||[]).length;$('remoteAppCount').textContent=(s.remote_apps||[]).filter(x=>x.published&&x.path_exists&&x.in_sync).length+'/'+(s.remote_apps||[]).length;$('master').innerHTML=d.master_error?'<span class="status-badge status-danger">Offline</span>':'<span class="status-badge status-online">Verbunden</span>';renderSessions(s);renderRemoteApps(s);renderProfiles(s);renderEvents(s.events||[])}catch(e){$('master').innerHTML='<span class="bad">'+esc(e.message)+'</span>'}}
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 relativ zum Profil<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 konfiguriert.</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})}