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
}