Health: CLI probe and mac UI surfacing

This commit is contained in:
Peter Steinberger
2025-12-07 04:38:20 +00:00
parent ddf8aef4f7
commit cb5c932447
9 changed files with 252 additions and 16 deletions

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct GeneralSettings: View {
@ObservedObject var state: AppState
@ObservedObject private var healthStore = HealthStore.shared
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
@@ -55,6 +56,12 @@ struct GeneralSettings: View {
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Health")
.font(.callout.weight(.semibold))
self.healthCard
}
VStack(alignment: .leading, spacing: 6) {
Text("CLI helper")
.font(.callout.weight(.semibold))
@@ -143,4 +150,69 @@ struct GeneralSettings: View {
self.cliInstallLocation = installLocation
self.cliInstalled = installLocation != nil
}
private var healthCard: some View {
let snapshot = self.healthStore.snapshot
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Circle()
.fill(self.healthStore.state.tint)
.frame(width: 10, height: 10)
Text(self.healthStore.summaryLine)
.font(.callout.weight(.semibold))
}
if let snap = snapshot {
Text("Linked auth age: \(healthAgeString(snap.web.authAgeMs))")
.font(.caption)
.foregroundStyle(.secondary)
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
.font(.caption)
.foregroundStyle(.secondary)
if let recent = snap.sessions.recent.first {
Text("Last activity: \(recent.key) \(recent.updatedAt != nil ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000)) : "unknown")")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Last check: \(relativeAge(from: self.healthStore.lastSuccess))")
.font(.caption)
.foregroundStyle(.secondary)
} else if let error = self.healthStore.lastError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
} else {
Text("Health check pending…")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
Button {
Task { await self.healthStore.refresh(onDemand: true) }
} label: {
if self.healthStore.isRefreshing {
ProgressView().controlSize(.small)
} else {
Label("Run Health Check", systemImage: "arrow.clockwise")
}
}
.disabled(self.healthStore.isRefreshing)
Button {
NSWorkspace.shared.selectFile("/tmp/clawdis/clawdis.log", inFileViewerRootedAtPath: "/tmp/clawdis/")
} label: {
Label("Reveal Logs", systemImage: "doc.text.magnifyingglass")
}
}
}
.padding(12)
.background(Color.gray.opacity(0.08))
.cornerRadius(10)
}
}
private func healthAgeString(_ ms: Double?) -> String {
guard let ms else { return "unknown" }
return msToAge(ms)
}

View File

@@ -0,0 +1,149 @@
import Foundation
import SwiftUI
struct HealthSnapshot: Codable, Sendable {
struct Web: Codable, Sendable {
struct Connect: Codable, Sendable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
}
let linked: Bool
let authAgeMs: Double?
let connect: Connect?
}
struct SessionInfo: Codable, Sendable {
let key: String
let updatedAt: Double?
let age: Double?
}
struct Sessions: Codable, Sendable {
let path: String
let count: Int
let recent: [SessionInfo]
}
struct IPC: Codable, Sendable {
let path: String
let exists: Bool
}
let ts: Double
let durationMs: Double
let web: Web
let heartbeatSeconds: Int?
let sessions: Sessions
let ipc: IPC
}
enum HealthState: Equatable {
case unknown
case ok
case linkingNeeded
case degraded(String)
var tint: Color {
switch self {
case .ok: .green
case .linkingNeeded: .red
case .degraded: .orange
case .unknown: .secondary
}
}
}
@MainActor
final class HealthStore: ObservableObject {
static let shared = HealthStore()
@Published private(set) var snapshot: HealthSnapshot?
@Published private(set) var lastSuccess: Date?
@Published private(set) var lastError: String?
@Published private(set) var isRefreshing = false
private var loopTask: Task<Void, Never>?
private let refreshInterval: TimeInterval = 60
private init() {
self.start()
}
func start() {
guard self.loopTask == nil else { return }
self.loopTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
await self.refresh()
try? await Task.sleep(nanoseconds: UInt64(self.refreshInterval * 1_000_000_000))
}
}
}
func stop() {
self.loopTask?.cancel()
self.loopTask = nil
}
func refresh(onDemand: Bool = false) async {
guard !self.isRefreshing else { return }
self.isRefreshing = true
defer { self.isRefreshing = false }
let response = await ShellRunner.run(
command: ["clawdis", "health", "--json"],
cwd: nil,
env: nil,
timeout: 15)
guard response.ok, let data = response.payload, !data.isEmpty else {
self.lastError = response.message ?? "health probe failed"
if onDemand { self.snapshot = nil }
return
}
do {
let decoded = try JSONDecoder().decode(HealthSnapshot.self, from: data)
self.snapshot = decoded
self.lastSuccess = Date()
self.lastError = nil
} catch {
self.lastError = error.localizedDescription
if onDemand { self.snapshot = nil }
}
}
var state: HealthState {
guard let snap = self.snapshot else { return .unknown }
if !snap.web.linked { return .linkingNeeded }
if let connect = snap.web.connect, !connect.ok {
let reason = connect.error ?? "connect failed"
return .degraded(reason)
}
return .ok
}
var summaryLine: String {
guard let snap = self.snapshot else { return "Health check pending" }
if !snap.web.linked { return "Not linked — run clawdis login" }
let auth = snap.web.authAgeMs.map { msToAge($0) } ?? "unknown"
if let connect = snap.web.connect, !connect.ok {
let code = connect.status.map(String.init) ?? "?"
return "Link stale? status \(code)"
}
return "linked · auth \(auth) · socket ok"
}
}
func msToAge(_ ms: Double) -> String {
let minutes = Int(round(ms / 60000))
if minutes < 1 { return "just now" }
if minutes < 60 { return "\(minutes)m" }
let hours = Int(round(Double(minutes) / 60))
if hours < 48 { return "\(hours)h" }
let days = Int(round(Double(hours) / 24))
return "\(days)d"
}

View File

@@ -54,12 +54,14 @@ struct ClawdisApp: App {
private struct MenuContent: View {
@ObservedObject var state: AppState
@ObservedObject private var relayManager = RelayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared
@Environment(\.openSettings) private var openSettings
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: self.activeBinding) { Text("Clawdis Active") }
self.relayStatusRow
self.healthStatusRow
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
.disabled(!voiceWakeSupported)
.opacity(voiceWakeSupported ? 1 : 0.5)
@@ -68,6 +70,7 @@ private struct MenuContent: View {
Button("Settings…") { self.open(tab: .general) }
.keyboardShortcut(",", modifiers: [.command])
Button("About Clawdis") { self.open(tab: .about) }
Button("Run Health Check") { Task { await self.healthStore.refresh(onDemand: true) } }
Divider()
Button("Quit") { NSApplication.shared.terminate(nil) }
}
@@ -93,6 +96,19 @@ private struct MenuContent: View {
.padding(.vertical, 4)
}
private var healthStatusRow: some View {
let state = self.healthStore.state
return HStack(spacing: 8) {
Circle()
.fill(state.tint)
.frame(width: 8, height: 8)
Text(self.healthStore.summaryLine)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
}
.padding(.vertical, 2)
}
private func statusColor(_ status: RelayProcessManager.Status) -> Color {
switch status {
case .running: .green