health: surface ssh output when probe fails

This commit is contained in:
Peter Steinberger
2025-12-08 00:52:31 +01:00
parent 4b5c43f080
commit 42012389c4
2 changed files with 69 additions and 11 deletions

View File

@@ -1,5 +1,6 @@
import Foundation
import SwiftUI
import OSLog
struct HealthSnapshot: Codable, Sendable {
struct Web: Codable, Sendable {
@@ -60,6 +61,8 @@ enum HealthState: Equatable {
final class HealthStore: ObservableObject {
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 lastSuccess: Date?
@Published private(set) var lastError: String?
@@ -108,18 +111,23 @@ final class HealthStore: ObservableObject {
env: env,
timeout: 15)
// Always try to decode JSON even when the CLI exits non-zero; it prints the
// failure snapshot before exiting so we can surface a useful message.
if let data = response.payload, !data.isEmpty,
let decoded = try? JSONDecoder().decode(HealthSnapshot.self, from: data)
{
self.snapshot = decoded
if response.ok {
self.lastSuccess = Date()
self.lastError = nil
} else {
self.lastError = self.describeFailure(from: decoded, fallback: response.message)
if let data = response.payload, !data.isEmpty {
if let decoded = decodeHealthSnapshot(from: data) {
self.snapshot = decoded
if response.ok {
self.lastSuccess = Date()
self.lastError = nil
} else {
self.lastError = self.describeFailure(from: decoded, fallback: response.message)
}
return
}
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
}
@@ -183,3 +191,18 @@ func msToAge(_ ms: Double) -> String {
let days = Int(round(Double(hours) / 24))
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)
}

View 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)
}
}