health: surface ssh output when probe fails
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import OSLog
|
||||||
|
|
||||||
struct HealthSnapshot: Codable, Sendable {
|
struct HealthSnapshot: Codable, Sendable {
|
||||||
struct Web: Codable, Sendable {
|
struct Web: Codable, Sendable {
|
||||||
@@ -60,6 +61,8 @@ enum HealthState: Equatable {
|
|||||||
final class HealthStore: ObservableObject {
|
final class HealthStore: ObservableObject {
|
||||||
static let shared = HealthStore()
|
static let shared = HealthStore()
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "health")
|
||||||
|
|
||||||
@Published private(set) var snapshot: HealthSnapshot?
|
@Published private(set) var snapshot: HealthSnapshot?
|
||||||
@Published private(set) var lastSuccess: Date?
|
@Published private(set) var lastSuccess: Date?
|
||||||
@Published private(set) var lastError: String?
|
@Published private(set) var lastError: String?
|
||||||
@@ -108,18 +111,23 @@ final class HealthStore: ObservableObject {
|
|||||||
env: env,
|
env: env,
|
||||||
timeout: 15)
|
timeout: 15)
|
||||||
|
|
||||||
// Always try to decode JSON even when the CLI exits non-zero; it prints the
|
if let data = response.payload, !data.isEmpty {
|
||||||
// failure snapshot before exiting so we can surface a useful message.
|
if let decoded = decodeHealthSnapshot(from: data) {
|
||||||
if let data = response.payload, !data.isEmpty,
|
self.snapshot = decoded
|
||||||
let decoded = try? JSONDecoder().decode(HealthSnapshot.self, from: data)
|
if response.ok {
|
||||||
{
|
self.lastSuccess = Date()
|
||||||
self.snapshot = decoded
|
self.lastError = nil
|
||||||
if response.ok {
|
} else {
|
||||||
self.lastSuccess = Date()
|
self.lastError = self.describeFailure(from: decoded, fallback: response.message)
|
||||||
self.lastError = nil
|
}
|
||||||
} else {
|
return
|
||||||
self.lastError = self.describeFailure(from: decoded, fallback: response.message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let snippet = String(text.prefix(220))
|
||||||
|
Self.logger.error("health decode failed; payload=\(snippet, privacy: .public)")
|
||||||
|
self.lastError = snippet.isEmpty ? (response.message ?? "health probe failed") : "health output not JSON: \(snippet)"
|
||||||
|
if onDemand { self.snapshot = nil }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,3 +191,18 @@ func msToAge(_ ms: Double) -> String {
|
|||||||
let days = Int(round(Double(hours) / 24))
|
let days = Int(round(Double(hours) / 24))
|
||||||
return "\(days)d"
|
return "\(days)d"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode a health snapshot, tolerating stray log lines before/after the JSON blob.
|
||||||
|
func decodeHealthSnapshot(from data: Data) -> HealthSnapshot? {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
if let snap = try? decoder.decode(HealthSnapshot.self, from: data) {
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else { return nil }
|
||||||
|
guard let firstBrace = text.firstIndex(of: "{"), let lastBrace = text.lastIndex(of: "}") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let slice = text[firstBrace...lastBrace]
|
||||||
|
let cleaned = Data(slice.utf8)
|
||||||
|
return try? decoder.decode(HealthSnapshot.self, from: cleaned)
|
||||||
|
}
|
||||||
|
|||||||
35
apps/macos/Tests/ClawdisIPCTests/HealthDecodeTests.swift
Normal file
35
apps/macos/Tests/ClawdisIPCTests/HealthDecodeTests.swift
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
|
|
||||||
|
@Suite struct HealthDecodeTests {
|
||||||
|
private let sampleJSON: String = {
|
||||||
|
// minimal but complete payload
|
||||||
|
"""
|
||||||
|
{"ts":1733622000,"durationMs":420,"web":{"linked":true,"authAgeMs":120000,"connect":{"ok":true,"status":200,"error":null,"elapsedMs":800}},"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]},"ipc":{"path":"/tmp/ipc.sock","exists":true}}
|
||||||
|
"""
|
||||||
|
}()
|
||||||
|
|
||||||
|
@Test func decodesCleanJSON() async throws {
|
||||||
|
let data = Data(sampleJSON.utf8)
|
||||||
|
let snap = decodeHealthSnapshot(from: data)
|
||||||
|
|
||||||
|
#expect(snap?.web.linked == true)
|
||||||
|
#expect(snap?.sessions.count == 1)
|
||||||
|
#expect(snap?.ipc.exists == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodesWithLeadingNoise() async throws {
|
||||||
|
let noisy = "debug: something logged\n" + sampleJSON + "\ntrailer"
|
||||||
|
let snap = decodeHealthSnapshot(from: Data(noisy.utf8))
|
||||||
|
|
||||||
|
#expect(snap?.web.connect?.status == 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func failsWithoutBraces() async throws {
|
||||||
|
let data = Data("no json here".utf8)
|
||||||
|
let snap = decodeHealthSnapshot(from: data)
|
||||||
|
|
||||||
|
#expect(snap == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user