Watch: status, environment and notifications screens
A gear on the feed opens a status overview that leads with what is wrong: offline hosts, hosts on a fallback route, tokens expiring within 7 days, and push problems, with healthy hosts folded into one row. Each host opens its route ladder with Reconnect. The notifications page shows registration, the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN fallback, and snapshot tests render the three screens.
This commit is contained in:
1 parent
9707daf1fe
commit
7ca0362745
17 files changed
+1063
-14
No files matched your search
+17
-4
@@ -45,10 +45,13 @@ the pairing screen has a "Try the demo" button, and launch arguments open a scre
|
||||
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo # feed
|
||||
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -thread littleman/t-next # thread
|
||||
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new # new thread
|
||||
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen status # status overview
|
||||
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -host laptop # one environment
|
||||
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications
|
||||
```
|
||||
|
||||
`screenshots/` holds the screens captured this way. The complication and the multi-select question
|
||||
are rendered by `SnapshotTests` instead, which only run when given a directory:
|
||||
`screenshots/` holds the screens captured this way. The complication, the multi-select question and
|
||||
the status screens are rendered by `SnapshotTests` instead, which only run when given a directory:
|
||||
|
||||
```sh
|
||||
TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \
|
||||
@@ -58,6 +61,15 @@ TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \
|
||||
|
||||
In demo mode the complication shows the demo counts too.
|
||||
|
||||
## Status and settings
|
||||
|
||||
The gear at the top left of the feed opens the status overview (`GET /v1/status`). It leads with
|
||||
"All connected" or "Needs attention", lists only the issues worst first (offline hosts, a host on a
|
||||
fallback route, tokens expiring within 7 days, push problems), and folds the healthy hosts into one
|
||||
row. Each host opens its route ladder with Reconnect. The Notifications row shows whether this watch
|
||||
is registered, the last push, Send test, the gateway, and Unpair. Each screen loads once when it
|
||||
appears; nothing polls.
|
||||
|
||||
## Complication
|
||||
|
||||
`WristCodeWidget` shows how many threads wait on you, from `GET /v1/summary`, in the circular,
|
||||
@@ -87,6 +99,7 @@ To add it: long-press the watch face, Edit, swipe to Complications, pick a slot,
|
||||
| `Auth/DeviceStore.swift` | Device token and gateway URL in the shared Keychain group |
|
||||
| `Shared/SharedState.swift` | Demo flag and last summary in the shared Keychain group, for the complication |
|
||||
| `Demo/DemoGateway.swift` | In-memory gateway with fixtures |
|
||||
| `Views/` | Pair, Feed, Thread, New thread, and the mock B theme |
|
||||
| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host` and their actions |
|
||||
| `Views/` | Pair, Feed, Thread, New thread, Status, and the mock B theme |
|
||||
| `Views/StatusReport.swift` | What the status overview says: headline, issues worst first, healthy hosts |
|
||||
| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host`, `test` and their actions |
|
||||
| `../WristCodeWidget/` | The complication: timeline provider, and one view per family |
|
||||
@@ -52,6 +52,12 @@ protocol GatewayAPI: Sendable {
|
||||
func projects() async throws -> ProjectsResponse
|
||||
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse
|
||||
func registerPush(_ registration: PushRegistration) async throws
|
||||
func status() async throws -> StatusResponse
|
||||
/// Connects the host again from its first route.
|
||||
func reconnect(hostId: String) async throws
|
||||
func testPush() async throws -> PushResult
|
||||
/// Revokes this watch's device token.
|
||||
func unpair() async throws
|
||||
}
|
||||
|
||||
struct GatewayClient: GatewayAPI {
|
||||
@@ -101,6 +107,22 @@ struct GatewayClient: GatewayAPI {
|
||||
try await callIgnoringBody("PUT", ["v1", "device", "push"], body: registration)
|
||||
}
|
||||
|
||||
func status() async throws -> StatusResponse {
|
||||
try await call("GET", ["v1", "status"])
|
||||
}
|
||||
|
||||
func reconnect(hostId: String) async throws {
|
||||
try await callIgnoringBody("POST", ["v1", "hosts", hostId, "reconnect"], body: nil as SendMessageRequest?)
|
||||
}
|
||||
|
||||
func testPush() async throws -> PushResult {
|
||||
try await call("POST", ["v1", "device", "test-push"])
|
||||
}
|
||||
|
||||
func unpair() async throws {
|
||||
try await callIgnoringBody("DELETE", ["v1", "device"], body: nil as SendMessageRequest?)
|
||||
}
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
private func call<Response: Decodable>(_ method: String, _ path: [String]) async throws -> Response {
|
||||
|
||||
@@ -230,6 +230,111 @@ struct ProjectsResponse: Codable, Hashable, Sendable {
|
||||
var hosts: [Host]
|
||||
}
|
||||
|
||||
/// How a route reaches its host, guessed by the gateway from the URL.
|
||||
struct RouteKind: RawRepresentable, Codable, Hashable, Sendable {
|
||||
let rawValue: String
|
||||
init(rawValue: String) { self.rawValue = rawValue }
|
||||
|
||||
static let direct = RouteKind(rawValue: "direct")
|
||||
static let vpn = RouteKind(rawValue: "vpn")
|
||||
static let `public` = RouteKind(rawValue: "public")
|
||||
|
||||
/// "direct", "VPN" or "public", for sentences like "Laptop is on its VPN route".
|
||||
var name: String { self == .vpn ? "VPN" : rawValue }
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .direct: "house.fill"
|
||||
case .vpn: "lock.shield.fill"
|
||||
default: "cloud.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One way to reach a host. `Route` in the contract, renamed here because `Route` is the app's
|
||||
/// navigation.
|
||||
struct HostRoute: Codable, Hashable, Sendable {
|
||||
struct Result: RawRepresentable, Codable, Hashable, Sendable {
|
||||
let rawValue: String
|
||||
init(rawValue: String) { self.rawValue = rawValue }
|
||||
|
||||
static let ok = Result(rawValue: "ok")
|
||||
static let failed = Result(rawValue: "failed")
|
||||
static let untried = Result(rawValue: "untried")
|
||||
}
|
||||
|
||||
var url: String
|
||||
var kind: RouteKind
|
||||
/// From the latest connect. `untried` when an earlier route answered first.
|
||||
var result: Result
|
||||
var error: String?
|
||||
var checkedAt: Date?
|
||||
|
||||
/// The URL without its scheme, such as `laptop.gib:3773`.
|
||||
var hostPort: String {
|
||||
guard let components = URLComponents(string: url), let host = components.host else { return url }
|
||||
return components.port.map { "\(host):\($0)" } ?? host
|
||||
}
|
||||
}
|
||||
|
||||
/// One host as the status screens show it.
|
||||
struct HostStatusDetail: Codable, Hashable, Sendable, Identifiable {
|
||||
var id: String
|
||||
var label: String
|
||||
var status: HostStatus
|
||||
var error: String?
|
||||
/// The route in use. Null unless online.
|
||||
var activeUrl: String?
|
||||
/// Preferred first, the order the gateway tries them.
|
||||
var routes: [HostRoute]
|
||||
var onlineSince: Date?
|
||||
var lastOnlineAt: Date?
|
||||
/// When the gateway tries again, while it waits to retry.
|
||||
var nextRetryAt: Date?
|
||||
var tokenExpiresAt: Date?
|
||||
var serverVersion: String?
|
||||
|
||||
var activeRoute: HostRoute? { routes.first { $0.url == activeUrl } }
|
||||
|
||||
/// Online, but not on the first route.
|
||||
var onFallback: Bool {
|
||||
status == .online && activeUrl != nil && activeUrl != routes.first?.url
|
||||
}
|
||||
}
|
||||
|
||||
/// What Apple answered for one push.
|
||||
struct PushResult: Codable, Hashable, Sendable {
|
||||
var ok: Bool
|
||||
var at: Date
|
||||
/// Apple's HTTP status, or 0 when the push never reached Apple.
|
||||
var status: Int
|
||||
/// Apple's reason, such as `BadDeviceToken`, or why the gateway could not send.
|
||||
var reason: String?
|
||||
}
|
||||
|
||||
/// `GET /v1/status`: everything the settings screens show.
|
||||
struct StatusResponse: Codable, Hashable, Sendable {
|
||||
struct Gateway: Codable, Hashable, Sendable {
|
||||
var startedAt: Date
|
||||
/// False when the gateway has no APNs key, so nothing can be pushed.
|
||||
var pushConfigured: Bool
|
||||
var lastPush: PushResult?
|
||||
}
|
||||
|
||||
/// The watch asking.
|
||||
struct Device: Codable, Hashable, Sendable {
|
||||
var name: String
|
||||
var pairedAt: Date
|
||||
/// True once the watch has sent its APNs token.
|
||||
var pushRegistered: Bool
|
||||
var apnsEnvironment: PushRegistration.Environment?
|
||||
}
|
||||
|
||||
var hosts: [HostStatusDetail]
|
||||
var gateway: Gateway
|
||||
var device: Device
|
||||
}
|
||||
|
||||
struct PairRequest: Codable, Sendable {
|
||||
var code: String
|
||||
var deviceName: String
|
||||
@@ -303,7 +408,7 @@ struct ErrorResponse: Codable, Sendable {
|
||||
|
||||
/// Notification categories the watch registers actions for.
|
||||
enum PushCategory: String, CaseIterable, Sendable {
|
||||
case approval, finished, failed, input, host
|
||||
case approval, finished, failed, input, host, test
|
||||
}
|
||||
|
||||
enum JSON {
|
||||
|
||||
@@ -5,10 +5,15 @@ import Foundation
|
||||
enum Route: Hashable, Sendable {
|
||||
case thread(hostId: String, threadId: String)
|
||||
case newThread
|
||||
/// The status overview, and from it one host or the notifications page.
|
||||
case status
|
||||
case host(id: String)
|
||||
case notifications
|
||||
|
||||
static let scheme = "wristcode"
|
||||
|
||||
/// `wristcode://thread/<hostId>/<threadId>` or `wristcode://new`.
|
||||
/// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://status`,
|
||||
/// `wristcode://host/<hostId>` or `wristcode://notifications`.
|
||||
var url: URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = Self.scheme
|
||||
@@ -20,6 +25,13 @@ enum Route: Hashable, Sendable {
|
||||
.joined(separator: "/")
|
||||
case .newThread:
|
||||
components.host = "new"
|
||||
case .status:
|
||||
components.host = "status"
|
||||
case let .host(id):
|
||||
components.host = "host"
|
||||
components.percentEncodedPath = "/" + (id.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? id)
|
||||
case .notifications:
|
||||
components.host = "notifications"
|
||||
}
|
||||
return components.url!
|
||||
}
|
||||
@@ -35,6 +47,12 @@ enum Route: Hashable, Sendable {
|
||||
self = .thread(hostId: parts[0], threadId: parts[1])
|
||||
case "new":
|
||||
self = .newThread
|
||||
case "status":
|
||||
self = .status
|
||||
case "host" where parts.count == 1 && !parts[0].isEmpty:
|
||||
self = .host(id: parts[0])
|
||||
case "notifications":
|
||||
self = .notifications
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,18 +37,29 @@ final class AppModel {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
/// `-demo` starts in demo mode. `-screen new` or `-thread <hostId>/<threadId>` opens that
|
||||
/// screen, which is how the screenshots in watch/screenshots are taken.
|
||||
/// `-demo` starts in demo mode. `-screen new|status|notifications`, `-host <hostId>` or
|
||||
/// `-thread <hostId>/<threadId>` opens that screen, which is how the screenshots in
|
||||
/// watch/screenshots are taken.
|
||||
convenience init(launchArguments args: [String]) {
|
||||
DeviceStore.migrateToSharedGroup()
|
||||
let demo = args.contains("-demo")
|
||||
let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired
|
||||
var path: [Route] = []
|
||||
if demo, let i = args.firstIndex(of: "-screen"), i + 1 < args.count, args[i + 1] == "new" {
|
||||
path = [.newThread]
|
||||
func argument(_ flag: String) -> String? {
|
||||
guard demo, let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil }
|
||||
return args[i + 1]
|
||||
}
|
||||
if demo, let i = args.firstIndex(of: "-thread"), i + 1 < args.count {
|
||||
let parts = args[i + 1].split(separator: "/", maxSplits: 1).map(String.init)
|
||||
var path: [Route] = []
|
||||
switch argument("-screen") {
|
||||
case "new": path = [.newThread]
|
||||
case "status": path = [.status]
|
||||
case "notifications": path = [.status, .notifications]
|
||||
default: break
|
||||
}
|
||||
if let hostId = argument("-host") {
|
||||
path = [.status, .host(id: hostId)]
|
||||
}
|
||||
if let thread = argument("-thread") {
|
||||
let parts = thread.split(separator: "/", maxSplits: 1).map(String.init)
|
||||
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
|
||||
}
|
||||
self.init(mode: mode, path: path)
|
||||
@@ -101,6 +112,19 @@ final class AppModel {
|
||||
path = []
|
||||
}
|
||||
|
||||
/// Revokes the device token on the gateway, then signs out. Signs out even when the gateway
|
||||
/// cannot be reached, since the token is forgotten either way.
|
||||
func unpair() async {
|
||||
if isPaired { try? await api.unpair() }
|
||||
signOut()
|
||||
}
|
||||
|
||||
/// The gateway's host name, such as `wristcode.gbrown.org`. Nil in demo mode.
|
||||
var gatewayHost: String? {
|
||||
if case let .paired(credentials) = mode { return credentials.gatewayURL.host() }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The message to show for a failed call. A 401 sends the app back to pairing and returns nil.
|
||||
func message(for error: any Error) -> String? {
|
||||
if error is CancellationError { return nil }
|
||||
|
||||
@@ -11,6 +11,7 @@ actor DemoGateway: GatewayAPI {
|
||||
private var messages: [String: [Message]]
|
||||
private let hosts: [HostSummary]
|
||||
private let projectHosts: [ProjectsResponse.Host]
|
||||
private var statusResponse: StatusResponse
|
||||
private var nextId = 1
|
||||
|
||||
init(now: Date = .now) {
|
||||
@@ -19,6 +20,7 @@ actor DemoGateway: GatewayAPI {
|
||||
messages = fixtures.messages
|
||||
hosts = fixtures.hosts
|
||||
projectHosts = fixtures.projectHosts
|
||||
statusResponse = fixtures.status
|
||||
}
|
||||
|
||||
func feed() async throws -> Feed {
|
||||
@@ -93,6 +95,20 @@ actor DemoGateway: GatewayAPI {
|
||||
|
||||
func registerPush(_ registration: PushRegistration) async throws {}
|
||||
|
||||
func status() async throws -> StatusResponse {
|
||||
statusResponse
|
||||
}
|
||||
|
||||
func reconnect(hostId: String) async throws {}
|
||||
|
||||
func testPush() async throws -> PushResult {
|
||||
let result = PushResult(ok: true, at: .now, status: 200, reason: nil)
|
||||
statusResponse.gateway.lastPush = result
|
||||
return result
|
||||
}
|
||||
|
||||
func unpair() async throws {}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func item(_ hostId: String, _ threadId: String) -> FeedItem? {
|
||||
@@ -122,6 +138,7 @@ struct DemoFixtures {
|
||||
let messages: [String: [Message]]
|
||||
let hosts: [HostSummary]
|
||||
let projectHosts: [ProjectsResponse.Host]
|
||||
let status: StatusResponse
|
||||
|
||||
init(now: Date) {
|
||||
func ago(_ minutes: Double) -> Date { now.addingTimeInterval(-minutes * 60) }
|
||||
@@ -129,6 +146,7 @@ struct DemoFixtures {
|
||||
hosts = [
|
||||
HostSummary(id: "mac-mini", label: "Mac mini", status: .online, error: nil),
|
||||
HostSummary(id: "server-gib", label: "server-gib", status: .online, error: nil),
|
||||
HostSummary(id: "laptop", label: "Laptop", status: .online, error: nil),
|
||||
HostSummary(id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused"),
|
||||
]
|
||||
|
||||
@@ -251,6 +269,42 @@ struct DemoFixtures {
|
||||
id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused",
|
||||
projects: [.init(id: "p-site", title: "gbrown.org", model: "claude-opus-5-5")]),
|
||||
]
|
||||
|
||||
// Mac mini and server-gib on their first route, Laptop on its VPN fallback, LittleMan offline.
|
||||
func route(_ url: String, _ kind: RouteKind, _ result: HostRoute.Result, _ error: String? = nil, _ minutes: Double? = nil) -> HostRoute {
|
||||
HostRoute(url: url, kind: kind, result: result, error: error, checkedAt: minutes.map(ago))
|
||||
}
|
||||
func days(_ count: Double) -> Date { now.addingTimeInterval(count * 86_400) }
|
||||
status = StatusResponse(
|
||||
hosts: [
|
||||
HostStatusDetail(
|
||||
id: "mac-mini", label: "Mac mini", status: .online, error: nil, activeUrl: "http://mac.gib:3773",
|
||||
routes: [route("http://mac.gib:3773", .direct, .ok, nil, 95), route("https://t3mac.gbrown.org", .public, .untried)],
|
||||
onlineSince: ago(95), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(29), serverVersion: "0.1.5"),
|
||||
HostStatusDetail(
|
||||
id: "server-gib", label: "server-gib", status: .online, error: nil, activeUrl: "http://server.gib:3773",
|
||||
routes: [route("http://server.gib:3773", .direct, .ok, nil, 300), route("https://t3server.gbrown.org", .public, .untried)],
|
||||
onlineSince: ago(300), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(5.5), serverVersion: "0.1.5"),
|
||||
HostStatusDetail(
|
||||
id: "laptop", label: "Laptop", status: .online, error: nil, activeUrl: "http://laptopvpn.gib:3773",
|
||||
routes: [
|
||||
route("http://laptop.gib:3773", .direct, .failed, "no answer in 5 s", 29),
|
||||
route("http://laptopvpn.gib:3773", .vpn, .ok, nil, 29),
|
||||
route("https://t3laptop.gbrown.org", .public, .untried),
|
||||
route("https://t3laptopvpn.gbrown.org", .public, .untried),
|
||||
],
|
||||
onlineSince: ago(29), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(29.5), serverVersion: "0.1.5"),
|
||||
HostStatusDetail(
|
||||
id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused", activeUrl: nil,
|
||||
routes: [
|
||||
route("http://littleman.gib:3773", .direct, .failed, "refused", 0.3),
|
||||
route("https://t3littleman.gbrown.org", .public, .failed, "502", 0.3),
|
||||
],
|
||||
onlineSince: nil, lastOnlineAt: ago(4), nextRetryAt: now.addingTimeInterval(40),
|
||||
tokenExpiresAt: days(29.5), serverVersion: "0.1.4"),
|
||||
],
|
||||
gateway: .init(startedAt: ago(600), pushConfigured: true, lastPush: PushResult(ok: true, at: ago(2), status: 200, reason: nil)),
|
||||
device: .init(name: "Apple Watch", pairedAt: days(-3), pushRegistered: true, apnsEnvironment: .production))
|
||||
}
|
||||
|
||||
/// A feed for previews, without going through the actor.
|
||||
|
||||
@@ -76,6 +76,7 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
||||
category(.finished, [reply, open]),
|
||||
category(.failed, [open]),
|
||||
category(.host, []),
|
||||
category(.test, []),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,12 @@ struct FeedView: View {
|
||||
.listStyle(.plain)
|
||||
.refreshable { await model.refreshFeed() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
NavigationLink(value: Route.status) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
}
|
||||
.accessibilityLabel("Status and settings")
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink(value: Route.newThread) {
|
||||
Image(systemName: "plus")
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// What the status overview says about a `StatusResponse`: a headline, the issues worst first, and
|
||||
// the healthy hosts folded into one row. Worked out at render, so relative times stay current.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct StatusIssue: Hashable, Identifiable {
|
||||
enum Severity: Int, Comparable {
|
||||
case warning, problem
|
||||
static func < (a: Self, b: Self) -> Bool { a.rawValue < b.rawValue }
|
||||
}
|
||||
|
||||
enum Kind: Hashable {
|
||||
case host, fallback, token, push
|
||||
}
|
||||
|
||||
var id: String
|
||||
var severity: Severity
|
||||
var kind: Kind
|
||||
var title: String
|
||||
var detail: String?
|
||||
/// The screen that explains it.
|
||||
var route: Route
|
||||
}
|
||||
|
||||
struct StatusReport {
|
||||
let status: StatusResponse
|
||||
let now: Date
|
||||
|
||||
init(_ status: StatusResponse, now: Date = .now) {
|
||||
self.status = status
|
||||
self.now = now
|
||||
}
|
||||
|
||||
/// A host on its fallback route is still connected, so only that leaves the headline alone.
|
||||
var allConnected: Bool { issues.allSatisfy { $0.kind == .fallback } }
|
||||
|
||||
var worst: StatusIssue.Severity? { issues.map(\.severity).max() }
|
||||
|
||||
/// "5 environments · pushes OK".
|
||||
var subline: String {
|
||||
let count = status.hosts.count
|
||||
return "\(count) environment\(count == 1 ? "" : "s") · \(pushSummary)"
|
||||
}
|
||||
|
||||
var pushesOK: Bool { pushIssues.isEmpty }
|
||||
|
||||
var pushSummary: String {
|
||||
if !status.gateway.pushConfigured { return "pushes off" }
|
||||
if !status.device.pushRegistered { return "watch not registered" }
|
||||
if status.gateway.lastPush?.ok == false { return "last push failed" }
|
||||
return "pushes OK"
|
||||
}
|
||||
|
||||
var issues: [StatusIssue] {
|
||||
var issues = status.hosts.compactMap(hostIssue)
|
||||
if let token = tokenIssue { issues.append(token) }
|
||||
issues += pushIssues
|
||||
// Stable within a severity, so hosts keep the gateway's order.
|
||||
return issues.enumerated()
|
||||
.sorted { a, b in
|
||||
a.element.severity == b.element.severity ? a.offset < b.offset : a.element.severity > b.element.severity
|
||||
}
|
||||
.map(\.element)
|
||||
}
|
||||
|
||||
/// Hosts with nothing to say: online on their first route.
|
||||
var healthy: [HostStatusDetail] {
|
||||
status.hosts.filter { $0.status == .online && !$0.onFallback }
|
||||
}
|
||||
|
||||
/// "4 more, all direct", or "all online" when they use different kinds of route.
|
||||
var healthySummary: String? {
|
||||
let hosts = healthy
|
||||
guard !hosts.isEmpty else { return nil }
|
||||
let kinds = Set(hosts.compactMap { $0.activeRoute?.kind })
|
||||
let how = kinds.count == 1 ? "all \(kinds.first!.name)" : "all online"
|
||||
let anyHostIssue = issues.contains { $0.kind == .host || $0.kind == .fallback }
|
||||
if anyHostIssue { return "\(hosts.count) more, \(how)" }
|
||||
return "\(hosts.count) environment\(hosts.count == 1 ? "" : "s"), \(how)"
|
||||
}
|
||||
|
||||
// MARK: - Issues
|
||||
|
||||
private func hostIssue(_ host: HostStatusDetail) -> StatusIssue? {
|
||||
let route = Route.host(id: host.id)
|
||||
if host.onFallback, let active = host.activeRoute {
|
||||
return StatusIssue(
|
||||
id: "fallback-\(host.id)", severity: .warning, kind: .fallback,
|
||||
title: "\(host.label) is on its \(active.kind.name) route",
|
||||
detail: host.routes.first.map(Self.failure), route: route)
|
||||
}
|
||||
switch host.status {
|
||||
case .online:
|
||||
return nil
|
||||
case .connecting:
|
||||
return StatusIssue(
|
||||
id: "host-\(host.id)", severity: .warning, kind: .host,
|
||||
title: "\(host.label) connecting", detail: nil, route: route)
|
||||
case .authInvalid:
|
||||
return StatusIssue(
|
||||
id: "host-\(host.id)", severity: .problem, kind: .host,
|
||||
title: "\(host.label) needs a new token", detail: "Run renew-hosts.sh", route: route)
|
||||
case .incompatible:
|
||||
return StatusIssue(
|
||||
id: "host-\(host.id)", severity: .problem, kind: .host,
|
||||
title: "\(host.label) is incompatible", detail: host.error, route: route)
|
||||
default:
|
||||
return StatusIssue(
|
||||
id: "host-\(host.id)", severity: .problem, kind: .host,
|
||||
title: "\(host.label) offline", detail: offlineDetail(host), route: route)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Both routes failed · 4 min", or the error when no route was tried.
|
||||
private func offlineDetail(_ host: HostStatusDetail) -> String? {
|
||||
let failed = host.routes.filter { $0.result == .failed }.count
|
||||
let cause: String? = switch failed {
|
||||
case 0: host.error
|
||||
case host.routes.count: host.routes.count == 1 ? "Its route failed" : host.routes.count == 2 ? "Both routes failed" : "All \(failed) routes failed"
|
||||
default: "\(failed) of \(host.routes.count) routes failed"
|
||||
}
|
||||
let since = host.lastOnlineAt.map { Format.age(of: $0, now: now) }
|
||||
return [cause, since].compactMap(\.self).joined(separator: " · ").nilIfEmpty
|
||||
}
|
||||
|
||||
/// Tokens that expire within 7 days, as one issue.
|
||||
private var tokenIssue: StatusIssue? {
|
||||
let soon = status.hosts.compactMap { host in
|
||||
host.tokenExpiresAt.map { (host, $0) }
|
||||
}
|
||||
.filter { $0.1.timeIntervalSince(now) < 7 * 86_400 }
|
||||
.sorted { $0.1 < $1.1 }
|
||||
guard let (first, expiry) = soon.first else { return nil }
|
||||
let when = Format.expiry(expiry, now: now)
|
||||
let title = soon.count == 1 ? "\(first.label) token \(when)" : "\(soon.count) tokens \(when)"
|
||||
return StatusIssue(
|
||||
id: "token", severity: .warning, kind: .token, title: title,
|
||||
detail: "Run renew-hosts.sh", route: soon.count == 1 ? .host(id: first.id) : .status)
|
||||
}
|
||||
|
||||
private var pushIssues: [StatusIssue] {
|
||||
let gateway = status.gateway
|
||||
if !gateway.pushConfigured {
|
||||
return [StatusIssue(
|
||||
id: "push", severity: .problem, kind: .push, title: "Pushes are off",
|
||||
detail: "The gateway has no APNs key", route: .notifications)]
|
||||
}
|
||||
if !status.device.pushRegistered {
|
||||
return [StatusIssue(
|
||||
id: "push", severity: .warning, kind: .push, title: "This watch gets no pushes",
|
||||
detail: "It has not registered with the gateway", route: .notifications)]
|
||||
}
|
||||
if let push = gateway.lastPush, !push.ok {
|
||||
return [StatusIssue(
|
||||
id: "push", severity: .problem, kind: .push, title: "Last push failed",
|
||||
detail: push.reason ?? "Status \(push.status)", route: .notifications)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/// "laptop.gib did not answer", or the host and its error.
|
||||
static func failure(of route: HostRoute) -> String {
|
||||
guard let error = route.error else { return "\(route.hostPort) did not connect" }
|
||||
if error.hasPrefix("no answer") { return "\(route.hostPort) did not answer" }
|
||||
return "\(route.hostPort): \(error)"
|
||||
}
|
||||
}
|
||||
|
||||
extension PushResult {
|
||||
/// A short hint for a failed push, when the cause is a common one.
|
||||
var hint: String? {
|
||||
guard !ok else { return nil }
|
||||
if status == 0 { return "The push never reached Apple." }
|
||||
return switch reason {
|
||||
case "BadDeviceToken": "Usually a sandbox/production mismatch. Re-pair this build."
|
||||
case "Unregistered": "Apple no longer knows this watch. Reopen the app to register again."
|
||||
case "DeviceTokenNotForTopic": "The gateway's APNs topic is not this app's bundle ID."
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
// Settings mock C: an overview that leads with what is wrong, an environment page with mock B's
|
||||
// route ladder, and a notifications page with a test push and Unpair. Each screen loads
|
||||
// `GET /v1/status` when it appears; nothing polls.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The overview: a headline, the issues worst first, then the healthy hosts folded into one row.
|
||||
struct StatusView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var expanded = false
|
||||
var status: StatusResponse?
|
||||
var scrolls = true
|
||||
|
||||
var body: some View {
|
||||
StatusLoader(status: status, scrolls: scrolls) { status in
|
||||
let report = StatusReport(status)
|
||||
Headline(report: report)
|
||||
|
||||
ForEach(report.issues) { issue in
|
||||
Button { model.path.append(issue.route) } label: {
|
||||
Card(edge: issue.severity == .problem ? Theme.bad : Theme.you) {
|
||||
RowText(title: issue.title, detail: issue.detail)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if let summary = report.healthySummary {
|
||||
Button { expanded.toggle() } label: {
|
||||
Card(edge: Theme.done) {
|
||||
HStack {
|
||||
RowText(title: summary)
|
||||
Spacer(minLength: 4)
|
||||
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if expanded {
|
||||
ForEach(report.healthy) { host in
|
||||
Button { model.path.append(.host(id: host.id)) } label: {
|
||||
Card(edge: Theme.work) {
|
||||
HStack(spacing: 6) {
|
||||
if let kind = host.activeRoute?.kind {
|
||||
Image(systemName: kind.symbol)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.work)
|
||||
}
|
||||
RowText(title: host.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button { model.path.append(.notifications) } label: {
|
||||
Card(edge: Theme.done) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
RowText(title: "Notifications")
|
||||
Spacer(minLength: 4)
|
||||
Text(report.pushesOK ? "OK" : "Check")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(report.pushesOK ? Theme.work : Theme.you)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.navigationTitle("Status")
|
||||
}
|
||||
}
|
||||
|
||||
private struct Headline: View {
|
||||
var report: StatusReport
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(report.allConnected ? "All connected" : "Needs attention")
|
||||
.font(.system(size: 20, weight: .heavy, design: .rounded))
|
||||
.foregroundStyle(report.allConnected ? Theme.work : report.worst == .problem ? Theme.bad : Theme.you)
|
||||
Text(report.subline)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 4)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
/// One environment: where it stands, its routes as a ladder, and Reconnect.
|
||||
struct HostStatusView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let hostId: String
|
||||
var status: StatusResponse?
|
||||
var scrolls = true
|
||||
@State private var reload = 0
|
||||
@State private var reconnecting = false
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
StatusLoader(reload: reload, status: status, scrolls: scrolls) { status in
|
||||
if let host = status.hosts.first(where: { $0.id == hostId }) {
|
||||
content(host, now: .now)
|
||||
} else {
|
||||
RowText(title: "This environment is gone.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(_ host: HostStatusDetail, now: Date) -> some View {
|
||||
let line = statusLine(host)
|
||||
Text(line.text)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(line.color)
|
||||
.padding(.horizontal, 4)
|
||||
.navigationTitle(host.label)
|
||||
|
||||
RouteLadder(host: host)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if host.status != .online {
|
||||
let parts = [
|
||||
host.nextRetryAt.map { "Next try in \(Format.countdown(to: $0, now: now))" },
|
||||
host.lastOnlineAt.map { "last online \($0.formatted(date: .omitted, time: .shortened))" },
|
||||
].compactMap(\.self)
|
||||
if !parts.isEmpty {
|
||||
Text(parts.joined(separator: " · "))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
if let expiry = host.tokenExpiresAt {
|
||||
let days = expiry.timeIntervalSince(now) / 86_400
|
||||
Text("Token \(Format.expiry(expiry, now: now))")
|
||||
.foregroundStyle(days < 0 ? Theme.bad : days < 7 ? Theme.you : Theme.secondaryText)
|
||||
}
|
||||
if let version = host.serverVersion {
|
||||
Text("T3 \(version)")
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 11))
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
Button(reconnecting ? "Reconnecting" : "Reconnect") { reconnect() }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
.disabled(reconnecting)
|
||||
.padding(.top, 6)
|
||||
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
|
||||
private func statusLine(_ host: HostStatusDetail) -> (text: String, color: Color) {
|
||||
switch host.status {
|
||||
case .online:
|
||||
let kind = host.activeRoute?.kind.name ?? "direct"
|
||||
return host.onFallback ? ("On its \(kind) route", Theme.you) : ("Online · \(kind)", Theme.work)
|
||||
case .connecting:
|
||||
return ("Connecting", Theme.secondaryText)
|
||||
case .authInvalid:
|
||||
return ("Needs a new token", Theme.bad)
|
||||
case .incompatible:
|
||||
return (host.error.map { "Incompatible: \($0)" } ?? "Incompatible", Theme.bad)
|
||||
default:
|
||||
return ("Offline", Theme.bad)
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the gateway to start again from the first route, then looks again once it has had a
|
||||
/// moment to try.
|
||||
private func reconnect() {
|
||||
reconnecting = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await model.api.reconnect(hostId: hostId)
|
||||
try await Task.sleep(for: .seconds(2))
|
||||
reload += 1
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
reconnecting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The routes in the order the gateway walks them, each with its kind and what happened.
|
||||
private struct RouteLadder: View {
|
||||
var host: HostStatusDetail
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
ForEach(Array(host.routes.enumerated()), id: \.offset) { _, route in
|
||||
HStack(alignment: .top, spacing: 7) {
|
||||
Image(systemName: route.kind.symbol)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(color(route))
|
||||
.frame(width: 20, height: 20)
|
||||
.background(Circle().fill(.black))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(route.hostPort)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(route.result == .untried ? Theme.done : route.result == .ok ? Theme.work : .white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
if let detail = detail(route) {
|
||||
Text(detail)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(color(route))
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.18))
|
||||
.frame(width: 2)
|
||||
.padding(.leading, 9)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func color(_ route: HostRoute) -> Color {
|
||||
switch route.result {
|
||||
case .ok: Theme.work
|
||||
case .failed: Theme.bad
|
||||
default: Theme.done
|
||||
}
|
||||
}
|
||||
|
||||
private func detail(_ route: HostRoute) -> String? {
|
||||
switch route.result {
|
||||
case .failed:
|
||||
return route.error ?? "failed"
|
||||
case .ok:
|
||||
let at = route.checkedAt ?? host.onlineSince
|
||||
return at.map { "connected \($0.formatted(date: .omitted, time: .shortened))" } ?? "connected"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this watch gets pushes, the last push, a test push, the gateway, and Unpair.
|
||||
struct NotificationsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var status: StatusResponse?
|
||||
var scrolls = true
|
||||
@State private var reload = 0
|
||||
@State private var sending = false
|
||||
@State private var result: PushResult?
|
||||
@State private var error: String?
|
||||
@State private var confirmingUnpair = false
|
||||
|
||||
/// `status` and `result` start the screen loaded and after a test push, for snapshots.
|
||||
init(status: StatusResponse? = nil, result: PushResult? = nil, scrolls: Bool = true) {
|
||||
self.status = status
|
||||
self.scrolls = scrolls
|
||||
_result = State(initialValue: result)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
StatusLoader(reload: reload, status: status, scrolls: scrolls) { status in
|
||||
content(status, now: .now)
|
||||
}
|
||||
.navigationTitle("Notifications")
|
||||
.confirmationDialog("Unpair this watch?", isPresented: $confirmingUnpair) {
|
||||
Button("Unpair", role: .destructive) { Task { await model.unpair() } }
|
||||
} message: {
|
||||
Text("It stops getting notifications until you pair it again.")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(_ status: StatusResponse, now: Date) -> some View {
|
||||
let device = status.device
|
||||
Card(edge: device.pushRegistered ? Theme.work : Theme.you) {
|
||||
HStack {
|
||||
RowText(title: "This watch", detail: device.apnsEnvironment?.rawValue)
|
||||
Spacer(minLength: 4)
|
||||
Text(device.pushRegistered ? "registered" : "not registered")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(device.pushRegistered ? Theme.work : Theme.you)
|
||||
}
|
||||
}
|
||||
|
||||
if !status.gateway.pushConfigured {
|
||||
Card(edge: Theme.bad) {
|
||||
RowText(title: "Pushes are off", detail: "The gateway has no APNs key")
|
||||
}
|
||||
}
|
||||
|
||||
let last = status.gateway.lastPush
|
||||
Card(edge: last.map { $0.ok ? Theme.work : Theme.bad } ?? Theme.done) {
|
||||
HStack {
|
||||
RowText(title: "Last push")
|
||||
Spacer(minLength: 4)
|
||||
if let last {
|
||||
let age = Format.age(of: last.at, now: now)
|
||||
Text("\(age == "now" ? "just now" : "\(age) ago") \(last.ok ? "✓" : "✗")")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(last.ok ? Theme.work : Theme.bad)
|
||||
} else {
|
||||
Text("none yet")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
if let last, !last.ok {
|
||||
Text([last.reason ?? "Status \(last.status)", last.hint].compactMap(\.self).joined(separator: ". "))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
Button(sending ? "Sending" : "Send test") { sendTest() }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.you))
|
||||
.disabled(sending)
|
||||
.padding(.top, 4)
|
||||
|
||||
if let result {
|
||||
VStack(spacing: 2) {
|
||||
if result.ok {
|
||||
Text("Apple accepted it at \(result.at.formatted(.dateTime.hour().minute().second()))")
|
||||
.foregroundStyle(Theme.work)
|
||||
} else {
|
||||
Text(result.reason ?? "Status \(result.status)")
|
||||
.foregroundStyle(Theme.bad)
|
||||
if let hint = result.hint {
|
||||
Text(hint)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(size: 11))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Gateway")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Spacer(minLength: 4)
|
||||
Text(model.gatewayHost ?? "demo")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 8)
|
||||
|
||||
Button("Unpair") { confirmingUnpair = true }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: Theme.bad))
|
||||
}
|
||||
|
||||
private func sendTest() {
|
||||
sending = true
|
||||
result = nil
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
result = try await model.api.testPush()
|
||||
reload += 1
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
sending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the status when it appears, and again whenever `reload` changes, then shows `content`.
|
||||
/// The last status stays on screen while a reload runs.
|
||||
private struct StatusLoader<Content: View>: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var reload: Int
|
||||
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
|
||||
var scrolls: Bool
|
||||
var content: (StatusResponse) -> Content
|
||||
@State private var status: StatusResponse?
|
||||
@State private var error: String?
|
||||
|
||||
/// `status` starts the screen loaded, for snapshots and previews.
|
||||
init(
|
||||
reload: Int = 0, status: StatusResponse? = nil, scrolls: Bool = true,
|
||||
@ViewBuilder content: @escaping (StatusResponse) -> Content
|
||||
) {
|
||||
self.reload = reload
|
||||
self.scrolls = scrolls
|
||||
self.content = content
|
||||
_status = State(initialValue: status)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if scrolls {
|
||||
ScrollView { column }
|
||||
.task(id: reload) { await load() }
|
||||
} else {
|
||||
column
|
||||
}
|
||||
}
|
||||
|
||||
private var column: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let status {
|
||||
content(status)
|
||||
} else if error == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 4)
|
||||
if status == nil {
|
||||
Button("Try again") { Task { await load() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
do {
|
||||
status = try await model.api.status()
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bold line and an optional grey one under it, as on mock C's issue cards.
|
||||
private struct RowText: View {
|
||||
var title: String
|
||||
var detail: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Status") {
|
||||
NavigationStack {
|
||||
StatusView()
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
|
||||
#Preview("Environment") {
|
||||
NavigationStack {
|
||||
HostStatusView(hostId: "laptop")
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
|
||||
#Preview("Notifications") {
|
||||
NavigationStack {
|
||||
NotificationsView()
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done.
|
||||
// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done,
|
||||
// and red means broken.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -10,6 +11,7 @@ enum Theme {
|
||||
static let control = Color(red: 0.173, green: 0.173, blue: 0.180) // #2c2c2e
|
||||
static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2
|
||||
static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea
|
||||
static let bad = Color(red: 1.0, green: 0.420, blue: 0.369) // #ff6b5e
|
||||
}
|
||||
|
||||
extension Phase {
|
||||
@@ -87,6 +89,25 @@ enum Format {
|
||||
return "\(hours / 24) d"
|
||||
}
|
||||
|
||||
/// How long until a date, such as "40 s", "3 min" or "2 h".
|
||||
static func countdown(to date: Date, now: Date = .now) -> String {
|
||||
let seconds = max(0, Int(date.timeIntervalSince(now).rounded()))
|
||||
if seconds < 60 { return "\(seconds) s" }
|
||||
if seconds < 3600 { return "\(seconds / 60) min" }
|
||||
return "\(seconds / 3600) h"
|
||||
}
|
||||
|
||||
/// "expires in 29 days", "expires today" or "expired 2 days ago", for a token.
|
||||
static func expiry(_ date: Date, now: Date = .now) -> String {
|
||||
let days = Int((date.timeIntervalSince(now) / 86_400).rounded(.down))
|
||||
if date < now {
|
||||
let ago = Int(now.timeIntervalSince(date) / 86_400)
|
||||
return ago < 1 ? "expired today" : "expired \(ago) day\(ago == 1 ? "" : "s") ago"
|
||||
}
|
||||
if days < 1 { return "expires today" }
|
||||
return "expires in \(days) day\(days == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
/// A model id made readable: `claude-opus-5-5` becomes "Claude Opus 5.5".
|
||||
static func modelName(_ id: String) -> String {
|
||||
var words: [String] = []
|
||||
|
||||
@@ -33,6 +33,12 @@ struct RootView: View {
|
||||
ThreadView(hostId: hostId, threadId: threadId)
|
||||
case .newThread:
|
||||
NewThreadView()
|
||||
case .status:
|
||||
StatusView()
|
||||
case let .host(id):
|
||||
HostStatusView(hostId: id)
|
||||
case .notifications:
|
||||
NotificationsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,33 @@ private let feedItemJSON = """
|
||||
#expect(try decode(ErrorResponse.self, #"{ "error": "That host is offline" }"#).error == "That host is offline")
|
||||
}
|
||||
|
||||
@Test func statusFromDocs() throws {
|
||||
let status = try decode(StatusResponse.self, """
|
||||
{
|
||||
"hosts": [{
|
||||
"id": "laptop", "label": "Laptop", "status": "online", "error": null,
|
||||
"activeUrl": "http://laptopvpn.gib:3773",
|
||||
"routes": [
|
||||
{ "url": "http://laptop.gib:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "2026-11-01T09:12:00.000Z" },
|
||||
{ "url": "http://laptopvpn.gib:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "2026-11-01T09:12:00.000Z" },
|
||||
{ "url": "https://t3laptop.gbrown.org", "kind": "public", "result": "untried", "error": null, "checkedAt": null }
|
||||
],
|
||||
"onlineSince": "2026-11-01T09:12:00.000Z", "lastOnlineAt": null, "nextRetryAt": null,
|
||||
"tokenExpiresAt": "2026-12-01T00:00:00.000Z", "serverVersion": "0.1.5"
|
||||
}],
|
||||
"gateway": { "startedAt": "2026-11-01T08:00:00.000Z", "pushConfigured": true,
|
||||
"lastPush": { "ok": false, "at": "2026-11-01T09:40:00.000Z", "status": 400, "reason": "BadDeviceToken" } },
|
||||
"device": { "name": "Apple Watch", "pairedAt": "2026-10-30T08:00:00.000Z", "pushRegistered": true, "apnsEnvironment": null }
|
||||
}
|
||||
""")
|
||||
let host = try #require(status.hosts.first)
|
||||
#expect(host.onFallback)
|
||||
#expect(host.activeRoute?.kind == .vpn)
|
||||
#expect(host.routes.map(\.hostPort) == ["laptop.gib:3773", "laptopvpn.gib:3773", "t3laptop.gbrown.org"])
|
||||
#expect(status.device.apnsEnvironment == nil)
|
||||
#expect(status.gateway.lastPush?.hint?.contains("sandbox/production") == true)
|
||||
}
|
||||
|
||||
@Test func badDateFails() {
|
||||
#expect(throws: DecodingError.self) {
|
||||
try decode(Message.self, #"{ "id": "m", "role": "user", "text": "x", "createdAt": "yesterday" }"#)
|
||||
@@ -236,6 +263,9 @@ private let feedItemJSON = """
|
||||
#expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1")
|
||||
#expect(Route(url: thread.url) == thread)
|
||||
#expect(Route(url: Route.newThread.url) == .newThread)
|
||||
for route in [Route.status, .host(id: "mac mini"), .notifications] {
|
||||
#expect(Route(url: route.url) == route)
|
||||
}
|
||||
#expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil)
|
||||
#expect(Route(url: URL(string: "https://thread/h/t")!) == nil)
|
||||
}
|
||||
@@ -322,6 +352,41 @@ private let feedItemJSON = """
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct StatusReportTests {
|
||||
private let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
|
||||
@Test func demoListsIssuesWorstFirst() {
|
||||
let report = StatusReport(DemoFixtures(now: now).status, now: now)
|
||||
#expect(report.issues.map(\.title) == [
|
||||
"LittleMan offline", "Laptop is on its VPN route", "server-gib token expires in 5 days",
|
||||
])
|
||||
#expect(report.issues.map(\.detail) == ["Both routes failed · 4 min", "laptop.gib:3773 did not answer", "Run renew-hosts.sh"])
|
||||
#expect(!report.allConnected)
|
||||
#expect(report.worst == .problem)
|
||||
#expect(report.healthySummary == "2 more, all direct")
|
||||
#expect(report.subline == "4 environments · pushes OK")
|
||||
}
|
||||
|
||||
@Test func fallbackAloneIsStillAllConnected() {
|
||||
var status = DemoFixtures(now: now).status
|
||||
status.hosts.removeAll { $0.id == "littleman" }
|
||||
for i in status.hosts.indices { status.hosts[i].tokenExpiresAt = now.addingTimeInterval(30 * 86_400) }
|
||||
let report = StatusReport(status, now: now)
|
||||
#expect(report.allConnected)
|
||||
#expect(report.issues.map(\.kind) == [.fallback])
|
||||
}
|
||||
|
||||
@Test func pushProblems() {
|
||||
var status = DemoFixtures(now: now).status
|
||||
status.device.pushRegistered = false
|
||||
#expect(StatusReport(status, now: now).pushSummary == "watch not registered")
|
||||
status.gateway.pushConfigured = false
|
||||
let report = StatusReport(status, now: now)
|
||||
#expect(report.pushSummary == "pushes off")
|
||||
#expect(report.issues.first { $0.kind == .push }?.route == .notifications)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct DemoGatewayTests {
|
||||
@Test func allowMovesThreadToWorking() async throws {
|
||||
let gateway = DemoGateway()
|
||||
|
||||
@@ -42,6 +42,29 @@ struct SnapshotTests {
|
||||
try write(view, "thread-multiselect.png")
|
||||
}
|
||||
|
||||
@Test func statusScreens() async throws {
|
||||
let status = try await DemoGateway().status()
|
||||
try write(titled("Status", StatusView(status: status, scrolls: false)), "status.png")
|
||||
try write(titled("Laptop", HostStatusView(hostId: "laptop", status: status, scrolls: false)), "status-environment.png")
|
||||
let result = PushResult(ok: true, at: .now, status: 200, reason: nil)
|
||||
try write(titled("Notifications", NotificationsView(status: status, result: result, scrolls: false)), "status-notifications.png")
|
||||
}
|
||||
|
||||
/// The screen under a title like the navigation bar's, which ImageRenderer leaves out.
|
||||
private func titled(_ title: String, _ view: some View) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 10)
|
||||
view
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
.tint(Theme.work)
|
||||
}
|
||||
|
||||
/// Renders at the screen's width and as tall as the content, as if scrolled through.
|
||||
private func write(_ view: some View, _ name: String) throws {
|
||||
let renderer = ImageRenderer(content: view
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Reference in new issue
Block a user