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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user