diff --git a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift index f1ef7c4..2d4589d 100644 --- a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift +++ b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift @@ -1,4 +1,20 @@ import Foundation +import AppKit +import os + +/// Observed state of the local `hermes dashboard` web UI (introduced in +/// Hermes v0.10.x). `port` defaults to 9119 — the CLI's default and the only +/// value Scarf launches with today. +struct WebDashboardStatus: Sendable, Equatable { + var running: Bool + var port: Int + /// True while a start/stop transition is in flight so the UI can disable + /// buttons and show a spinner. + var busy: Bool + + static let defaultPort = 9119 + static let unknown = WebDashboardStatus(running: false, port: defaultPort, busy: false) +} struct HealthCheck: Identifiable { let id = UUID() @@ -50,6 +66,19 @@ final class HealthViewModel { var diagnosticsOutput: String = "" var isSharingDebug = false + /// Liveness + control state for `hermes dashboard` (local web UI). The + /// section in `HealthView` is hidden for remote contexts — the dashboard + /// binds 127.0.0.1 by default and remote probing / tunneling is out of + /// scope for v1. + var dashboardStatus: WebDashboardStatus = .unknown + /// Our own spawned subprocess, if the user hit "Launch Dashboard" from + /// Scarf. Nil when the dashboard was started externally (we still detect + /// it via the probe but can't terminate it cleanly via `Process.terminate`). + private var dashboardProcess: Process? + /// Background polling loop; started in `startDashboardMonitoring()` and + /// cancelled on view disappear. + private var dashboardProbeTask: Task? + func load() { isLoading = true let ctx = context @@ -447,4 +476,191 @@ final class HealthViewModel { private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { context.runHermes(arguments) } + + // MARK: - Web Dashboard (`hermes dashboard`) + + /// Called from `HealthView.onAppear`. Starts a background loop that + /// probes `http://127.0.0.1:/api/status` every 3s and keeps + /// `dashboardStatus.running` in sync with reality — whether we launched + /// the dashboard or the user did via terminal. No-op on remote contexts. + func startDashboardMonitoring() { + guard !context.isRemote else { return } + dashboardProbeTask?.cancel() + let port = dashboardStatus.port + dashboardProbeTask = Task { [weak self] in + while !Task.isCancelled { + let running = await Self.probeDashboard(port: port) + await MainActor.run { [weak self] in + guard let self else { return } + // Preserve `busy` so the button stays disabled during an + // in-flight start/stop; only toggle the `running` bit. + self.dashboardStatus = WebDashboardStatus( + running: running, + port: self.dashboardStatus.port, + busy: self.dashboardStatus.busy + ) + // Reap our spawned process if it exited externally. + if !running, let p = self.dashboardProcess, !p.isRunning { + self.dashboardProcess = nil + } + } + try? await Task.sleep(nanoseconds: 3_000_000_000) + } + } + } + + func stopDashboardMonitoring() { + dashboardProbeTask?.cancel() + dashboardProbeTask = nil + } + + /// Launch `hermes dashboard --no-open --port 9119` detached. We pass + /// `--no-open` so Hermes doesn't try to open its own browser tab — Scarf + /// opens the URL after the probe confirms the server is listening, which + /// avoids the "Safari tab loads faster than uvicorn binds the port" race. + func launchDashboard() { + guard !context.isRemote else { return } + guard !dashboardStatus.running, !dashboardStatus.busy else { return } + guard let binary = fileService.hermesBinaryPath() else { + actionMessage = "hermes binary not found" + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.actionMessage = nil + } + return + } + + dashboardStatus = WebDashboardStatus( + running: dashboardStatus.running, + port: dashboardStatus.port, + busy: true + ) + actionMessage = "Starting dashboard…" + + let port = dashboardStatus.port + let proc = Process() + proc.executableURL = URL(fileURLWithPath: binary) + proc.arguments = ["dashboard", "--no-open", "--port", String(port)] + proc.environment = HermesFileService.enrichedEnvironment() + // Discard stdout/stderr — we rely on the HTTP probe for liveness and + // don't want a growing pipe buffer to block the subprocess. + proc.standardOutput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + + do { + try proc.run() + dashboardProcess = proc + Task { [weak self] in + // Give uvicorn up to ~6 seconds to bind the port, probing + // every 300ms. First 200 response opens the browser. + for _ in 0..<20 { + if await Self.probeDashboard(port: port) { + if let url = URL(string: "http://127.0.0.1:\(port)") { + await MainActor.run { + NSWorkspace.shared.open(url) + } + } + break + } + try? await Task.sleep(nanoseconds: 300_000_000) + } + await MainActor.run { [weak self] in + guard let self else { return } + self.dashboardStatus = WebDashboardStatus( + running: self.dashboardStatus.running, + port: self.dashboardStatus.port, + busy: false + ) + self.actionMessage = nil + } + } + } catch { + Self.dashboardLogger.error("Failed to spawn hermes dashboard: \(error.localizedDescription, privacy: .public)") + dashboardProcess = nil + dashboardStatus = WebDashboardStatus( + running: dashboardStatus.running, + port: dashboardStatus.port, + busy: false + ) + actionMessage = "Failed to start: \(error.localizedDescription)" + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.actionMessage = nil + } + } + } + + /// Stop the dashboard. If Scarf spawned it, send SIGTERM directly. If an + /// external instance is running, fall back to `pkill -f "hermes dashboard"` + /// so the Stop button works regardless of who launched it. + func stopDashboard() { + guard !context.isRemote else { return } + dashboardStatus = WebDashboardStatus( + running: dashboardStatus.running, + port: dashboardStatus.port, + busy: true + ) + actionMessage = "Stopping dashboard…" + + if let proc = dashboardProcess, proc.isRunning { + proc.terminate() + dashboardProcess = nil + } else { + // External instance — best-effort pkill. + let kill = Process() + kill.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + kill.arguments = ["-f", "hermes dashboard"] + _ = try? kill.run() + kill.waitUntilExit() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in + guard let self else { return } + Task { + let running = await Self.probeDashboard(port: self.dashboardStatus.port) + await MainActor.run { + self.dashboardStatus = WebDashboardStatus( + running: running, + port: self.dashboardStatus.port, + busy: false + ) + self.actionMessage = nil + } + } + } + } + + /// Open the dashboard in the default browser. Safe to call only when the + /// probe reports `running: true` — UI gates the button on that. + func openDashboardInBrowser() { + guard let url = URL(string: "http://127.0.0.1:\(dashboardStatus.port)") else { return } + NSWorkspace.shared.open(url) + } + + /// HEAD-shaped GET against `/api/status`. Returns true on any 2xx response. + /// `/api/status` is whitelisted in `_PUBLIC_API_PATHS` in Hermes's + /// `web_server.py` — no token required, so a bare GET works. + /// + /// `nonisolated` + `async` so the polling loop can call it without + /// bouncing through MainActor on every tick. + nonisolated private static func probeDashboard(port: Int) async -> Bool { + guard let url = URL(string: "http://127.0.0.1:\(port)/api/status") else { return false } + var request = URLRequest(url: url) + request.timeoutInterval = 0.5 + request.httpMethod = "GET" + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 0.5 + config.timeoutIntervalForResource = 1.0 + let session = URLSession(configuration: config) + defer { session.invalidateAndCancel() } + do { + let (_, response) = try await session.data(for: request) + if let http = response as? HTTPURLResponse { + return (200..<300).contains(http.statusCode) + } + return false + } catch { + return false + } + } + + nonisolated private static let dashboardLogger = Logger(subsystem: "com.scarf", category: "WebDashboard") } diff --git a/scarf/scarf/Features/Health/Views/HealthView.swift b/scarf/scarf/Features/Health/Views/HealthView.swift index a2482fd..ce885e3 100644 --- a/scarf/scarf/Features/Health/Views/HealthView.swift +++ b/scarf/scarf/Features/Health/Views/HealthView.swift @@ -53,7 +53,11 @@ struct HealthView: View { label: "Running health checks…", isEmpty: viewModel.statusSections.isEmpty && viewModel.doctorSections.isEmpty ) - .onAppear { viewModel.load() } + .onAppear { + viewModel.load() + viewModel.startDashboardMonitoring() + } + .onDisappear { viewModel.stopDashboardMonitoring() } .confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) { Button("Upload", role: .destructive) { viewModel.runDebugShare() @@ -161,9 +165,56 @@ struct HealthView: View { } .padding(.horizontal) .padding(.vertical, 8) + + if !viewModel.context.isRemote { + Divider() + webDashboardRow + } } } + /// Status + controls for `hermes dashboard` (the web UI introduced in + /// v0.10.x). Hidden for remote contexts — the dashboard binds 127.0.0.1 + /// and remote tunneling is deferred. + private var webDashboardRow: some View { + HStack(spacing: 16) { + HStack(spacing: 6) { + Image(systemName: "safari") + .foregroundStyle(viewModel.dashboardStatus.running ? .green : .secondary) + .font(.caption) + if viewModel.dashboardStatus.running { + Text("Web Dashboard on :\(viewModel.dashboardStatus.port)") + .font(.caption.bold()) + } else { + Text("Web Dashboard") + .font(.caption.bold()) + Text("not running") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + HStack(spacing: 8) { + if viewModel.dashboardStatus.running { + Button("Open in Browser") { viewModel.openDashboardInBrowser() } + Button("Stop") { viewModel.stopDashboard() } + .disabled(viewModel.dashboardStatus.busy) + } else { + Button("Launch Dashboard") { viewModel.launchDashboard() } + .disabled(viewModel.dashboardStatus.busy) + } + if viewModel.dashboardStatus.busy { + ProgressView().controlSize(.small) + } + } + .controlSize(.small) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + // MARK: - Grid private func sectionGrid(_ sections: [HealthSection]) -> some View {