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>
299 lines
7.6 KiB
Go
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
|
|
}
|