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:
2026-06-14 18:57:51 +04:00
parent 09f05549a0
commit fd67a6c68d
13 changed files with 285 additions and 3 deletions
+41
View File
@@ -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"},
+5
View File
@@ -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
+62
View File
@@ -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 {
+4 -1
View File
@@ -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
+20
View File
@@ -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":
+2 -2
View File
@@ -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)"))
@@ -164,6 +164,39 @@ func TestDecodeGoldenServerFrames(t *testing.T) {
}
},
},
{
name: "health.checks carries nested health report",
json: `{"type":"health.checks","health":{"overall":"DEGRADED","subjects":[` +
`{"subject":"DISK","status":"DEGRADED","metric":"free_bytes","observedValue":512000000,` +
`"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"}],` +
`"checkedAt":"2026-06-14T10:05:00Z"}}`,
wantType: TypeHealthChecks,
eventBear: false,
check: func(t *testing.T, m ServerMessage) {
if m.Health == nil {
t.Fatalf("health.checks: Health is nil")
}
if m.Health.Overall != "DEGRADED" {
t.Fatalf("health.checks overall = %q, want DEGRADED", m.Health.Overall)
}
if len(m.Health.Subjects) != 2 {
t.Fatalf("health.checks subjects len = %d, want 2", len(m.Health.Subjects))
}
disk := m.Health.Subjects[0]
if disk.Subject != "DISK" || disk.Status != "DEGRADED" || disk.ObservedValue != 512000000 {
t.Fatalf("health.checks DISK subject: %+v", disk)
}
es := m.Health.Subjects[1]
if es.Subject != "EVENT_STORE" || es.Status != "HEALTHY" {
t.Fatalf("health.checks EVENT_STORE subject: %+v", es)
}
if m.Health.CheckedAt != "2026-06-14T10:05:00Z" {
t.Fatalf("health.checks checkedAt = %q", m.Health.CheckedAt)
}
},
},
{
name: "workflow.proposed carries candidate workflows",
json: `{"type":"workflow.proposed","sessionId":"s1","proposalId":"prop-1","prompt":"Run one?",` +
@@ -248,6 +281,11 @@ func TestEncodeGoldenClientFrames(t *testing.T) {
}
},
},
{
name: "GetHealthChecks encodes correctly",
frame: GetHealthChecks(),
wantType: clientPrefix + "GetHealthChecks",
},
{
name: "DiscardIdea carries the ideaId",
frame: DiscardIdea("i1"),
+29
View File
@@ -55,6 +55,7 @@ const (
TypeConfigSnapshot = "config.snapshot"
TypeSessionStats = "session.stats"
TypeIdeaList = "idea.list"
TypeHealthChecks = "health.checks"
)
// ServerMessage is a flat decode of every server->client variant. Field names
@@ -139,6 +140,9 @@ type ServerMessage struct {
// session.stats — derived metrics for a session (reply to GetSessionStats)
Stats *StatsDto `json:"stats"`
// health.checks — system health report (reply to GetHealthChecks)
Health *HealthDto `json:"health"`
// clarification.required — open questions a stage raised for the operator
Questions []ClarificationQuestionDto `json:"questions"`
@@ -242,6 +246,26 @@ type FailureMetricsDto struct {
WorkflowFailures int64 `json:"workflowFailures"`
}
// HealthSubjectDto is one monitored subject's health status. Mirrors Kotlin HealthSubjectReport.
// status is "HEALTHY" or "DEGRADED"; since is an ISO-8601 string for the last edge event.
type HealthSubjectDto struct {
Subject string `json:"subject"`
Status string `json:"status"`
Metric string `json:"metric"`
ObservedValue int64 `json:"observedValue"`
Detail string `json:"detail"`
Since string `json:"since"`
}
// HealthDto is the system health snapshot. Mirrors Kotlin HealthReport.
// overall is "HEALTHY" or "DEGRADED". subjects is empty when health monitoring is disabled
// or no probes have fired yet — the TUI renders a visible fallback in that case.
type HealthDto struct {
Overall string `json:"overall"`
Subjects []HealthSubjectDto `json:"subjects"`
CheckedAt string `json:"checkedAt"`
}
type ConfigFieldDto struct {
Key string `json:"key"`
Type string `json:"type"`
@@ -465,3 +489,8 @@ func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolN
func Hello(workingDir string) []byte {
return encode("Hello", map[string]any{"workingDir": workingDir})
}
// GetHealthChecks requests the system health-check report (replied to with health.checks).
func GetHealthChecks() []byte {
return encode("GetHealthChecks", map[string]any{})
}