Health: CLI probe and mac UI surfacing
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
149
apps/macos/Sources/Clawdis/HealthStore.swift
Normal file
149
apps/macos/Sources/Clawdis/HealthStore.swift
Normal 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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user