feat(tui-go): health-checks pane
Surface the health subsystem (llama/event-store/disk probes) in the TUI, mirroring the session-stats pane: ClientMessage.GetHealthChecks -> ServerMessage.HealthChecks (health.checks, reuses HealthReport) -> StreamQueries.healthChecks via HealthInspectionService; Go OverlayHealth on key H + palette 'health checks', pull-based fetch, per-subject status with degraded loud. Empty/disabled -> visible fallback (no blank/lie). Go+Kotlin golden tests pin the wire contract field-for-field. Observability spec §4/§3.
This commit is contained in:
@@ -152,6 +152,14 @@ func PreviewFrame(kind string, w, h int) string {
|
||||
m.overlay = OverlayStats
|
||||
m.statsFor = "04a546aa"
|
||||
m.stats = sampleStats()
|
||||
|
||||
case "health":
|
||||
m.connected = true
|
||||
m.currentModel = "llama-cpp:default"
|
||||
m.sessions = sampleSessions()
|
||||
m.selectedID = "04a546aa"
|
||||
m.overlay = OverlayHealth
|
||||
m.health = sampleHealth()
|
||||
}
|
||||
return m.View()
|
||||
}
|
||||
@@ -193,6 +201,39 @@ func sampleStats() *protocol.StatsDto {
|
||||
}
|
||||
}
|
||||
|
||||
func sampleHealth() *protocol.HealthDto {
|
||||
return &protocol.HealthDto{
|
||||
Overall: "DEGRADED",
|
||||
Subjects: []protocol.HealthSubjectDto{
|
||||
{
|
||||
Subject: "DISK",
|
||||
Status: "DEGRADED",
|
||||
Metric: "free_bytes",
|
||||
ObservedValue: 512_000_000,
|
||||
Detail: "disk free below 1 GB threshold",
|
||||
Since: "2026-06-14T10:00:00Z",
|
||||
},
|
||||
{
|
||||
Subject: "EVENT_STORE",
|
||||
Status: "HEALTHY",
|
||||
Metric: "write_latency_ms",
|
||||
ObservedValue: 4,
|
||||
Detail: "ok",
|
||||
Since: "2026-06-14T09:00:00Z",
|
||||
},
|
||||
{
|
||||
Subject: "LLAMA_SERVER",
|
||||
Status: "HEALTHY",
|
||||
Metric: "http_status",
|
||||
ObservedValue: 200,
|
||||
Detail: "ok",
|
||||
Since: "2026-06-14T08:30:00Z",
|
||||
},
|
||||
},
|
||||
CheckedAt: "2026-06-14T10:05:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
func sampleWorkflows() []Workflow {
|
||||
return []Workflow{
|
||||
{ID: "healthcheck", Description: "ping endpoints and report status"},
|
||||
|
||||
@@ -53,6 +53,7 @@ const (
|
||||
OverlayConfig
|
||||
OverlayStats
|
||||
OverlayIdeas
|
||||
OverlayHealth
|
||||
)
|
||||
|
||||
// RouterEntry is one line in a session's conversation transcript.
|
||||
@@ -259,6 +260,10 @@ type Model struct {
|
||||
ideasIndex int
|
||||
ideasLoading bool
|
||||
|
||||
// health checks (OverlayHealth) — system-scoped, populated by the health.checks reply
|
||||
health *protocol.HealthDto
|
||||
healthLoading bool
|
||||
|
||||
// command palette
|
||||
paletteFilter string
|
||||
paletteIndex int
|
||||
|
||||
@@ -61,6 +61,8 @@ func (m Model) renderOverlay(base string) string {
|
||||
return m.center(m.statsModal())
|
||||
case OverlayIdeas:
|
||||
return m.center(m.ideasModal())
|
||||
case OverlayHealth:
|
||||
return m.center(m.healthModal())
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -750,6 +752,66 @@ func (m Model) statsModal() string {
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) healthModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("health checks"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if m.healthLoading || m.health == nil {
|
||||
msg := "loading…"
|
||||
if !m.healthLoading && m.health == nil {
|
||||
msg = "no health report available"
|
||||
}
|
||||
b.WriteString(mbg(t, " "+msg, t.P.Faint) + "\n")
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"H/esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
h := m.health
|
||||
cw := w - 4
|
||||
|
||||
// overall status header
|
||||
overallFg := t.P.OK
|
||||
if h.Overall == "DEGRADED" {
|
||||
overallFg = t.P.Bad
|
||||
}
|
||||
overall := lipgloss.NewStyle().Foreground(overallFg).Background(t.P.BgPanel).Bold(true).Render(h.Overall)
|
||||
b.WriteString(clip(mbg(t, " overall ", t.P.Faint)+overall+mbg(t, " checked "+h.CheckedAt, t.P.Faint), cw) + "\n\n")
|
||||
|
||||
if len(h.Subjects) == 0 {
|
||||
b.WriteString(mbg(t, " no health probes recorded — health monitoring may be disabled", t.P.Faint) + "\n")
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"H/esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
section := func(label string) string {
|
||||
return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
|
||||
}
|
||||
put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") }
|
||||
faint := func(s string) string { return mbg(t, s, t.P.Faint) }
|
||||
|
||||
for _, sub := range h.Subjects {
|
||||
statusFg := t.P.OK
|
||||
if sub.Status == "DEGRADED" {
|
||||
statusFg = t.P.Bad
|
||||
}
|
||||
statusLabel := lipgloss.NewStyle().Foreground(statusFg).Background(t.P.BgPanel).Bold(sub.Status == "DEGRADED").Render(sub.Status)
|
||||
b.WriteString(section(sub.Subject) + mbg(t, " ", t.P.BgPanel) + statusLabel + "\n")
|
||||
put(faint(fmt.Sprintf(" metric %-18s value %d", sub.Metric, sub.ObservedValue)))
|
||||
if sub.Detail != "" {
|
||||
put(faint(" " + truncate(sub.Detail, cw-4)))
|
||||
}
|
||||
put(faint(" since " + sub.Since))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString(modalHints(t, [][2]string{{"H/esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
// shortID trims a long artifact id for the list column while staying identifiable.
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 14 {
|
||||
|
||||
@@ -322,6 +322,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
if m.ideasIndex >= len(m.ideas) {
|
||||
m.ideasIndex = 0
|
||||
}
|
||||
case protocol.TypeHealthChecks:
|
||||
m.health = msg.Health
|
||||
m.healthLoading = false
|
||||
case protocol.TypeConfigSnapshot:
|
||||
m.configFields = msg.ConfigFields
|
||||
m.configRestart = msg.ConfigRestartRequired
|
||||
@@ -367,7 +370,7 @@ func sessionIDOf(msg protocol.ServerMessage) string {
|
||||
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
||||
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
||||
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
|
||||
protocol.TypeIdeaList:
|
||||
protocol.TypeIdeaList, protocol.TypeHealthChecks:
|
||||
return ""
|
||||
default:
|
||||
return msg.SessionID
|
||||
|
||||
@@ -178,6 +178,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.openArtifacts()
|
||||
case "I":
|
||||
m.openIdeas()
|
||||
case "H":
|
||||
m.openHealth()
|
||||
case "S":
|
||||
m.openStats()
|
||||
case "g":
|
||||
@@ -444,6 +446,10 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if runeIs(k, "S") {
|
||||
m.overlay = OverlayNone
|
||||
}
|
||||
case OverlayHealth:
|
||||
if runeIs(k, "H") {
|
||||
m.overlay = OverlayNone
|
||||
}
|
||||
case OverlayIdeas:
|
||||
switch {
|
||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
||||
@@ -513,6 +519,17 @@ func (m *Model) openStats() {
|
||||
m.client.Send(protocol.GetSessionStats(m.selectedID))
|
||||
}
|
||||
|
||||
// openHealth opens the system health-checks pane and fetches the current report.
|
||||
// Health is system-scoped (not session-scoped), so it opens from anywhere.
|
||||
func (m *Model) openHealth() {
|
||||
m.overlay = OverlayHealth
|
||||
// Always re-fetch and show a loading state: health changes out from under us, so a
|
||||
// cached report would be stale (TUI spec §2 — render from the fetched report).
|
||||
m.health = nil
|
||||
m.healthLoading = true
|
||||
m.client.Send(protocol.GetHealthChecks())
|
||||
}
|
||||
|
||||
// openModelsOverlay opens the model picker, pre-selecting the resident model.
|
||||
func (m *Model) openModelsOverlay() {
|
||||
m.overlay = OverlayModels
|
||||
@@ -566,6 +583,7 @@ func paletteCommands() []paletteCmd {
|
||||
{"events", "event inspector", "browse the event stream"},
|
||||
{"artifacts", "view artifacts", "browse this session's artifacts"},
|
||||
{"stats", "session stats", "metrics for the selected session"},
|
||||
{"health", "health checks", "system health probe status"},
|
||||
{"config", "edit config", "view / change correx settings"},
|
||||
{"mode", "toggle mode", "switch chat / steering"},
|
||||
{"cancel", "cancel session", "stop the selected session"},
|
||||
@@ -605,6 +623,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
||||
m.openArtifacts()
|
||||
case "stats":
|
||||
m.openStats()
|
||||
case "health":
|
||||
m.openHealth()
|
||||
case "config":
|
||||
m.openConfig()
|
||||
case "mode":
|
||||
|
||||
@@ -238,9 +238,9 @@ func (m Model) renderFooter() string {
|
||||
}
|
||||
default: // StateInSession
|
||||
if nrw {
|
||||
hints = []string{hint("i", "msg"), hint("e", "events"), hint("S", "stats"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
||||
hints = []string{hint("i", "msg"), hint("e", "events"), hint("S", "stats"), hint("H", "health"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
||||
} else {
|
||||
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
||||
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("H", "health"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
||||
}
|
||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
||||
hints = append(hints, hint("a", "approval (pending)"))
|
||||
|
||||
Reference in New Issue
Block a user