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:
Alan Wizemann
2026-03-31 12:32:29 -04:00
parent b79200e950
commit c09f167760
5 changed files with 397 additions and 1 deletions
+2
View File
@@ -30,6 +30,8 @@ struct ContentView: View {
SkillsView()
case .tools:
ToolsView()
case .gateway:
GatewayView()
case .cron:
CronView()
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 skills = "Skills"
case tools = "Tools"
case gateway = "Gateway"
case cron = "Cron"
case logs = "Logs"
case settings = "Settings"
@@ -25,6 +26,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .memory: return "brain"
case .skills: return "lightbulb"
case .tools: return "wrench.and.screwdriver"
case .gateway: return "antenna.radiowaves.left.and.right"
case .cron: return "clock.arrow.2.circlepath"
case .logs: return "doc.text"
case .settings: return "gearshape"
+1 -1
View File
@@ -19,7 +19,7 @@ struct SidebarView: View {
}
}
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)
.tag(section)
}