mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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()
|
GatewayView()
|
||||||
case .cron:
|
case .cron:
|
||||||
CronView()
|
CronView()
|
||||||
|
case .health:
|
||||||
|
HealthView()
|
||||||
case .logs:
|
case .logs:
|
||||||
LogsView()
|
LogsView()
|
||||||
case .settings:
|
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 tools = "Tools"
|
||||||
case gateway = "Gateway"
|
case gateway = "Gateway"
|
||||||
case cron = "Cron"
|
case cron = "Cron"
|
||||||
|
case health = "Health"
|
||||||
case logs = "Logs"
|
case logs = "Logs"
|
||||||
case settings = "Settings"
|
case settings = "Settings"
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .tools: return "wrench.and.screwdriver"
|
case .tools: return "wrench.and.screwdriver"
|
||||||
case .gateway: return "antenna.radiowaves.left.and.right"
|
case .gateway: return "antenna.radiowaves.left.and.right"
|
||||||
case .cron: return "clock.arrow.2.circlepath"
|
case .cron: return "clock.arrow.2.circlepath"
|
||||||
|
case .health: return "stethoscope"
|
||||||
case .logs: return "doc.text"
|
case .logs: return "doc.text"
|
||||||
case .settings: return "gearshape"
|
case .settings: return "gearshape"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Manage") {
|
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)
|
Label(section.rawValue, systemImage: section.icon)
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user