mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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 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 {
|
struct HealthCheck: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
@@ -50,6 +66,19 @@ final class HealthViewModel {
|
|||||||
var diagnosticsOutput: String = ""
|
var diagnosticsOutput: String = ""
|
||||||
var isSharingDebug = false
|
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() {
|
func load() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
let ctx = context
|
let ctx = context
|
||||||
@@ -447,4 +476,191 @@ final class HealthViewModel {
|
|||||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
context.runHermes(arguments)
|
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…",
|
label: "Running health checks…",
|
||||||
isEmpty: viewModel.statusSections.isEmpty && viewModel.doctorSections.isEmpty
|
isEmpty: viewModel.statusSections.isEmpty && viewModel.doctorSections.isEmpty
|
||||||
)
|
)
|
||||||
.onAppear { viewModel.load() }
|
.onAppear {
|
||||||
|
viewModel.load()
|
||||||
|
viewModel.startDashboardMonitoring()
|
||||||
|
}
|
||||||
|
.onDisappear { viewModel.stopDashboardMonitoring() }
|
||||||
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
|
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
|
||||||
Button("Upload", role: .destructive) {
|
Button("Upload", role: .destructive) {
|
||||||
viewModel.runDebugShare()
|
viewModel.runDebugShare()
|
||||||
@@ -161,9 +165,56 @@ struct HealthView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 8)
|
.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
|
// MARK: - Grid
|
||||||
|
|
||||||
private func sectionGrid(_ sections: [HealthSection]) -> some View {
|
private func sectionGrid(_ sections: [HealthSection]) -> some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user