Add System Health view with status and diagnostics

New Health section in the Manage group combining hermes status and
hermes doctor output:

- Version header with update available banner (e.g. "47 commits behind")
- Summary badges: passing/warning/issue counts
- Status sections: environment, API keys, auth providers, terminal
  backend, messaging platforms, gateway service, scheduled jobs
- Diagnostics sections: Python environment, required/optional packages,
  config files, directory structure, external tools, API connectivity,
  submodules, tool availability, Skills Hub, Honcho memory
- Each check shows green/orange/red icon with label and detail
- Refresh button to re-run both commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-03-31 12:36:56 -04:00
parent b4c93ac79c
commit c6f45ac22e
5 changed files with 332 additions and 1 deletions
+2
View File
@@ -34,6 +34,8 @@ struct ContentView: View {
GatewayView()
case .cron:
CronView()
case .health:
HealthView()
case .logs:
LogsView()
case .settings:
@@ -0,0 +1,179 @@
import Foundation
struct HealthCheck: Identifiable {
let id = UUID()
let label: String
let status: CheckStatus
let detail: String?
enum CheckStatus {
case ok
case warning
case error
}
}
struct HealthSection: Identifiable {
let id = UUID()
let title: String
let icon: String
let checks: [HealthCheck]
}
@Observable
final class HealthViewModel {
var version = ""
var updateInfo = ""
var hasUpdate = false
var statusSections: [HealthSection] = []
var doctorSections: [HealthSection] = []
var issueCount = 0
var warningCount = 0
var okCount = 0
var isLoading = false
func load() {
isLoading = true
loadVersion()
let statusOutput = runHermes(["status"]).output
statusSections = parseOutput(statusOutput)
let doctorOutput = runHermes(["doctor"]).output
doctorSections = parseOutput(doctorOutput)
computeCounts()
isLoading = false
}
private func loadVersion() {
let output = runHermes(["version"]).output
let lines = output.components(separatedBy: "\n")
version = lines.first ?? ""
if let updateLine = lines.first(where: { $0.contains("commits behind") }) {
updateInfo = updateLine.trimmingCharacters(in: .whitespaces)
hasUpdate = true
} else {
updateInfo = ""
hasUpdate = false
}
}
private func parseOutput(_ output: String) -> [HealthSection] {
var sections: [HealthSection] = []
var currentTitle = ""
var currentChecks: [HealthCheck] = []
for line in output.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("") {
if !currentTitle.isEmpty {
sections.append(HealthSection(
title: currentTitle,
icon: iconForSection(currentTitle),
checks: currentChecks
))
}
currentTitle = String(trimmed.dropFirst(2))
currentChecks = []
continue
}
if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .ok, detail: detail))
} else if trimmed.hasPrefix("") || trimmed.hasPrefix("") {
let text = trimmed.replacingOccurrences(of: "", with: "").replacingOccurrences(of: "", with: "")
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .warning, detail: detail))
} else if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .error, detail: detail))
} else if trimmed.hasPrefix("") || trimmed.hasPrefix("Error:") {
if !currentChecks.isEmpty {
let last = currentChecks.removeLast()
let extra = trimmed.replacingOccurrences(of: "", with: "").replacingOccurrences(of: "Error:", with: "").trimmingCharacters(in: .whitespaces)
let combined = [last.detail, extra].compactMap { $0 }.joined(separator: " ")
currentChecks.append(HealthCheck(label: last.label, status: last.status, detail: combined))
}
} else if !trimmed.isEmpty && trimmed.contains(":") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("Run ") && !trimmed.hasPrefix("Found ") && !trimmed.hasPrefix("Tip:") {
let parts = trimmed.split(separator: ":", maxSplits: 1)
if parts.count == 2 {
let key = parts[0].trimmingCharacters(in: .whitespaces)
let val = parts[1].trimmingCharacters(in: .whitespaces)
if !key.isEmpty && key.count < 30 {
currentChecks.append(HealthCheck(label: key, status: .ok, detail: val))
}
}
}
}
if !currentTitle.isEmpty {
sections.append(HealthSection(
title: currentTitle,
icon: iconForSection(currentTitle),
checks: currentChecks
))
}
return sections
}
private func splitCheck(_ text: String) -> (String, String?) {
if let parenStart = text.firstIndex(of: "(") {
let label = text[text.startIndex..<parenStart].trimmingCharacters(in: .whitespaces)
let detail = String(text[parenStart...]).trimmingCharacters(in: CharacterSet(charactersIn: "()"))
return (label, detail)
}
return (text, nil)
}
private func computeCounts() {
let allChecks = (statusSections + doctorSections).flatMap(\.checks)
okCount = allChecks.filter { $0.status == .ok }.count
warningCount = allChecks.filter { $0.status == .warning }.count
issueCount = allChecks.filter { $0.status == .error }.count
}
private func iconForSection(_ title: String) -> String {
switch title {
case "Environment": return "gearshape.2"
case "API Keys": return "key"
case "Auth Providers": return "person.badge.key"
case "API-Key Providers": return "key.horizontal"
case "Terminal Backend": return "terminal"
case "Messaging Platforms": return "bubble.left.and.bubble.right"
case "Gateway Service": return "antenna.radiowaves.left.and.right"
case "Scheduled Jobs": return "clock.arrow.2.circlepath"
case "Sessions": return "text.bubble"
case "Python Environment": return "chevron.left.forwardslash.chevron.right"
case "Required Packages": return "shippingbox"
case "Configuration Files": return "doc.text"
case "Directory Structure": return "folder"
case "External Tools": return "wrench"
case "API Connectivity": return "wifi"
case "Submodules": return "arrow.triangle.branch"
case "Tool Availability": return "wrench.and.screwdriver"
case "Skills Hub": return "lightbulb"
case "Honcho Memory": return "brain"
default: return "circle"
}
}
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
} catch {
return ("", -1)
}
}
}
@@ -0,0 +1,148 @@
import SwiftUI
struct HealthView: View {
@State private var viewModel = HealthViewModel()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
headerSection
if !viewModel.statusSections.isEmpty {
sectionGroup("Status", sections: viewModel.statusSections)
}
if !viewModel.doctorSections.isEmpty {
sectionGroup("Diagnostics", sections: viewModel.doctorSections)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Health")
.onAppear { viewModel.load() }
}
private var headerSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 16) {
if !viewModel.version.isEmpty {
Text(viewModel.version)
.font(.system(.body, design: .monospaced))
}
Spacer()
Button("Refresh") { viewModel.load() }
.controlSize(.small)
}
if viewModel.hasUpdate {
HStack(spacing: 8) {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(.orange)
Text(viewModel.updateInfo)
.font(.caption)
Text("Run `hermes update` in terminal")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
HStack(spacing: 16) {
CountBadge(count: viewModel.okCount, label: "Passing", color: .green, icon: "checkmark.circle.fill")
CountBadge(count: viewModel.warningCount, label: "Warnings", color: .orange, icon: "exclamationmark.triangle.fill")
CountBadge(count: viewModel.issueCount, label: "Issues", color: .red, icon: "xmark.circle.fill")
}
}
}
private func sectionGroup(_ title: String, sections: [HealthSection]) -> some View {
VStack(alignment: .leading, spacing: 16) {
Text(title)
.font(.title3.bold())
ForEach(sections) { section in
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: section.icon)
.foregroundStyle(.secondary)
.frame(width: 16)
Text(section.title)
.font(.headline)
}
VStack(alignment: .leading, spacing: 2) {
ForEach(section.checks) { check in
CheckRow(check: check)
}
}
.padding(.leading, 22)
}
.padding(12)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
struct CheckRow: View {
let check: HealthCheck
var body: some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
.font(.caption)
.frame(width: 14)
VStack(alignment: .leading, spacing: 1) {
Text(check.label)
.font(.caption)
if let detail = check.detail {
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 1)
}
private var statusIcon: String {
switch check.status {
case .ok: return "checkmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .error: return "xmark.circle.fill"
}
}
private var statusColor: Color {
switch check.status {
case .ok: return .green
case .warning: return .orange
case .error: return .red
}
}
}
struct CountBadge: View {
let count: Int
let label: String
let color: Color
let icon: String
var body: some View {
HStack(spacing: 6) {
Image(systemName: icon)
.foregroundStyle(color)
Text("\(count)")
.font(.system(.title3, design: .monospaced, weight: .semibold))
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -11,6 +11,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case tools = "Tools"
case gateway = "Gateway"
case cron = "Cron"
case health = "Health"
case logs = "Logs"
case settings = "Settings"
@@ -28,6 +29,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .tools: return "wrench.and.screwdriver"
case .gateway: return "antenna.radiowaves.left.and.right"
case .cron: return "clock.arrow.2.circlepath"
case .health: return "stethoscope"
case .logs: return "doc.text"
case .settings: return "gearshape"
}
+1 -1
View File
@@ -19,7 +19,7 @@ struct SidebarView: View {
}
}
Section("Manage") {
ForEach([SidebarSection.tools, .gateway, .cron, .logs, .settings]) { section in
ForEach([SidebarSection.tools, .gateway, .cron, .health, .logs, .settings]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
}