066d669f3a
iOS app (ios/, SwiftUI, iOS 17+) in the web UI's styling with the Kamon logo. It covers everything the web interface does except password, API tokens and backups: dashboard, peers with search and filter, peer detail with traffic charts, add/edit peers, one-time config with QR code and share sheet, server settings with apply bar, app settings, data retention and log viewer. - Pairing by QR code or pasted pairing code; token kept in the keychain; self-signed certificates are pinned by SHA-256 fingerprint. - Colour providers and logo drawing are nonisolated: SwiftUI's background renderer calls them, and main-actor closures crashed there when the camera scanner was open. - App Store: version 1.0, export compliance, privacy manifest, app icon, release.sh (archive and upload), listing text, review notes and 6.9" screenshots in ios/AppStore. Server: - Full-access API tokens may use settings, logs and restart; password, tokens, backup/restore and the admin username stay admin-only. - Web pairing dialog gains "Copy pairing code". - The development simulator reports health checks in Linux wording.
107 lines
4.1 KiB
Swift
107 lines
4.1 KiB
Swift
import SwiftUI
|
|
|
|
struct DashboardView: View {
|
|
@Environment(AppSession.self) private var session
|
|
@State private var status: Status?
|
|
@State private var peers: [Peer] = []
|
|
@State private var points: [StatPoint] = []
|
|
@State private var error: String?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
if let error { Notice(text: error, isError: true) }
|
|
if let s = status {
|
|
content(s)
|
|
} else if error == nil {
|
|
ProgressView().padding(40)
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
.background(Color.gwGround)
|
|
.navigationTitle("Dashboard")
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) { KamonMark(size: 30) }
|
|
}
|
|
.navigationDestination(for: String.self) { PeerDetailView(peerID: $0) }
|
|
.refreshable { await load() }
|
|
.task {
|
|
while !Task.isCancelled {
|
|
await load()
|
|
try? await Task.sleep(for: .seconds(30))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func content(_ s: Status) -> some View {
|
|
let failing = s.checks.filter { !$0.ok }
|
|
let ifUp = s.checks.first { $0.name == "WireGuard interface" }?.ok ?? false
|
|
|
|
Text("Endpoint \(s.endpoint) · \(s.ipv4)")
|
|
.font(.mono(.caption))
|
|
.foregroundStyle(Color.gwText2)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if !failing.isEmpty {
|
|
Notice(text: "Needs attention: " + failing.map { "\($0.name) (\($0.detail))" }.joined(separator: " · "), isError: true)
|
|
}
|
|
|
|
// A Grid (not LazyVGrid) gives both tiles of a row the same height.
|
|
Grid(horizontalSpacing: 12, verticalSpacing: 12) {
|
|
GridRow {
|
|
Tile(title: "Peers online", value: "\(s.peers.online)", suffix: "/ \(s.peers.total)",
|
|
sub: "\(s.peers.disabled) disabled · \(s.peers.never) never connected")
|
|
Tile(title: "Interface", value: ifUp ? "Up" : "Down", dot: ifUp ? .gwGood : .gwBad,
|
|
sub: s.healthy ? "All checks pass" : "\(failing.count) check(s) failing")
|
|
}
|
|
GridRow {
|
|
Tile(title: "Last 24 h", value: fmtBytes(s.traffic24h.down + s.traffic24h.up),
|
|
sub: "Down \(fmtBytes(s.traffic24h.down)) · Up \(fmtBytes(s.traffic24h.up))")
|
|
Tile(title: "Last 30 days", value: fmtBytes(s.traffic30d.down + s.traffic30d.up),
|
|
sub: s.topPeer30d.isEmpty ? "No traffic yet" : "Top peer: \(s.topPeer30d)")
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
SectionTitle(text: "Traffic, all peers · 24 h")
|
|
TrafficChart(points: points, range: "24h", mode: .total)
|
|
}
|
|
.card()
|
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
SectionTitle(text: "Peers").padding(.bottom, 8)
|
|
let top = peers.sorted { $0.stats.down24h + $0.stats.up24h > $1.stats.down24h + $1.stats.up24h }.prefix(6)
|
|
if top.isEmpty {
|
|
Text("No peers yet.").font(.footnote).foregroundStyle(Color.gwText2).padding(.vertical, 8)
|
|
}
|
|
ForEach(Array(top)) { p in
|
|
NavigationLink(value: p.id) {
|
|
PeerRow(peer: p, period: .day)
|
|
}
|
|
.buttonStyle(.plain)
|
|
if p.id != top.last?.id { Divider() }
|
|
}
|
|
}
|
|
.card()
|
|
}
|
|
|
|
private func load() async {
|
|
guard let api = session.api else { return }
|
|
do {
|
|
async let s: Status = api.get("/status")
|
|
async let p: PeerList = api.get("/peers")
|
|
async let st: StatsResponse = api.get("/stats?range=24h")
|
|
let (a, b, c) = try await (s, p, st)
|
|
status = a
|
|
peers = b.peers
|
|
points = c.points
|
|
error = nil
|
|
} catch {
|
|
self.error = session.message(for: error)
|
|
}
|
|
}
|
|
}
|