Files
andros 7a70727e77 Split connection status into Server and Mesh node
The Status panel now distinguishes the app-to-server reachability
from the server-to-radio link, and uses the v1 nodeResponsive flag
to show three radio states: Online (green), Online (idle, orange)
and Offline (red).
2026-05-07 10:45:13 +02:00

218 lines
6.2 KiB
Swift

import SwiftUI
struct SetupView: View {
@Environment(Settings.self) private var settings
@Environment(MeshDataStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var host: String = ""
@State private var portText: String = "8080"
@State private var token: String = ""
@State private var useTLS: Bool = false
@State private var testing = false
@State private var testResult: String?
@State private var testOK = false
let isInitial: Bool
var body: some View {
NavigationStack {
Form {
if !isInitial { statusSection }
Section("Server") {
TextField("Host or IP", text: $host)
.textContentType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
TextField("Port", text: $portText)
.keyboardType(.numberPad)
Toggle("Use HTTPS", isOn: $useTLS)
}
Section("Authentication") {
SecureField("Bearer token", text: $token)
.textContentType(.password)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
Link(destination: URL(string: "https://meshmonitor.org/development/api.html")!) {
Label("How to create a token", systemImage: "questionmark.circle")
.font(.footnote)
}
}
Section {
Button {
Task { await testConnection() }
} label: {
HStack {
Text("Test connection")
Spacer()
if testing { ProgressView() }
}
}
.disabled(testing || host.trimmingCharacters(in: .whitespaces).isEmpty)
if let r = testResult {
Label(r, systemImage: testOK ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.foregroundStyle(testOK ? .green : .red)
.font(.footnote)
}
}
Section {
Text("Configure your MeshMonitor server URL and a Bearer token. Token is stored in the iOS Keychain.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section {
Link(destination: URL(string: "https://git.andros.dev/andros/ios-meshmonitor-chat")!) {
Label("Source code", systemImage: "chevron.left.forwardslash.chevron.right")
}
Link(destination: URL(string: "https://git.andros.dev/andros/contribute")!) {
Label("Report a bug", systemImage: "ant")
}
}
}
.navigationTitle(isInitial ? "Connect" : "Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if !isInitial {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.disabled(!canSave)
}
}
.onAppear(perform: load)
}
}
@ViewBuilder
private var statusSection: some View {
Section("Status") {
if let s = store.status {
row("Server", value: "Reachable", valueColor: .green)
row("Mesh node",
value: meshStateLabel(s.meshState),
valueColor: meshStateColor(s.meshState))
if let v = s.version { row("Version", value: v) }
if let name = s.localNodeName {
let id = s.localNodeId.map { " (\($0))" } ?? ""
row("Node", value: "\(name)\(id)")
}
if let up = s.uptime {
row("Uptime", value: formatUptime(up))
}
if let stats = s.statistics {
if let n = stats.nodes { row("Nodes", value: "\(n)") }
if let m = stats.messages { row("Messages", value: "\(m)") }
if let c = stats.channels { row("Channels", value: "\(c)") }
}
} else if store.loading {
HStack { Text("Status").foregroundStyle(.secondary); Spacer(); ProgressView() }
} else if let err = store.error {
row("Server", value: "Unreachable", valueColor: .red)
Text(err).font(.footnote).foregroundStyle(.red)
} else {
Text("No data yet.").foregroundStyle(.secondary)
}
}
}
private func row(_ key: String, value: String, valueColor: Color? = nil) -> some View {
HStack {
Text(key).foregroundStyle(.secondary)
Spacer()
Text(value)
.foregroundStyle(valueColor ?? .primary)
.multilineTextAlignment(.trailing)
}
}
private func meshStateLabel(_ state: ServerStatus.MeshState) -> String {
switch state {
case .online: return "Online"
case .idle: return "Online (idle)"
case .offline: return "Offline"
}
}
private func meshStateColor(_ state: ServerStatus.MeshState) -> Color {
switch state {
case .online: return .green
case .idle: return .orange
case .offline: return .red
}
}
private func formatUptime(_ secs: Double) -> String {
let total = Int(secs)
let days = total / 86400
let hours = (total % 86400) / 3600
let mins = (total % 3600) / 60
if days > 0 { return "\(days)d \(hours)h" }
if hours > 0 { return "\(hours)h \(mins)m" }
return "\(mins)m"
}
private var canSave: Bool {
!host.trimmingCharacters(in: .whitespaces).isEmpty &&
!token.trimmingCharacters(in: .whitespaces).isEmpty &&
(Int(portText) ?? 0) > 0
}
private func load() {
host = settings.host
portText = String(settings.port)
useTLS = settings.useTLS
token = settings.token
}
private func save() {
settings.host = host.trimmingCharacters(in: .whitespaces)
settings.port = Int(portText) ?? 8080
settings.useTLS = useTLS
settings.token = token.trimmingCharacters(in: .whitespaces)
dismiss()
}
@MainActor
private func testConnection() async {
testing = true
defer { testing = false }
testResult = nil
let probe = ServerConfig(
host: host.trimmingCharacters(in: .whitespaces),
port: Int(portText) ?? 8080,
useTLS: useTLS,
token: token.trimmingCharacters(in: .whitespaces)
)
guard probe.isConfigured else {
testOK = false
testResult = "Host and token are required."
return
}
let client = APIClient(settings: settings, config: probe)
do {
let channels = try await client.fetchChannels()
let status = try? await client.fetchStatus()
testOK = true
let nodeBit = status?.localNodeName.map { "\($0)" } ?? ""
testResult = "Connected, \(channels.count) channel\(channels.count == 1 ? "" : "s")\(nodeBit)"
} catch APIError.unauthorized {
testOK = false
testResult = "Invalid token (unauthorized)."
} catch {
testOK = false
testResult = error.localizedDescription
}
}
}
#Preview {
SetupView(isInitial: true)
.environment(Settings())
.environment(MeshDataStore())
}