mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
Add Gateway Control Center with service control and pairing management
New Gateway section in the Manage group: - Service controls: Start/Stop/Restart buttons calling hermes gateway CLI - Status display: state (running/stopped), PID, loaded indicator, stale service warning, exit reason, last update timestamp - Platform cards: each connected messaging platform with connection state (reads from gateway_state.json) - Pairing management: approved users list with revoke button, pending pairing codes with approve button - Auto-refreshes via HermesFileWatcher when gateway state changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,8 @@ struct ContentView: View {
|
|||||||
SkillsView()
|
SkillsView()
|
||||||
case .tools:
|
case .tools:
|
||||||
ToolsView()
|
ToolsView()
|
||||||
|
case .gateway:
|
||||||
|
GatewayView()
|
||||||
case .cron:
|
case .cron:
|
||||||
CronView()
|
CronView()
|
||||||
case .logs:
|
case .logs:
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct GatewayInfo {
|
||||||
|
let pid: Int?
|
||||||
|
let state: String
|
||||||
|
let exitReason: String?
|
||||||
|
let startTime: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
let platforms: [PlatformInfo]
|
||||||
|
let isLoaded: Bool
|
||||||
|
let isStale: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlatformInfo: Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let state: String
|
||||||
|
let updatedAt: String?
|
||||||
|
|
||||||
|
var isConnected: Bool { state == "connected" }
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch name {
|
||||||
|
case "telegram": return "paperplane"
|
||||||
|
case "discord": return "bubble.left.and.bubble.right"
|
||||||
|
case "slack": return "number"
|
||||||
|
case "whatsapp": return "phone.bubble"
|
||||||
|
case "signal": return "lock.shield"
|
||||||
|
case "email": return "envelope"
|
||||||
|
default: return "bubble.left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PairedUser: Identifiable {
|
||||||
|
var id: String { platform + userId }
|
||||||
|
let platform: String
|
||||||
|
let userId: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingPairing: Identifiable {
|
||||||
|
var id: String { platform + code }
|
||||||
|
let platform: String
|
||||||
|
let code: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class GatewayViewModel {
|
||||||
|
var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false)
|
||||||
|
var approvedUsers: [PairedUser] = []
|
||||||
|
var pendingPairings: [PendingPairing] = []
|
||||||
|
var isLoading = false
|
||||||
|
var actionMessage: String?
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
isLoading = true
|
||||||
|
loadGatewayStatus()
|
||||||
|
loadPairing()
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func startGateway() {
|
||||||
|
runHermes(["gateway", "start"])
|
||||||
|
actionMessage = "Gateway start requested"
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.loadGatewayStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGateway() {
|
||||||
|
runHermes(["gateway", "stop"])
|
||||||
|
actionMessage = "Gateway stop requested"
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.loadGatewayStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartGateway() {
|
||||||
|
runHermes(["gateway", "restart"])
|
||||||
|
actionMessage = "Gateway restart requested"
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.loadGatewayStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func approvePairing(platform: String, code: String) {
|
||||||
|
runHermes(["pairing", "approve", platform, code])
|
||||||
|
loadPairing()
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeUser(_ user: PairedUser) {
|
||||||
|
runHermes(["pairing", "revoke", user.platform, user.userId])
|
||||||
|
approvedUsers.removeAll { $0.id == user.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func loadGatewayStatus() {
|
||||||
|
let stateJSON = FileManager.default.contents(atPath: HermesPaths.gatewayStateJSON)
|
||||||
|
var pid: Int?
|
||||||
|
var state = "unknown"
|
||||||
|
var exitReason: String?
|
||||||
|
var startTime: String?
|
||||||
|
var updatedAt: String?
|
||||||
|
var platforms: [PlatformInfo] = []
|
||||||
|
|
||||||
|
if let data = stateJSON,
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
pid = json["pid"] as? Int
|
||||||
|
state = json["gateway_state"] as? String ?? "unknown"
|
||||||
|
exitReason = json["exit_reason"] as? String
|
||||||
|
startTime = json["start_time"] as? String
|
||||||
|
updatedAt = json["updated_at"] as? String
|
||||||
|
if let plats = json["platforms"] as? [String: Any] {
|
||||||
|
platforms = plats.compactMap { key, value in
|
||||||
|
guard let info = value as? [String: Any] else { return nil }
|
||||||
|
return PlatformInfo(
|
||||||
|
name: key,
|
||||||
|
state: info["state"] as? String ?? "unknown",
|
||||||
|
updatedAt: info["updated_at"] as? String
|
||||||
|
)
|
||||||
|
}.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusOutput = runHermes(["gateway", "status"]).output
|
||||||
|
let isLoaded = statusOutput.contains("service is loaded")
|
||||||
|
let isStale = statusOutput.contains("stale")
|
||||||
|
|
||||||
|
gateway = GatewayInfo(
|
||||||
|
pid: pid, state: state, exitReason: exitReason,
|
||||||
|
startTime: startTime, updatedAt: updatedAt,
|
||||||
|
platforms: platforms, isLoaded: isLoaded, isStale: isStale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPairing() {
|
||||||
|
let output = runHermes(["pairing", "list"]).output
|
||||||
|
approvedUsers = []
|
||||||
|
pendingPairings = []
|
||||||
|
|
||||||
|
var inApproved = false
|
||||||
|
var inPending = false
|
||||||
|
|
||||||
|
for line in output.components(separatedBy: "\n") {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.contains("Approved Users") { inApproved = true; inPending = false; continue }
|
||||||
|
if trimmed.contains("Pending") { inPending = true; inApproved = false; continue }
|
||||||
|
if trimmed.isEmpty || trimmed.hasPrefix("Platform") || trimmed.hasPrefix("--------") { continue }
|
||||||
|
|
||||||
|
let parts = trimmed.split(separator: " ", omittingEmptySubsequences: true)
|
||||||
|
if inApproved && parts.count >= 3 {
|
||||||
|
let platform = String(parts[0])
|
||||||
|
let userId = String(parts[1])
|
||||||
|
let name = parts[2...].joined(separator: " ")
|
||||||
|
approvedUsers.append(PairedUser(platform: platform, userId: userId, name: name))
|
||||||
|
}
|
||||||
|
if inPending && parts.count >= 2 {
|
||||||
|
let platform = String(parts[0])
|
||||||
|
let code = String(parts[1])
|
||||||
|
pendingPairings.append(PendingPairing(platform: platform, code: code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||||
|
process.arguments = arguments
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = Pipe()
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||||
|
} catch {
|
||||||
|
return ("", -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GatewayView: View {
|
||||||
|
@State private var viewModel = GatewayViewModel()
|
||||||
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
serviceSection
|
||||||
|
platformsSection
|
||||||
|
pairingSection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
.navigationTitle("Gateway")
|
||||||
|
.onAppear { viewModel.load() }
|
||||||
|
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Service
|
||||||
|
|
||||||
|
private var serviceSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("Service")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if let msg = viewModel.actionMessage {
|
||||||
|
Text(msg)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Start") { viewModel.startGateway() }
|
||||||
|
Button("Stop") { viewModel.stopGateway() }
|
||||||
|
Button("Restart") { viewModel.restartGateway() }
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
StatusBadge(
|
||||||
|
label: viewModel.gateway.state,
|
||||||
|
isActive: viewModel.gateway.state == "running"
|
||||||
|
)
|
||||||
|
if let pid = viewModel.gateway.pid {
|
||||||
|
Label("PID \(pid)", systemImage: "number")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if viewModel.gateway.isLoaded {
|
||||||
|
Label("Loaded", systemImage: "checkmark.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
if viewModel.gateway.isStale {
|
||||||
|
Label("Service definition stale", systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let reason = viewModel.gateway.exitReason, !reason.isEmpty {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(reason)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let updated = viewModel.gateway.updatedAt {
|
||||||
|
Text("Last updated: \(updated)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Platforms
|
||||||
|
|
||||||
|
private var platformsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Platforms")
|
||||||
|
.font(.headline)
|
||||||
|
if viewModel.gateway.platforms.isEmpty {
|
||||||
|
Text("No platforms connected")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(viewModel.gateway.platforms) { platform in
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: platform.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(platform.isConnected ? Color.accentColor : .secondary)
|
||||||
|
Text(platform.name.capitalized)
|
||||||
|
.font(.caption.bold())
|
||||||
|
StatusBadge(
|
||||||
|
label: platform.state,
|
||||||
|
isActive: platform.isConnected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pairing
|
||||||
|
|
||||||
|
private var pairingSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Paired Users")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if !viewModel.pendingPairings.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Pending Approvals", systemImage: "clock.badge.questionmark")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
ForEach(viewModel.pendingPairings) { pending in
|
||||||
|
HStack {
|
||||||
|
Label(pending.platform.capitalized, systemImage: platformIcon(pending.platform))
|
||||||
|
Text("Code: \(pending.code)")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
Spacer()
|
||||||
|
Button("Approve") {
|
||||||
|
viewModel.approvePairing(platform: pending.platform, code: pending.code)
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(8)
|
||||||
|
.background(.orange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.approvedUsers.isEmpty && viewModel.pendingPairings.isEmpty {
|
||||||
|
Text("No paired users")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.approvedUsers) { user in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: platformIcon(user.platform))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 20)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(user.name)
|
||||||
|
Text("\(user.platform.capitalized) · \(user.userId)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Revoke", role: .destructive) {
|
||||||
|
viewModel.revokeUser(user)
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func platformIcon(_ platform: String) -> String {
|
||||||
|
switch platform {
|
||||||
|
case "telegram": return "paperplane"
|
||||||
|
case "discord": return "bubble.left.and.bubble.right"
|
||||||
|
case "slack": return "number"
|
||||||
|
case "whatsapp": return "phone.bubble"
|
||||||
|
case "signal": return "lock.shield"
|
||||||
|
case "email": return "envelope"
|
||||||
|
default: return "bubble.left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusBadge: View {
|
||||||
|
let label: String
|
||||||
|
let isActive: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(isActive ? .green : .secondary)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case memory = "Memory"
|
case memory = "Memory"
|
||||||
case skills = "Skills"
|
case skills = "Skills"
|
||||||
case tools = "Tools"
|
case tools = "Tools"
|
||||||
|
case gateway = "Gateway"
|
||||||
case cron = "Cron"
|
case cron = "Cron"
|
||||||
case logs = "Logs"
|
case logs = "Logs"
|
||||||
case settings = "Settings"
|
case settings = "Settings"
|
||||||
@@ -25,6 +26,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .memory: return "brain"
|
case .memory: return "brain"
|
||||||
case .skills: return "lightbulb"
|
case .skills: return "lightbulb"
|
||||||
case .tools: return "wrench.and.screwdriver"
|
case .tools: return "wrench.and.screwdriver"
|
||||||
|
case .gateway: return "antenna.radiowaves.left.and.right"
|
||||||
case .cron: return "clock.arrow.2.circlepath"
|
case .cron: return "clock.arrow.2.circlepath"
|
||||||
case .logs: return "doc.text"
|
case .logs: return "doc.text"
|
||||||
case .settings: return "gearshape"
|
case .settings: return "gearshape"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Manage") {
|
Section("Manage") {
|
||||||
ForEach([SidebarSection.tools, .cron, .logs, .settings]) { section in
|
ForEach([SidebarSection.tools, .gateway, .cron, .logs, .settings]) { section in
|
||||||
Label(section.rawValue, systemImage: section.icon)
|
Label(section.rawValue, systemImage: section.icon)
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user