fetch_ml/cmd/tui/internal/view/view.go
Jeremie Fraeys 6028779239
feat: update CLI, TUI, and security documentation
- Add safety checks to Zig build
- Add TUI with job management and narrative views
- Add WebSocket support and export services
- Add smart configuration defaults
- Update API routes with security headers
- Update SECURITY.md with comprehensive policy
- Add Makefile security scanning targets
2026-02-19 15:35:05 -05:00

298 lines
11 KiB
Go

// Package view provides TUI rendering functionality
package view
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/jfraeys/fetch_ml/cmd/tui/internal/model"
)
const (
headerfgLight = "#d35400"
headerfgDark = "#ff9e64"
activeBorderfgLight = "#3498db"
activeBorderfgDark = "#7aa2f7"
errorbgLight = "#fee"
errorbgDark = "#633"
errorfgLight = "#a00"
errorfgDark = "#faa"
titlefgLight = "#d35400"
titlefgDark = "#ff9e64"
statusfgLight = "#2e3440"
statusfgDark = "#d8dee9"
statusbgLight = "#e5e9f0"
statusbgDark = "#2e3440"
borderfgLight = "#d8dee9"
borderfgDark = "#4c566a"
helpfgLight = "#4c566a"
helpfgDark = "#88c0d0"
)
var (
docStyle = lipgloss.NewStyle().Margin(1, 2)
activeBorderStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.AdaptiveColor{Light: activeBorderfgLight, Dark: activeBorderfgDark}).
Padding(1, 2)
errorStyle = lipgloss.NewStyle().
Background(lipgloss.AdaptiveColor{Light: errorbgLight, Dark: errorbgDark}).
Foreground(lipgloss.AdaptiveColor{Light: errorfgLight, Dark: errorfgDark}).
Padding(0, 1).
Bold(true)
titleStyle = (lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: titlefgLight, Dark: titlefgDark}).
MarginBottom(1))
statusStyle = (lipgloss.NewStyle().
Background(lipgloss.AdaptiveColor{Light: statusbgLight, Dark: statusbgDark}).
Foreground(lipgloss.AdaptiveColor{Light: statusfgLight, Dark: statusfgDark}).
Padding(0, 1))
borderStyle = (lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.AdaptiveColor{Light: borderfgLight, Dark: borderfgDark}).
Padding(0, 1))
helpStyle = (lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: helpfgLight, Dark: helpfgDark}))
)
// Render renders the TUI view
func Render(m model.State) string {
if m.Width == 0 {
return "Loading..."
}
// Title
title := titleStyle.Width(m.Width - 4).Render("🤖 ML Experiment Manager")
// Left panel - Job list (30% width)
leftWidth := int(float64(m.Width) * 0.3)
leftPanel := getJobListPanel(m, leftWidth)
// Right panel - Dynamic content (70% width)
rightWidth := m.Width - leftWidth - 4
rightPanel := getRightPanel(m, rightWidth)
// Main content
main := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
// Status bar
statusBar := getStatusBar(m)
// Error bar (if present)
var errorBar string
if m.ErrorMsg != "" {
errorBar = errorStyle.Width(m.Width - 4).Render("⚠ Error: " + m.ErrorMsg)
}
// Help view (toggleable)
var helpView string
if m.ShowHelp {
helpView = helpStyle.Width(m.Width-4).
Padding(1, 2).
Render(helpText(m))
}
// Quick help bar
quickHelp := helpStyle.Width(m.Width - 4).Render(getQuickHelp(m))
// Compose final layout
parts := []string{title, main, statusBar}
if errorBar != "" {
parts = append(parts, errorBar)
}
if helpView != "" {
parts = append(parts, helpView)
}
parts = append(parts, quickHelp)
return docStyle.Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
}
func getJobListPanel(m model.State, width int) string {
style := borderStyle
if m.ActiveView == model.ViewModeJobs {
style = activeBorderStyle
}
// Ensure the job list has proper dimensions to prevent rendering issues
// Note: We can't modify the model here as it's passed by value,
// but the View() method of list.Model uses its internal state.
// Ideally, the controller should have set the size.
// For now, we assume the controller handles resizing or we act on a copy.
// But list.Model.SetSize modifies the model.
// Since we receive 'm' by value, modifications to m.JobList won't persist.
// However, we need to render it with the correct size.
// So we can modify our local copy 'm'.
h, v := style.GetFrameSize()
m.JobList.SetSize(width-h, m.Height-v-4) // Adjust height for title/help/status
// Custom empty state
if len(m.JobList.Items()) == 0 {
return style.Width(width - h).Render(
lipgloss.JoinVertical(lipgloss.Left,
m.JobList.Styles.Title.Render(m.JobList.Title),
"\n No jobs found.",
" Press 't' to queue.",
),
)
}
return style.Width(width - h).Render(m.JobList.View())
}
func getRightPanel(m model.State, width int) string {
var content string
var viewTitle string
style := borderStyle
switch m.ActiveView {
case model.ViewModeGPU:
style = activeBorderStyle
viewTitle = "🎮 GPU Status"
content = m.GpuView.View()
case model.ViewModeContainer:
style = activeBorderStyle
viewTitle = "🐳 Container Status"
content = m.ContainerView.View()
case model.ViewModeQueue:
style = activeBorderStyle
viewTitle = "⏳ Task Queue"
content = m.QueueView.View()
case model.ViewModeSettings:
style = activeBorderStyle
viewTitle = "⚙️ Settings"
content = m.SettingsView.View()
case model.ViewModeExperiments:
style = activeBorderStyle
viewTitle = "🧪 Experiments"
content = m.ExperimentsView.View()
case model.ViewModeJobs:
style = activeBorderStyle
viewTitle = "📋 Job Details"
content = m.JobList.View()
case model.ViewModeDatasets:
style = activeBorderStyle
viewTitle = "📦 Datasets"
content = m.DatasetView.View()
case model.ViewModeNarrative:
style = activeBorderStyle
viewTitle = "📊 Research Context"
narrativeView := &NarrativeView{Width: width, Height: m.Height}
content = narrativeView.View(m.SelectedJob)
case model.ViewModeTeam:
style = activeBorderStyle
viewTitle = "👥 Team Jobs"
content = "Team collaboration view - shows jobs from all team members\n\n(Requires API: GET /api/jobs?all_users=true)"
case model.ViewModeExperimentHistory:
style = activeBorderStyle
viewTitle = "📜 Experiment History"
content = m.ExperimentHistoryView.View()
case model.ViewModeConfig:
style = activeBorderStyle
viewTitle = "⚙️ Config"
content = m.ConfigView.View()
case model.ViewModeLogs:
style = activeBorderStyle
viewTitle = "📜 Logs"
content = m.LogsView.View()
default:
viewTitle = "📊 System Overview"
content = getOverviewPanel(m)
}
header := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: headerfgLight, Dark: headerfgDark}).
Render(viewTitle)
h, _ := style.GetFrameSize()
return style.Width(width - h).Render(header + "\n\n" + content)
}
func getOverviewPanel(m model.State) string {
var sections []string
sections = append(sections, "🎮 GPU\n"+strings.Repeat("─", 40))
sections = append(sections, m.GpuView.View())
sections = append(sections, "\n🐳 Containers\n"+strings.Repeat("─", 40))
sections = append(sections, m.ContainerView.View())
return strings.Join(sections, "\n")
}
func getStatusBar(m model.State) string {
spinnerStr := m.Spinner.View()
if !m.IsLoading {
if m.ShowHelp {
spinnerStr = "?"
} else {
spinnerStr = "●"
}
}
statusText := m.Status
if m.ShowHelp {
statusText = "Press 'h' to hide help"
}
// Add refresh rate indicator
refreshInfo := ""
if m.RefreshRate > 0 {
refreshInfo = fmt.Sprintf(" | %.0fms", m.RefreshRate)
}
return statusStyle.Width(m.Width - 4).Render(spinnerStr + " " + statusText + refreshInfo)
}
func helpText(m model.State) string {
if m.ActiveView == model.ViewModeSettings {
return `╔═══════════════════════════════════════════════════════════════╗
║ Settings Shortcuts ║
╠═══════════════════════════════════════════════════════════════╣
║ Navigation ║
║ j/k, ↑/↓ : Move selection ║
║ Enter : Edit / Save ║
║ Esc : Exit Settings ║
║ ║
║ General ║
║ h or ? : Toggle this help q/Ctrl+C : Quit ║
╚═══════════════════════════════════════════════════════════════╝`
}
return `╔═══════════════════════════════════════════════════════════════╗
║ Keyboard Shortcuts ║
╠═══════════════════════════════════════════════════════════════╣
║ Navigation ║
║ j/k, ↑/↓ : Move selection / : Filter jobs ║
║ 1 : Job list view 2 : Datasets view ║
║ 3 : Experiments view q : Queue view ║
║ g : GPU view o : Container view ║
║ s : Settings view n : Narrative view ║
║ m : Team view e : Experiment history ║
║ c : Config view l : Logs (job stream) ║
║ E : Export job @ : Filter by team ║
║ ║
║ Actions ║
║ t : Queue job a : Queue w/ args ║
║ x : Cancel task d : Delete pending ║
║ f : Mark as failed r : Refresh all ║
║ G : Refresh GPU only ║
║ ║
║ General ║
║ h or ? : Toggle this help Ctrl+C : Quit ║
╚═══════════════════════════════════════════════════════════════╝`
}
func getQuickHelp(m model.State) string {
if m.ActiveView == model.ViewModeSettings {
return " ↑/↓:move enter:select esc:exit settings Ctrl+C:quit"
}
return " h:help 1:jobs 2:datasets 3:experiments q:queue g:gpu o:containers " +
"n:narrative m:team e:history c:config l:logs E:export @:filter s:settings t:trigger r:refresh Ctrl+C:quit"
}