0.4.0
This commit is contained in:
+125
-2
@@ -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
@@ -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.
|
||||
|
||||
@@ -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})}
|
||||
|
||||
Reference in New Issue
Block a user