7a70727e77
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).
218 lines
6.2 KiB
Swift
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())
|
|
}
|