mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user