fetch_ml/cmd/tui/internal/view/view.go
Jeremie Fraeys 803677be57 feat: implement Go backend with comprehensive API and internal packages
- Add API server with WebSocket support and REST endpoints
- Implement authentication system with API keys and permissions
- Add task queue system with Redis backend and error handling
- Include storage layer with database migrations and schemas
- Add comprehensive logging, metrics, and telemetry
- Implement security middleware and network utilities
- Add experiment management and container orchestration
- Include configuration management with smart defaults
2025-12-04 16:53:53 -05:00

255 lines
9.1 KiB
Go

package view
import (
"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}))
)
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()
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"
}
return statusStyle.Width(m.Width - 4).Render(spinnerStr + " " + statusText)
}
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 v : Queue view ║
║ g : GPU view o : Container view ║
║ s : Settings view ║
║ ║
║ Actions ║
║ t : Queue job a : Queue w/ args ║
║ c : Cancel task d : Delete pending ║
║ f : Mark as failed r : Refresh all ║
║ G : Refresh GPU only ║
║ ║
║ General ║
║ h or ? : Toggle this help q/Ctrl+C : Quit ║
╚═══════════════════════════════════════════════════════════════╝`
}
func getQuickHelp(m model.State) string {
if m.ActiveView == model.ViewModeSettings {
return " ↑/↓:move enter:select esc:exit settings q:quit"
}
return " h:help 1:jobs 2:datasets 3:experiments v:queue g:gpu o:containers s:settings t:queue r:refresh q:quit"
}