Files
scarf/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift
T
Alan Wizemann c6f45ac22e 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>
2026-03-31 12:36:56 -04:00

180 lines
7.1 KiB
Swift

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)
}
}
}