mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user