feat(health): surface hermes dashboard web UI in Health

Hermes v0.10.x ships a local web dashboard launchable via `hermes
dashboard` on port 9119. Scarf now detects it via a 3s
`/api/status` probe and offers Launch / Stop / Open-in-Browser
controls on the Health tab. Local contexts only — the dashboard
binds 127.0.0.1 and remote tunneling is deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 03:41:12 +02:00
parent 5498a08b11
commit fe104b83fa
2 changed files with 268 additions and 1 deletions
@@ -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<Void, Never>?
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:<port>/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")
}
@@ -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 {