// 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 } }