feat: RHS panel initial fetch, floating widget, session cleanup (#5)

Phase 1: Fix RHS panel to fetch existing sessions on mount
- Add initial API fetch in useAllStatusUpdates() hook
- Allow GET /sessions endpoint without shared secret auth
- RHS panel now shows sessions after page refresh

Phase 2: Floating widget component (registerRootComponent)
- New floating_widget.tsx with auto-show/hide behavior
- Draggable, collapsible to pulsing dot with session count
- Shows last 5 lines of most recent active session
- Position persisted to localStorage
- CSS styles using Mattermost theme variables

Phase 3: Session cleanup and KV optimization
- Add LastUpdateMs field to SessionData for staleness tracking
- Set LastUpdateMs on session create and update
- Add periodic cleanup goroutine (every 5 min)
- Stale active sessions (>30 min no update) marked interrupted
- Expired non-active sessions (>1 hr) deleted from KV
- Add ListAllSessions and keep ListActiveSessions as helper
- Add debug logging to daemon file polling

Closes #5
This commit is contained in:
sol
2026-03-09 14:15:04 +00:00
parent 9ec52a418d
commit 79d5e82803
10 changed files with 520 additions and 32 deletions

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"strings"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
@@ -13,19 +14,24 @@ import (
// ServeHTTP handles HTTP requests to the plugin.
func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
// Auth middleware: validate shared secret
config := p.getConfiguration()
if config.SharedSecret != "" {
auth := r.Header.Get("Authorization")
expected := "Bearer " + config.SharedSecret
if auth != expected {
http.Error(w, `{"error": "unauthorized"}`, http.StatusUnauthorized)
return
path := r.URL.Path
// Auth middleware: validate shared secret for write operations.
// Read-only endpoints (GET /sessions, GET /health) are accessible to any
// authenticated Mattermost user — no shared secret required.
isReadOnly := r.Method == http.MethodGet && (path == "/api/v1/sessions" || path == "/api/v1/health")
if !isReadOnly {
config := p.getConfiguration()
if config.SharedSecret != "" {
auth := r.Header.Get("Authorization")
expected := "Bearer " + config.SharedSecret
if auth != expected {
http.Error(w, `{"error": "unauthorized"}`, http.StatusUnauthorized)
return
}
}
}
path := r.URL.Path
switch {
case path == "/api/v1/health" && r.Method == http.MethodGet:
p.handleHealth(w, r)
@@ -46,10 +52,14 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req
// handleHealth returns plugin health status.
func (p *Plugin) handleHealth(w http.ResponseWriter, r *http.Request) {
sessions, err := p.store.ListActiveSessions()
sessions, err := p.store.ListAllSessions()
count := 0
if err == nil {
count = len(sessions)
for _, s := range sessions {
if s.Status == "active" {
count++
}
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
@@ -59,9 +69,9 @@ func (p *Plugin) handleHealth(w http.ResponseWriter, r *http.Request) {
})
}
// handleListSessions returns all active sessions.
// handleListSessions returns all sessions (active and non-active).
func (p *Plugin) handleListSessions(w http.ResponseWriter, r *http.Request) {
sessions, err := p.store.ListActiveSessions()
sessions, err := p.store.ListAllSessions()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
@@ -93,8 +103,14 @@ func (p *Plugin) handleCreateSession(w http.ResponseWriter, r *http.Request) {
// Check max active sessions
config := p.getConfiguration()
sessions, _ := p.store.ListActiveSessions()
if len(sessions) >= config.MaxActiveSessions {
allSessions, _ := p.store.ListAllSessions()
activeCount := 0
for _, s := range allSessions {
if s.Status == "active" {
activeCount++
}
}
if activeCount >= config.MaxActiveSessions {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "max active sessions reached"})
return
}
@@ -133,14 +149,17 @@ func (p *Plugin) handleCreateSession(w http.ResponseWriter, r *http.Request) {
}
// Store session data
now := time.Now().UnixMilli()
sessionData := SessionData{
SessionKey: req.SessionKey,
PostID: createdPost.Id,
ChannelID: req.ChannelID,
RootID: req.RootID,
AgentID: req.AgentID,
Status: "active",
Lines: []string{},
SessionKey: req.SessionKey,
PostID: createdPost.Id,
ChannelID: req.ChannelID,
RootID: req.RootID,
AgentID: req.AgentID,
Status: "active",
Lines: []string{},
StartTimeMs: now,
LastUpdateMs: now,
}
if err := p.store.SaveSession(req.SessionKey, sessionData); err != nil {
p.API.LogWarn("Failed to save session", "error", err.Error())
@@ -187,6 +206,7 @@ func (p *Plugin) handleUpdateSession(w http.ResponseWriter, r *http.Request, ses
existing.ElapsedMs = req.ElapsedMs
existing.TokenCount = req.TokenCount
existing.Children = req.Children
existing.LastUpdateMs = time.Now().UnixMilli()
if req.StartTimeMs > 0 {
existing.StartTimeMs = req.StartTimeMs
}

View File

@@ -2,6 +2,7 @@ package main
import (
"sync"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
@@ -22,6 +23,9 @@ type Plugin struct {
// botUserID is the plugin's bot user ID (created on activation).
botUserID string
// stopCleanup signals the cleanup goroutine to stop.
stopCleanup chan struct{}
}
// OnActivate is called when the plugin is activated.
@@ -41,10 +45,36 @@ func (p *Plugin) OnActivate() error {
p.API.LogInfo("Plugin bot user ensured", "botUserID", botID)
}
// Start session cleanup goroutine
p.stopCleanup = make(chan struct{})
go p.sessionCleanupLoop()
p.API.LogInfo("OpenClaw Live Status plugin activated")
return nil
}
// sessionCleanupLoop runs periodically to clean up stale and expired sessions.
func (p *Plugin) sessionCleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
staleThreshold := int64(30 * 60 * 1000) // 30 minutes — active sessions with no update
expireThreshold := int64(60 * 60 * 1000) // 1 hour — completed/interrupted sessions
cleaned, expired, err := p.store.CleanStaleSessions(staleThreshold, expireThreshold)
if err != nil {
p.API.LogWarn("Session cleanup error", "error", err.Error())
} else if cleaned > 0 || expired > 0 {
p.API.LogInfo("Session cleanup completed", "stale_marked", cleaned, "expired_deleted", expired)
}
case <-p.stopCleanup:
return
}
}
}
// getBotUserID returns the plugin's bot user ID.
func (p *Plugin) getBotUserID() string {
return p.botUserID
@@ -52,8 +82,13 @@ func (p *Plugin) getBotUserID() string {
// OnDeactivate is called when the plugin is deactivated.
func (p *Plugin) OnDeactivate() error {
// Stop cleanup goroutine
if p.stopCleanup != nil {
close(p.stopCleanup)
}
// Mark all active sessions as interrupted
sessions, err := p.store.ListActiveSessions()
sessions, err := p.store.ListAllSessions()
if err != nil {
p.API.LogWarn("Failed to list sessions on deactivate", "error", err.Error())
} else {

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/url"
"strings"
"time"
"github.com/mattermost/mattermost/server/public/plugin"
)
@@ -22,8 +23,9 @@ type SessionData struct {
Lines []string `json:"lines"`
ElapsedMs int64 `json:"elapsed_ms"`
TokenCount int `json:"token_count"`
Children []SessionData `json:"children,omitempty"`
StartTimeMs int64 `json:"start_time_ms"`
Children []SessionData `json:"children,omitempty"`
StartTimeMs int64 `json:"start_time_ms"`
LastUpdateMs int64 `json:"last_update_ms"`
}
// Store wraps Mattermost KV store operations for session persistence.
@@ -79,8 +81,8 @@ func (s *Store) DeleteSession(sessionKey string) error {
return nil
}
// ListActiveSessions returns all active sessions from the KV store.
func (s *Store) ListActiveSessions() ([]SessionData, error) {
// ListAllSessions returns all sessions from the KV store (active and non-active).
func (s *Store) ListAllSessions() ([]SessionData, error) {
var sessions []SessionData
page := 0
perPage := 100
@@ -106,12 +108,59 @@ func (s *Store) ListActiveSessions() ([]SessionData, error) {
if err := json.Unmarshal(b, &data); err != nil {
continue
}
if data.Status == "active" {
sessions = append(sessions, data)
}
sessions = append(sessions, data)
}
page++
}
return sessions, nil
}
// ListActiveSessions returns only active sessions from the KV store.
func (s *Store) ListActiveSessions() ([]SessionData, error) {
all, err := s.ListAllSessions()
if err != nil {
return nil, err
}
var active []SessionData
for _, sess := range all {
if sess.Status == "active" {
active = append(active, sess)
}
}
return active, nil
}
// CleanStaleSessions marks stale active sessions as interrupted and deletes expired completed sessions.
// staleThresholdMs: active sessions with no update for this long are marked interrupted.
// expireThresholdMs: non-active sessions older than this are deleted from KV.
func (s *Store) CleanStaleSessions(staleThresholdMs, expireThresholdMs int64) (cleaned int, expired int, err error) {
now := time.Now().UnixMilli()
all, err := s.ListAllSessions()
if err != nil {
return 0, 0, err
}
for _, session := range all {
lastUpdate := session.LastUpdateMs
if lastUpdate == 0 {
lastUpdate = session.StartTimeMs
}
if lastUpdate == 0 {
continue
}
age := now - lastUpdate
if session.Status == "active" && age > staleThresholdMs {
// Mark stale sessions as interrupted
session.Status = "interrupted"
session.LastUpdateMs = now
_ = s.SaveSession(session.SessionKey, session)
cleaned++
} else if session.Status != "active" && age > expireThresholdMs {
// Delete expired completed/interrupted sessions
_ = s.DeleteSession(session.SessionKey)
expired++
}
}
return cleaned, expired, nil
}