LEM/cmd/lem-desktop/dashboard.go
Claude 774f097855
feat: scaffold LEM Desktop app (Wails v3 system tray + Docker stack)
Inspired by BugSETI architecture — system tray with WebView2 windows,
Docker Compose stack (Forgejo + InfluxDB + inference proxy), and
scoring agent integration. Builds as signed native binary on macOS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:43:19 +00:00

299 lines
7.6 KiB
Go

package main
import (
"context"
"fmt"
"log"
"sync"
"time"
"forge.lthn.ai/lthn/lem/pkg/lem"
"github.com/wailsapp/wails/v3/pkg/application"
)
// DashboardService bridges pkg/lem CLI functions for the desktop UI.
// Provides real-time status, model inventory, and scoring progress
// to the frontend via Wails bindings.
type DashboardService struct {
influx *lem.InfluxClient
dbPath string
mu sync.RWMutex
// Cached state (refreshed periodically).
trainingStatus []TrainingRow
generationStats GenerationStats
modelInventory []ModelInfo
lastRefresh time.Time
}
// TrainingRow represents a single model's training progress.
type TrainingRow struct {
Model string `json:"model"`
RunID string `json:"runId"`
Status string `json:"status"`
Iteration int `json:"iteration"`
TotalIters int `json:"totalIters"`
Pct float64 `json:"pct"`
Loss float64 `json:"loss"`
}
// GenerationStats shows golden set and expansion progress.
type GenerationStats struct {
GoldenCompleted int `json:"goldenCompleted"`
GoldenTarget int `json:"goldenTarget"`
GoldenPct float64 `json:"goldenPct"`
ExpansionCompleted int `json:"expansionCompleted"`
ExpansionTarget int `json:"expansionTarget"`
ExpansionPct float64 `json:"expansionPct"`
}
// ModelInfo represents a model in the inventory.
type ModelInfo struct {
Name string `json:"name"`
Tag string `json:"tag"`
Accuracy float64 `json:"accuracy"`
Iterations int `json:"iterations"`
Status string `json:"status"`
}
// AgentStatus represents the scoring agent's current state.
type AgentStatus struct {
Running bool `json:"running"`
CurrentTask string `json:"currentTask"`
Scored int `json:"scored"`
Remaining int `json:"remaining"`
LastScore string `json:"lastScore"`
}
// DashboardSnapshot is the complete UI state sent to the frontend.
type DashboardSnapshot struct {
Training []TrainingRow `json:"training"`
Generation GenerationStats `json:"generation"`
Models []ModelInfo `json:"models"`
Agent AgentStatus `json:"agent"`
DBPath string `json:"dbPath"`
UpdatedAt string `json:"updatedAt"`
}
// NewDashboardService creates a DashboardService.
func NewDashboardService(influxURL, influxDB, dbPath string) *DashboardService {
return &DashboardService{
influx: lem.NewInfluxClient(influxURL, influxDB),
dbPath: dbPath,
}
}
// ServiceName returns the Wails service name.
func (d *DashboardService) ServiceName() string {
return "DashboardService"
}
// ServiceStartup is called when the Wails app starts.
func (d *DashboardService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
log.Println("DashboardService started")
go d.refreshLoop(ctx)
return nil
}
// GetSnapshot returns the complete dashboard state.
func (d *DashboardService) GetSnapshot() DashboardSnapshot {
d.mu.RLock()
defer d.mu.RUnlock()
return DashboardSnapshot{
Training: d.trainingStatus,
Generation: d.generationStats,
Models: d.modelInventory,
DBPath: d.dbPath,
UpdatedAt: d.lastRefresh.Format(time.RFC3339),
}
}
// GetTraining returns current training status.
func (d *DashboardService) GetTraining() []TrainingRow {
d.mu.RLock()
defer d.mu.RUnlock()
return d.trainingStatus
}
// GetGeneration returns generation progress.
func (d *DashboardService) GetGeneration() GenerationStats {
d.mu.RLock()
defer d.mu.RUnlock()
return d.generationStats
}
// GetModels returns the model inventory.
func (d *DashboardService) GetModels() []ModelInfo {
d.mu.RLock()
defer d.mu.RUnlock()
return d.modelInventory
}
// Refresh forces an immediate data refresh.
func (d *DashboardService) Refresh() error {
return d.refresh()
}
// RunQuery executes an ad-hoc SQL query against DuckDB.
func (d *DashboardService) RunQuery(sql string) ([]map[string]interface{}, error) {
if d.dbPath == "" {
return nil, fmt.Errorf("no database configured")
}
db, err := lem.OpenDB(d.dbPath)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
defer db.Close()
rows, err := db.QueryRows(sql)
if err != nil {
return nil, fmt.Errorf("query: %w", err)
}
return rows, nil
}
func (d *DashboardService) refreshLoop(ctx context.Context) {
// Initial refresh.
if err := d.refresh(); err != nil {
log.Printf("Dashboard refresh error: %v", err)
}
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := d.refresh(); err != nil {
log.Printf("Dashboard refresh error: %v", err)
}
}
}
}
func (d *DashboardService) refresh() error {
d.mu.Lock()
defer d.mu.Unlock()
// Query training status from InfluxDB.
rows, err := d.influx.QuerySQL(`
SELECT model, run_id, status, iteration, total_iters, pct
FROM training_status
ORDER BY time DESC LIMIT 10
`)
if err == nil {
d.trainingStatus = nil
for _, row := range rows {
d.trainingStatus = append(d.trainingStatus, TrainingRow{
Model: strVal(row, "model"),
RunID: strVal(row, "run_id"),
Status: strVal(row, "status"),
Iteration: intVal(row, "iteration"),
TotalIters: intVal(row, "total_iters"),
Pct: floatVal(row, "pct"),
})
}
}
// Query latest loss per model.
lossRows, err := d.influx.QuerySQL(`
SELECT model, loss FROM training_loss
WHERE loss_type = 'train'
ORDER BY time DESC LIMIT 10
`)
if err == nil {
lossMap := make(map[string]float64)
for _, row := range lossRows {
model := strVal(row, "model")
if _, exists := lossMap[model]; !exists {
lossMap[model] = floatVal(row, "loss")
}
}
for i, t := range d.trainingStatus {
if loss, ok := lossMap[t.Model]; ok {
d.trainingStatus[i].Loss = loss
}
}
}
// Query golden set progress.
goldenRows, err := d.influx.QuerySQL(`
SELECT completed, target, pct FROM golden_gen_progress
ORDER BY time DESC LIMIT 1
`)
if err == nil && len(goldenRows) > 0 {
d.generationStats.GoldenCompleted = intVal(goldenRows[0], "completed")
d.generationStats.GoldenTarget = intVal(goldenRows[0], "target")
d.generationStats.GoldenPct = floatVal(goldenRows[0], "pct")
}
// Query expansion progress.
expRows, err := d.influx.QuerySQL(`
SELECT completed, target, pct FROM expansion_progress
ORDER BY time DESC LIMIT 1
`)
if err == nil && len(expRows) > 0 {
d.generationStats.ExpansionCompleted = intVal(expRows[0], "completed")
d.generationStats.ExpansionTarget = intVal(expRows[0], "target")
d.generationStats.ExpansionPct = floatVal(expRows[0], "pct")
}
// Query model capability scores.
capRows, err := d.influx.QuerySQL(`
SELECT model, label, accuracy, iteration FROM capability_score
WHERE category = 'overall'
ORDER BY time DESC LIMIT 20
`)
if err == nil {
d.modelInventory = nil
seen := make(map[string]bool)
for _, row := range capRows {
label := strVal(row, "label")
if seen[label] {
continue
}
seen[label] = true
d.modelInventory = append(d.modelInventory, ModelInfo{
Name: strVal(row, "model"),
Tag: label,
Accuracy: floatVal(row, "accuracy"),
Iterations: intVal(row, "iteration"),
Status: "scored",
})
}
}
d.lastRefresh = time.Now()
return nil
}
func strVal(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
return fmt.Sprintf("%v", v)
}
return ""
}
func intVal(m map[string]interface{}, key string) int {
if v, ok := m[key]; ok {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
}
}
return 0
}
func floatVal(m map[string]interface{}, key string) float64 {
if v, ok := m[key]; ok {
if f, ok := v.(float64); ok {
return f
}
}
return 0
}