- 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.
266 lines
9.4 KiB
Go
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"
|
|
}
|