From c6f45ac22e3602af9329f0ad01e7646d6dc535c2 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 12:36:56 -0400 Subject: [PATCH] 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) --- scarf/scarf/ContentView.swift | 2 + .../Health/ViewModels/HealthViewModel.swift | 179 ++++++++++++++++++ .../Features/Health/Views/HealthView.swift | 148 +++++++++++++++ scarf/scarf/Navigation/AppCoordinator.swift | 2 + scarf/scarf/Navigation/SidebarView.swift | 2 +- 5 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift create mode 100644 scarf/scarf/Features/Health/Views/HealthView.swift diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index d317d13..ac408a8 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -34,6 +34,8 @@ struct ContentView: View { GatewayView() case .cron: CronView() + case .health: + HealthView() case .logs: LogsView() case .settings: diff --git a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift new file mode 100644 index 0000000..c1c1962 --- /dev/null +++ b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift @@ -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.. 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) + } + } +} diff --git a/scarf/scarf/Features/Health/Views/HealthView.swift b/scarf/scarf/Features/Health/Views/HealthView.swift new file mode 100644 index 0000000..a09089d --- /dev/null +++ b/scarf/scarf/Features/Health/Views/HealthView.swift @@ -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)) + } +} diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index 33b7ce1..d8e45d7 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -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" } diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index 647cbe0..3bfd15d 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -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) }