fetch_ml/cmd/tui/internal/view/view.go
Jeremie Fraeys ea15af1833 Fix multi-user authentication and clean up debug code
- Fix YAML tags in auth config struct (json -> yaml)
- Update CLI configs to use pre-hashed API keys
- Remove double hashing in WebSocket client
- Fix port mapping (9102 -> 9103) in CLI commands
- Update permission keys to use jobs:read, jobs:create, etc.
- Clean up all debug logging from CLI and server
- All user roles now authenticate correctly:
  * Admin: Can queue jobs and see all jobs
  * Researcher: Can queue jobs and see own jobs
  * Analyst: Can see status (read-only access)

Multi-user authentication is now fully functional.
2025-12-06 12:35:32 -05:00

266 lines
9.4 KiB
Go

// Package view provides TUI rendering functionality
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}))
)
// 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()
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"
}