Lightweight Go-based dashboard for Raspberry Pi cluster: Backend: - chi router with REST API - Embedded static file serving - JSON file-based state storage - Health checks and CORS support Frontend: - Responsive dark theme UI - Status view with nodes, alerts, ArgoCD apps - Pending actions with approve/reject - Action history and audit trail - Workflow listing and manual triggers Deployment: - Multi-stage Dockerfile (small Alpine image) - Kubernetes manifests with Pi 3 tolerations - Resource limits: 32-64Mi memory, 10-100m CPU - ArgoCD application manifest - Kustomize configuration API endpoints: - GET /api/status - Cluster status - GET/POST /api/pending - Action management - GET /api/history - Action audit trail - GET/POST /api/workflows - Workflow management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
245 lines
6.0 KiB
Go
245 lines
6.0 KiB
Go
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/will/k8s-agent-dashboard/internal/models"
|
|
)
|
|
|
|
// Store manages persistent state for the dashboard
|
|
type Store struct {
|
|
dataDir string
|
|
mu sync.RWMutex
|
|
|
|
// In-memory cache
|
|
status *models.ClusterStatus
|
|
pending []models.PendingAction
|
|
history []models.ActionHistory
|
|
workflows []models.Workflow
|
|
}
|
|
|
|
// New creates a new store instance
|
|
func New(dataDir string) (*Store, error) {
|
|
// Ensure data directory exists
|
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create data dir: %w", err)
|
|
}
|
|
|
|
s := &Store{
|
|
dataDir: dataDir,
|
|
pending: make([]models.PendingAction, 0),
|
|
history: make([]models.ActionHistory, 0),
|
|
workflows: make([]models.Workflow, 0),
|
|
}
|
|
|
|
// Load existing data
|
|
if err := s.load(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Store) load() error {
|
|
// Load pending actions
|
|
pendingPath := filepath.Join(s.dataDir, "pending.json")
|
|
if data, err := os.ReadFile(pendingPath); err == nil {
|
|
if err := json.Unmarshal(data, &s.pending); err != nil {
|
|
return fmt.Errorf("failed to parse pending.json: %w", err)
|
|
}
|
|
}
|
|
|
|
// Load history
|
|
historyPath := filepath.Join(s.dataDir, "history.json")
|
|
if data, err := os.ReadFile(historyPath); err == nil {
|
|
if err := json.Unmarshal(data, &s.history); err != nil {
|
|
return fmt.Errorf("failed to parse history.json: %w", err)
|
|
}
|
|
}
|
|
|
|
// Load status
|
|
statusPath := filepath.Join(s.dataDir, "status.json")
|
|
if data, err := os.ReadFile(statusPath); err == nil {
|
|
s.status = &models.ClusterStatus{}
|
|
if err := json.Unmarshal(data, s.status); err != nil {
|
|
return fmt.Errorf("failed to parse status.json: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) save(filename string, data interface{}) error {
|
|
path := filepath.Join(s.dataDir, filename)
|
|
bytes, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, bytes, 0644)
|
|
}
|
|
|
|
// GetClusterStatus returns the current cluster status
|
|
func (s *Store) GetClusterStatus() *models.ClusterStatus {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.status == nil {
|
|
// Return demo status if none exists
|
|
return &models.ClusterStatus{
|
|
Health: "Unknown",
|
|
UpdatedAt: time.Now(),
|
|
Nodes: []models.NodeStatus{},
|
|
Alerts: []models.Alert{},
|
|
Apps: []models.AppStatus{},
|
|
}
|
|
}
|
|
return s.status
|
|
}
|
|
|
|
// UpdateClusterStatus updates the cluster status
|
|
func (s *Store) UpdateClusterStatus(status *models.ClusterStatus) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
status.UpdatedAt = time.Now()
|
|
s.status = status
|
|
return s.save("status.json", status)
|
|
}
|
|
|
|
// GetPendingActions returns all pending actions
|
|
func (s *Store) GetPendingActions() []models.PendingAction {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.pending
|
|
}
|
|
|
|
// AddPendingAction adds a new pending action
|
|
func (s *Store) AddPendingAction(action models.PendingAction) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
action.CreatedAt = time.Now()
|
|
s.pending = append(s.pending, action)
|
|
return s.save("pending.json", s.pending)
|
|
}
|
|
|
|
// ApproveAction approves a pending action
|
|
func (s *Store) ApproveAction(id string, reason string) (*models.PendingAction, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, action := range s.pending {
|
|
if action.ID == id {
|
|
// Remove from pending
|
|
s.pending = append(s.pending[:i], s.pending[i+1:]...)
|
|
|
|
// Add to history
|
|
historyEntry := models.ActionHistory{
|
|
ID: action.ID,
|
|
Timestamp: time.Now(),
|
|
Agent: action.Agent,
|
|
Action: action.Action,
|
|
Description: action.Description,
|
|
Details: action.Details,
|
|
Result: "approved",
|
|
AutoApproved: false,
|
|
Workflow: action.Workflow,
|
|
}
|
|
s.history = append([]models.ActionHistory{historyEntry}, s.history...)
|
|
|
|
// Keep only last 100 history entries
|
|
if len(s.history) > 100 {
|
|
s.history = s.history[:100]
|
|
}
|
|
|
|
// Save both files
|
|
s.save("pending.json", s.pending)
|
|
s.save("history.json", s.history)
|
|
|
|
return &action, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("action not found: %s", id)
|
|
}
|
|
|
|
// RejectAction rejects a pending action
|
|
func (s *Store) RejectAction(id string, reason string) (*models.PendingAction, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, action := range s.pending {
|
|
if action.ID == id {
|
|
// Remove from pending
|
|
s.pending = append(s.pending[:i], s.pending[i+1:]...)
|
|
|
|
// Add to history as rejected
|
|
historyEntry := models.ActionHistory{
|
|
ID: action.ID,
|
|
Timestamp: time.Now(),
|
|
Agent: action.Agent,
|
|
Action: action.Action,
|
|
Description: action.Description + " (REJECTED: " + reason + ")",
|
|
Details: action.Details,
|
|
Result: "rejected",
|
|
AutoApproved: false,
|
|
Workflow: action.Workflow,
|
|
}
|
|
s.history = append([]models.ActionHistory{historyEntry}, s.history...)
|
|
|
|
if len(s.history) > 100 {
|
|
s.history = s.history[:100]
|
|
}
|
|
|
|
s.save("pending.json", s.pending)
|
|
s.save("history.json", s.history)
|
|
|
|
return &action, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("action not found: %s", id)
|
|
}
|
|
|
|
// GetActionHistory returns the action history
|
|
func (s *Store) GetActionHistory(limit int) []models.ActionHistory {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if limit <= 0 || limit > len(s.history) {
|
|
return s.history
|
|
}
|
|
return s.history[:limit]
|
|
}
|
|
|
|
// GetWorkflows returns all defined workflows
|
|
func (s *Store) GetWorkflows() []models.Workflow {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Return predefined workflows based on what we have in ~/.claude/workflows
|
|
return []models.Workflow{
|
|
{
|
|
Name: "cluster-health-check",
|
|
Description: "Comprehensive cluster health assessment",
|
|
Triggers: []string{"schedule: 0 */6 * * *", "manual"},
|
|
Status: "idle",
|
|
},
|
|
{
|
|
Name: "deploy-app",
|
|
Description: "Deploy or update an application",
|
|
Triggers: []string{"manual"},
|
|
Status: "idle",
|
|
},
|
|
{
|
|
Name: "pod-crashloop-remediation",
|
|
Description: "Diagnose and remediate pods in CrashLoopBackOff",
|
|
Triggers: []string{"alert: KubePodCrashLooping", "manual"},
|
|
Status: "idle",
|
|
},
|
|
}
|
|
}
|