Health: CLI probe and mac UI surfacing
parent
ddf8aef4f7
commit
cb5c932447
|
|
@ -3,6 +3,7 @@ import SwiftUI
|
||||||
|
|
||||||
struct GeneralSettings: View {
|
struct GeneralSettings: View {
|
||||||
@ObservedObject var state: AppState
|
@ObservedObject var state: AppState
|
||||||
|
@ObservedObject private var healthStore = HealthStore.shared
|
||||||
@State private var isInstallingCLI = false
|
@State private var isInstallingCLI = false
|
||||||
@State private var cliStatus: String?
|
@State private var cliStatus: String?
|
||||||
@State private var cliInstalled = false
|
@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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("CLI helper")
|
Text("CLI helper")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
|
|
@ -143,4 +150,69 @@ struct GeneralSettings: View {
|
||||||
self.cliInstallLocation = installLocation
|
self.cliInstallLocation = installLocation
|
||||||
self.cliInstalled = installLocation != nil
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
private struct MenuContent: View {
|
||||||
@ObservedObject var state: AppState
|
@ObservedObject var state: AppState
|
||||||
@ObservedObject private var relayManager = RelayProcessManager.shared
|
@ObservedObject private var relayManager = RelayProcessManager.shared
|
||||||
|
@ObservedObject private var healthStore = HealthStore.shared
|
||||||
@Environment(\.openSettings) private var openSettings
|
@Environment(\.openSettings) private var openSettings
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Toggle(isOn: self.activeBinding) { Text("Clawdis Active") }
|
Toggle(isOn: self.activeBinding) { Text("Clawdis Active") }
|
||||||
self.relayStatusRow
|
self.relayStatusRow
|
||||||
|
self.healthStatusRow
|
||||||
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
||||||
.disabled(!voiceWakeSupported)
|
.disabled(!voiceWakeSupported)
|
||||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||||
|
|
@ -68,6 +70,7 @@ private struct MenuContent: View {
|
||||||
Button("Settings…") { self.open(tab: .general) }
|
Button("Settings…") { self.open(tab: .general) }
|
||||||
.keyboardShortcut(",", modifiers: [.command])
|
.keyboardShortcut(",", modifiers: [.command])
|
||||||
Button("About Clawdis") { self.open(tab: .about) }
|
Button("About Clawdis") { self.open(tab: .about) }
|
||||||
|
Button("Run Health Check") { Task { await self.healthStore.refresh(onDemand: true) } }
|
||||||
Divider()
|
Divider()
|
||||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
Button("Quit") { NSApplication.shared.terminate(nil) }
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +96,19 @@ private struct MenuContent: View {
|
||||||
.padding(.vertical, 4)
|
.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 {
|
private func statusColor(_ status: RelayProcessManager.Status) -> Color {
|
||||||
switch status {
|
switch status {
|
||||||
case .running: .green
|
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.
|
- 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.
|
- 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
|
## Dedicated "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.
|
`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.
|
How to see whether the WhatsApp Web/Baileys bridge is healthy from the menu bar app.
|
||||||
|
|
||||||
## Menu bar (planned)
|
## Menu bar
|
||||||
- Status dot expands beyond “relay running” to reflect Baileys health:
|
- Status dot now reflects Baileys health:
|
||||||
- Green: linked + socket opened recently.
|
- Green: linked + socket opened recently.
|
||||||
- Orange: connecting/retrying.
|
- Orange: connecting/retrying.
|
||||||
- Red: logged out or probe failed.
|
- Red: logged out or probe failed.
|
||||||
- Secondary line reads "Web: linked · auth 12m · socket ok" or shows the failure reason.
|
- Secondary line reads "Web: linked · auth 12m · socket ok" or shows the failure reason.
|
||||||
- "Run Health Check" menu item triggers an on-demand probe.
|
- "Run Health Check" menu item triggers an on-demand probe.
|
||||||
|
|
||||||
## Settings (planned)
|
## Settings
|
||||||
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Cache the last good snapshot and the last error separately to avoid flicker; show the timestamp of each.
|
||||||
|
|
||||||
## Until the UI ships
|
## When in doubt
|
||||||
- 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`.
|
- 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 { defaultRuntime } from "../runtime.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_HEARTBEAT_SECONDS,
|
|
||||||
resolveHeartbeatSeconds,
|
resolveHeartbeatSeconds,
|
||||||
resolveReconnectPolicy,
|
resolveReconnectPolicy,
|
||||||
} from "../web/reconnect.js";
|
} from "../web/reconnect.js";
|
||||||
|
|
|
||||||
|
|
@ -214,8 +214,8 @@ export async function runWebHeartbeatOnce(opts: {
|
||||||
|
|
||||||
const cfg = cfgOverride ?? loadConfig();
|
const cfg = cfgOverride ?? loadConfig();
|
||||||
const sessionCfg = cfg.inbound?.reply?.session;
|
const sessionCfg = cfg.inbound?.reply?.session;
|
||||||
const mainKey = sessionCfg?.mainKey ?? "main";
|
|
||||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||||
|
const mainKey = sessionCfg?.mainKey;
|
||||||
const sessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey);
|
const sessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store);
|
||||||
|
|
@ -439,7 +439,7 @@ function getSessionSnapshot(
|
||||||
const key = resolveSessionKey(
|
const key = resolveSessionKey(
|
||||||
scope,
|
scope,
|
||||||
{ From: from, To: "", Body: "" },
|
{ From: from, To: "", Body: "" },
|
||||||
sessionCfg?.mainKey ?? "main",
|
sessionCfg?.mainKey,
|
||||||
);
|
);
|
||||||
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
|
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
|
||||||
const entry = store[key];
|
const entry = store[key];
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ describe("loginWeb coverage", () => {
|
||||||
.mockResolvedValueOnce(undefined);
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
const runtime = { log: vi.fn(), error: vi.fn() } as never;
|
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);
|
expect(createWaSocket).toHaveBeenCalledTimes(2);
|
||||||
const firstSock = await createWaSocket.mock.results[0].value;
|
const firstSock = await createWaSocket.mock.results[0].value;
|
||||||
|
|
@ -55,7 +55,7 @@ describe("loginWeb coverage", () => {
|
||||||
output: { statusCode: DisconnectReason.loggedOut },
|
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,
|
/cache cleared/i,
|
||||||
);
|
);
|
||||||
expect(rmMock).toHaveBeenCalledWith("/tmp/wa-creds", {
|
expect(rmMock).toHaveBeenCalledWith("/tmp/wa-creds", {
|
||||||
|
|
@ -66,7 +66,7 @@ describe("loginWeb coverage", () => {
|
||||||
|
|
||||||
it("formats and rethrows generic errors", async () => {
|
it("formats and rethrows generic errors", async () => {
|
||||||
waitForWaConnection.mockRejectedValueOnce(new Error("boom"));
|
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",
|
"formatted:Error: boom",
|
||||||
);
|
);
|
||||||
expect(formatError).toHaveBeenCalled();
|
expect(formatError).toHaveBeenCalled();
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ describe("web login", () => {
|
||||||
const waiter: typeof waitForWaConnection = vi
|
const waiter: typeof waitForWaConnection = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(undefined);
|
.mockResolvedValue(undefined);
|
||||||
await loginWeb(false, waiter);
|
await loginWeb(false, "web", waiter);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||||
expect(sock.ws.close).toHaveBeenCalled();
|
expect(sock.ws.close).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue