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

View File

@@ -19,5 +19,5 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
- Repeated reconnect exits → tune `web.reconnect` (flags: `--web-retries`, `--web-retry-initial`, `--web-retry-max`) and rerun relay.
- No inbound messages → confirm linked phone is online and sender is allowed; use `pnpm clawdis heartbeat --all --verbose` to test each known recipient.
## Planned "health" command
A dedicated `clawdis health --json` probe (connect-only, no sends) is planned to report: linked creds, auth age, Baileys connect result/status code, session-store summary, and IPC presence. Until it lands, use the checks above.
## Dedicated "health" command
`pnpm clawdis health --json` runs a connect-only probe (no sends) and reports: linked creds, auth age, Baileys connect result/status code, session-store summary, IPC presence, and a probe duration. It exits non-zero if not linked or if the connect fails/timeouts. Use `--timeout <ms>` to override the 10s default.

View File

@@ -2,21 +2,21 @@
How to see whether the WhatsApp Web/Baileys bridge is healthy from the menu bar app.
## Menu bar (planned)
- Status dot expands beyond “relay running” to reflect Baileys health:
## Menu bar
- Status dot now reflects Baileys health:
- Green: linked + socket opened recently.
- Orange: connecting/retrying.
- Red: logged out or probe failed.
- Secondary line reads "Web: linked · auth 12m · socket ok" or shows the failure reason.
- "Run Health Check" menu item triggers an on-demand probe.
## Settings (planned)
- General tab gains a Health card showing: linked E.164, auth age, session-store path/count, last check time, last error/status code, and buttons for Run Health Check / Reveal Logs / Relink.
## Settings
- General tab gains a Health card showing: linked auth age, session-store path/count, last check time, last error/status code, and buttons for Run Health Check / Reveal Logs.
- Uses a cached snapshot so the UI loads instantly and falls back gracefully when offline.
## How the probe works (planned)
## How the probe works
- App runs `clawdis health --json` via `ShellRunner` every ~60s and on demand. The probe loads creds, attempts a short Baileys connect, and reports status without sending messages.
- Cache the last good snapshot and the last error separately to avoid flicker; show the timestamp of each.
## Until the UI ships
- Use the CLI flow in `docs/health.md` (status, heartbeat dry-run, relay heartbeat) and tail `/tmp/clawdis/clawdis.log` for `web-heartbeat` / `web-reconnect`.
## When in doubt
- You can still use the CLI flow in `docs/health.md` (status, heartbeat dry-run, relay heartbeat) and tail `/tmp/clawdis/clawdis.log` for `web-heartbeat` / `web-reconnect`.

View File

@@ -19,7 +19,6 @@ import {
import { defaultRuntime } from "../runtime.js";
import { VERSION } from "../version.js";
import {
DEFAULT_HEARTBEAT_SECONDS,
resolveHeartbeatSeconds,
resolveReconnectPolicy,
} from "../web/reconnect.js";

View File

@@ -214,8 +214,8 @@ export async function runWebHeartbeatOnce(opts: {
const cfg = cfgOverride ?? loadConfig();
const sessionCfg = cfg.inbound?.reply?.session;
const mainKey = sessionCfg?.mainKey ?? "main";
const sessionScope = sessionCfg?.scope ?? "per-sender";
const mainKey = sessionCfg?.mainKey;
const sessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey);
if (sessionId) {
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
@@ -439,7 +439,7 @@ function getSessionSnapshot(
const key = resolveSessionKey(
scope,
{ From: from, To: "", Body: "" },
sessionCfg?.mainKey ?? "main",
sessionCfg?.mainKey,
);
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
const entry = store[key];

View File

@@ -40,7 +40,7 @@ describe("loginWeb coverage", () => {
.mockResolvedValueOnce(undefined);
const runtime = { log: vi.fn(), error: vi.fn() } as never;
await loginWeb(false, waitForWaConnection as never, runtime);
await loginWeb(false, "web", waitForWaConnection as never, runtime);
expect(createWaSocket).toHaveBeenCalledTimes(2);
const firstSock = await createWaSocket.mock.results[0].value;
@@ -55,7 +55,7 @@ describe("loginWeb coverage", () => {
output: { statusCode: DisconnectReason.loggedOut },
});
await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow(
await expect(loginWeb(false, "web", waitForWaConnection as never)).rejects.toThrow(
/cache cleared/i,
);
expect(rmMock).toHaveBeenCalledWith("/tmp/wa-creds", {
@@ -66,7 +66,7 @@ describe("loginWeb coverage", () => {
it("formats and rethrows generic errors", async () => {
waitForWaConnection.mockRejectedValueOnce(new Error("boom"));
await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow(
await expect(loginWeb(false, "web", waitForWaConnection as never)).rejects.toThrow(
"formatted:Error: boom",
);
expect(formatError).toHaveBeenCalled();

View File

@@ -38,7 +38,7 @@ describe("web login", () => {
const waiter: typeof waitForWaConnection = vi
.fn()
.mockResolvedValue(undefined);
await loginWeb(false, waiter);
await loginWeb(false, "web", waiter);
await new Promise((resolve) => setTimeout(resolve, 550));
expect(sock.ws.close).toHaveBeenCalled();
});