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
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user