Files
byre/mac/Sources/Byre/Status.swift
T

435 lines
16 KiB
Swift
Raw Normal View History

import ByreAPI
2026-10-17 07:26:18 -07:00
import SwiftUI
typealias AgentProcess = Components.Schemas.AgentProcess
typealias Zombie = Components.Schemas.Zombie
private let refreshKinds: Set<String> = ["session.created", "session.state", "task.created", "task.merged", "task.discarded", "project.created", "admin.health", "admin.clients"]
2026-10-17 07:26:18 -07:00
private let zombieLabel: [Zombie.KindPayload: String] = [
.runningWithoutProcess: "Running with no process",
.processWithoutSession: "Process with no session",
]
private func since(_ date: Date) -> String {
let seconds = max(0, Int(Date().timeIntervalSince(date).rounded()))
if seconds < 60 {
return "\(seconds)s ago"
}
if seconds < 3600 {
return "\(seconds / 60)m \(seconds % 60)s ago"
}
return ago(date)
}
private func bytes(_ n: Int64) -> String {
let units = ["B", "KB", "MB", "GB", "TB"]
var i = 0
var v = Double(n)
while v >= 1024 && i < units.count - 1 {
v /= 1024
i += 1
}
return "\(v.formatted(.number.precision(.fractionLength(i == 0 ? 0 : 1)).grouping(.never))) \(units[i])"
2026-10-17 07:26:18 -07:00
}
private func resetText(_ unixSeconds: Double) -> String {
let at = Date(timeIntervalSince1970: unixSeconds)
let time = at.formatted(Date.FormatStyle().weekday(.abbreviated).hour().minute())
let minutes = Int((at.timeIntervalSinceNow / 60).rounded())
if minutes <= 0 {
return "\(time) (passed)"
}
let rel = minutes < 60 ? "\(minutes)m" : minutes < 1440 ? "\(minutes / 60)h \(minutes % 60)m" : "\(minutes / 1440)d \((minutes % 1440) / 60)h"
return "\(time) (in \(rel))"
}
private func number(_ v: (any Sendable)??) -> Double? {
switch v {
case .some(.some(let x)):
if let d = x as? Double {
return d
}
if let i = x as? Int {
return Double(i)
}
return nil
default:
return nil
}
}
struct StatusView: View {
@Environment(Store.self) private var store
@ViewState private var status: ServerStatus?
@ViewState private var error = ""
@ViewState private var inFlight = false
@ViewState private var again = false
@ViewState private var off: (() -> Void)?
var body: some View {
Group {
if let status {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if !error.isEmpty {
Text(error).foregroundStyle(Palette.danger).padding(.top, 8)
}
Problems(status: status)
section("Running turns")
TurnList(status: status)
section("Rate limits")
2026-10-18 23:26:12 -07:00
RateLimits(limits: status.rateLimits.additionalProperties.mapValues { $0.value })
section("Clients")
ClientTable(status: status)
2026-10-17 07:26:18 -07:00
section("Server")
ServerRows(status: status)
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
EmptyText(text: error.isEmpty ? "Loading…" : error, error: !error.isEmpty)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.bg)
.onAppear {
off = store.events.add { ev in
event(ev)
}
refresh()
}
.onDisappear {
off?()
off = nil
}
.onChange(of: store.connected) {
if store.connected {
refresh()
}
}
}
private func section(_ title: String) -> some View {
Text(title)
.font(Fonts.font(13, weight: 700))
.padding(.top, 16)
.padding(.bottom, 8)
}
private func refresh() {
if inFlight {
again = true
return
}
inFlight = true
Task {
do {
status = try await store.api.client.getStatus().ok.body.json
error = ""
} catch {
self.error = "Couldn't load status: \(errorText(error))"
}
inFlight = false
if again {
again = false
refresh()
}
}
}
private func event(_ ev: ByreEvent) {
if refreshKinds.contains(ev.kind) || (ev.kind == "agent.vendor" && ev.payload.value["type"] as? String == "rate_limit_event") {
refresh()
return
}
guard var s = status else {
return
}
var changed = false
if let i = s.agentProcesses.firstIndex(where: { $0.sessionId == ev.agentSessionId }), ev.createdAt.timeIntervalSince(s.agentProcesses[i].lastEventAt) >= 1 {
s.agentProcesses[i].lastEventAt = ev.createdAt
changed = true
}
if ev.seq > s.lastEventSeq {
s.lastEventSeq = ev.seq
changed = true
}
if changed {
status = s
}
}
}
private struct TaskLinkText: View {
let taskId: String
@Environment(Store.self) private var store
var body: some View {
let task = store.findTask(taskId)
let project = task.flatMap { t in store.projects.first { $0.id == t.projectId } }
Button {
store.focusTask(taskId)
} label: {
Text("\(project.map { "\($0.name) ▸ " } ?? "")\(task?.title ?? taskId)")
.foregroundStyle(store.taskColor(taskId) ?? Palette.accent)
}
.buttonStyle(.plain)
}
}
private struct Problems: View {
let status: ServerStatus
var body: some View {
let pe = status.protocolErrors
let vendors = pe.counts.additionalProperties.keys.sorted()
if !status.zombies.isEmpty || !vendors.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Problems")
.font(Fonts.font(13, weight: 700))
.padding(.top, 16)
.padding(.bottom, 4)
ForEach(Array(status.zombies.enumerated()), id: \.offset) { _, z in
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(zombieLabel[z.kind]!).foregroundStyle(Palette.danger)
if let taskId = z.taskId {
TaskLinkText(taskId: taskId)
}
Text(z.detail).foregroundStyle(Palette.muted).textSelection(.enabled)
}
}
ForEach(vendors, id: \.self) { v in
let n = pe.counts.additionalProperties[v] ?? 0
VStack(alignment: .leading, spacing: 2) {
Text("\(v): \(n) bad stream line\(n == 1 ? "" : "s")").foregroundStyle(Palette.danger)
if let latest = pe.recent.first(where: { $0.vendor == v }) {
DetailsBox(boxed: false) {
Text("latest \(since(latest.at)): \(latest.reason)").foregroundStyle(Palette.muted)
} content: {
PreBox(text: latest.raw + (latest.raw.utf8.count < latest.size ? "… (\(bytes(Int64(latest.size))))" : ""), maxHeight: 180, size: 11)
.padding(.top, 4)
}
}
}
}
}
}
}
}
private struct TurnList: View {
let status: ServerStatus
@Environment(Store.self) private var store
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(status.runningTurns, id: \.sessionId) { t in
let task = store.findTask(t.taskId)
let project = task.flatMap { tk in store.projects.first { $0.id == tk.projectId } }
let color = store.taskColor(t.taskId) ?? Palette.accent
HoverRow {
store.focusTask(t.taskId)
} content: {
StateDot(filled: Palette.ok).help("running")
HStack(spacing: 0) {
if let project {
Text("\(project.name) ▸ ").foregroundStyle(Palette.muted)
}
Text(task?.title ?? t.taskId).foregroundStyle(color)
}
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
if let p = status.agentProcesses.first(where: { $0.sessionId == t.sessionId }) {
Text(p.health == .ok ? since(p.lastEventAt) : "\(p.health.rawValue) · \(since(p.lastEventAt))")
.font(Fonts.font(11))
.foregroundStyle(p.health == .ok ? Palette.muted : Palette.danger)
}
Text(t.vendor).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
if status.runningTurns.isEmpty {
Text("No turns running.")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
}
}
}
2026-10-18 23:26:12 -07:00
private struct RateLimits: View {
let limits: [String: JSONDict]
2026-10-17 07:26:18 -07:00
var body: some View {
2026-10-18 23:26:12 -07:00
if limits.isEmpty {
Text("No vendor has reported a rate-limit status since byre started.").foregroundStyle(Palette.muted)
} else {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 2) {
GridRow {
ForEach(["Window", "Used", "Elapsed", "Pace", "Resets"], id: \.self) { h in
Text(h).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
ForEach(limits.keys.sorted(), id: \.self) { vendor in
vendorRows(vendor, limits[vendor]!)
}
}
}
}
@ViewBuilder
private func vendorRows(_ vendor: String, _ info: JSONDict) -> some View {
2026-10-17 07:26:18 -07:00
let statusText = info["status"].map { jsString($0) } ?? "unknown"
let tone = statusText == "allowed" ? Palette.ok : statusText == "rejected" ? Palette.danger : Palette.warn
let windows = dict(info, "unifiedWindows") ?? [:]
let rest = info.filter { !["status", "rateLimitType", "resetsAt", "unifiedWindows"].contains($0.key) }.sorted { $0.key < $1.key }
2026-10-18 23:26:12 -07:00
GridRow {
2026-10-17 07:26:18 -07:00
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(vendor).font(Fonts.font(13, weight: 700)).foregroundStyle(Palette.text)
Text(statusText).font(Fonts.font(11, weight: 700)).foregroundStyle(tone)
if let type = info["rateLimitType"] {
Text(jsString(type))
}
Spacer(minLength: 0)
if let resets = number(info["resetsAt"]) {
Text("resets \(resetText(resets))")
}
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
2026-10-18 23:26:12 -07:00
.padding(.top, 12)
.gridCellColumns(5)
}
Divider().overlay(Palette.border)
ForEach(windows.keys.sorted(), id: \.self) { name in
let w = dict(windows, name) ?? [:]
let used = number(w["utilization"])
let elapsed = elapsedFraction(w)
GridRow {
Text(name)
Text(used.map { "\(Int(($0 * 100).rounded()))%" } ?? "—")
.foregroundStyle(used.map { $0 >= 0.9 ? Palette.danger : $0 >= 0.7 ? Palette.warn : Palette.text } ?? Palette.text)
Text(elapsed.map { "\(Int(($0 * 100).rounded()))%" } ?? "—")
PaceBar(used: used, elapsed: elapsed)
Text(number(w["resetsAt"]).map(resetText) ?? "—")
2026-10-17 07:26:18 -07:00
}
2026-10-18 23:26:12 -07:00
Divider().overlay(Palette.border)
}
ForEach(rest, id: \.key) { k, v in
GridRow {
Text(k).foregroundStyle(Palette.muted)
Text(v is JSONDict || v is [(any Sendable)?] ? compactJSONText(v) : jsString(v))
.textSelection(.enabled)
.gridCellColumns(4)
2026-10-17 07:26:18 -07:00
}
}
}
private func elapsedFraction(_ w: JSONDict) -> Double? {
guard let resets = number(w["resetsAt"]), let mins = number(w["windowMins"]) else {
return nil
}
let remaining = resets * 1000 - Date().timeIntervalSince1970 * 1000
return min(1, max(0, 1 - remaining / (mins * 60000)))
}
}
private func compactJSONText(_ v: (any Sendable)?) -> String {
prettyJSON(v).components(separatedBy: "\n").map { trimmed($0) }.joined(separator: " ")
}
private struct PaceBar: View {
let used: Double?
let elapsed: Double?
var body: some View {
if let used, let elapsed {
let diff = Int(((used - elapsed) * 100).rounded())
let title = diff > 0 ? "\(diff) points ahead of an even pace" : diff < 0 ? "\(-diff) points behind an even pace" : "on an even pace"
GeometryReader { g in
ZStack(alignment: .leading) {
Rectangle()
2026-10-17 10:54:06 -07:00
.fill(Palette.raised)
.overlay((used > elapsed ? Palette.warn : Palette.ok).opacity(0.55))
2026-10-17 07:26:18 -07:00
.frame(width: g.size.width * min(1, used))
Rectangle()
.fill(Palette.text)
.frame(width: 2, height: g.size.height + 6)
.offset(x: g.size.width * elapsed - 1)
}
}
2026-10-17 10:54:06 -07:00
.padding(1)
.background {
RoundedRectangle(cornerRadius: 2)
.fill(Palette.surface)
.overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border))
}
2026-10-17 07:26:18 -07:00
.frame(minWidth: 80, maxWidth: .infinity)
.frame(height: 10)
.help(title)
} else {
Text("—")
}
}
}
private struct ClientTable: View {
let status: ServerStatus
var body: some View {
if status.clients.isEmpty {
Text("No clients connected.").foregroundStyle(Palette.muted)
} else {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 2) {
GridRow {
ForEach(["Mac", "User", "Build", "Connected", "Last action"], id: \.self) { h in
Text(h).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
Divider().overlay(Palette.border)
ForEach(Array(status.clients.enumerated()), id: \.offset) { _, c in
GridRow {
Text(c.host.isEmpty ? "—" : c.host)
Text(c.login)
Text(c.commit.isEmpty ? "—" : String(c.commit.prefix(8))).help(c.commit)
Text(since(c.connectedAt))
Text(c.lastActionAt.map(since) ?? "—")
}
.textSelection(.enabled)
Divider().overlay(Palette.border)
}
}
}
}
}
2026-10-17 07:26:18 -07:00
private struct ServerRows: View {
let status: ServerStatus
var body: some View {
let rows: [(String, String)] = [
("Started", "\(status.startedAt.formatted(date: .numeric, time: .standard)) (\(ago(status.startedAt)))"),
("Agent processes", String(status.liveAgents)),
("Projects", String(status.projects)),
("Active tasks", String(status.activeTasks)),
("Live clients", String(status.sseClients)),
("Database", bytes(status.dbBytes)),
("Last event", "#\(status.lastEventSeq)"),
]
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 2) {
ForEach(rows, id: \.0) { k, v in
GridRow {
Text(k).foregroundStyle(Palette.muted)
Text(v).textSelection(.enabled)
}
}
}
.padding(.top, 8)
}
}