Files
GHOSTWIRE/ios/GHOSTWIRE/TrafficChart.swift
T
Daniel Redetzke f31bb360c9 Add native iOS app with App Store preparation
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.

Claude-Session: https://claude.ai/code/session_01RAnLbyQZ5ZTA7KqwXP98nw
2026-10-03 18:34:41 +03:00

103 lines
4.1 KiB
Swift

import Charts
import SwiftUI
/// Traffic bars like the web UI: one blue bar per bucket (total), or a blue
/// download and orange upload bar side by side (pair). Touch a bar to see
/// its values in the line above the chart.
struct TrafficChart: View {
enum Mode { case total, pair }
let points: [StatPoint]
let range: String
let mode: Mode
@State private var selected: Date?
private var unit: Calendar.Component { range == "24h" ? .hour : .day }
private var selectedPoint: StatPoint? {
guard let selected else { return nil }
return points.first { Calendar.current.isDate($0.date, equalTo: selected, toGranularity: unit) }
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
readout
.font(.footnote)
.foregroundStyle(Color.gwText2)
.frame(minHeight: 18, alignment: .leading)
Chart {
ForEach(points) { p in
if mode == .pair {
BarMark(x: .value("Time", p.date, unit: unit), y: .value("Bytes", Double(p.down)))
.foregroundStyle(by: .value("Series", "Download"))
.position(by: .value("Series", "Download"))
.cornerRadius(3)
BarMark(x: .value("Time", p.date, unit: unit), y: .value("Bytes", Double(p.up)))
.foregroundStyle(by: .value("Series", "Upload"))
.position(by: .value("Series", "Upload"))
.cornerRadius(3)
} else {
BarMark(x: .value("Time", p.date, unit: unit), y: .value("Bytes", Double(p.down + p.up)))
.foregroundStyle(by: .value("Series", "Download"))
.cornerRadius(3)
.opacity(selectedPoint == nil || selectedPoint == p ? 1 : 0.45)
}
}
}
.chartForegroundStyleScale(["Download": Color.gwDown, "Upload": Color.gwUp])
.chartLegend(.hidden)
.chartYAxis {
AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { v in
AxisGridLine()
AxisValueLabel {
if let b = v.as(Double.self) { Text(fmtBytes(Int64(b))).font(.caption2) }
}
}
}
.chartXAxis {
AxisMarks(values: .automatic(desiredCount: 4)) { _ in
AxisValueLabel(format: range == "24h" ? .dateTime.hour() : .dateTime.day().month(.abbreviated))
}
}
.chartXSelection(value: $selected)
.frame(height: 180)
}
}
@ViewBuilder private var readout: some View {
if let p = selectedPoint {
let label = pointLabel(p, range: range)
if mode == .pair {
Text("\(label) · Download **\(fmtBytes(p.down))** · Upload **\(fmtBytes(p.up))**")
} else {
Text("**\(fmtBytes(p.down + p.up))** · \(label)")
}
} else if mode == .total, let peak = points.max(by: { $0.down + $0.up < $1.down + $1.up }), peak.down + peak.up > 0 {
Text("**\(fmtBytes(peak.down + peak.up))** · peak, \(pointLabel(peak, range: range))")
} else {
Text("Touch a bar to see its values.")
}
}
}
/// Legend with totals for the pair chart.
struct TrafficTotals: View {
let points: [StatPoint]
var body: some View {
let down = points.reduce(0) { $0 + $1.down }
let up = points.reduce(0) { $0 + $1.up }
HStack(spacing: 16) {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 3).fill(Color.gwDown).frame(width: 12, height: 12)
Text("Download **\(fmtBytes(down))**")
}
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 3).fill(Color.gwUp).frame(width: 12, height: 12)
Text("Upload **\(fmtBytes(up))**")
}
}
.font(.footnote)
.foregroundStyle(Color.gwText2)
}
}