commit 8de962eeb83f95c573b006f94dddee1e2c706896 Author: Jon Vanvik Date: Sun May 31 14:11:29 2026 +0200 initial commit diff --git a/323-1961-302_mcms_6_2_pon_manager_user_guide.pdf b/323-1961-302_mcms_6_2_pon_manager_user_guide.pdf new file mode 100644 index 0000000..9621a50 Binary files /dev/null and b/323-1961-302_mcms_6_2_pon_manager_user_guide.pdf differ diff --git a/323-1961-306_mcms_6_2_rest_api_developer_guide.pdf b/323-1961-306_mcms_6_2_rest_api_developer_guide.pdf new file mode 100644 index 0000000..eb7ec07 Binary files /dev/null and b/323-1961-306_mcms_6_2_rest_api_developer_guide.pdf differ diff --git a/APIClient.swift b/APIClient.swift new file mode 100644 index 0000000..8568edd --- /dev/null +++ b/APIClient.swift @@ -0,0 +1,276 @@ +import Foundation + +/// Transport layer for the MCMS REST API. +/// +/// Responsibilities: +/// - build versioned requests from `MCMSEndpoint` + `APIQuery` +/// - inject auth headers: `Referer`, `Content-Type`, and `X-CSRFToken` +/// (sourced from the CSRF cookie) on mutating requests +/// - let `URLSession` manage the session/CSRF cookies +/// - unwrap the `{ status, data, details }` envelope and map status codes to `APIError` +/// +/// Works identically for direct and proxied access — only `APIConfiguration.baseURL` +/// (and optionally `refererOverride`) differ. +@MainActor +final class APIClient { + + let config: APIConfiguration + private let session: URLSession + private let trustDelegate: ServerTrustEvaluator + private let session_: SessionStore + + /// Invoked when a request fails with 401/403 so the app can route back to + /// login. Set by `MCMSConnection`/`AppEnvironment`. + var onAuthExpired: (@MainActor () -> Void)? + + /// Documents decode their bodies into `JSONValue`, so no `Date` is ever + /// decoded here — a custom `dateDecodingStrategy` would be dead code (and it + /// tripped a `@MainActor`-isolation warning). Timestamps are surfaced as + /// strings and parsed for display by `MongoTimestamp`. If you promote a field + /// to a typed `Date`, install a strategy backed by `MongoTimestamp.date(from:)`. + static func makeDecoder() -> JSONDecoder { JSONDecoder() } + + private let decoder = APIClient.makeDecoder() + private let encoder = JSONEncoder() + + init(config: APIConfiguration, session: SessionStore) { + self.config = config + self.session_ = session + self.trustDelegate = ServerTrustEvaluator(policy: config.trustPolicy) + + let cfg = URLSessionConfiguration.default + cfg.httpCookieStorage = session.cookieStorage + cfg.httpShouldSetCookies = true + cfg.httpCookieAcceptPolicy = .always + cfg.timeoutIntervalForRequest = config.timeout + cfg.requestCachePolicy = .reloadIgnoringLocalCacheData + self.session = URLSession(configuration: cfg, delegate: trustDelegate, delegateQueue: nil) + } + + // MARK: - Public API + + /// GET returning a decoded `T` from the envelope's `data`. + func get( + _ endpoint: MCMSEndpoint, + query: APIQuery = .empty, + as type: T.Type = T.self, + version: String? = nil + ) async throws -> T { + let request = try makeRequest(method: "GET", endpoint: endpoint, query: query, version: version) + return try await execute(request) + } + + /// GET returning the raw JSON body as `JSONValue`, bypassing the + /// `{status,data}` envelope — for web-helper endpoints (e.g. the CPE helper) + /// that return a bare array. + func getRaw(_ endpoint: MCMSEndpoint, query: APIQuery = .empty, version: String? = nil) async throws -> JSONValue { + let request = try makeRequest(method: "GET", endpoint: endpoint, query: query, version: version) + let (data, response) = try await perform(request) + let http = try httpResponse(response) + guard (200...299).contains(http.statusCode) else { + throw failure(status: http.statusCode, data: data) + } + guard !data.isEmpty else { return .null } + do { return try decoder.decode(JSONValue.self, from: data) } + catch { throw APIError.decoding(message: error.localizedDescription) } + } + + /// PUT/POST/PATCH with an `Encodable` body. By default the body is wrapped + /// as `{ "data": }` per the standard endpoints; pass + /// `wrapInData: false` for action endpoints that take a raw body + /// (e.g. disable-onu `{ "disable": true }`). + @discardableResult + func send( + _ method: String, + _ endpoint: MCMSEndpoint, + body: B, + wrapInData: Bool = true, + query: APIQuery = .empty, + as type: T.Type = T.self, + version: String? = nil + ) async throws -> T { + let data: Data + do { + data = wrapInData + ? try encoder.encode(DataEnvelope(data: body)) + : try encoder.encode(body) + } catch { + throw APIError.encoding(message: error.localizedDescription) + } + var request = try makeRequest(method: method, endpoint: endpoint, query: query, version: version) + request.httpBody = data + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + injectCSRF(into: &request) + return try await execute(request) + } + + /// Mutating request with no body (e.g. ONU reset, broadcast-enable). + @discardableResult + func action( + _ method: String, + _ endpoint: MCMSEndpoint, + as type: T.Type = T.self, + version: String? = nil + ) async throws -> T { + var request = try makeRequest(method: method, endpoint: endpoint, query: .empty, version: version) + injectCSRF(into: &request) + return try await execute(request) + } + + /// DELETE; returns the raw envelope details (often empty / 204). + func delete(_ endpoint: MCMSEndpoint, version: String? = nil) async throws { + var request = try makeRequest(method: "DELETE", endpoint: endpoint, query: .empty, version: version) + injectCSRF(into: &request) + let _: JSONValue? = try await executeOptional(request) + } + + /// Fetches a full list by following the `next` cursor until a short page + /// is returned. `pageLimit` defaults to 100: full Mongo docs are ~30 KB, so + /// larger pages (the server default is 1000) can exceed the front-end proxy + /// timeout on big fleets. See MCMS field guide §3.3–3.4. + func getAll( + _ endpoint: MCMSEndpoint, + baseQuery: APIQuery = .empty, + pageLimit: Int = 100, + idOf: (T) -> String?, + version: String? = nil + ) async throws -> [T] { + var all: [T] = [] + var cursor: String? = nil + repeat { + var q = baseQuery + q.limit = pageLimit + q.next = cursor + let page: [T] = try await get(endpoint, query: q, as: [T].self, version: version) + all.append(contentsOf: page) + cursor = page.count == pageLimit ? page.last.flatMap(idOf) : nil + } while cursor != nil + return all + } + + // MARK: - Request construction + + private func makeRequest( + method: String, + endpoint: MCMSEndpoint, + query: APIQuery, + version: String? + ) throws -> URLRequest { + let path = endpoint.path(version: version ?? config.apiVersion) + guard var components = URLComponents(url: config.baseURL, resolvingAgainstBaseURL: false) + else { throw APIError.invalidURL } + // Append the endpoint path and let URLComponents percent-encode it ONCE. + // `appendingPathComponent` re-encodes an already-encoded id (a MAC's `:` → + // `%3A` → `%253A`), which MCMS rejects as an invalid id — so ids are kept + // raw in MCMSEndpoint and encoded here. + components.path += path + + let items = query.queryItems() + if !items.isEmpty { components.queryItems = items } + guard let url = components.url else { throw APIError.invalidURL } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(config.refererValue, forHTTPHeaderField: "Referer") + return request + } + + /// Echo the CSRF cookie value as the `X-CSRFToken` header. Without this, + /// writes are rejected with 403. + private func injectCSRF(into request: inout URLRequest) { + if let token = session_.csrfToken() { + request.setValue(token, forHTTPHeaderField: "X-CSRFToken") + } + } + + // MARK: - Execution + + private func execute(_ request: URLRequest) async throws -> T { + let (data, response) = try await perform(request) + let http = try httpResponse(response) + + guard (200...299).contains(http.statusCode) else { + throw failure(status: http.statusCode, data: data) + } + if http.statusCode == 204 || data.isEmpty { + if let empty = emptyValue(for: T.self) { return empty } + throw APIError.decoding(message: "Empty body for non-optional response") + } + return try decodeEnvelope(data) + } + + private func executeOptional(_ request: URLRequest) async throws -> T? { + let (data, response) = try await perform(request) + let http = try httpResponse(response) + guard (200...299).contains(http.statusCode) else { + throw failure(status: http.statusCode, data: data) + } + if http.statusCode == 204 || data.isEmpty { return nil } + return try decodeEnvelope(data) + } + + /// Maps a non-2xx status to an `APIError` and fires the re-auth hook on 401/403. + private func failure(status: Int, data: Data) -> APIError { + let error = APIError.from(statusCode: status, details: decodeDetails(data)) + if error.requiresReauthentication { onAuthExpired?() } + return error + } + + /// The "empty" instance for a type that opts into `EmptyRepresentable`. + private func emptyValue(for type: T.Type) -> T? { + (type as? EmptyRepresentable.Type)?.emptyValue as? T + } + + private func perform(_ request: URLRequest) async throws -> (Data, URLResponse) { + do { + return try await session.data(for: request) + } catch let urlError as URLError { + switch urlError.code { + case .serverCertificateUntrusted, .serverCertificateHasBadDate, + .serverCertificateHasUnknownRoot, .serverCertificateNotYetValid, + .secureConnectionFailed: + throw APIError.serverTrust + default: + throw APIError.transport(message: urlError.localizedDescription) + } + } catch { + throw APIError.transport(message: error.localizedDescription) + } + } + + private func httpResponse(_ response: URLResponse) throws -> HTTPURLResponse { + guard let http = response as? HTTPURLResponse else { + throw APIError.transport(message: "Non-HTTP response") + } + return http + } + + private func decodeEnvelope(_ data: Data) throws -> T { + do { + let envelope = try decoder.decode(APIResponse.self, from: data) + guard envelope.isSuccess else { + throw APIError.apiFailure(details: envelope.details) + } + if let value = envelope.data { return value } + // A successful PUT/POST/PATCH replies `{"status":"success"}` with no + // `data` (only GET returns `data`). Hand back the type's empty value + // instead of failing a request that actually succeeded. + if let empty = emptyValue(for: T.self) { return empty } + throw APIError.decoding(message: "Success response missing `data`") + } catch let apiError as APIError { + throw apiError + } catch { + throw APIError.decoding(message: error.localizedDescription) + } + } + + /// Best-effort decode of the `details` payload from an error response. + private func decodeDetails(_ data: Data) -> JSONValue? { + guard !data.isEmpty else { return nil } + if let env = try? decoder.decode(APIResponse.self, from: data) { + return env.details + } + return try? decoder.decode(JSONValue.self, from: data) + } +} diff --git a/APIConfiguration.swift b/APIConfiguration.swift new file mode 100644 index 0000000..ef4e395 --- /dev/null +++ b/APIConfiguration.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Connection configuration for the MCMS REST API. +/// +/// This is intentionally URL-driven so the same build works whether you hit the +/// PON Controller **directly** or through a **reverse proxy** — the only thing +/// that changes is `baseURL` (and possibly `refererOverride` / cookie names). +/// +/// Direct example: `https://10.2.10.29/api` +/// Proxy example: `https://mcms.internal.example.com` (proxy forwards to /api) +/// +/// NOTE: confirm the exact prefix (`/api/v1` vs `/v1`) against the shipped +/// `openapi.json` before shipping. `apiVersion` is applied per-request by the +/// endpoint builder, so it is kept separate from `baseURL`. +struct APIConfiguration: Equatable, Codable { + + /// Scheme + host (+ optional port) + any fixed path prefix, WITHOUT the + /// version segment. e.g. `https://10.2.10.29/api` + var baseURL: URL + + /// API version segment inserted after `baseURL`. Endpoints are versioned + /// independently; default to v1 and override per-call where needed. + var apiVersion: String = "v1" + + /// The MCMS database to select for the session (optional). When non-nil the + /// client issues `PUT /v{n}/databases/selection/` after login. + var databaseId: String? + + /// Value sent in the `Referer` header. The API uses this to identify the + /// calling client. Defaults to `baseURL` when nil. Behind a proxy you may + /// need to set this to the proxy's public origin. + var refererOverride: String? + + /// Cookie names returned by the server on login. These match a standard + /// Django/DRF backend, which is what the CSRF + `Referer` requirements + /// imply — but verify against a real login response and override if needed. + var sessionCookieName: String = "sessionid" + var csrfCookieName: String = "csrftoken" + + /// How to treat the server's TLS certificate. These boxes typically present + /// a self-signed / internal-CA cert. See `ServerTrustEvaluator`. + var trustPolicy: ServerTrustPolicy = .system + + /// Request timeout (seconds). + var timeout: TimeInterval = 30 + + init( + baseURL: URL, + apiVersion: String = "v1", + databaseId: String? = nil, + refererOverride: String? = nil, + trustPolicy: ServerTrustPolicy = .system + ) { + self.baseURL = baseURL + self.apiVersion = apiVersion + self.databaseId = databaseId + self.refererOverride = refererOverride + self.trustPolicy = trustPolicy + } + + /// The value to send in the `Referer` header. + var refererValue: String { + refererOverride ?? baseURL.absoluteString + } + + /// Host used for trust pinning / cookie scoping. + var host: String { baseURL.host ?? "" } +} diff --git a/APIEnvelope.swift b/APIEnvelope.swift new file mode 100644 index 0000000..10fa611 --- /dev/null +++ b/APIEnvelope.swift @@ -0,0 +1,113 @@ +import Foundation + +/// The common MCMS response envelope. +/// +/// Success: `{ "status": "success", "data": { ... } }` +/// Failure: `{ "status": "fail", "details": { ... } }` +/// +/// `data` is generic; `details` is free-form so we keep it as `JSONValue`. +struct APIResponse: Decodable { + let status: String + let data: T? + let details: JSONValue? + + var isSuccess: Bool { status.lowercased() == "success" } +} + +/// Write requests wrap their document under a `data` field: +/// `{ "data": { } }` +struct DataEnvelope: Encodable { + let data: B +} + +// MARK: - JSONValue + +/// A fully dynamic, `Codable` JSON value. Used to (a) carry the free-form +/// `details` payload on failures and (b) hold MongoDB documents whose full +/// schema we haven't typed yet, so screens can display/edit raw config without +/// every field being modeled up front. Replace with concrete structs as you +/// pin down shapes from `openapi.json`. +indirect enum JSONValue: Codable, Equatable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { + self = .null + } else if let b = try? c.decode(Bool.self) { + self = .bool(b) + } else if let i = try? c.decode(Int.self) { + self = .int(i) + } else if let d = try? c.decode(Double.self) { + self = .double(d) + } else if let s = try? c.decode(String.self) { + self = .string(s) + } else if let a = try? c.decode([JSONValue].self) { + self = .array(a) + } else if let o = try? c.decode([String: JSONValue].self) { + self = .object(o) + } else { + throw DecodingError.dataCorruptedError( + in: c, debugDescription: "Unsupported JSON value") + } + } + + func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .null: try c.encodeNil() + case .bool(let b): try c.encode(b) + case .int(let i): try c.encode(i) + case .double(let d): try c.encode(d) + case .string(let s): try c.encode(s) + case .array(let a): try c.encode(a) + case .object(let o): try c.encode(o) + } + } + + // Convenience accessors + subscript(_ key: String) -> JSONValue? { + if case .object(let o) = self { return o[key] } + return nil + } + var stringValue: String? { if case .string(let s) = self { return s }; return nil } + var intValue: Int? { + switch self { case .int(let i): return i; case .double(let d): return Int(d); default: return nil } + } + var doubleValue: Double? { + switch self { case .double(let d): return d; case .int(let i): return Double(i); default: return nil } + } + var boolValue: Bool? { if case .bool(let b) = self { return b }; return nil } + var arrayValue: [JSONValue]? { if case .array(let a) = self { return a }; return nil } + var objectValue: [String: JSONValue]? { if case .object(let o) = self { return o }; return nil } + + /// A short, human-readable rendering for error surfaces. + var compactDescription: String { + switch self { + case .null: return "null" + case .bool(let b): return "\(b)" + case .int(let i): return "\(i)" + case .double(let d): return "\(d)" + case .string(let s): return s + case .array(let a): return a.map(\.compactDescription).joined(separator: ", ") + case .object(let o): + return o.map { "\($0): \($1.compactDescription)" }.joined(separator: "; ") + } + } +} + +/// Types that can represent an "empty" value, returned by the client when the +/// server responds 204 / no body to a request whose return type is non-optional. +protocol EmptyRepresentable { + static var emptyValue: Self { get } +} + +extension JSONValue: EmptyRepresentable { + static var emptyValue: JSONValue { .null } +} diff --git a/APIError.swift b/APIError.swift new file mode 100644 index 0000000..184cd55 --- /dev/null +++ b/APIError.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Typed errors surfaced by `APIClient`. HTTP status codes from the MCMS docs +/// are mapped to specific cases so the UI can react (e.g. re-auth on `.unauthorized`). +enum APIError: Error, Equatable { + /// No usable base URL / malformed endpoint. + case invalidURL + /// The request never produced an HTTP response (offline, host unreachable, VPN off). + case transport(message: String) + /// TLS trust evaluation failed for the server. + case serverTrust + /// Could not encode the request body. + case encoding(message: String) + /// Could not decode the response body into the expected type. + case decoding(message: String) + + /// 400 — request/validation error. `details` carries the server payload. + case badRequest(details: JSONValue?) + /// 401 — not authenticated (failed login or expired session). Trigger re-login. + case unauthorized + /// 403 — authenticated but not permitted, OR a missing/invalid CSRF token. + case forbidden(details: JSONValue?) + /// 404 — resource (URL, file, or document) not found. + case notFound + /// 409 — ID already in use (uploads / creates). + case conflict(details: JSONValue?) + /// 415 — unsupported Content-Type. + case unsupportedMediaType + /// 500 — generic server error. + case server(details: JSONValue?) + + /// The API returned HTTP 2xx but `status == "fail"`. + case apiFailure(details: JSONValue?) + /// Any other unexpected status code. + case unexpectedStatus(code: Int, details: JSONValue?) + + /// Maps an HTTP status code (with optional decoded `details`) to a case. + static func from(statusCode: Int, details: JSONValue?) -> APIError { + switch statusCode { + case 400: return .badRequest(details: details) + case 401: return .unauthorized + case 403: return .forbidden(details: details) + case 404: return .notFound + case 409: return .conflict(details: details) + case 415: return .unsupportedMediaType + case 500: return .server(details: details) + default: return .unexpectedStatus(code: statusCode, details: details) + } + } + + /// True when the right response is to send the user back to the login screen. + /// Only 401 (expired/invalid session) qualifies — a 403 is usually a + /// permission or CSRF issue where re-login won't help, so it must NOT bounce + /// the user out mid-action. + var requiresReauthentication: Bool { + switch self { + case .unauthorized: return true + default: return false + } + } +} + +extension APIError: LocalizedError { + var errorDescription: String? { + switch self { + case .invalidURL: return "The request URL was invalid." + case .transport(let m): return "Network error: \(m)" + case .serverTrust: return "The server's TLS certificate could not be trusted." + case .encoding(let m): return "Failed to encode request: \(m)" + case .decoding(let m): return "Failed to read server response: \(m)" + case .badRequest(let d): return "Request rejected (400). \(Self.detailText(d))" + case .unauthorized: return "Not authenticated (401). Please sign in again." + case .forbidden(let d): return "Forbidden (403) — your account may not have permission for this action. \(Self.detailText(d))" + case .notFound: return "Resource not found (404)." + case .conflict(let d): return "Conflict (409) — ID already in use. \(Self.detailText(d))" + case .unsupportedMediaType: return "Unsupported media type (415)." + case .server(let d): return "Server error (500). \(Self.detailText(d))" + case .apiFailure(let d): return "Request failed. \(Self.detailText(d))" + case .unexpectedStatus(let c, let d): return "Unexpected response (\(c)). \(Self.detailText(d))" + } + } + + private static func detailText(_ details: JSONValue?) -> String { + guard let details else { return "" } + return details.compactDescription + } +} diff --git a/APIQuery.swift b/APIQuery.swift new file mode 100644 index 0000000..95da4bf --- /dev/null +++ b/APIQuery.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Builder for the MCMS list-endpoint query parameters. +/// +/// Supported by most list endpoints: `query`, `projection`, `sort`, `limit` +/// (server default 1000), `skip`, `next` (cursor = a document `_id`), `distinct`. +/// Stats/log endpoints additionally require `start-time` and accept `end-time` +/// (UTC `yyyy-MM-dd HH:mm:ss`). +struct APIQuery { + /// Custom Mongo query as `key=value` pairs (joined with commas by the API). + var query: [String: String] = [:] + /// Field inclusion/exclusion: `1` include, `0` exclude. + var projection: [String: Int] = [:] + /// Sort field → direction (`1` asc, `-1` desc). + var sort: [String: Int] = [:] + var limit: Int? + var skip: Int? + /// Pagination cursor: excludes all items at/before this `_id`. + var next: String? + /// Attribute to fetch unique values for. + var distinct: String? + var startTime: Date? + var endTime: Date? + + static let empty = APIQuery() + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + return f + }() + + func queryItems() -> [URLQueryItem] { + var items: [URLQueryItem] = [] + + if !query.isEmpty { + let joined = query.map { "\($0)=\($1)" }.joined(separator: ",") + items.append(.init(name: "query", value: joined)) + } + if !projection.isEmpty { + let joined = projection.map { "\($0)=\($1)" }.joined(separator: ",") + items.append(.init(name: "projection", value: joined)) + } + if !sort.isEmpty { + let joined = sort.map { "\($0)=\($1)" }.joined(separator: ",") + items.append(.init(name: "sort", value: joined)) + } + if let limit { items.append(.init(name: "limit", value: "\(limit)")) } + if let skip { items.append(.init(name: "skip", value: "\(skip)")) } + if let next { items.append(.init(name: "next", value: next)) } + if let distinct { items.append(.init(name: "distinct", value: distinct)) } + if let startTime { + items.append(.init(name: "start-time", value: Self.timeFormatter.string(from: startTime))) + } + if let endTime { + items.append(.init(name: "end-time", value: Self.timeFormatter.string(from: endTime))) + } + return items + } + + /// Convenience for a recent time window on stats/log endpoints. + static func window(lastHours hours: Int, limit: Int? = nil) -> APIQuery { + var q = APIQuery() + q.startTime = Calendar.current.date(byAdding: .hour, value: -hours, to: Date()) + q.limit = limit + return q + } +} diff --git a/AppEnvironment.swift b/AppEnvironment.swift new file mode 100644 index 0000000..35b56c6 --- /dev/null +++ b/AppEnvironment.swift @@ -0,0 +1,107 @@ +import Foundation +import Observation + +/// Top-level app state. Owns the active `MCMSConnection`, persists the +/// (non-secret) `APIConfiguration` to UserDefaults, and stores credentials in +/// the Keychain. Rebuild the connection by calling `configure(_:)`. +@MainActor +@Observable +final class AppEnvironment { + + private(set) var configuration: APIConfiguration? + private(set) var connection: MCMSConnection? + var isAuthenticated = false + + private let keychain = KeychainStore() + private let defaults = UserDefaults.standard + private let configKey = "mcms.configuration" + + init() { loadConfiguration() } + + var isConfigured: Bool { configuration != nil } + + // MARK: Configuration + + func configure(_ config: APIConfiguration) { + configuration = config + connection = makeConnection(config) + isAuthenticated = false + persist(config) + } + + /// Builds a connection wired to route 401/403 back to the login screen. + private func makeConnection(_ config: APIConfiguration) -> MCMSConnection { + let connection = MCMSConnection(config: config) + connection.onAuthExpired = { [weak self] in self?.handleAuthExpired() } + return connection + } + + /// A request hit 401/403 — the session expired. Drop to login; saved + /// credentials remain so the user can sign back in. + private func handleAuthExpired() { + guard isAuthenticated else { return } + isAuthenticated = false + } + + private func persist(_ config: APIConfiguration) { + if let data = try? JSONEncoder().encode(config) { + defaults.set(data, forKey: configKey) + } + } + + private func loadConfiguration() { + guard let data = defaults.data(forKey: configKey), + let config = try? JSONDecoder().decode(APIConfiguration.self, from: data) + else { return } + configuration = config + connection = makeConnection(config) + } + + // MARK: Credentials (Keychain) + + var savedEmail: String? { keychain.get(account: "email") } + var savedPassword: String? { keychain.get(account: "password") } + + private func saveCredentials(email: String, password: String) { + keychain.set(email, account: "email") + keychain.set(password, account: "password") + } + + func forgetCredentials() { + keychain.delete(account: "email") + keychain.delete(account: "password") + } + + // MARK: Session + + func signIn(email: String, password: String, remember: Bool) async throws { + guard let connection else { throw APIError.transport(message: "No connection configured") } + try await connection.auth.login(email: email, password: password) + if remember { saveCredentials(email: email, password: password) } + isAuthenticated = true + } + + func signOut() async { + await connection?.auth.logout() + isAuthenticated = false + } + + /// Reachability / trust probe used by the Settings screen. Builds a + /// throwaway connection so testing does NOT disturb the active session. + /// A 401/403/404 still means the host answered over a trusted TLS channel — + /// that's a successful reachability result; only transport/trust errors fail. + func probe(_ config: APIConfiguration) async throws -> String { + let connection = MCMSConnection(config: config) + do { + let version = try await connection.auth.version() + return version.displayVersion.map { "PON Manager \($0)" } ?? "Reachable" + } catch let error as APIError { + switch error { + case .unauthorized, .forbidden, .notFound: + return "Reachable — sign in to continue" + default: + throw error + } + } + } +} diff --git a/Assets.xcassets/AppIcon.appiconset/Contents.json b/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..42f5c3f --- /dev/null +++ b/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [ { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..75388e8 Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/AuthModels.swift b/AuthModels.swift new file mode 100644 index 0000000..cb92110 --- /dev/null +++ b/AuthModels.swift @@ -0,0 +1,39 @@ +import Foundation + +/// `POST /v{n}/users/authenticate/` +struct AuthRequest: Encodable { + let email: String + let password: String +} + +/// A successful login replies `{"status":"success"}` with no `data` (dev guide +/// §Response format). We don't depend on a payload — auth state lives in +/// cookies — so `AuthResponse` is empty-representable and captures `data` raw +/// only when a build happens to include it. +struct AuthResponse: Decodable, EmptyRepresentable { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + private init(raw: JSONValue) { self.raw = raw } + + static var emptyValue: AuthResponse { AuthResponse(raw: .null) } + + var email: String? { raw["email"]?.stringValue ?? raw["User"]?["email"]?.stringValue } +} + +/// `GET /v{n}/ponmgr/version/` — attribute set describing the install. +/// Exact keys vary; surface a best-effort display string and keep raw. +struct VersionInfo: Decodable { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + + var displayVersion: String? { + raw["version"]?.stringValue + ?? raw["Version"]?.stringValue + ?? raw["ponmgr"]?["version"]?.stringValue + } +} + +// `PUT /v{n}/databases/selection/` takes the database id as a bare string under +// the data envelope — `{ "data": "" }` (dev guide §Request Format). It is +// sent by passing the id String straight to `client.send(wrapInData: true)`, so +// no wrapper struct is needed here. diff --git a/AuthRepository.swift b/AuthRepository.swift new file mode 100644 index 0000000..96b2481 --- /dev/null +++ b/AuthRepository.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Authentication, session, and system info. +@MainActor +final class AuthRepository { + private let client: APIClient + private let session: SessionStore + private let config: APIConfiguration + + init(client: APIClient, session: SessionStore, config: APIConfiguration) { + self.client = client + self.session = session + self.config = config + } + + /// Logs in, then (optionally) selects the configured database. Auth state is + /// carried by cookies that `URLSession` stores automatically. + @discardableResult + func login(email: String, password: String) async throws -> AuthResponse { + let response: AuthResponse = try await client.send( + "POST", .authenticate, + body: AuthRequest(email: email, password: password), + wrapInData: false, // login body is sent unwrapped + as: AuthResponse.self + ) + session.markAuthenticated() + if let db = config.databaseId { + try await selectDatabase(db) + } + return response + } + + func selectDatabase(_ id: String) async throws { + // Body is `{ "data": "" }` — a bare string under the envelope. + let _: JSONValue = try await client.send( + "PUT", .databaseSelection, + body: id, + wrapInData: true, + as: JSONValue.self + ) + } + + func logout() async { + // Best effort; clear local session regardless of outcome. + _ = try? await client.action("GET", .logout, as: JSONValue.self) + session.clear() + } + + func version() async throws -> VersionInfo { + try await client.get(.version, as: VersionInfo.self) + } +} diff --git a/Components.swift b/Components.swift new file mode 100644 index 0000000..94f9806 --- /dev/null +++ b/Components.swift @@ -0,0 +1,126 @@ +import SwiftUI + +/// Simple load state for screen view models. +enum LoadPhase: Equatable { + case idle + case loading + case loaded + case failed(String) +} + +// MARK: - State / severity badges + +struct StateBadge: View { + let text: String + let color: Color + + var body: some View { + Text(text) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(color.opacity(0.18)) + .foregroundStyle(color) + .clipShape(Capsule()) + } +} + +extension ONULifecycleState { + var color: Color { + switch self { + case .registered: return .green + case .unspecified: return .mint // also "online" (dev guide Procedure 2) + case .unprovisioned: return .blue + case .deregistered: return .gray + case .dyingGasp, .disallowedError: return .red + case .disabled, .disallowedAdmin, .disallowedRegID: return .orange + case .unknown: return .secondary + } + } + var label: String { self == .unknown ? "Unknown" : rawValue } +} + +extension DeviceState { + var color: Color { + switch self { + case .online: return .green + case .offline: return .gray + case .unattached: return .orange + case .preProvisioned: return .blue + case .unknown: return .secondary + } + } + var label: String { self == .unknown ? "Unknown" : rawValue } +} + +extension AlarmSeverity { + var color: Color { + switch self { + case .emergency, .alert, .critical: return .red + case .error: return .orange + case .warning: return .yellow + case .notice, .info: return .blue + case .debug: return .gray + case .unknown: return .secondary + } + } + var label: String { self == .unknown ? "unknown" : rawValue } +} + +struct SeverityBadge: View { + let severity: AlarmSeverity + var body: some View { StateBadge(text: severity.label, color: severity.color) } +} + +// MARK: - Count tile (dashboard) + +struct CountTile: View { + let title: String + let value: Int + var systemImage: String? = nil + var tint: Color = .accentColor + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + if let systemImage { Image(systemName: systemImage).foregroundStyle(tint) } + Text(title).font(.caption).foregroundStyle(.secondary) + } + Text("\(value)").font(.system(.title, design: .rounded).weight(.semibold)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +// MARK: - Loading / error + +struct LoadingView: View { + var label: String = "Loading…" + var body: some View { + VStack(spacing: 12) { + ProgressView() + Text(label).font(.subheadline).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct ErrorStateView: View { + let message: String + var retry: (() -> Void)? = nil + + var body: some View { + ContentUnavailableView { + Label("Something went wrong", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + if let retry { + Button("Retry", action: retry).buttonStyle(.borderedProminent) + } + } + } +} diff --git a/DashboardDrilldownViews.swift b/DashboardDrilldownViews.swift new file mode 100644 index 0000000..5caeaf7 --- /dev/null +++ b/DashboardDrilldownViews.swift @@ -0,0 +1,70 @@ +import SwiftUI + +/// ONUs flagged with an abnormal Rx optical level — drilled into from the +/// dashboard Health section. Uses the docs the dashboard already scanned. +struct AbnormalRxListView: View { + let connection: MCMSConnection + let onus: [ONUStateDoc] + + var body: some View { + List(onus) { onu in + NavigationLink { + ONUDetailView(connection: connection, onuId: onu.id) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(onu.displayName).font(.body.weight(.medium)) + if let olt = onu.parentOLT { + Text("OLT \(olt)").font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if let rx = onu.rxOpticalDBm { + VStack(alignment: .trailing, spacing: 2) { + Text("\(rx.formatted(.number.precision(.fractionLength(1)))) dBm") + .font(.callout.monospacedDigit()).foregroundStyle(.orange) + Text(rx < -28 ? "weak" : "strong") + .font(.caption2).foregroundStyle(.secondary) + } + } + } + } + } + .listStyle(.plain) + .navigationTitle("Abnormal Rx") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if onus.isEmpty { + ContentUnavailableView("All Rx levels normal", systemImage: "checkmark.circle") + } + } + } +} + +/// OLTs whose laser is shut down — drilled into from the dashboard Health section. +struct LaserOffListView: View { + let connection: MCMSConnection + let olts: [OLTStateDoc] + + var body: some View { + List(olts) { olt in + NavigationLink { + OLTDetailView(connection: connection, oltId: olt.id, title: olt.displayName) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(olt.displayName).font(.body.weight(.medium)) + Text(olt.laserShutdown ?? "Laser off") + .font(.caption).foregroundStyle(.orange) + } + } + } + .listStyle(.plain) + .navigationTitle("Laser off") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if olts.isEmpty { + ContentUnavailableView("All lasers on", systemImage: "checkmark.circle") + } + } + } +} diff --git a/DashboardView.swift b/DashboardView.swift new file mode 100644 index 0000000..8f97103 --- /dev/null +++ b/DashboardView.swift @@ -0,0 +1,185 @@ +import SwiftUI + +struct DashboardView: View { + private let connection: MCMSConnection + @State private var vm: DashboardViewModel + @AppStorage("dashboard.extraStats") private var extraStats = false + @AppStorage("dashboard.autoRefresh") private var autoRefresh = false + + init(connection: MCMSConnection) { + self.connection = connection + _vm = State(initialValue: DashboardViewModel(connection: connection)) + } + + private let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] + + var body: some View { + Group { + switch vm.phase { + case .idle: + LoadingView(label: "Loading network…") + case .loading where vm.onuTotal == 0: + LoadingView(label: "Loading network…") + case .failed(let msg): + ErrorStateView(message: msg) { Task { await vm.load(extraStats: extraStats) } } + default: + content + } + } + .navigationTitle("Dashboard") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Toggle("Detailed stats (Rx scan)", isOn: $extraStats) + Toggle("Auto-refresh · 30s", isOn: $autoRefresh) + } label: { + Image(systemName: autoRefresh ? "arrow.clockwise.circle.fill" : "ellipsis.circle") + } + } + } + // Reloads on appear, and whenever a toggle flips; keeps refreshing every + // 30s while auto-refresh is on. Cancelled when the view goes away. + .task(id: "\(extraStats)-\(autoRefresh)") { + while !Task.isCancelled { + await vm.load(extraStats: extraStats) + guard autoRefresh else { break } + try? await Task.sleep(for: .seconds(30)) + } + } + .refreshable { await vm.load(extraStats: extraStats) } + } + + private var content: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + LazyVGrid(columns: columns, spacing: 12) { + CountTile(title: "ONUs", value: vm.onuTotal, + systemImage: "point.3.connected.trianglepath.dotted", tint: .blue) + CountTile(title: "OLTs", value: vm.oltCount, + systemImage: "antenna.radiowaves.left.and.right", tint: .teal) + CountTile(title: "Controllers", value: vm.controllerCount, + systemImage: "cpu", tint: .indigo) + } + + if vm.totalDownstreamBps > 0 || vm.totalUpstreamBps > 0 { + section(title: "Traffic · all OLTs") { + HStack { + trafficStat("Downstream", systemImage: "arrow.down", bps: vm.totalDownstreamBps, tint: .blue) + Spacer() + trafficStat("Upstream", systemImage: "arrow.up", bps: vm.totalUpstreamBps, tint: .teal) + } + } + } + + if vm.opticalAvailable || vm.lasersOffCount > 0 { + section(title: "Health") { + if vm.opticalAvailable { + healthLink(title: "ONUs with abnormal Rx", detail: "< −28 or > −10 dBm", count: vm.abnormalRxCount) { + AbnormalRxListView(connection: connection, onus: vm.abnormalRxONUs) + } + } + healthLink(title: "OLTs with laser off", detail: nil, count: vm.lasersOffCount) { + LaserOffListView(connection: connection, olts: vm.lasersOffOLTs) + } + } + } + + if !vm.onuCounts.isEmpty { + section(title: "ONU states") { + ForEach(vm.onuCounts, id: \.state) { row in + NavigationLink { + ONUListView(connection: connection, + stateFilter: row.state, + title: row.state.label) + } label: { + HStack { + StateBadge(text: row.state.label, color: row.state.color) + Spacer() + Text("\(row.count)").font(.body.monospacedDigit()) + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + + if !vm.severityCounts.isEmpty { + section(title: "ONU alarms by severity") { + ForEach(vm.severityCounts, id: \.severity) { row in + HStack { + SeverityBadge(severity: row.severity) + Spacer() + Text("\(row.count)").font(.body.monospacedDigit()) + } + .padding(.vertical, 4) + } + } + } + } + .padding() + } + } + + @ViewBuilder + private func section(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + VStack(spacing: 0) { content() } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + + private func trafficStat(_ title: String, systemImage: String, bps: Double, tint: Color) -> some View { + VStack(alignment: .leading, spacing: 4) { + Label(title, systemImage: systemImage).font(.caption).foregroundStyle(.secondary) + Text(Format.bps(bps)) + .font(.system(.title3, design: .rounded).weight(.semibold)) + .foregroundStyle(tint) + .monospacedDigit() + } + } + + /// A health row that becomes a navigation link into the affected devices + /// when the count is non-zero. + @ViewBuilder + private func healthLink( + title: String, detail: String?, count: Int, + @ViewBuilder destination: () -> Destination + ) -> some View { + if count > 0 { + NavigationLink(destination: destination()) { + healthRow(title, detail: detail, count: count, tappable: true) + } + .buttonStyle(.plain) + } else { + healthRow(title, detail: detail, count: count, tappable: false) + } + } + + private func healthRow(_ title: String, detail: String?, count: Int, tappable: Bool) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(title) + if let detail { Text(detail).font(.caption).foregroundStyle(.secondary) } + } + Spacer() + Text("\(count)") + .font(.body.monospacedDigit().weight(.semibold)) + .foregroundStyle(count > 0 ? .orange : .green) + if tappable { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } +} diff --git a/DashboardViewModel.swift b/DashboardViewModel.swift new file mode 100644 index 0000000..afade79 --- /dev/null +++ b/DashboardViewModel.swift @@ -0,0 +1,79 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class DashboardViewModel { + var phase: LoadPhase = .idle + var onuCounts: [(state: ONULifecycleState, count: Int)] = [] + var onuTotal = 0 + var oltCount = 0 + var controllerCount = 0 + var severityCounts: [(severity: AlarmSeverity, count: Int)] = [] + + // Aggregates derived from the state docs already fetched for the counts above + // — no extra API calls. + var totalDownstreamBps = 0.0 + var totalUpstreamBps = 0.0 + var abnormalRxCount = 0 // ONUs with Rx optical < −28 or > −10 dBm + var lasersOffCount = 0 // OLTs with the laser shut down + var opticalAvailable = false // whether the bulk ONU-STATE response carried Rx levels + + // The actual offenders, kept for drill-down (no re-fetch). + var abnormalRxONUs: [ONUStateDoc] = [] + var lasersOffOLTs: [OLTStateDoc] = [] + + private let connection: MCMSConnection + init(connection: MCMSConnection) { self.connection = connection } + + /// Light by default: the ONU count, state breakdown, OLT traffic and + /// laser-off count all come from the (small) OLT-STATE docs. The heavy + /// per-ONU optical scan runs only when `extraStats` is on. + func load(extraStats: Bool = false) async { + phase = .loading + do { + // OLT-STATE drives the OLT count, ONU registration (dev guide + // Procedure 2), total traffic, and laser state — all from a handful + // of small docs. + let olts = try await connection.olt.allStates() + oltCount = olts.count + + let registration = OLTRepository.registrationMap(from: olts) + onuTotal = registration.count + onuCounts = Dictionary(grouping: registration.values, by: { $0 }) + .map { (state: $0.key, count: $0.value.count) } + .sorted { $0.count > $1.count } + + totalDownstreamBps = olts.compactMap(\.txRateBps).reduce(0, +) + totalUpstreamBps = olts.compactMap(\.rxRateBps).reduce(0, +) + lasersOffOLTs = olts.filter(\.isLaserOff) + lasersOffCount = lasersOffOLTs.count + + controllerCount = (try? await connection.controller.allConfigs().count) ?? 0 + + if let alarms = try? await connection.onu.alarmHistories() { + severityCounts = Dictionary(grouping: alarms, by: { $0.severity }) + .map { (severity: $0.key, count: $0.value.count) } + .sorted { $0.severity.rank < $1.severity.rank } + } + + // Opt-in heavy scan: pull every ONU-STATE for the Rx optical check. + if extraStats { + let onus = try await connection.onu.allStates() + opticalAvailable = onus.contains { $0.rxOpticalDBm != nil } + abnormalRxONUs = onus.filter { onu in + guard let rx = onu.rxOpticalDBm else { return false } + return rx < -28 || rx > -10 + } + abnormalRxCount = abnormalRxONUs.count + } else { + opticalAvailable = false + abnormalRxONUs = [] + abnormalRxCount = 0 + } + phase = .loaded + } catch { + phase = .failed((error as? APIError)?.localizedDescription ?? error.localizedDescription) + } + } +} diff --git a/DeviceModels.swift b/DeviceModels.swift new file mode 100644 index 0000000..9727daf --- /dev/null +++ b/DeviceModels.swift @@ -0,0 +1,409 @@ +import Foundation + +// MARK: - Enums (tolerant decoding with `.unknown` fallback) + +/// Device states for PON Controllers, OLTs, and ONUs (dashboard-level). +enum DeviceState: String, Decodable, CaseIterable { + case online = "Online" + case offline = "Offline" + case unattached = "Unattached" + case preProvisioned = "Pre-provisioned" + case unknown + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = DeviceState(rawValue: raw) ?? .unknown + } +} + +/// ONU registration state. These are the buckets MCMS keeps on the **OLT-STATE** +/// document under `"ONU States"` — not a field on ONU-STATE (dev guide +/// Procedure 2). Raw values match the bucket names exactly. +enum ONULifecycleState: String, Decodable, CaseIterable { + case registered = "Registered" + case unspecified = "Unspecified" + case unprovisioned = "Unprovisioned" + case deregistered = "Deregistered" + case dyingGasp = "Dying Gasp" + case disabled = "Disabled" + case disallowedAdmin = "Disallowed Admin" + case disallowedError = "Disallowed Error" + case disallowedRegID = "Disallowed Reg ID" + case unknown + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = ONULifecycleState(rawValue: raw) ?? .unknown + } + + /// Per dev guide Procedure 2: an ONU in `Registered` or `Unspecified` is online. + var isOnline: Bool { self == .registered || self == .unspecified } +} + +/// Automation roll-up states shown on the dashboard. +enum AutomationState: String, Decodable, CaseIterable { + case done = "Done" + case error = "Error" + case waitingForInput = "Waiting For Input" + case waitingForDevice = "Waiting For Device" + case disabled = "Disabled" + case unknown + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = AutomationState(rawValue: raw) ?? .unknown + } +} + +/// Syslog-style alarm/severity levels (lower rank = more severe). +enum AlarmSeverity: String, Decodable, CaseIterable, Comparable { + case emergency, alert, critical, error, warning, notice, info, debug + case unknown + + var rank: Int { + switch self { + case .emergency: return 0 + case .alert: return 1 + case .critical: return 2 + case .error: return 3 + case .warning: return 4 + case .notice: return 5 + case .info: return 6 + case .debug: return 7 + case .unknown: return 8 + } + } + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = AlarmSeverity(rawValue: raw.lowercased()) ?? .unknown + } + + static func < (lhs: AlarmSeverity, rhs: AlarmSeverity) -> Bool { lhs.rank < rhs.rank } +} + +// MARK: - Flexible documents +// +// Full MongoDB document schemas (ONU-CFG, ONU-STATE, OLT-CFG, CNTL-CFG, …) are +// large and version-specific. Rather than hand-transcribe every field (and risk +// getting it wrong), each document decodes its full body into `raw: JSONValue` +// and exposes a few convenience accessors. The accessor key paths below are +// best-effort guesses — RECONCILE THEM WITH `openapi.json` and a live response, +// then promote the fields you actually use into typed properties. + +protocol MongoDocument: Decodable, Identifiable { + var raw: JSONValue { get } +} + +extension MongoDocument { + var documentId: String? { raw["_id"]?.stringValue } +} + +/// ONU state document (`ONU-STATE`). +struct ONUStateDoc: MongoDocument { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + + var id: String { raw["_id"]?.stringValue ?? UUID().uuidString } + + var serialNumber: String? { raw["_id"]?.stringValue } + /// Parent OLT MAC, from `OLT.MAC Address` (dev guide Procedure 2 §2). + var parentOLT: String? { raw["OLT"]?["MAC Address"]?.stringValue } + var lastUpdate: String? { raw["Time"]?.stringValue } + + /// Registration state is **not** carried on ONU-STATE — it lives in the + /// OLT-STATE buckets. The list/dashboard view models resolve it via + /// `OLTRepository.registrationMap()` and stamp it here. `nil` until resolved. + var resolvedLifecycle: ONULifecycleState? + var lifecycle: ONULifecycleState { resolvedLifecycle ?? .unknown } + + /// Operator-set label from ONU-CFG (`ONU.Name`); resolved + stamped by the + /// list VM via `ONURepository.nameMap()`. `nil` until resolved / if unset. + var resolvedName: String? + /// Best label to show: the operator name if set, else the serial/MAC id. + var displayName: String { + if let name = resolvedName, !name.isEmpty { return name } + return id + } + + // Live optical + FEC. Current values live on ONU-STATE.STATS, not on the + // /onus/stats/ time-series (field guide §3.12 / §7). + private func ponStat(_ pon: String, _ key: String) -> Double? { + raw["STATS"]?[pon]?[key]?.doubleValue + } + var rxOpticalDBm: Double? { ponStat("ONU-PON", "RX Optical Level") } + var txOpticalDBm: Double? { ponStat("ONU-PON", "TX Optical Level") } + var onuPreFEC: Double? { ponStat("ONU-PON", "RX Pre-FEC BER") } + var onuPostFEC: Double? { ponStat("ONU-PON", "RX Post-FEC BER") } + var oltPreFEC: Double? { ponStat("OLT-PON", "RX Pre-FEC BER") } + var oltPostFEC: Double? { ponStat("OLT-PON", "RX Post-FEC BER") } +} + +/// ONU config document (`ONU-CFG`). +struct ONUConfigDoc: MongoDocument { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + var id: String { raw["_id"]?.stringValue ?? UUID().uuidString } + /// Operator-set label (`ONU.Name`, §5.1). + var name: String? { raw["ONU"]?["Name"]?.stringValue } + var netconfName: String? { raw["Netconf"]?["Name"]?.stringValue } +} + +/// OLT config document (`OLT-CFG`). +struct OLTConfigDoc: MongoDocument { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + var id: String { raw["_id"]?.stringValue ?? UUID().uuidString } // MAC address + /// Operator label from `OLT.Name` (confirmed against a live OLT-CFG, e.g. + /// "OLT69-RIN"). Note `NETCONF.Name` defaults to the MAC, so it's *not* the + /// friendly name. Returns nil when unset. + var name: String? { + guard let value = raw["OLT"]?["Name"]?.stringValue, !value.isEmpty else { return nil } + return value + } +} + +/// OLT state document (`OLT-STATE`). Carries the per-OLT ONU registration +/// buckets used to resolve each ONU's lifecycle (dev guide Procedure 2). +struct OLTStateDoc: MongoDocument { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + var id: String { raw["_id"]?.stringValue ?? UUID().uuidString } // OLT MAC address + var lastUpdate: String? { raw["Time"]?.stringValue } + + /// Operator name from OLT-CFG (`Netconf.Name`); resolved + stamped by the + /// list VM via `OLTRepository.nameMap()`. `nil` until resolved / if unset. + var resolvedName: String? + /// Best label to show: the operator name if set, else the MAC id. + var displayName: String { + if let name = resolvedName, !name.isEmpty { return name } + return id + } + + /// `"ONU States"` → bucket name → [ONU IDs]. + var onuStatesByBucket: [String: [String]] { + guard let obj = raw["ONU States"]?.objectValue else { return [:] } + return obj.mapValues { $0.arrayValue?.compactMap(\.stringValue) ?? [] } + } + + var totalONUs: Int { onuStatesByBucket.values.reduce(0) { $0 + $1.count } } + /// Online = Registered + Unspecified (dev guide Procedure 2). + var onlineONUs: Int { + onuStatesByBucket.reduce(0) { sum, entry in + (ONULifecycleState(rawValue: entry.key)?.isOnline ?? false) ? sum + entry.value.count : sum + } + } + + /// Non-empty registration buckets, ordered by lifecycle severity. + var populatedBuckets: [(state: ONULifecycleState, ids: [String])] { + onuStatesByBucket + .filter { !$0.value.isEmpty } + .map { (state: ONULifecycleState(rawValue: $0.key) ?? .unknown, ids: $0.value.sorted()) } + .sorted { + let order = ONULifecycleState.allCases + return (order.firstIndex(of: $0.state) ?? .max) < (order.firstIndex(of: $1.state) ?? .max) + } + } +} + +/// PON Controller config document (`CNTL-CFG`). +struct ControllerConfigDoc: MongoDocument { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + var id: String { raw["_id"]?.stringValue ?? UUID().uuidString } // MAC address + var version: String? { raw["CNTL"]?["Version"]?.stringValue } +} + +/// Alarm-history document (`*-ALARM-HIST-STATE`). +struct AlarmHistoryDoc: MongoDocument { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + var id: String { raw["_id"]?.stringValue ?? UUID().uuidString } + + // TODO: confirm key paths. + var severity: AlarmSeverity { + guard let s = raw["Severity"]?.stringValue ?? raw["severity"]?.stringValue + else { return .unknown } + return AlarmSeverity(rawValue: s.lowercased()) ?? .unknown + } + var message: String? { raw["Message"]?.stringValue ?? raw["message"]?.stringValue } + var raisedAt: String? { raw["Time"]?.stringValue ?? raw["timestamp"]?.stringValue } +} + +/// One statistics sample (`STATS-*`). Timestamp + arbitrary counters. +struct StatSample: MongoDocument { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + var id: String { raw["_id"]?.stringValue ?? UUID().uuidString } + var timestamp: String? { raw["Time"]?.stringValue ?? raw["timestamp"]?.stringValue } + + /// Pull a numeric counter by key for charting. + func metric(_ key: String) -> Double? { + switch raw[key] { + case .some(.double(let d)): return d + case .some(.int(let i)): return Double(i) + default: return nil + } + } +} + +/// Syslog line (`SYSLOG-*`). +struct LogEntry: MongoDocument { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + var id: String { raw["_id"]?.stringValue ?? UUID().uuidString } + var severity: AlarmSeverity { + guard let s = raw["Severity"]?.stringValue ?? raw["severity"]?.stringValue + else { return .unknown } + return AlarmSeverity(rawValue: s.lowercased()) ?? .unknown + } + var message: String? { raw["Message"]?.stringValue ?? raw["message"]?.stringValue } + var time: String? { raw["Time"]?.stringValue ?? raw["timestamp"]?.stringValue } +} + +/// A CPE behind an ONU, with its DHCP lease — from the `/cpe/onu//` helper. +struct CPEState: MongoDocument { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + init(raw: JSONValue) { self.raw = raw } + var id: String { raw["_id"]?.stringValue ?? UUID().uuidString } + var mac: String? { raw["_id"]?.stringValue } + + // IPv4 (DHCP) + var dhcpState: String? { nonEmpty(raw["DHCP"]?["Client State"]?.stringValue) } + var ipv4: String? { nonEmpty(raw["DHCP"]?["Client IP Addr"]?.stringValue) } + var dhcpServer: String? { nonEmpty(raw["DHCP"]?["Server ID"]?.stringValue) } + var dhcpLeaseSeconds: Int? { raw["DHCP"]?["Lease Time"]?.intValue } + var circuitId: String? { nonEmpty(raw["DHCP"]?["Circuit ID"]?.stringValue) } + var dhcpLastSuccess: String? { nonEmpty(raw["DHCP"]?["Last Success Time"]?.stringValue) } + + // IPv6 (DHCPv6) + var dhcpv6State: String? { nonEmpty(raw["DHCPV6"]?["Client State"]?.stringValue) } + var ipv6: String? { nonEmpty(raw["DHCPV6"]?["Client IP Addr"]?.stringValue) } + var ipv6ValidLifetime: String? { nonEmpty(raw["DHCPV6"]?["Valid Lifetime"]?.stringValue) } + var ipv6Prefix: String? { + guard let entry = raw["DHCPV6"]?["Prefix Delegation"]?.arrayValue?.first, + let prefixObj = entry["Prefixes"]?.arrayValue?.first, + let prefix = prefixObj["Prefix"]?.stringValue, + let len = prefixObj["Prefix Length"]?.intValue else { return nil } + return "\(prefix)/\(len)" + } + + var hasLease: Bool { ipv4 != nil || ipv6 != nil } + + private func nonEmpty(_ value: String?) -> String? { (value?.isEmpty == false) ? value : nil } +} + +// MARK: - ONU-STATE detail accessors (status / firmware / UNI / traffic) + +/// One UNI port parsed from a `UNI-ETH N` / `UNI-POTS N` entry on ONU-STATE. +struct UNIPort: Identifiable { + let name: String + let raw: JSONValue + var id: String { name } + var isEthernet: Bool { name.localizedCaseInsensitiveContains("ETH") } + var enabled: Bool? { raw["Enable"]?.boolValue } + var speed: String? { raw["Speed"]?.stringValue } + var duplex: String? { raw["Duplex"]?.stringValue } + /// Resolved link state string on ETH ports, e.g. "10G Ethernet Full Duplex". + var state: String? { raw["State"]?.stringValue } +} + +/// Per-service traffic parsed from an `OLT-PON Service N` entry on ONU-STATE.STATS. +struct PONServiceStats: Identifiable { + let name: String + let raw: JSONValue + var id: String { name } + var rxRateBps: Double? { raw["RX Rate bps"]?.doubleValue } + var txRateBps: Double? { raw["TX Rate bps"]?.doubleValue } + var rxTotalOctets: Double? { raw["RX Total Octets"]?.doubleValue } + var txTotalOctets: Double? { raw["TX Total Octets"]?.doubleValue } +} + +extension ONUStateDoc { + private var onu: JSONValue? { raw["ONU"] } + + // Status / identity + var registrationState: String? { onu?["Registration State"]?.stringValue } + var equipmentId: String? { onu?["Equipment ID"]?.stringValue } + var vendor: String? { onu?["Vendor"]?.stringValue } + var ponMode: String? { onu?["PON Mode"]?.stringValue } + var onlineTime: String? { onu?["Online Time"]?.stringValue } + var temperatureC: Double? { raw["GPON"]?["Temperature"]?.doubleValue } + + // Firmware + var fwVersion: String? { onu?["FW Version"]?.stringValue } + var fwBankVersions: [String] { onu?["FW Bank Versions"]?.arrayValue?.compactMap(\.stringValue) ?? [] } + var fwBankPtr: Int? { onu?["FW Bank Ptr"]?.intValue } + /// Non-empty only while an upgrade is in progress / recorded. + var fwUpgradeStatus: [String: JSONValue]? { + let obj = onu?["FW Upgrade Status"]?.objectValue + return (obj?.isEmpty == false) ? obj : nil + } + + /// UNI ports, from the top-level `UNI-*` entries. + var uniPorts: [UNIPort] { + guard let obj = raw.objectValue else { return [] } + return obj.keys.filter { $0.hasPrefix("UNI-") }.sorted() + .compactMap { key in obj[key].map { UNIPort(name: key, raw: $0) } } + } + + /// Per-service traffic, from `STATS["OLT-PON Service N"]`. + var ponServices: [PONServiceStats] { + guard let stats = raw["STATS"]?.objectValue else { return [] } + return stats.keys.filter { $0.hasPrefix("OLT-PON Service ") }.sorted() + .compactMap { key in stats[key].map { PONServiceStats(name: key, raw: $0) } } + } +} + +// MARK: - OLT-STATE detail accessors (uptime / traffic / environment / uplink) + +extension OLTStateDoc { + private var olt: JSONValue? { raw["OLT"] } + private var ponStats: JSONValue? { raw["STATS"]?["OLT-PON"] } + + var onlineTime: String? { olt?["Online Time"]?.stringValue } + var firmwareVersion: String? { olt?["FW Version"]?.stringValue } + var serialNumber: String? { olt?["Serial Number"]?.stringValue } + var model: String? { olt?["Model"]?.stringValue } + var ponMode: String? { olt?["PON Mode"]?.stringValue } + + var laserShutdown: String? { olt?["Laser Shutdown"]?.stringValue } + /// True when the OLT laser isn't "Laser ON" (shut down / inactive). Only + /// meaningful when the field is present. + var isLaserOff: Bool { + guard let value = laserShutdown else { return false } + return value.caseInsensitiveCompare("Laser ON") != .orderedSame + } + + var statsTotalONUs: Int? { ponStats?["Total ONUs Count"]?.intValue } + var statsOnlineONUs: Int? { ponStats?["Online ONUs Count"]?.intValue } + var statsOfflineONUs: Int? { ponStats?["Offline ONUs Count"]?.intValue } + var rxUtilPercent: Int? { ponStats?["RX BW Total Util"]?.intValue } + var txUtilPercent: Int? { ponStats?["TX BW Total Util"]?.intValue } + var rxRateBps: Double? { ponStats?["RX BW Ethernet Rate bps"]?.doubleValue } + var txRateBps: Double? { ponStats?["TX BW Ethernet Rate bps"]?.doubleValue } + + var asicTempC: Int? { raw["STATS"]?["OLT-TEMP"]?["ASIC"]?.intValue } + var voltage: Double? { raw["STATS"]?["OLT-ENV"]?["Voltage"]?.doubleValue } + + // Upstream switch (LLDP neighbour). + var switchName: String? { nonEmpty(raw["Switch"]?["System Name"]?.stringValue) } + var switchPort: String? { nonEmpty(raw["Switch"]?["Port ID"]?.stringValue) } + var switchAddress: String? { nonEmpty(raw["Switch"]?["IPv4 Address"]?.stringValue) } + + private func nonEmpty(_ s: String?) -> String? { (s?.isEmpty == false) ? s : nil } +} + +// MARK: - OLT action payloads + +/// Body for `PUT /olts//disable-onu//` — sent WITHOUT the data wrapper. +struct DisableONURequest: Encodable { + let disable: Bool +} + +/// OLT action responses return `{ "Pending": true|false }`. +struct PendingStatus: Decodable { + let pending: Bool + enum CodingKeys: String, CodingKey { case pending = "Pending" } +} diff --git a/DeviceRepositories.swift b/DeviceRepositories.swift new file mode 100644 index 0000000..7453700 --- /dev/null +++ b/DeviceRepositories.swift @@ -0,0 +1,148 @@ +import Foundation + +/// OLT resources and device actions. +@MainActor +final class OLTRepository { + private let client: APIClient + init(client: APIClient) { self.client = client } + + func configs(query: APIQuery = .empty) async throws -> [OLTConfigDoc] { + try await client.get(.oltConfigs, query: query, as: [OLTConfigDoc].self) + } + + func allConfigs() async throws -> [OLTConfigDoc] { + try await client.getAll(.oltConfigs, idOf: { $0.id }) + } + + /// Map of OLT id → operator name (`OLT.Name`), via projection. Skips names + /// equal to the id, so only real labels come through. + func nameMap() async throws -> [String: String] { + var query = APIQuery() + query.projection = ["OLT.Name": 1] + let configs: [OLTConfigDoc] = try await client.getAll(.oltConfigs, baseQuery: query, idOf: { $0.id }) + return configs.reduce(into: [:]) { map, config in + if let name = config.name, !name.isEmpty, name != config.id { map[config.id] = name } + } + } + + func config(id: String) async throws -> OLTConfigDoc { + try await client.get(.oltConfig(id: id), as: OLTConfigDoc.self) + } + + // MARK: OLT-STATE / ONU registration + + func states(query: APIQuery = .empty) async throws -> [OLTStateDoc] { + try await client.get(.oltStates, query: query, as: [OLTStateDoc].self) + } + + func allStates() async throws -> [OLTStateDoc] { + try await client.getAll(.oltStates, idOf: { $0.id }) + } + + func state(id: String) async throws -> OLTStateDoc { + try await client.get(.oltState(id: id), as: OLTStateDoc.self) + } + + /// Resolve every ONU's registration state by inverting the OLT-STATE + /// `"ONU States"` buckets (dev guide Procedure 2). + func registrationMap() async throws -> [String: ONULifecycleState] { + Self.registrationMap(from: try await allStates()) + } + + /// Pure inversion of OLT-STATE buckets → `onuId: state`. On the rare ONU + /// that appears twice, prefer the non-`Registered` bucket — that's the + /// interesting signal (field guide §3.11). + static func registrationMap(from olts: [OLTStateDoc]) -> [String: ONULifecycleState] { + var map: [String: ONULifecycleState] = [:] + for olt in olts { + for (bucket, ids) in olt.onuStatesByBucket { + let state = ONULifecycleState(rawValue: bucket) ?? .unknown + for id in ids { + if let existing = map[id] { + if existing == .registered && state != .registered { map[id] = state } + } else { + map[id] = state + } + } + } + } + return map + } + + func stats(id: String, lastHours: Int = 24) async throws -> [StatSample] { + try await client.get(.oltStats(id: id), + query: .window(lastHours: lastHours), + as: [StatSample].self) + } + + func logs(id: String, lastHours: Int = 24) async throws -> [LogEntry] { + try await client.get(.oltLogs(id: id), + query: .window(lastHours: lastHours), + as: [LogEntry].self) + } + + // MARK: Actions + + /// `PUT /olts//reset/` — reboots the OLT. Service-affecting. + func reset(id: String) async throws { + let _: JSONValue = try await client.action("PUT", .oltReset(id: id), as: JSONValue.self) + } + + /// `PUT /olts//disable-onu//` with body `{ "disable": Bool }` (unwrapped). + func setONUDisabled(olt: String, onu: String, disabled: Bool) async throws { + let _: JSONValue = try await client.send( + "PUT", .oltDisableONU(olt: olt, onu: onu), + body: DisableONURequest(disable: disabled), + wrapInData: false, as: JSONValue.self) + } + + /// `GET /olts//disable-onu//` → pending status of the action. + func disableONUPending(olt: String, onu: String) async throws -> Bool { + let status: PendingStatus = try await client.get( + .oltDisableONU(olt: olt, onu: onu), as: PendingStatus.self) + return status.pending + } + + /// `PUT /olts//broadcast-enable-onus/` + func broadcastEnableONUs(olt: String) async throws { + let _: JSONValue = try await client.action( + "PUT", .oltBroadcastEnableONUs(olt: olt), as: JSONValue.self) + } + + func broadcastEnablePending(olt: String) async throws -> Bool { + let status: PendingStatus = try await client.get( + .oltBroadcastEnableONUs(olt: olt), as: PendingStatus.self) + return status.pending + } +} + +/// PON Controller resources. +@MainActor +final class ControllerRepository { + private let client: APIClient + init(client: APIClient) { self.client = client } + + func configs(query: APIQuery = .empty) async throws -> [ControllerConfigDoc] { + try await client.get(.controllerConfigs, query: query, as: [ControllerConfigDoc].self) + } + + func allConfigs() async throws -> [ControllerConfigDoc] { + try await client.getAll(.controllerConfigs, idOf: { $0.id }) + } + + func config(id: String) async throws -> ControllerConfigDoc { + try await client.get(.controllerConfig(id: id), as: ControllerConfigDoc.self) + } + + func stats(id: String, lastHours: Int = 24) async throws -> [StatSample] { + try await client.get(.controllerStats(id: id), + query: .window(lastHours: lastHours), + as: [StatSample].self) + } + + func logs(id: String, lastHours: Int = 24) async throws -> [LogEntry] { + try await client.get(.controllerLogs(id: id), + query: .window(lastHours: lastHours), + as: [LogEntry].self) + } +} diff --git a/Firmware.swift b/Firmware.swift new file mode 100644 index 0000000..2818f82 --- /dev/null +++ b/Firmware.swift @@ -0,0 +1,56 @@ +import Foundation + +/// One uploaded ONU firmware image, from `GET /files/onu-firmware/`. +struct FirmwareFile: MongoDocument { + let raw: JSONValue + init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) } + init(raw: JSONValue) { self.raw = raw } + + var id: String { raw["_id"]?.stringValue ?? filename ?? UUID().uuidString } + var filename: String? { raw["filename"]?.stringValue ?? raw["_id"]?.stringValue } + var version: String? { raw["metadata"]?["Version"]?.stringValue } + var manufacturer: String? { raw["metadata"]?["Compatible Manufacturer"]?.stringValue } + var models: [String] { raw["metadata"]?["Compatible Model"]?.arrayValue?.compactMap(\.stringValue) ?? [] } + var sizeBytes: Double? { raw["length"]?.doubleValue } + var uploadDate: String? { raw["uploadDate"]?.stringValue } + + /// True when this image targets the given ONU: manufacturer matches the ONU + /// vendor AND the ONU's equipment id is one of the compatible models (exact, + /// case-insensitive — substring matching would wrongly pass e.g. an + /// "XGS2110" image for an "FTXGS2110B" ONU). + func isCompatible(vendor: String?, equipmentId: String?) -> Bool { + if let vendor, let manufacturer, !manufacturer.isEmpty, + manufacturer.caseInsensitiveCompare(vendor) != .orderedSame { return false } + guard let equipmentId, !models.isEmpty else { return true } + return models.contains { $0.caseInsensitiveCompare(equipmentId) == .orderedSame } + } +} + +/// Procedure 7 (per-ONU firmware upgrade) as a pure transform so it can be +/// unit-tested before it ever touches a live device. +enum FirmwareUpgrade { + /// Mutate a full ONU-CFG document to stage `filename`/`version` in the ONU's + /// **inactive** firmware bank and point at it. Writes to the inactive bank so + /// the running image is preserved (§6.3): `FW Bank Ptr` 0→1, 1→0, anything + /// else (incl. 65535 unset) → 1. Returns the new document + the slot written, + /// or nil if the document isn't a recognizable ONU-CFG. + static func apply(to config: JSONValue, filename: String, version: String) -> (document: JSONValue, slot: Int)? { + guard var root = config.objectValue, var onu = root["ONU"]?.objectValue else { return nil } + + let currentPtr = onu["FW Bank Ptr"]?.intValue ?? 65535 + let slot = (currentPtr == 1) ? 0 : 1 + + var files = onu["FW Bank Files"]?.arrayValue ?? [] + var versions = onu["FW Bank Versions"]?.arrayValue ?? [] + while files.count <= slot { files.append(.string("")) } + while versions.count <= slot { versions.append(.string("")) } + files[slot] = .string(filename) + versions[slot] = .string(version) + + onu["FW Bank Files"] = .array(files) + onu["FW Bank Versions"] = .array(versions) + onu["FW Bank Ptr"] = .int(slot) + root["ONU"] = .object(onu) + return (.object(root), slot) + } +} diff --git a/FirmwareUpdateSheet.swift b/FirmwareUpdateSheet.swift new file mode 100644 index 0000000..f98ce2c --- /dev/null +++ b/FirmwareUpdateSheet.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// Firmware picker for a single ONU. Lists images compatible with the ONU's +/// model (with a "show all" escape hatch), and gates the actual push behind a +/// service-affecting confirmation. The push stages the image in the ONU's +/// **standby** bank, so the running image is untouched until the next reboot. +struct FirmwareUpdateSheet: View { + let vm: ONUDetailViewModel + let state: ONUStateDoc + + @Environment(\.dismiss) private var dismiss + @State private var showAll = false + @State private var pending: FirmwareFile? + + private var compatible: [FirmwareFile] { + vm.firmwareFiles.filter { $0.isCompatible(vendor: state.vendor, equipmentId: state.equipmentId) } + } + private var visible: [FirmwareFile] { showAll ? vm.firmwareFiles : compatible } + + var body: some View { + NavigationStack { + List { + Section { + LabeledContent("Current", value: state.fwVersion ?? "—") + if let eq = state.equipmentId { LabeledContent("Model", value: eq) } + } + + if let error = vm.firmwareLoadError { + Section { Text(error).foregroundStyle(.red).font(.footnote) } + } else if vm.firmwareFiles.isEmpty { + Section { + HStack(spacing: 10) { ProgressView(); Text("Loading firmware…").foregroundStyle(.secondary) } + } + } else { + Section { + ForEach(visible) { file in + Button { pending = file } label: { fileRow(file) } + .disabled(vm.isPerformingAction) + } + } header: { + Text(showAll ? "All firmware" : "Compatible firmware") + } footer: { + if !showAll && compatible.isEmpty { + Text("No image matches this ONU's model. Turn on “Show all” to choose one manually — make sure it's compatible.") + } + } + if vm.firmwareFiles.count > compatible.count { + Section { Toggle("Show all firmware", isOn: $showAll) } + } + } + } + .navigationTitle("Update firmware") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + } + .task { if vm.firmwareFiles.isEmpty { await vm.loadFirmware() } } + .confirmationDialog( + "Stage firmware?", + isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }), + titleVisibility: .visible, + presenting: pending + ) { file in + Button("Download \(file.version ?? "firmware")", role: .destructive) { + let selected = file + dismiss() + Task { await vm.upgrade(to: selected) } + } + Button("Cancel", role: .cancel) { pending = nil } + } message: { file in + Text("Stage \(file.version ?? "this image") on \(state.id) in the standby bank. It becomes active after the ONU's next reboot — this is service-affecting.") + } + } + .presentationDetents([.medium, .large]) + } + + private func fileRow(_ file: FirmwareFile) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(file.version ?? file.filename ?? "Unknown").font(.body.weight(.medium)) + Text(subtitle(file)).font(.caption).foregroundStyle(.secondary) + } + } + + private func subtitle(_ file: FirmwareFile) -> String { + var parts: [String] = [] + if let model = file.models.first { parts.append(model) } + if let size = file.sizeBytes { parts.append(Format.bytes(size)) } + return parts.joined(separator: " · ") + } +} diff --git a/Format.swift b/Format.swift new file mode 100644 index 0000000..448a93d --- /dev/null +++ b/Format.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Compact human formatting for counters surfaced in the UI. +enum Format { + /// Bit-rate, e.g. 224229 → "224 kbps", 7216819 → "7.2 Mbps". + static func bps(_ value: Double?) -> String { + guard let value, value >= 0 else { return "—" } + let units = ["bps", "kbps", "Mbps", "Gbps", "Tbps"] + var x = value, i = 0 + while x >= 1000 && i < units.count - 1 { x /= 1000; i += 1 } + let digits = (i == 0 || x >= 100) ? "%.0f" : "%.1f" + return "\(String(format: digits, x)) \(units[i])" + } + + /// Byte count, e.g. 7401438 → "7.1 MB". + static func bytes(_ value: Double?) -> String { + guard let value, value >= 0 else { return "—" } + let units = ["B", "KB", "MB", "GB", "TB", "PB"] + var x = value, i = 0 + while x >= 1024 && i < units.count - 1 { x /= 1024; i += 1 } + let digits = (i == 0 || x >= 100) ? "%.0f" : "%.1f" + return "\(String(format: digits, x)) \(units[i])" + } + + /// Integer percent, e.g. 13 → "13%". + static func percent(_ value: Int?) -> String { + guard let value else { return "—" } + return "\(value)%" + } +} diff --git a/GUI-NOTES.md b/GUI-NOTES.md new file mode 100644 index 0000000..f87e501 --- /dev/null +++ b/GUI-NOTES.md @@ -0,0 +1,75 @@ +# MCMS Mobile — GUI scaffold notes + +The SwiftUI layer sits on top of the networking core from the kickoff brief. It's +a working skeleton you run against a live MCMS box, then refine. + +## Layout + +``` +App/ MCMSMobileApp (@main), RootView routing, AppEnvironment +UI/ Shared components (badges, count tiles, load/error states, LoadPhase) +Features/ + Settings/ Connection config + test (direct or proxy, self-signed toggle) + Login/ Email/password, Keychain prefill + Dashboard/ Device + alarm counts (VM + View) + ONUList/ Searchable ONU list (VM + View) + ONUDetail/ Stats / Config / Alarms / Logs tabs + Reset op (VM + View) +``` + +Flow: `RootView` shows **Settings** until a connection is configured, then +**Login**, then the main **TabView**. `AppEnvironment` owns the `MCMSConnection`, +persists the (non-secret) `APIConfiguration` to `UserDefaults`, and keeps +credentials in the Keychain. + +## Putting it in Xcode + +1. New iOS App (SwiftUI lifecycle), **deployment target iOS 17.0+**, built with + **Xcode 26 (iOS 26 SDK)**. The 26 SDK is required for App Store / TestFlight + submission and makes standard components adopt the Liquid Glass look on iOS 26 + devices automatically — no code and no opt-out flag needed; the UI here is stock. +2. Delete the template `ContentView.swift` and the generated `App` struct — this + scaffold provides its own `@main` in `App/MCMSMobileApp.swift`. +3. Add the whole `MCMSMobile/` tree (networking core + this GUI). One module, + no external dependencies. + +The view models use the `@Observable` macro and views use `@State`/`@Environment` +(iOS 17+). If you ever need to drop below iOS 17, revert those to +`ObservableObject`/`@Published` + `@StateObject`/`@EnvironmentObject` and +reinstate a `ContentUnavailableView` shim. + +## App Transport Security (the gotcha) + +TLS *trust* for self-signed certs is handled in code by `ServerTrustEvaluator`, +but **ATS is separate** and can still block the connection: + +- **HTTPS to a self-signed host:** the trust delegate covers it; no Info.plist + change needed for cert validation itself. (Keep TLS 1.2+.) +- **Plain HTTP** (no TLS) or weak TLS: ATS blocks it by default. Add a *scoped* + exception for your host in Info.plist rather than a blanket allow: + +```xml +NSAppTransportSecurity + + NSExceptionDomains + + 10.2.10.29 + + NSExceptionAllowsInsecureHTTPLoads + NSIncludesSubdomains + + + +``` + +Avoid `NSAllowsArbitraryLoads`. Behind a proxy with a real cert, you need none of +this and should set the trust policy to `.system`. + +## Known TODOs carried from the core + +- Confirm `/api/v1` vs `/v1` and the `databases/selection` body key. +- The Mongo-document field accessors (ONU state/parent OLT, alarm severity/time, + stat counter keys) are best-effort guesses marked `// TODO` — pin them to the + real `openapi.json` and a live response. The Config tab's `JSONTreeView` shows + the full raw document, which helps you discover the true field paths quickly. +- Stats tab lists the latest sample's numeric counters; wire a chosen counter + into Swift Charts once the keys are known. diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..0e74333 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,77 @@ +# Handoff — PON Go iOS + +Context for the next session/developer. Pairs with [`README.md`](README.md) and +[`MCMS_API.md`](MCMS_API.md). + +## Status + +Feature-complete and (per off-device typecheck + the user's Xcode builds) +compiling. Screens: Login, Settings, Dashboard (counts, total OLT traffic, +Health with tappable drill-downs, ONU-state breakdown, detailed-stats + +auto-refresh toggles), ONU list + detail (Overview with status/firmware+update/ +optical-FEC/UNI/traffic, CPE DHCP, Config raw-JSON tree, Alarms, Logs; reset + +firmware behind hardened confirms), OLT list + detail (status/uptime/traffic/ +environment/uplink/registration buckets, reset). + +## Architecture + +- `AppEnvironment` (`@MainActor @Observable`) owns the active `MCMSConnection`, + persists the non-secret `APIConfiguration` to UserDefaults, keeps credentials in + the Keychain, and routes `RootView` (Settings → Login → main `TabView`). +- Networking core: `APIConfiguration` → `SessionStore`/`APIClient` → repositories. + Mongo documents are kept as a dynamic `JSONValue` (`APIEnvelope.swift`) and read + via typed accessors on `MongoDocument` structs (`DeviceModels.swift`) — type the + fields you use, leave the rest raw. +- View models are `@MainActor @Observable` classes with a `LoadPhase` + `load()`. + +## API gotchas (handled in code — don't regress) + +- **Single-encode URLs.** `APIClient.makeRequest` builds the path with + `URLComponents.path` (encodes once, keeps `:` in MAC ids raw); `MCMSEndpoint` + keeps ids RAW. `appendingPathComponent` double-encoded MAC colons → MCMS + "invalid id". (Verified empirically.) +- **401 bounces to login; 403 does NOT** — `APIError.requiresReauthentication` is + 401-only (403 is permission/CSRF; re-login won't help). The `onAuthExpired` hook + flows `APIClient → MCMSConnection → AppEnvironment`. +- **Writes return `{"status":"success"}` with no `data`** — `decodeEnvelope` + returns the type's empty value instead of throwing (so login + all writes work). +- **ONU registration is on OLT-STATE buckets** (`OLTRepository.registrationMap`), + not ONU-STATE. Parent OLT is `OLT.MAC Address`. Friendly names are `ONU.Name` / + `OLT.Name` (NOT `Netconf.Name`/`NETCONF.Name`, which default to the id), fetched + via `projection`. +- **CPE DHCP** = web helper `GET /api/cpe/onu//` (versionless, bare + `[code, cpe…]` array — `APIClient.getRaw`). +- **Firmware** = Procedure 7 (`FirmwareUpgrade.apply`, unit-tested against a real + config): GET ONU-CFG → write filename/version into the **inactive** bank → full- + doc PUT. Inactive-bank only; still test on a spare ONU before production. +- **Timestamps** are UTC `yyyy-MM-dd HH:mm:ss[.SSSSSS]` with a literal space + (`MongoTimestamp`, not ISO8601). Uptime = state `Time` − `Online Time`. +- Everything is `/api/v1` except `/v3/tasks/configs/` (unused). Cookies may be + `__Host-`-prefixed (`SessionStore` matches either). `/api` is auto-appended to + the base URL in Settings. + +## Known real-world notes + +- One lab box (`mcms.lab.svorka.net`) once sent an illegal `Upgrade: h2` header + over HTTP/2 that iOS rejects (`-1005`). Fix is server-side (Apache `Protocols + http/1.1`) or an HTTP/1.1 reverse proxy; iOS has no supported "force HTTP/1.1". +- The base URL must include `/api`; Settings normalises this. + +## Verify loop + +`swiftc -typecheck` against the macOS SDK for the core + view models (see README). +Tricky SwiftUI bits: compile isolated snippets sans iOS-only modifiers. No iOS SDK +on the dev machine, so full builds are the user's Xcode step. + +## Repo split + +Recommended: consolidate the flat sources + `Assets.xcassets` into the Xcode +project and git-init that as the iOS repo (with `docs/MCMS_API.md` + the PDFs). +The included `.gitignore` covers Xcode/Swift build artifacts. The Android app is a +separate repo (`../mcms_android`), self-contained. + +## Possible next steps + +OLT bulk firmware (Procedure 8 task); Swift Charts for PM history; live optical on +the list rows; richer alarm handling; widen test coverage of the firmware PUT +against more ONU-CFG shapes. diff --git a/KeychainStore.swift b/KeychainStore.swift new file mode 100644 index 0000000..4db53d9 --- /dev/null +++ b/KeychainStore.swift @@ -0,0 +1,54 @@ +import Foundation +import Security + +/// Minimal Keychain wrapper for storing the MCMS credentials. Stores the +/// password (and optionally host/email) keyed by a service + account. Never +/// use UserDefaults for secrets. +struct KeychainStore { + + let service: String + + init(service: String = "com.example.mcmsmobile.credentials") { + self.service = service + } + + @discardableResult + func set(_ value: String, account: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) + + var attributes = query + attributes[kSecValueData as String] = data + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess + } + + func get(account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + @discardableResult + func delete(account: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + return SecItemDelete(query as CFDictionary) == errSecSuccess + } +} diff --git a/LoginView.swift b/LoginView.swift new file mode 100644 index 0000000..afd83e1 --- /dev/null +++ b/LoginView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct LoginView: View { + @Environment(AppEnvironment.self) private var env + + @State private var email = "" + @State private var password = "" + @State private var remember = true + @State private var phase: LoadPhase = .idle + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Email", text: $email) + .textContentType(.username) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("Password", text: $password) + .textContentType(.password) + Toggle("Remember me", isOn: $remember) + } header: { + Text("Sign in") + } footer: { + if let host = env.configuration?.host { + Text("Connecting to \(host)") + } + } + + Section { + Button { + Task { await signIn() } + } label: { + HStack { + Spacer() + if phase == .loading { ProgressView() } else { Text("Sign in") } + Spacer() + } + } + .disabled(email.isEmpty || password.isEmpty || phase == .loading) + } + + if case .failed(let msg) = phase { + Section { Text(msg).foregroundStyle(.red).font(.footnote) } + } + } + .navigationTitle("MCMS") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + NavigationLink { + SettingsView(isInitialSetup: false) + } label: { + Image(systemName: "gearshape") + } + } + } + .onAppear { + email = env.savedEmail ?? "" + password = env.savedPassword ?? "" + } + } + } + + private func signIn() async { + phase = .loading + do { + try await env.signIn(email: email, password: password, remember: remember) + phase = .loaded + } catch { + phase = .failed((error as? APIError)?.localizedDescription ?? error.localizedDescription) + } + } +} diff --git a/MCMSConnection.swift b/MCMSConnection.swift new file mode 100644 index 0000000..081921c --- /dev/null +++ b/MCMSConnection.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Builds and holds the networking stack for one MCMS connection. Construct this +/// once you have a host + trust policy, then hand the repositories to your view +/// models. Re-create it (or call `reset`) when the host/config changes. +@MainActor +final class MCMSConnection { + let config: APIConfiguration + let session: SessionStore + let client: APIClient + + let auth: AuthRepository + let onu: ONURepository + let olt: OLTRepository + let controller: ControllerRepository + + init(config: APIConfiguration) { + self.config = config + let session = SessionStore(config: config) + let client = APIClient(config: config, session: session) + self.session = session + self.client = client + self.auth = AuthRepository(client: client, session: session, config: config) + self.onu = ONURepository(client: client) + self.olt = OLTRepository(client: client) + self.controller = ControllerRepository(client: client) + } + + /// Fired when any request fails with 401/403 so the app can route back to + /// login. Forwarded to the underlying `APIClient`. + var onAuthExpired: (@MainActor () -> Void)? { + get { client.onAuthExpired } + set { client.onAuthExpired = newValue } + } +} + +/* + USAGE SKETCH (delete once real views exist) + + // Direct access to the appliance with a self-signed cert on a controlled net: + let url = URL(string: "https://10.2.10.29/api")! + let config = APIConfiguration( + baseURL: url, + apiVersion: "v1", // confirm /api/v1 vs /v1 from openapi.json + databaseId: nil, // or a specific DB id + trustPolicy: .allowSelfSignedForHost("10.2.10.29") + ) + + // Behind a reverse proxy with a real cert — change ONLY the URL + trust: + // let config = APIConfiguration(baseURL: URL(string: "https://mcms.example.com")!, + // trustPolicy: .system) + + let connection = MCMSConnection(config: config) + + // Login (creds pulled from Keychain in real code): + try await connection.auth.login(email: email, password: password) + + // Dashboard data: + let onus = try await connection.onu.allStates() + let counts = Dictionary(grouping: onus, by: { $0.lifecycle }).mapValues(\.count) + + // ONU detail: + let stats = try await connection.onu.stats(id: "BFWS00123193", lastHours: 24) + + // An operation (gate behind a confirm dialog): + try await connection.onu.reset(id: "BFWS00123193") + + try await connection.auth.logout() +*/ diff --git a/MCMSEndpoint.swift b/MCMSEndpoint.swift new file mode 100644 index 0000000..0a330ec --- /dev/null +++ b/MCMSEndpoint.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Builds versioned endpoint paths (without host). The leading version segment +/// is applied here so call sites read like the REST docs. +/// +/// Reconcile exact paths with `openapi.json`; these mirror the MCMS 6.0 +/// REST API Developer Guide. +enum MCMSEndpoint { + // Auth / session / system + case authenticate + case logout + case databaseSelection + case databaseStatus + case version + + // PON Controllers + case controllerConfigs + case controllerConfig(id: String) + case controllerStates // CNTL-STATE list + case controllerStats(id: String) + case controllerLogs(id: String) + + // OLTs + case oltConfigs + case oltConfig(id: String) + case oltStates // OLT-STATE list (carries ONU registration buckets) + case oltState(id: String) + case oltStats(id: String) + case oltLogs(id: String) + case oltDisableONU(olt: String, onu: String) + case oltBroadcastEnableONUs(olt: String) + case oltReset(id: String) + + // ONUs + case onuConfigs + case onuConfig(id: String) + case onuStates + case onuState(id: String) + case onuStats(id: String) + case onuLogs(id: String) + case onuAlarmHistories + case onuAlarmHistory(id: String) + case onuAlarmConfigs + case onuAlarmConfig(id: String) + case onuCPEStates + case onuAutomationConfigs + case onuAutomationConfig(id: String) + case onuReset(id: String) + case onuFirmwareFiles // GET /files/onu-firmware/ + case onuCPE(id: String) // web helper /cpe/onu// (no version segment) + + /// Path relative to `baseURL`, including the version segment. + func path(version: String) -> String { + let v = "/\(version)" + switch self { + case .authenticate: return "\(v)/users/authenticate/" + case .logout: return "\(v)/users/logout/" + case .databaseSelection: return "\(v)/databases/selection/" + case .databaseStatus: return "\(v)/databases/status/" + case .version: return "\(v)/ponmgr/version/" + + case .controllerConfigs: return "\(v)/controllers/configs/" + case .controllerConfig(let id): return "\(v)/controllers/configs/\(enc(id))/" + case .controllerStates: return "\(v)/controllers/states/" + case .controllerStats(let id): return "\(v)/controllers/stats/\(enc(id))/" + case .controllerLogs(let id): return "\(v)/controllers/logs/\(enc(id))/" + + case .oltConfigs: return "\(v)/olts/configs/" + case .oltConfig(let id): return "\(v)/olts/configs/\(enc(id))/" + case .oltStates: return "\(v)/olts/states/" + case .oltState(let id): return "\(v)/olts/states/\(enc(id))/" + case .oltStats(let id): return "\(v)/olts/stats/\(enc(id))/" + case .oltLogs(let id): return "\(v)/olts/logs/\(enc(id))/" + case .oltDisableONU(let olt, let onu): + return "\(v)/olts/\(enc(olt))/disable-onu/\(enc(onu))/" + case .oltBroadcastEnableONUs(let olt): + return "\(v)/olts/\(enc(olt))/broadcast-enable-onus/" + case .oltReset(let id): return "\(v)/olts/\(enc(id))/reset/" + + case .onuConfigs: return "\(v)/onus/configs/" + case .onuConfig(let id): return "\(v)/onus/configs/\(enc(id))/" + case .onuStates: return "\(v)/onus/states/" + case .onuState(let id): return "\(v)/onus/states/\(enc(id))/" + case .onuStats(let id): return "\(v)/onus/stats/\(enc(id))/" + case .onuLogs(let id): return "\(v)/onus/logs/\(enc(id))/" + case .onuAlarmHistories: return "\(v)/onus/alarm-histories/" + case .onuAlarmHistory(let id): return "\(v)/onus/alarm-histories/\(enc(id))/" + case .onuAlarmConfigs: return "\(v)/onus/alarm-configs/" + case .onuAlarmConfig(let id): return "\(v)/onus/alarm-configs/\(enc(id))/" + case .onuCPEStates: return "\(v)/onus/cpe-states/" + case .onuAutomationConfigs: return "\(v)/onus/automation/configs/" + case .onuAutomationConfig(let id): return "\(v)/onus/automation/configs/\(enc(id))/" + case .onuReset(let id): return "\(v)/onus/\(enc(id))/reset/" + case .onuFirmwareFiles: return "\(v)/files/onu-firmware/" + case .onuCPE(let id): return "/cpe/onu/\(enc(id))/" // versionless web helper + } + } + + /// IDs (serials / MAC addresses) are kept RAW here — the full path is + /// percent-encoded exactly once by `URLComponents.path` in `APIClient`. + /// Pre-encoding double-encoded MAC colons (`:` → `%3A` → `%253A`), which MCMS + /// rejected as an invalid id. + private func enc(_ s: String) -> String { s } +} diff --git a/MCMSMobileApp.swift b/MCMSMobileApp.swift new file mode 100644 index 0000000..5784759 --- /dev/null +++ b/MCMSMobileApp.swift @@ -0,0 +1,53 @@ +import SwiftUI + +@main +struct MCMSMobileApp: App { + @State private var env = AppEnvironment() + + var body: some Scene { + WindowGroup { + RootView() + .environment(env) + } + } +} + +/// Routes the app: no config → Settings; configured but signed out → Login; +/// authenticated → main tabs. +struct RootView: View { + @Environment(AppEnvironment.self) private var env + + var body: some View { + Group { + if !env.isConfigured { + NavigationStack { SettingsView(isInitialSetup: true) } + } else if !env.isAuthenticated { + LoginView() + } else if let connection = env.connection { + MainTabView(connection: connection) + } else { + NavigationStack { SettingsView(isInitialSetup: true) } + } + } + } +} + +struct MainTabView: View { + let connection: MCMSConnection + + var body: some View { + TabView { + NavigationStack { DashboardView(connection: connection) } + .tabItem { Label("Dashboard", systemImage: "chart.pie") } + + NavigationStack { ONUListView(connection: connection) } + .tabItem { Label("ONUs", systemImage: "point.3.connected.trianglepath.dotted") } + + NavigationStack { OLTListView(connection: connection) } + .tabItem { Label("OLTs", systemImage: "antenna.radiowaves.left.and.right") } + + NavigationStack { SettingsView(isInitialSetup: false) } + .tabItem { Label("Settings", systemImage: "gearshape") } + } + } +} diff --git a/MCMS_API.md b/MCMS_API.md new file mode 100644 index 0000000..0836031 --- /dev/null +++ b/MCMS_API.md @@ -0,0 +1,758 @@ +# Ciena MCMS 6.2 REST API — Implementer's Field Guide + +Reference for building an HTTP client against a Ciena MicroClimate +Management System (MCMS) 6.2 PON Manager. Distilled from the official +dev guide (`323-1961-306`) plus empirical findings against a working +deployment with GNXS / Tibit MicroPlug ONUs on Interos Everest 5.x +firmware. + +Language-agnostic — every quirk listed here was hit in production. If +you skip the gotchas in §3, you will lose an afternoon to one of them. + +--- + +## 1. Connection basics + +- **Base URL.** The root of the PON Manager web UI (e.g. + `https://mcms.example.net`). All REST paths are prefixed with `/api`. +- **Transport.** HTTPS. Internal deployments commonly use self-signed + certificates on private IPs — your client needs an opt-in to skip + TLS verification for that case (do **not** disable globally). +- **HTTP version.** HTTP/1.1 works. The server also negotiates HTTP/2 + but the dev-guide examples are 1.1 and the only behavioural + difference observed is irrelevant to clients. +- **Path normalisation.** The dev guide writes paths as + `/v1/users/authenticate/` — the real wire path is + `/api/v1/users/authenticate/`. Normalise once in your client. +- **API versions.** Both `/api/v1/...` and `/api/v3/...` exist and serve + different endpoints. **Use whichever the dev guide lists for that + resource — they are not interchangeable.** See §4 endpoint table. +- **Trailing slashes.** Required. `/api/v1/onus/configs//` works; + `/api/v1/onus/configs/` does not (Django's `APPEND_SLASH` is + on, but a few endpoints 500 instead of redirecting). + +--- + +## 2. Login & session + +MCMS uses Django session cookies + a CSRF token. Sessions do **not** +auto-expire on idle — log out explicitly when done. + +### 2.1 Authenticate + +``` +POST /api/v1/users/authenticate/ +Content-Type: application/json + +{ "data": { "email": "user@example.net", "password": "..." } } +``` + +Response on success (`HTTP 200`): + +```json +{ "status": "success" } +``` + +Set-Cookie headers carry the session. On observed MCMS 6.2 builds: + +``` +Set-Cookie: __Host-csrftoken=; Path=/; Secure; SameSite=Lax +Set-Cookie: __Host-sessionid=; Path=/; Secure; HttpOnly; SameSite=Lax +Set-Cookie: __Host-sessionexpire=; Path=/; Secure; SameSite=Lax +``` + +→ **The `__Host-` prefix is significant.** Some MCMS builds (older +ones, mostly) use bare `csrftoken` / `sessionid`. Your cookie reader +should accept either prefix. + +→ Some build variants accept an unwrapped body too: +`{"email": ..., "password": ...}` with no `data` envelope. If wrapped +returns HTTP 400, retry unwrapped. + +### 2.2 Subsequent requests + +Every authenticated request must send: + +``` +Cookie: __Host-csrftoken=; __Host-sessionid=; ... +X-CSRFToken: # the value from __Host-csrftoken +Referer: / # MCMS rejects POST/PUT/DELETE without this +User-Agent: # see §3.1 — must not be empty +Accept: application/json +``` + +For PUT/POST/PATCH also: + +``` +Content-Type: application/json +Content-Length: +``` + +### 2.3 (Optional) Database selection + +If the deployment manages multiple databases: + +``` +PUT /api/v1/databases/selection/ +{ "data": "" } +``` + +Most deployments only have one and skip this step. + +### 2.4 Logout + +``` +GET /api/v1/users/logout/ +``` + +Best-effort — don't fail your client if this errors. + +--- + +## 3. The gotchas (read this section twice) + +### 3.1 User-Agent header is REQUIRED + +If you don't send a `User-Agent`, some MCMS middleware dereferences +`HTTP_USER_AGENT` without a null check and returns **HTTP 500** with +body: + +```json +{"status": "fail", "details": {"message": "Internal server error. See server logs for details."}} +``` + +Reproduce: `curl -A '' https://mcms/api/v3/onus/configs/`. Any non-empty +string works (`PonFleetClient/1.0`, etc.). + +**For iOS/Swift**: `URLSession` sets a default User-Agent automatically +(e.g. `/1 CFNetwork/... Darwin/...`), so this is unlikely to +bite you there — but if you ever set a custom `URLRequest` with +`setValue(nil, forHTTPHeaderField: "User-Agent")`, the 500s will +return. + +### 3.2 CSRF token field name + +The token cookie is `__Host-csrftoken` on new builds, bare `csrftoken` +on older. The header you send back is always `X-CSRFToken` (no +prefix, capital `T` in `Token`). Both Django case-fold the header +name, so casing is fine — but the body name is exact. + +### 3.3 Pagination: use `next`, NOT `skip` + +`/api/v3/onus/configs/?skip=N` has been observed returning HTTP 500. +The working approach uses the last document's `_id` as a cursor: + +``` +GET /api/v3/onus/configs/?limit=100 + → returns 100 docs, the last with _id = "X" +GET /api/v3/onus/configs/?limit=100&next=X + → returns the next 100, EXCLUDING all _ids <= X +``` + +Loop until you get a short page (< limit) or an empty page. Most list +endpoints accept this pattern. + +### 3.4 Page size 100 is the safe default + +`?limit=1000` against `/api/v3/onus/configs/` on a ~1500-ONU fleet +times out at the Apache front-end before Django logs the request. +Full `ONU-CFG` documents are ~30 KB each, so 1000 × 30 KB = 30 MB, +which exceeds the proxy timeout. **100 per page keeps each response +~3 MB** and well under any default proxy budget. + +This applies to all bulk list endpoints (configs, states, OLT states). + +### 3.5 Request body envelope: `{"data": payload}` + +PUT/POST/PATCH bodies are wrapped: + +```json +{ "data": { "_id": "...", "ONU": { ... } } } +``` + +Response envelope is similar: + +```json +{ "status": "success", "data": { ... } } +{ "status": "fail", "details": { "message": "..." } } +{ "status": "warning", "data": { ... }, "details": { ... } } +``` + +`"warning"` is treated as success by MCMS (the write happened) but +indicates a schema mismatch you should log. + +### 3.6 PUT is full-document replace, NOT patch + +`PUT /api/v1/onus/configs//` replaces the entire `ONU-CFG` +document. You **must** GET the current doc, mutate fields, and PUT +the entire result. There is no PATCH equivalent that works reliably +on MCMS 6.2. + +Forgetting this turns into "I PUT'd just the FW Bank fields and now +the ONU has no service config" — the dev guide is explicit but easy +to skim past. + +### 3.7 Timestamp format + +MCMS emits and accepts timestamps as: + +``` +"YYYY-MM-DD HH:MM:SS" # second precision +"YYYY-MM-DD HH:MM:SS.ffffff" # microsecond precision (for STATE.Time) +``` + +Always **UTC**, always with a **literal space** between date and time +(not ISO-8601 `T`). Used on: + +- `ONU-STATE.Time` +- `OLT-STATE.Time` +- Alarm timestamps +- `AUTO-TASK-CFG.Task["Scheduled Start Time"]` +- `start-time` / `end-time` URL params on stats endpoints + +**For Swift**: `ISO8601DateFormatter` will NOT parse this. Use a +plain `DateFormatter` with: + +```swift +let fmt = DateFormatter() +fmt.timeZone = TimeZone(identifier: "UTC") +fmt.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS" // or "yyyy-MM-dd HH:mm:ss" +fmt.locale = Locale(identifier: "en_US_POSIX") +``` + +The `en_US_POSIX` locale matters — without it, devices set to a +24-hour-format locale will still parse fine but devices set to +12-hour-format locales will fail. POSIX locale forces parse rules. + +### 3.8 Server-side query/projection filters are brittle + +The dev guide documents `query`, `projection`, `sort`, `limit`, +`skip`, `next`, `distinct` URL parameters. In practice: + +- **`query`** (Mongo-style filter) works for simple equality on + bare field names. Anything with **spaces in field names**, quoted + values with non-ASCII characters, or projection paths that don't + exist on every document version is unreliable — you'll get either + empty results or 400s. +- **`projection`** is *very* picky about paths. Better to fetch full + docs and project client-side. + +**Recommended approach**: load the full fleet (paginated by `next`) +and filter client-side. The data volume is fine for any device with +enough RAM to hold a few thousand 30 KB documents. + +### 3.9 URL encoding + +Use percent-encoding (`encodeURIComponent` equivalent). MCMS does NOT +accept `+` for spaces in URL parameters — use `%20`. + +This matters for query params like `start-time` which carry literal +spaces in the timestamp. + +### 3.10 ONU IDs + +The `_id` field of an ONU document is: + +- The **serial number** for XGS-PON / GPON ONUs (e.g. `GNXS05057fee`) +- The **MAC address** for 10G-EPON ONUs + +Treat as opaque strings. They're URL-safe in observed deployments +but always `encodeURIComponent` to be defensive. + +### 3.11 Registration status lives on OLT-STATE, not ONU-STATE + +This is non-obvious. The 9 registration buckets live as arrays on the +OLT's state document: + +```json +{ + "_id": "", + "ONU States": { + "Registered": ["GNXS01", "GNXS02", ...], + "Deregistered": ["GNXS09", ...], + "Dying Gasp": [...], + "Disabled": [...], + "Disallowed Admin": [...], + "Disallowed Error": [...], + "Disallowed Reg ID": [...], + "Unspecified": [...], + "Unprovisioned": [...] + } +} +``` + +To resolve "what's the status of ONU X?", you walk every OLT-STATE +doc, find which bucket contains X, and that's your answer. See +Procedure 2 in the dev guide. + +An ONU should appear in exactly one bucket on exactly one OLT. In the +rare duplicate case, prefer the non-`Registered` bucket — that's the +interesting signal. + +### 3.12 FEC counters live on ONU-STATE.STATS, not on `/onus/stats/` + +`GET /api/v1/onus/stats//?start-time=...` returns historical PM +samples (time-series). For the *current* FEC counters and optical +levels, read the live `ONU-STATE` document: + +``` +GET /api/v3/onus/states// +→ data.STATS["ONU-PON"]["RX Pre-FEC BER"] +→ data.STATS["ONU-PON"]["RX Post-FEC BER"] +→ data.STATS["ONU-PON"]["RX Optical Level"] (dBm) +→ data.STATS["ONU-PON"]["TX Optical Level"] (dBm) +→ data.STATS["OLT-PON"]["RX Pre-FEC BER"] +→ data.STATS["OLT-PON"]["RX Post-FEC BER"] +``` + +The MCMS Web UI's `/api/onu/summary` helper returns the same data +under `data.state_collection.STATS.*` (wrapper shape). + +### 3.13 Bulk endpoints don't return everything in one shot + +Even on a small fleet, MCMS may silently truncate large list +responses. Always paginate — don't trust `?limit=large_number` to be +authoritative. + +### 3.14 Verbose error logging matters + +When MCMS 500s, the response body sometimes contains useful detail +(missing required param, schema validation failure, etc.). Always log +both the response headers and the FULL body on non-2xx during +development — most "mysterious 500" cases resolve to a one-line hint +in the body. + +--- + +## 4. Endpoints used by the fleet-upgrade app + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/api/v1/users/authenticate/` | Login | +| `GET` | `/api/v1/users/logout/` | Logout | +| `PUT` | `/api/v1/databases/selection/` | (Optional) DB selection | +| `GET` | `/api/v3/onus/configs/` | List ONU configs (paginated) | +| `GET` | `/api/v1/onus/configs//` | Get single ONU config | +| `PUT` | `/api/v1/onus/configs//` | Replace ONU config (full doc) | +| `DELETE` | `/api/v1/onus/configs//` | Delete ONU (Procedure 43) | +| `GET` | `/api/v3/onus/states/` | List ONU states (paginated) | +| `GET` | `/api/v3/onus/states//` | Get single ONU state | +| `DELETE` | `/api/v1/onus/states//` | Delete ONU state (best-effort) | +| `GET` | `/api/v3/olts/states/` | List OLT states (paginated; registration) | +| `GET` | `/api/v1/files/onu-firmware/` | List uploaded firmware files | +| `POST` | `/api/v1/files/onu-firmware//` | Upload firmware | +| `PUT` | `/api/v3/tasks/configs//` | Create bulk upgrade task (Procedure 8) | +| `GET` | `/api/v3/tasks/configs//` | Poll bulk task config | +| `GET` | `/api/v1/onus//upgrade/status/` | (Build-dependent) per-ONU status | + +### Other endpoints documented but not (yet) used + +| Method | Path | Notes | +|---|---|---| +| `GET` | `/api/v1/onus/stats//?start-time=...` | Time-series ONU PM data | +| `GET` | `/api/v1/olts/stats//?start-time=...` | OLT PM data | +| `GET` | `/api/v1/onus/logs//?start-time=...` | Per-ONU syslog | +| `GET` | `/api/v1/olts/logs//?start-time=...` | Per-OLT syslog | +| `GET` | `/api/v3/tasks/states//` | Bulk task progress (good follow-up) | +| `GET` | `/api/v1/onus/automation/states/` | PON automation state | +| `GET` | `/api/v1/onus/cpe-states/` | ONU CPE states (downstream devices) | + +--- + +## 5. Data shapes (observed) + +These are not formally specified in the dev guide for MCMS 6.2 — they +were extracted from live traffic. + +### 5.1 ONU-CFG + +```jsonc +{ + "_id": "GNXS05057fee", + "ONU": { + "Name": "Operator-set label", + "Address": "Street, city", + "PON Mode": "GPON", // GPON | XGS-PON | 10G-EPON + "Equipment ID": "FT-XGS2110", + + "FW Bank Files": ["...bin", "...bin"], // [slot 0, slot 1] + "FW Bank Versions": ["EV05110R", "EV05100R"], + "FW Bank Ptr": 0, // 0 | 1 | 65535 (unset) + + "ONU-ALARM-CFG": "Default", + "Vendor": "GNXS", + "Serial Number": "GNXS05057fee", + "Host MAC Address": "58:00:32:05:7f:ee", + // ...many more fields - preserve all of them when PUTing + }, + "OLT-Service 0": { ... }, // service config slots + "OLT-Service 1": { ... }, + // ... +} +``` + +**Critical**: when mutating for an upgrade, only change `ONU["FW Bank +Files"]`, `ONU["FW Bank Versions"]`, and `ONU["FW Bank Ptr"]`. Pass +through everything else unchanged. + +### 5.2 ONU-STATE + +```jsonc +{ + "_id": "GNXS05057fee", + "Time": "2026-05-04 21:51:57.394127", // UTC, last update + "ONU": { + "Equipment ID": "FT-XGS2110", + "FW Bank Files": [...], + "FW Bank Versions": [...], + "FW Bank Ptr": 0, + "FW Version": "EV05110R", // resolved active version + "FW Upgrade Status": { + "Bank": 0, + "Status": "Success", // status string + "Progress": 100, // percent + "Upgrade Time": "2026-03-18 17:12:43.093893", + "Total Blocks": 482915, + "Sent Blocks": 482915, + "Failures": 0, + // ... + }, + "PON Mode": "GPON", + "Online Time": "2026-03-18 17:13:42.285965", + // ... + }, + "STATS": { + "ONU-PON": { + "RX Pre-FEC BER": 22388041291, // cumulative since reset + "RX Post-FEC BER": 22379111198, + "RX Optical Level": -17.544, // dBm + "TX Optical Level": 5.532 // dBm + }, + "OLT-PON": { + "RX Pre-FEC BER": 0, // upstream view + "RX Post-FEC BER": 0, + "TX Optical Level": 5.6, + "RX Optical Level": -18.3 + }, + "ONU-EnhancedFecPmHistData-32769": { // NOT the same as the BER fields above + "uncorrectable_code_words": 1829, // (these are codeword counts, + "corrected_bytes": 181, // not bit-error-rate ratios) + "corrected_code_words": 181 + }, + // ...many more + }, + "Alarm": { + "0-EMERG": [], "1-ALERT": [], "2-CRIT": [], + "3-ERROR": [], "4-WARNING": [], "5-NOTICE": [], + "6-INFO": [], "7-DEBUG": [] + } +} +``` + +### 5.3 OLT-STATE (relevant subset) + +```jsonc +{ + "_id": "", + "Time": "2026-05-04 21:51:57.394127", + "ONU States": { + "Registered": ["onu1", "onu2", ...], + "Deregistered": [...], + "Dying Gasp": [...], + "Disabled": [...], + "Disallowed Admin": [...], + "Disallowed Error": [...], + "Disallowed Reg ID": [...], + "Unspecified": [...], + "Unprovisioned": [...] + } + // ... +} +``` + +### 5.4 AUTO-TASK-CFG (bulk firmware task, Procedure 8) + +```jsonc +{ + "_id": "", // unique, caller-generated + "Task": { + "Device Type": "ONU", + "Operation": "Firmware Upgrade", + "Scheduled Start Time": "2026-05-12 03:00:00" // UTC space format + }, + "Task Details": { + "Devices": ["GNXS01", "GNXS02", ...], // ONU IDs (serial / MAC) + "ONU": { + "FW Bank Ptr": 1, // target slot (single value!) + "FW Bank Files": { "0": "old.bin", "1": "new.bin" }, + "FW Bank Versions": { "0": "EV05110R", "1": "EV05120R" } + } + } +} +``` + +PUT to `/api/v3/tasks/configs//`. Note: `FW Bank Files` and +`FW Bank Versions` here are **objects keyed by slot index as strings** +(`"0"`, `"1"`), not arrays. This is different from the `ONU-CFG` +shape where the same fields are arrays — almost certainly a Mongo +serialisation quirk. Match it as-is. + +**Single bank per task.** Because `FW Bank Ptr` accepts one slot, if +your selection spans ONUs with different active banks, bucket them +by target slot and create one task per bucket. + +### 5.5 Firmware file entry (from `/api/v1/files/onu-firmware/`) + +```jsonc +{ + "_id": "...", + "filename": "Interos-Everest-5.12.0-R-EV05120R.bin", + "metadata": { + "Compatible Manufacturer": "TIBITCOM", + "Compatible Model": ["MicroPlug ONU"], + "Version": "EV05120R" + }, + // GridFS metadata... +} +``` + +To upload: + +``` +POST /api/v1/files/onu-firmware// +{ + "data": { + "file": "", + "metadata": { + "Compatible Manufacturer": "TIBITCOM", + "Compatible Model": ["MicroPlug ONU"], + "Version": "EV05120R" + } + } +} +``` + +--- + +## 6. Firmware upgrade flow + +Two methods documented: + +### 6.1 Procedure 7 — per-ONU PUT + +For each ONU: + +1. `GET /api/v1/onus/configs//` → full doc. +2. Compute the inactive bank (see §6.3). +3. Set: + - `ONU["FW Bank Files"][writeSlot] = ""` + - `ONU["FW Bank Versions"][writeSlot] = ""` + - `ONU["FW Bank Ptr"] = writeSlot` (this initiates the download) +4. `PUT /api/v1/onus/configs//` with the entire mutated document. + +Pros: see errors per ONU live. Cons: slow for large fleets, you own +retries. + +### 6.2 Procedure 8 — bulk task (recommended) + +Build one `AUTO-TASK-CFG` per (target slot) bucket: + +``` +PUT /api/v3/tasks/configs// + (body shape in §5.4) +``` + +MCMS owns the rollout: scheduled start, retries, pacing. +Monitor progress (build permitting): + +``` +GET /api/v3/tasks/states// +``` + +Or just poll the ONU configs and watch `FW Bank Versions` / +`FW Bank Ptr` flip. + +### 6.3 Inactive-bank selection + +`FW Bank Ptr` identifies the active bank (0, 1, or 65535 = unset). +**Always write to the inactive bank** so you don't blow away the +running image: + +| Current `FW Bank Ptr` | Write slot | Why | +|---|---|---| +| 0 | 1 | preserve active | +| 1 | 0 | preserve active | +| 65535 (unset) | 1 | matches Procedure 7 example; slot 0 stays as factory fallback | + +MCMS flips `FW Bank Ptr` to the new slot when the download completes. +The ONU reboots into the new image on its next reboot. + +--- + +## 7. FEC + optical interpretation + +### 7.1 Four FEC counters + +Per the user guide green-LED criteria (`323-1961-302` p149): + +| Field | Path | Green | Red | +|---|---|---|---| +| ONU Pre-FEC BER | `STATS["ONU-PON"]["RX Pre-FEC BER"]` | 0 | (informational) | +| ONU Post-FEC BER | `STATS["ONU-PON"]["RX Post-FEC BER"]` | 0 | > 0 | +| OLT Pre-FEC BER | `STATS["OLT-PON"]["RX Pre-FEC BER"]` | 0 | (informational) | +| OLT Post-FEC BER | `STATS["OLT-PON"]["RX Post-FEC BER"]` | 0 | > 0 | + +Note: the field name is "BER" but the magnitudes are big integers +(cumulative error byte counts since reset), not ratios in [0,1]. +Treat them as counts. + +### 7.2 Health bucket rules + +Suggested 4-state reduction: + +- **OK**: all four counters present and == 0. +- **Pre-FEC errors**: any pre > 0, all post == 0. (Marginal link; + FEC is correcting.) +- **Post-FEC errors**: any post > 0. (Uncorrected bits reaching the + user.) +- **Buggy (pre ≈ post)**: ONU pre > 0 AND post > 0 AND + `post / pre >= 0.95`. Real FEC reduces errors; if post is within + 5% of pre, the firmware is mirroring the counter. Observed on + Interos Everest ONUs. + +### 7.3 Optical levels + +``` +STATS["ONU-PON"]["RX Optical Level"] // ONU receive power, dBm +STATS["ONU-PON"]["TX Optical Level"] // ONU transmit power, dBm +``` + +User guide thresholds: + +| | Green | Red | +|---|---|---| +| RX | ≥ −30 dBm | < −30 dBm | +| TX | ≥ 3 dBm | < 3 dBm | + +Operators usually want a margin warning band — e.g. yellow when RX +is between −28 and −30 dBm. + +--- + +## 8. iOS-specific notes + +### 8.1 Library stack suggestions + +- **HTTP**: `URLSession` is fine. The features you need are standard + cookie storage, custom headers, and the option to bypass cert + validation. Don't reach for Alamofire just for this. +- **Cookies**: `HTTPCookieStorage` handles `__Host-` prefixes + correctly. Get the CSRF value via: + ```swift + HTTPCookieStorage.shared.cookies(for: baseURL)? + .first { $0.name == "__Host-csrftoken" || $0.name == "csrftoken" }?.value + ``` +- **Self-signed certs**: Implement + `URLSessionDelegate.urlSession(_:didReceive:completionHandler:)` + and accept `serverTrust` only when the user has explicitly toggled + "accept self-signed" for the configured host. Never default-on. +- **JSON**: `JSONSerialization` is simpler than `Codable` for the + envelope pattern (`{status, data, details}`) because the `data` + payload is heterogeneous. `Codable` works if you model each + endpoint individually. + +### 8.2 Concurrency + +The fleet load is N HTTPS round-trips for per-ONU state (the bulk +endpoint is the optimisation but the per-ID fallback may also be +needed). Use Swift Concurrency (`TaskGroup`) with a concurrency cap +of 8–10 to avoid stampeding the server. The desktop client uses 5 +for deletes and 8 for state fetches. + +### 8.3 Background uploads + +Firmware `.bin` files are 10–50 MB base64-encoded. If you implement +uploads: + +- Don't block the main thread. +- Consider `URLSession` background configuration so an upload can + survive an app suspend. +- The upload is a single POST; no chunking required. + +### 8.4 Storing the session + +For UX: persist the base URL and username in `UserDefaults`, never +the password. Re-prompt for password on launch. + +The session cookie itself goes in the per-session cookie storage +and is automatically discarded — don't try to persist it. + +### 8.5 Date handling + +See §3.7. Default `DateFormatter` with `en_US_POSIX` locale + UTC +timezone, format `"yyyy-MM-dd HH:mm:ss.SSSSSS"` (microseconds) or +`"yyyy-MM-dd HH:mm:ss"` (seconds). Try the microsecond format +first, fall back to second if it fails. + +### 8.6 Cell-vs-Wi-Fi + +MCMS APIs are typically reachable only from the operator's internal +network. Test on cellular: many internal hosts won't be reachable, +and your error UI needs to communicate that clearly. + +### 8.7 Self-signed cert prompt + +Make this a first-class onboarding step. The lab MCMS at +`https://mcms.lab.svorka.net/` uses a self-signed cert, and any +auto-rejection will silently break login. + +--- + +## 9. Procedures referenced in the dev guide + +| # | Title | Notes | +|---|---|---| +| 2 | Determining ONU registration status | OLT-STATE["ONU States"] buckets | +| 7 | Per-ONU firmware upgrade | Full-doc PUT to ONU-CFG | +| 8 | Bulk firmware upgrade | AUTO-TASK-CFG via PUT | +| 38 | Resetting bit error rate values | Names the four FEC fields | +| 43 | Deleting an ONU | DELETE on ONU-CFG | + +(Page numbers vary by document revision; cite by procedure number.) + +--- + +## 10. Quick implementation checklist for a new client + +A correct first POST-login GET against `/api/v3/onus/configs/?limit=1` +returning HTTP 200 means you've got the eight critical pieces right: + +- [ ] HTTPS reachable (with cert exception if needed) +- [ ] Path prefix `/api` added +- [ ] User-Agent set to non-empty value +- [ ] Cookie jar storing `__Host-sessionid` + `__Host-csrftoken` +- [ ] `X-CSRFToken` header pulled from `__Host-csrftoken` cookie value +- [ ] `Referer` header set to base URL +- [ ] `Accept: application/json` +- [ ] Trailing slash on the path + +Add for write operations: + +- [ ] `Content-Type: application/json` +- [ ] Body wrapped: `{"data": ...}` +- [ ] Full-document PUT (never partial) + +Add for list endpoints: + +- [ ] Page size ≤ 100 +- [ ] `next` cursor (not `skip`) +- [ ] Loop until short page + +Once those land, the API is comfortable to work with — the hard parts +are upfront. + +--- + +*Compiled from `323-1961-306` (REST API dev guide), `323-1961-302` +(PON Manager User Guide), and empirical testing against MCMS 6.2 with +Interos Everest 5.11/5.12 firmware on GNXS / Tibit MicroPlug ONUs.* diff --git a/MongoTimestamp.swift b/MongoTimestamp.swift new file mode 100644 index 0000000..624da8f --- /dev/null +++ b/MongoTimestamp.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Parses and renders MCMS timestamps. MCMS emits UTC timestamps with a literal +/// space (e.g. `2026-05-04 21:51:57.394127`), so `ISO8601DateFormatter` will not +/// parse them. Try microsecond precision first, then seconds (field guide +/// §3.7 / §8.5). The `en_US_POSIX` locale forces stable parse rules regardless +/// of the device's 12/24-hour setting. +enum MongoTimestamp { + private static let withMicroseconds = parser("yyyy-MM-dd HH:mm:ss.SSSSSS") + private static let seconds = parser("yyyy-MM-dd HH:mm:ss") + + private static func parser(_ format: String) -> DateFormatter { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + f.dateFormat = format + return f + } + + static func date(from string: String) -> Date? { + withMicroseconds.date(from: string) ?? seconds.date(from: string) + } + + /// Human duration between two MCMS timestamps — e.g. uptime computed as + /// (device state `Time` − `Online Time`), so it uses the device's own clock + /// rather than the phone's. Returns nil if either is unparseable or end < start. + static func duration(from startString: String?, to endString: String?) -> String? { + guard let startString, let start = date(from: startString), + let endString, let end = date(from: endString) else { return nil } + let total = Int(end.timeIntervalSince(start)) + guard total >= 0 else { return nil } + let days = total / 86400, hours = (total % 86400) / 3600, mins = (total % 3600) / 60 + if days > 0 { return "\(days)d \(hours)h" } + if hours > 0 { return "\(hours)h \(mins)m" } + return "\(mins)m" + } + + private static let display: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f + }() + + /// Render a raw MCMS timestamp in the device's locale/zone, falling back to + /// the original string if it doesn't parse. + static func displayString(_ string: String?) -> String? { + guard let string else { return nil } + guard let date = date(from: string) else { return string } + return display.string(from: date) + } +} diff --git a/OLTDetailView.swift b/OLTDetailView.swift new file mode 100644 index 0000000..0b6b9e0 --- /dev/null +++ b/OLTDetailView.swift @@ -0,0 +1,142 @@ +import SwiftUI + +struct OLTDetailView: View { + let connection: MCMSConnection + @State private var vm: OLTDetailViewModel + @State private var showResetConfirm = false + private let title: String + + init(connection: MCMSConnection, oltId: String, title: String? = nil) { + self.connection = connection + self.title = title ?? oltId + _vm = State(initialValue: OLTDetailViewModel(connection: connection, oltId: oltId)) + } + + var body: some View { + Group { + switch vm.phase { + case .idle: + LoadingView() + case .loading where vm.olt == nil: + LoadingView() + case .failed(let msg): + ErrorStateView(message: msg) { Task { await vm.load() } } + default: + content + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button(role: .destructive) { showResetConfirm = true } label: { + Label("Reset OLT", systemImage: "arrow.clockwise") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .disabled(vm.isPerformingAction) + } + } + .sheet(isPresented: $showResetConfirm) { + ResetConfirmSheet(deviceKind: "OLT", deviceName: title) { + Task { await vm.reset() } + } + } + .alert("Operation", isPresented: Binding( + get: { vm.actionMessage != nil }, + set: { if !$0 { vm.actionMessage = nil } } + )) { + Button("OK") { vm.actionMessage = nil } + } message: { + Text(vm.actionMessage ?? "") + } + .task { if vm.phase == .idle { await vm.load() } } + .refreshable { await vm.load() } + } + + @ViewBuilder + private var content: some View { + if let olt = vm.olt { + List { + Section("Status") { + LabeledContent("ONUs", value: onuSummary(olt)) + if let model = olt.model { LabeledContent("Model", value: model) } + if let mode = olt.ponMode { LabeledContent("PON mode", value: mode) } + if let fw = olt.firmwareVersion { LabeledContent("Firmware", value: fw) } + if let online = olt.onlineTime { + if let up = MongoTimestamp.duration(from: online, to: olt.lastUpdate) { + LabeledContent("Uptime", value: up) + } + LabeledContent("Online since", value: MongoTimestamp.displayString(online) ?? online) + } + if let updated = MongoTimestamp.displayString(olt.lastUpdate) { + LabeledContent("Last update", value: updated) + } + } + + if olt.rxRateBps != nil || olt.txRateBps != nil || olt.txUtilPercent != nil { + Section("PON traffic") { + LabeledContent("Downstream", + value: "\(Format.bps(olt.txRateBps)) · \(Format.percent(olt.txUtilPercent)) util") + LabeledContent("Upstream", + value: "\(Format.bps(olt.rxRateBps)) · \(Format.percent(olt.rxUtilPercent)) util") + } + } + + if olt.asicTempC != nil || olt.voltage != nil { + Section("Environment") { + if let temp = olt.asicTempC { LabeledContent("ASIC temp", value: "\(temp) °C") } + if let volts = olt.voltage { + LabeledContent("Voltage", value: "\(volts.formatted(.number.precision(.fractionLength(2)))) V") + } + } + } + + if let switchName = olt.switchName { + Section("Uplink") { + LabeledContent("Switch", value: switchName) + if let port = olt.switchPort { LabeledContent("Port", value: port) } + if let addr = olt.switchAddress { LabeledContent("Address", value: addr) } + } + } + + if olt.populatedBuckets.isEmpty { + Section { Text("No ONUs registered on this OLT.").foregroundStyle(.secondary) } + } else { + ForEach(olt.populatedBuckets, id: \.state) { bucket in + Section { + ForEach(bucket.ids, id: \.self) { onuId in + NavigationLink { + ONUDetailView(connection: connection, onuId: onuId) + } label: { + Text(onuId) + } + } + } header: { + HStack { + StateBadge(text: bucket.state.label, color: bucket.state.color) + Spacer() + Text("\(bucket.ids.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + } + + Section("Raw document") { + JSONTreeView(value: olt.raw) + } + } + } + } + + private func onuSummary(_ olt: OLTStateDoc) -> String { + let down = olt.totalONUs - olt.onlineONUs + var summary = "\(olt.totalONUs) · \(olt.onlineONUs) online" + if down > 0 { summary += " · \(down) down" } + return summary + } +} diff --git a/OLTDetailViewModel.swift b/OLTDetailViewModel.swift new file mode 100644 index 0000000..26a087a --- /dev/null +++ b/OLTDetailViewModel.swift @@ -0,0 +1,42 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class OLTDetailViewModel { + let oltId: String + private let connection: MCMSConnection + + var phase: LoadPhase = .idle + var olt: OLTStateDoc? + + var isPerformingAction = false + var actionMessage: String? + + init(connection: MCMSConnection, oltId: String) { + self.connection = connection + self.oltId = oltId + } + + func load() async { + phase = .loading + do { + olt = try await connection.olt.state(id: oltId) + phase = .loaded + } catch { + phase = .failed((error as? APIError)?.localizedDescription ?? error.localizedDescription) + } + } + + func reset() async { + isPerformingAction = true + actionMessage = nil + do { + try await connection.olt.reset(id: oltId) + actionMessage = "Reset requested." + } catch { + actionMessage = (error as? APIError)?.localizedDescription ?? error.localizedDescription + } + isPerformingAction = false + } +} diff --git a/OLTListView.swift b/OLTListView.swift new file mode 100644 index 0000000..932cedb --- /dev/null +++ b/OLTListView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +struct OLTListView: View { + let connection: MCMSConnection + @State private var vm: OLTListViewModel + + init(connection: MCMSConnection) { + self.connection = connection + _vm = State(initialValue: OLTListViewModel(connection: connection)) + } + + var body: some View { + @Bindable var vm = vm + Group { + switch vm.phase { + case .idle: + LoadingView(label: "Loading OLTs…") + case .loading where vm.olts.isEmpty: + LoadingView(label: "Loading OLTs…") + case .failed(let msg): + ErrorStateView(message: msg) { Task { await vm.load() } } + default: + list + } + } + .navigationTitle("OLTs") + .searchable(text: $vm.search, prompt: "OLT name or MAC") + .task { if vm.phase == .idle { await vm.load() } } + .refreshable { await vm.load() } + } + + private var list: some View { + List(vm.filtered) { olt in + NavigationLink { + OLTDetailView(connection: connection, oltId: olt.id, title: olt.displayName) + } label: { + row(olt) + } + } + .listStyle(.plain) + .overlay { + if vm.filtered.isEmpty { + if vm.search.isEmpty { + ContentUnavailableView("No OLTs", systemImage: "antenna.radiowaves.left.and.right") + } else { + ContentUnavailableView.search(text: vm.search) + } + } + } + } + + private func row(_ olt: OLTStateDoc) -> some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(olt.displayName).font(.body.weight(.medium)) + Text(subtitle(olt)).font(.caption).foregroundStyle(.secondary) + } + Spacer() + let down = olt.totalONUs - olt.onlineONUs + if olt.totalONUs == 0 { + StateBadge(text: "no ONUs", color: .secondary) + } else if down > 0 { + StateBadge(text: "\(down) down", color: .orange) + } else { + StateBadge(text: "all up", color: .green) + } + } + } + + /// Secondary line: the MAC (only when a name is the title) and the ONU counts. + private func subtitle(_ olt: OLTStateDoc) -> String { + let prefix = olt.resolvedName != nil ? "\(olt.id) · " : "" + return "\(prefix)\(olt.totalONUs) ONUs · \(olt.onlineONUs) online" + } +} diff --git a/OLTListViewModel.swift b/OLTListViewModel.swift new file mode 100644 index 0000000..4bc96bb --- /dev/null +++ b/OLTListViewModel.swift @@ -0,0 +1,36 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class OLTListViewModel { + var phase: LoadPhase = .idle + var olts: [OLTStateDoc] = [] + var search = "" + + private let connection: MCMSConnection + init(connection: MCMSConnection) { self.connection = connection } + + var filtered: [OLTStateDoc] { + let query = search.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { return olts } + return olts.filter { + $0.id.localizedCaseInsensitiveContains(query) + || ($0.resolvedName?.localizedCaseInsensitiveContains(query) ?? false) + } + } + + func load() async { + phase = .loading + do { + let fetched = try await connection.olt.allStates() + let names = (try? await connection.olt.nameMap()) ?? [:] + olts = fetched + .map { olt in var olt = olt; olt.resolvedName = names[olt.id]; return olt } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + phase = .loaded + } catch { + phase = .failed((error as? APIError)?.localizedDescription ?? error.localizedDescription) + } + } +} diff --git a/ONUDetailView.swift b/ONUDetailView.swift new file mode 100644 index 0000000..66e2cd5 --- /dev/null +++ b/ONUDetailView.swift @@ -0,0 +1,400 @@ +import SwiftUI + +struct ONUDetailView: View { + @State private var vm: ONUDetailViewModel + @State private var showResetConfirm = false + @State private var showFirmwareSheet = false + + init(connection: MCMSConnection, onuId: String) { + _vm = State(initialValue: ONUDetailViewModel(connection: connection, onuId: onuId)) + } + + var body: some View { + @Bindable var vm = vm + VStack(spacing: 0) { + Picker("Tab", selection: $vm.tab) { + ForEach(ONUDetailTab.allCases) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .padding() + + tabContent + } + .navigationTitle(vm.onuId) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button(role: .destructive) { showResetConfirm = true } label: { + Label("Reset ONU", systemImage: "arrow.clockwise") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .disabled(vm.isPerformingAction) + } + } + .sheet(isPresented: $showResetConfirm) { + ResetConfirmSheet(deviceKind: "ONU", deviceName: vm.onuId) { + Task { await vm.reset() } + } + } + .sheet(isPresented: $showFirmwareSheet) { + if let state = vm.liveState { + FirmwareUpdateSheet(vm: vm, state: state) + } + } + .alert("Operation", isPresented: Binding( + get: { vm.actionMessage != nil }, + set: { if !$0 { vm.actionMessage = nil } } + )) { + Button("OK") { vm.actionMessage = nil } + } message: { + Text(vm.actionMessage ?? "") + } + .task(id: vm.tab) { await vm.loadCurrentTab() } + .refreshable { await vm.refresh() } + } + + @ViewBuilder + private var tabContent: some View { + switch vm.phase { + case .idle, .loading: + LoadingView() + case .failed(let msg): + ErrorStateView(message: msg) { Task { await vm.loadCurrentTab() } } + case .loaded: + switch vm.tab { + case .stats: overviewView + case .cpe: cpeView + case .config: configView + case .alarms: alarmsView + case .logs: logsView + } + } + } + + // MARK: Stats + + private var overviewView: some View { + List { + if let s = vm.liveState { + statusSection(s) + firmwareSection(s) + opticalSection(s) + uniSection(s) + trafficSection(s) + if let updated = MongoTimestamp.displayString(s.lastUpdate) { + Section { LabeledContent("Last update", value: updated) } + } + } + historySection + } + } + + @ViewBuilder private func statusSection(_ s: ONUStateDoc) -> some View { + Section("Status") { + if let reg = s.registrationState { LabeledContent("Registration", value: reg) } + if let eq = s.equipmentId { LabeledContent("Equipment", value: eq) } + if let mode = s.ponMode { LabeledContent("PON mode", value: mode) } + if let temp = s.temperatureC { + LabeledContent("Temperature", value: "\(temp.formatted(.number.precision(.fractionLength(1)))) °C") + } + if let online = s.onlineTime { + LabeledContent("Online since", value: MongoTimestamp.displayString(online) ?? online) + if let up = MongoTimestamp.duration(from: online, to: s.lastUpdate) { + LabeledContent("Uptime", value: up) + } + } + } + } + + @ViewBuilder private func firmwareSection(_ s: ONUStateDoc) -> some View { + Section("Firmware") { + if let v = s.fwVersion { LabeledContent("Active version", value: v) } + ForEach(Array(s.fwBankVersions.enumerated()), id: \.offset) { idx, ver in + LabeledContent("Bank \(idx)" + (s.fwBankPtr == idx ? " · active" : ""), + value: ver.isEmpty ? "—" : ver) + } + if let st = s.fwUpgradeStatus { + if let status = st["Status"]?.stringValue { LabeledContent("Upgrade", value: status) } + if let prog = st["Progress"]?.intValue { LabeledContent("Progress", value: "\(prog)%") } + } + Button { showFirmwareSheet = true } label: { + Label("Update firmware…", systemImage: "arrow.up.circle") + } + .disabled(vm.isPerformingAction) + } + } + + @ViewBuilder private func opticalSection(_ s: ONUStateDoc) -> some View { + Section { + opticalRow("RX optical", s.rxOpticalDBm, green: -28, warn: -30) + opticalRow("TX optical", s.txOpticalDBm, green: 3, warn: 3) + } header: { + Text("Live optical") + } footer: { + Text("Green RX ≥ −30 dBm, TX ≥ 3 dBm (user guide §7).") + } + Section("FEC error counters") { + fecRow("ONU pre-FEC", s.onuPreFEC, isPost: false) + fecRow("ONU post-FEC", s.onuPostFEC, isPost: true) + fecRow("OLT pre-FEC", s.oltPreFEC, isPost: false) + fecRow("OLT post-FEC", s.oltPostFEC, isPost: true) + } + } + + @ViewBuilder private func uniSection(_ s: ONUStateDoc) -> some View { + if !s.uniPorts.isEmpty { + Section("UNI ports") { + ForEach(s.uniPorts) { port in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(port.name).font(.subheadline) + if let detail = uniDetail(port) { + Text(detail).font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if let enabled = port.enabled { + StateBadge(text: enabled ? "enabled" : "disabled", + color: enabled ? .green : .gray) + } + } + } + } + } + } + + private func uniDetail(_ port: UNIPort) -> String? { + if let state = port.state { return state } + let parts = [port.speed, port.duplex].compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + @ViewBuilder private func trafficSection(_ s: ONUStateDoc) -> some View { + if !s.ponServices.isEmpty { + Section("Traffic") { + ForEach(s.ponServices) { svc in + VStack(alignment: .leading, spacing: 6) { + Text(svc.name).font(.subheadline) + HStack(alignment: .top) { + trafficStat("↓ Rx", rate: svc.rxRateBps, total: svc.rxTotalOctets) + trafficStat("↑ Tx", rate: svc.txRateBps, total: svc.txTotalOctets) + } + } + .padding(.vertical, 2) + } + } + } + } + + private func trafficStat(_ label: String, rate: Double?, total: Double?) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption2).foregroundStyle(.secondary) + Text(Format.bps(rate)).font(.callout.monospacedDigit()) + Text("\(Format.bytes(total)) total").font(.caption2).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder private var historySection: some View { + Section("History (24h)") { + LabeledContent("Samples", value: "\(vm.stats.count)") + if let latest = vm.stats.first, let ts = MongoTimestamp.displayString(latest.timestamp) { + LabeledContent("Latest sample", value: ts) + } + } + if let latest = vm.stats.first, !latest.numericMetrics.isEmpty { + Section("Latest counters") { + ForEach(latest.numericMetrics, id: \.key) { metric in + LabeledContent(metric.key) { + Text(metric.value.formatted()).monospacedDigit() + } + } + } + } + } + + /// Optical level row, tinted by the user-guide thresholds (§7.3). `green` is + /// the full-pass floor, `warn` the margin floor; below `warn` is red. + private func opticalRow(_ title: String, _ value: Double?, green: Double, warn: Double) -> some View { + LabeledContent(title) { + if let value { + Text("\(value.formatted(.number.precision(.fractionLength(2)))) dBm") + .monospacedDigit() + .foregroundStyle(value >= green ? .green : (value >= warn ? .yellow : .red)) + } else { + Text("—").foregroundStyle(.secondary) + } + } + } + + /// FEC counter row. Post-FEC > 0 means uncorrected errors reached the user + /// (red); pre-FEC > 0 is informational; all-zero is healthy (§7.1–7.2). + private func fecRow(_ title: String, _ value: Double?, isPost: Bool) -> some View { + LabeledContent(title) { + if let value { + Text(Int(value).formatted()) + .monospacedDigit() + .foregroundStyle(isPost && value > 0 ? .red : (value > 0 ? .secondary : .green)) + } else { + Text("—").foregroundStyle(.secondary) + } + } + } + + // MARK: CPE (DHCP leases) + + private var cpeView: some View { + Group { + if vm.cpes.isEmpty { + ContentUnavailableView("No CPEs", systemImage: "laptopcomputer.slash") + } else { + List(vm.cpes) { cpe in + Section { + if let ip = cpe.ipv4 { + LabeledContent("IPv4") { + HStack(spacing: 6) { + Text(ip).monospacedDigit() + if let state = cpe.dhcpState { dhcpBadge(state) } + } + } + if let server = cpe.dhcpServer { LabeledContent("DHCP server", value: server) } + if let lease = cpe.dhcpLeaseSeconds { LabeledContent("Lease", value: leaseText(lease)) } + if let renew = MongoTimestamp.displayString(cpe.dhcpLastSuccess) { + LabeledContent("Last renew", value: renew) + } + } + if let ip6 = cpe.ipv6 { + LabeledContent("IPv6") { + HStack(spacing: 6) { + Text(ip6).font(.callout.monospaced()) + if let state = cpe.dhcpv6State { dhcpBadge(state) } + } + } + if let prefix = cpe.ipv6Prefix { LabeledContent("Delegated prefix", value: prefix) } + if let valid = MongoTimestamp.displayString(cpe.ipv6ValidLifetime) { + LabeledContent("Valid until", value: valid) + } + } + if let circuit = cpe.circuitId { LabeledContent("Circuit ID", value: circuit) } + if !cpe.hasLease { Text("No active lease.").foregroundStyle(.secondary) } + } header: { + Text(cpe.mac ?? cpe.id) + } + } + } + } + } + + private func dhcpBadge(_ state: String) -> some View { + let ok = state.lowercased().contains("bound") || state.lowercased() == "ack" + return StateBadge(text: state, color: ok ? .green : .orange) + } + + private func leaseText(_ seconds: Int) -> String { + if seconds % 86400 == 0 { return "\(seconds / 86400)d" } + if seconds % 3600 == 0 { return "\(seconds / 3600)h" } + return "\(seconds / 60)m" + } + + // MARK: Config + + private var configView: some View { + List { + if let config = vm.config { + if let name = config.netconfName { + Section("Identity") { LabeledContent("NETCONF name", value: name) } + } + Section("Raw document") { + JSONTreeView(value: config.raw) + } + } else { + Text("No configuration loaded.").foregroundStyle(.secondary) + } + } + } + + // MARK: Alarms + + private var alarmsView: some View { + Group { + if vm.alarms.isEmpty { + ContentUnavailableView("No alarms", systemImage: "bell.slash") + } else { + List(vm.alarms) { alarm in + VStack(alignment: .leading, spacing: 4) { + HStack { + SeverityBadge(severity: alarm.severity) + Spacer() + if let at = MongoTimestamp.displayString(alarm.raisedAt) { + Text(at).font(.caption).foregroundStyle(.secondary) + } + } + Text(alarm.message ?? alarm.id).font(.subheadline) + } + .padding(.vertical, 2) + } + .listStyle(.plain) + } + } + } + + // MARK: Logs + + private var logsView: some View { + Group { + if vm.logs.isEmpty { + ContentUnavailableView("No logs", systemImage: "doc.text") + } else { + List(vm.logs) { log in + VStack(alignment: .leading, spacing: 4) { + HStack { + SeverityBadge(severity: log.severity) + Spacer() + if let t = MongoTimestamp.displayString(log.time) { + Text(t).font(.caption).foregroundStyle(.secondary) + } + } + Text(log.message ?? log.id).font(.caption).foregroundStyle(.primary) + } + .padding(.vertical, 2) + } + .listStyle(.plain) + } + } + } +} + +/// Minimal recursive viewer for a `JSONValue` (read-only). Renders nested +/// objects/arrays as disclosure groups so the full Mongo document is browsable +/// before specific fields are typed. +struct JSONTreeView: View { + let value: JSONValue + var key: String? = nil + + var body: some View { + switch value { + case .object(let dict): + DisclosureGroup(key ?? "{ }") { + ForEach(dict.keys.sorted(), id: \.self) { k in + JSONTreeView(value: dict[k]!, key: k) + } + } + case .array(let arr): + DisclosureGroup(key.map { "\($0) [\(arr.count)]" } ?? "[ \(arr.count) ]") { + ForEach(Array(arr.enumerated()), id: \.offset) { idx, item in + JSONTreeView(value: item, key: "\(idx)") + } + } + default: + LabeledContent(key ?? "value") { + Text(value.compactDescription) + .font(.callout.monospaced()) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + } + } + } +} diff --git a/ONUDetailViewModel.swift b/ONUDetailViewModel.swift new file mode 100644 index 0000000..90df5ab --- /dev/null +++ b/ONUDetailViewModel.swift @@ -0,0 +1,125 @@ +import Foundation +import Observation + +enum ONUDetailTab: String, CaseIterable, Identifiable { + case stats = "Overview" + case cpe = "CPE" + case config = "Config" + case alarms = "Alarms" + case logs = "Logs" + var id: String { rawValue } +} + +@MainActor +@Observable +final class ONUDetailViewModel { + let onuId: String + private let connection: MCMSConnection + + var tab: ONUDetailTab = .stats + var phase: LoadPhase = .idle + + var config: ONUConfigDoc? + var liveState: ONUStateDoc? + var stats: [StatSample] = [] + var alarms: [AlarmHistoryDoc] = [] + var logs: [LogEntry] = [] + var cpes: [CPEState] = [] + + var firmwareFiles: [FirmwareFile] = [] + var firmwareLoadError: String? + + var isPerformingAction = false + var actionMessage: String? + + init(connection: MCMSConnection, onuId: String) { + self.connection = connection + self.onuId = onuId + } + + /// Tab switch / first load: blanks to a spinner while the new tab loads. + func loadCurrentTab() async { + phase = .loading + await reloadCurrentTab() + } + + /// Pull-to-refresh: reloads in place WITHOUT flipping to `.loading`, so the + /// scrollable content (and the refresh task driving it) isn't torn down + /// mid-request — which otherwise surfaces as a spurious "cancelled" error. + func refresh() async { + await reloadCurrentTab() + } + + private func reloadCurrentTab() async { + do { + switch tab { + case .stats: + // Live ONU-STATE carries the current optical/FEC levels (§3.12); + // the /onus/stats/ time-series is a best-effort supplement. + liveState = try await connection.onu.state(id: onuId) + stats = (try? await connection.onu.stats(id: onuId, lastHours: 24)) ?? [] + case .config: config = try await connection.onu.config(id: onuId) + case .cpe: cpes = try await connection.onu.cpes(id: onuId) + case .alarms: alarms = try await connection.onu.alarmHistories( + query: queryForONU()).sorted { $0.severity.rank < $1.severity.rank } + case .logs: logs = try await connection.onu.logs(id: onuId, lastHours: 24) + } + phase = .loaded + } catch { + phase = .failed((error as? APIError)?.localizedDescription ?? error.localizedDescription) + } + } + + /// Filter alarm-history list to this ONU. Reconcile the key with the real + /// document shape; `_id` is a safe default for the per-ONU collection. + private func queryForONU() -> APIQuery { + var q = APIQuery() + q.query = ["_id": onuId] + return q + } + + func reset() async { + isPerformingAction = true + actionMessage = nil + do { + try await connection.onu.reset(id: onuId) + actionMessage = "Reset requested." + } catch { + actionMessage = (error as? APIError)?.localizedDescription ?? error.localizedDescription + } + isPerformingAction = false + } + + func loadFirmware() async { + firmwareLoadError = nil + do { firmwareFiles = try await connection.onu.firmwareFiles() } + catch { firmwareLoadError = (error as? APIError)?.localizedDescription ?? error.localizedDescription } + } + + /// Stage a firmware image to the ONU's inactive bank (Procedure 7). Service-affecting. + func upgrade(to file: FirmwareFile) async { + isPerformingAction = true + actionMessage = nil + do { + let slot = try await connection.onu.upgradeFirmware(id: onuId, file: file) + actionMessage = "Firmware \(file.version ?? "image") staged to bank \(slot). The ONU starts it after its next reboot." + } catch { + actionMessage = (error as? APIError)?.localizedDescription ?? error.localizedDescription + } + isPerformingAction = false + } +} + +extension StatSample { + /// All numeric counters in the sample, sorted by key — for display/charting. + var numericMetrics: [(key: String, value: Double)] { + (raw.objectValue ?? [:]).compactMap { key, value in + switch value { + case .double(let d): return (key, d) + case .int(let i): return (key, Double(i)) + default: return nil + } + } + .sorted { $0.key < $1.key } + } +} diff --git a/ONUListView.swift b/ONUListView.swift new file mode 100644 index 0000000..209c7ee --- /dev/null +++ b/ONUListView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct ONUListView: View { + let connection: MCMSConnection + @State private var vm: ONUListViewModel + private let title: String + + init(connection: MCMSConnection, stateFilter: ONULifecycleState? = nil, title: String = "ONUs") { + self.connection = connection + self.title = title + _vm = State(initialValue: ONUListViewModel(connection: connection, stateFilter: stateFilter)) + } + + var body: some View { + @Bindable var vm = vm + Group { + switch vm.phase { + case .idle: + LoadingView(label: "Loading ONUs…") + case .loading where vm.onus.isEmpty: + LoadingView(label: "Loading ONUs…") + case .failed(let msg): + ErrorStateView(message: msg) { Task { await vm.load() } } + default: + list + } + } + .navigationTitle(title) + .searchable(text: $vm.search, prompt: "Name, serial, or OLT") + .task { if vm.phase == .idle { await vm.load() } } + .refreshable { await vm.load() } + } + + private var list: some View { + List(vm.filtered) { onu in + NavigationLink { + ONUDetailView(connection: connection, onuId: onu.id) + } label: { + row(onu) + } + } + .listStyle(.plain) + .overlay { + if vm.filtered.isEmpty { + if vm.search.isEmpty { + ContentUnavailableView("No ONUs", systemImage: "point.3.connected.trianglepath.dotted") + } else { + ContentUnavailableView.search(text: vm.search) + } + } + } + } + + private func row(_ onu: ONUStateDoc) -> some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(onu.displayName).font(.body.weight(.medium)) + if let sub = subtitle(onu) { + Text(sub).font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + StateBadge(text: onu.lifecycle.label, color: onu.lifecycle.color) + } + } + + /// Secondary line: the serial (shown only when a name is the title) and parent OLT. + private func subtitle(_ onu: ONUStateDoc) -> String? { + var parts: [String] = [] + if onu.resolvedName != nil { parts.append(onu.id) } + if let olt = onu.parentOLT { parts.append("OLT \(olt)") } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } +} diff --git a/ONUListViewModel.swift b/ONUListViewModel.swift new file mode 100644 index 0000000..a67ed71 --- /dev/null +++ b/ONUListViewModel.swift @@ -0,0 +1,54 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class ONUListViewModel { + var phase: LoadPhase = .idle + var onus: [ONUStateDoc] = [] + var search = "" + + /// When set, only ONUs in this registration state are shown (dashboard drill-down). + let stateFilter: ONULifecycleState? + + private let connection: MCMSConnection + init(connection: MCMSConnection, stateFilter: ONULifecycleState? = nil) { + self.connection = connection + self.stateFilter = stateFilter + } + + var filtered: [ONUStateDoc] { + var result = onus + if let stateFilter { + result = result.filter { $0.lifecycle == stateFilter } + } + let query = search.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { return result } + return result.filter { onu in + onu.id.localizedCaseInsensitiveContains(query) + || (onu.resolvedName?.localizedCaseInsensitiveContains(query) ?? false) + || (onu.parentOLT?.localizedCaseInsensitiveContains(query) ?? false) + } + } + + func load() async { + phase = .loading + do { + let fetched = try await connection.onu.allStates() + // Registration lives on OLT-STATE, operator names on ONU-CFG. Both best-effort. + let registration = (try? await connection.olt.registrationMap()) ?? [:] + let names = (try? await connection.onu.nameMap()) ?? [:] + onus = fetched + .map { onu in + var onu = onu + onu.resolvedLifecycle = registration[onu.id] + onu.resolvedName = names[onu.id] + return onu + } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + phase = .loaded + } catch { + phase = .failed((error as? APIError)?.localizedDescription ?? error.localizedDescription) + } + } +} diff --git a/ONURepository.swift b/ONURepository.swift new file mode 100644 index 0000000..09b3eaf --- /dev/null +++ b/ONURepository.swift @@ -0,0 +1,118 @@ +import Foundation + +/// ONU resources and operations. +@MainActor +final class ONURepository { + private let client: APIClient + init(client: APIClient) { self.client = client } + + // MARK: Lists + + func states(query: APIQuery = .empty) async throws -> [ONUStateDoc] { + try await client.get(.onuStates, query: query, as: [ONUStateDoc].self) + } + + /// Fetch every ONU state, paging through with the `next` cursor. + func allStates() async throws -> [ONUStateDoc] { + try await client.getAll(.onuStates, idOf: { $0.id }) + } + + func configs(query: APIQuery = .empty) async throws -> [ONUConfigDoc] { + try await client.get(.onuConfigs, query: query, as: [ONUConfigDoc].self) + } + + /// Map of ONU id → operator name. Uses a `projection` so only `ONU.Name` + /// comes back — full ONU-CFG docs are ~30 KB each, the name is a few bytes. + func nameMap() async throws -> [String: String] { + var query = APIQuery() + query.projection = ["ONU.Name": 1] + let configs: [ONUConfigDoc] = try await client.getAll(.onuConfigs, baseQuery: query, idOf: { $0.id }) + return configs.reduce(into: [:]) { map, config in + if let name = config.name, !name.isEmpty { map[config.id] = name } + } + } + + // MARK: Detail + + func state(id: String) async throws -> ONUStateDoc { + try await client.get(.onuState(id: id), as: ONUStateDoc.self) + } + + func config(id: String) async throws -> ONUConfigDoc { + try await client.get(.onuConfig(id: id), as: ONUConfigDoc.self) + } + + func stats(id: String, lastHours: Int = 24) async throws -> [StatSample] { + try await client.get(.onuStats(id: id), + query: .window(lastHours: lastHours), + as: [StatSample].self) + } + + func logs(id: String, lastHours: Int = 24) async throws -> [LogEntry] { + try await client.get(.onuLogs(id: id), + query: .window(lastHours: lastHours), + as: [LogEntry].self) + } + + func alarmHistory(id: String) async throws -> AlarmHistoryDoc { + try await client.get(.onuAlarmHistory(id: id), as: AlarmHistoryDoc.self) + } + + func alarmHistories(query: APIQuery = .empty) async throws -> [AlarmHistoryDoc] { + try await client.get(.onuAlarmHistories, query: query, as: [AlarmHistoryDoc].self) + } + + func cpeStates(query: APIQuery = .empty) async throws -> [CPEState] { + try await client.get(.onuCPEStates, query: query, as: [CPEState].self) + } + + /// Per-CPE DHCP/lease state from the web helper `/cpe/onu//`, which + /// returns a bare `[code, cpe, …]` array. Returns the CPE objects. + func cpes(id: String) async throws -> [CPEState] { + let raw = try await client.getRaw(.onuCPE(id: id)) + guard let array = raw.arrayValue else { return [] } + return array.compactMap { $0.objectValue != nil ? CPEState(raw: $0) : nil } + } + + // MARK: Firmware + + func firmwareFiles() async throws -> [FirmwareFile] { + try await client.get(.onuFirmwareFiles, as: [FirmwareFile].self) + } + + /// Procedure 7: stage `file` in the ONU's inactive firmware bank via a + /// full-document PUT. Returns the bank slot written. Service-affecting — + /// gate behind an explicit confirmation. + @discardableResult + func upgradeFirmware(id: String, file: FirmwareFile) async throws -> Int { + guard let filename = file.filename, let version = file.version else { + throw APIError.encoding(message: "Firmware file is missing a filename or version.") + } + let config = try await config(id: id) + guard let result = FirmwareUpgrade.apply(to: config.raw, filename: filename, version: version) else { + throw APIError.encoding(message: "Unexpected ONU-CFG shape — firmware write aborted.") + } + try await putConfig(id: id, document: result.document) + return result.slot + } + + // MARK: Operations (consequential — gate behind confirm dialogs) + + /// `PUT /onus//reset/` + func reset(id: String) async throws { + let _: JSONValue = try await client.action("PUT", .onuReset(id: id), as: JSONValue.self) + } + + /// `DELETE /onus/configs//` + func delete(id: String) async throws { + try await client.delete(.onuConfig(id: id)) + } + + /// GET-modify-PUT: upload a full replacement ONU-CFG document. + /// Mutate the `raw` JSONValue from `config(id:)`, then pass it here. + func putConfig(id: String, document: JSONValue) async throws { + let _: JSONValue = try await client.send( + "PUT", .onuConfig(id: id), + body: document, wrapInData: true, as: JSONValue.self) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6dca8b0 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# PON Go — iOS + +Native iOS app for Ciena **MCMS 6.2 PON Manager** basics on the go — dashboard, +ONU and OLT browsing/diagnostics, firmware updates, resets. SwiftUI + URLSession, +no external dependencies. Sibling of the Android app (`../mcms_android`); the REST +behaviour and field paths are identical and documented in +[`MCMS_API.md`](MCMS_API.md). + +App name **PON Go** (Xcode project/target is currently `SVO_MCMS`). + +## ⚠️ Source layout — read this first + +This folder holds the **canonical flat Swift sources** (plus docs, the app icon, +and the dev-guide PDFs). The actual **Xcode project lives elsewhere** +(`~/Documents/mcms_ios_xcode/run/SVO_MCMS/`); the workflow so far has been to +**copy changed `.swift` files into the Xcode project** and build there. + +For a clean git repo, **consolidate**: move these `.swift` files + +`Assets.xcassets/` into the Xcode project directory, make *that* the iOS repo +(with `docs/MCMS_API.md` and the PDFs alongside), and drop the copy-sync dance. +Until then, this folder is the source of truth — edit here, copy into Xcode. + +## Build (Xcode) + +- **Xcode 26 (iOS 26 SDK)**, deployment target **iOS 17+**. New iOS App + (SwiftUI lifecycle); delete the template `ContentView.swift` / generated `App` + struct (this scaffold provides its own `@main` in `MCMSMobileApp.swift`). +- **Build Settings → Default Actor Isolation → `nonisolated`.** Xcode 26 defaults + this to MainActor, which makes the pure-data types (`APIQuery.empty`, + `JSONValue: Equatable`, …) emit ~8 main-actor warnings (errors under Swift 6 + mode). The code is designed nonisolated-by-default; only UI/stateful classes are + explicitly `@MainActor`. +- **App name / icon:** set the target's **Display Name → "PON Go"**, and drag + `Assets.xcassets/AppIcon.appiconset` into the project's asset catalog (replacing + the existing AppIcon). +- **App Transport Security:** self-signed TLS is handled in code by + `ServerTrustEvaluator` (no Info.plist change for HTTPS cert validation). For + plain **HTTP** hosts, add a *scoped* `NSAppTransportSecurity` → + `NSExceptionDomains` entry for that host in Info.plist — never + `NSAllowsArbitraryLoads`. + +## Verify off-device (no iOS SDK needed) + +The networking core + `AppEnvironment` + view models import only +Foundation/Observation, so they typecheck against the macOS SDK: + +``` +swiftc -typecheck -sdk "$(xcrun --show-sdk-path)" \ + /tmp/loadphase_stub.swift +``` + +Add a one-line stub for `LoadPhase` (it lives in the SwiftUI `Components.swift`): +`enum LoadPhase: Equatable { case idle, loading, loaded; case failed(String) }`. +Target every run: **0 errors, 0 warnings**. SwiftUI views that use iOS-only APIs +can't be compiled this way — validate those with isolated snippets or in Xcode. + +## Layout + +``` +App: MCMSMobileApp (@main), RootView routing, AppEnvironment +Networking: APIConfiguration, MCMSConnection, APIClient, APIEnvelope (JSONValue), + APIError, APIQuery, MCMSEndpoint, SessionStore, ServerTrustEvaluator, + KeychainStore, MongoTimestamp, Format +Models: DeviceModels, AuthModels, Firmware +Repos: AuthRepository, ONURepository, DeviceRepositories +UI: Components, Login/Settings/Dashboard/ONUList/ONUDetail/OLTList/ + OLTDetail views + view models, ResetConfirmSheet, FirmwareUpdateSheet, + DashboardDrilldownViews +Docs: MCMS_API.md (field guide), the two 323-1961-30x dev-guide PDFs +``` + +See [`HANDOFF.md`](HANDOFF.md) for architecture, the API gotchas, and status. +`GUI-NOTES.md` is early scaffold notes (historical — superseded by this README). diff --git a/ResetConfirmSheet.swift b/ResetConfirmSheet.swift new file mode 100644 index 0000000..82f9901 --- /dev/null +++ b/ResetConfirmSheet.swift @@ -0,0 +1,62 @@ +import SwiftUI + +/// Strong, reusable confirmation for service-affecting device resets: a red +/// warning, an explicit acknowledgement the user must switch on, and a prominent +/// red action button that stays disabled until then. Presented as a sheet so it +/// can't be dismissed by an accidental background tap into a destructive action. +struct ResetConfirmSheet: View { + let deviceKind: String // "ONU" / "OLT" + let deviceName: String + let onConfirm: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var acknowledged = false + + var body: some View { + NavigationStack { + Form { + Section { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.title2) + .foregroundStyle(.red) + Text("Service-affecting").font(.headline) + } + Text("Resetting \(deviceKind) “\(deviceName)” reboots it and interrupts service for everything connected to it. This can take several minutes and can't be undone.") + .font(.subheadline) + } + + Section { + Toggle("I understand this will interrupt service", isOn: $acknowledged) + .tint(.red) + } + + Section { + Button { + onConfirm() + dismiss() + } label: { + HStack { + Spacer() + Label("Reset \(deviceKind)", systemImage: "arrow.clockwise") + .fontWeight(.semibold) + Spacer() + } + } + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(!acknowledged) + } + .listRowBackground(Color.clear) + } + .navigationTitle("Reset \(deviceKind)?") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .presentationDetents([.medium]) + } +} diff --git a/ServerTrustEvaluator.swift b/ServerTrustEvaluator.swift new file mode 100644 index 0000000..abb4ddb --- /dev/null +++ b/ServerTrustEvaluator.swift @@ -0,0 +1,105 @@ +import Foundation +import Security +import CryptoKit + +/// How to evaluate the server's TLS certificate. +/// +/// MCMS appliances usually present a self-signed or internal-CA certificate. +/// Pick the *narrowest* policy that works for your deployment. Never ship +/// `.allowSelfSignedForHost` against a host you don't control. +enum ServerTrustPolicy: Equatable, Codable { + /// Default OS trust evaluation (use this if the box has a real cert or a + /// CA your devices already trust via MDM/profile — strongly preferred). + case system + + /// Accept the server's certificate ONLY for this exact host, after a normal + /// trust evaluation that ignores the "untrusted root" failure. Scoped escape + /// hatch for self-signed boxes on a controlled mgmt network/VPN. + case allowSelfSignedForHost(String) + + /// Certificate/public-key pinning: trust only if the leaf's SHA-256 + /// public-key hash (base64) matches one of these. The safest option for a + /// fixed appliance — capture the hash once and pin it. + case pinPublicKeySHA256([String], host: String) +} + +/// `URLSessionDelegate` that applies a `ServerTrustPolicy`. Retained by the +/// `URLSession` it's attached to. +final class ServerTrustEvaluator: NSObject, URLSessionDelegate { + private let policy: ServerTrustPolicy + + init(policy: ServerTrustPolicy) { + self.policy = policy + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust else { + completionHandler(.performDefaultHandling, nil) + return + } + + let host = challenge.protectionSpace.host + + switch policy { + case .system: + completionHandler(.performDefaultHandling, nil) + + case .allowSelfSignedForHost(let allowedHost): + // Scoped escape hatch for self-signed appliances on a controlled + // network. Because a self-signed root fails normal evaluation, this + // accepts ANY certificate the allowed host presents — including + // expired or name-mismatched ones. The only gate is the hostname, so + // use this only on hosts you control; prefer `.pinPublicKeySHA256` + // once you can capture the appliance's key hash. + guard host == allowedHost else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + completionHandler(.useCredential, URLCredential(trust: trust)) + + case .pinPublicKeySHA256(let pins, let pinnedHost): + guard host == pinnedHost, + let leaf = Self.leafCertificate(from: trust), + let hash = Self.publicKeySHA256Base64(for: leaf), + pins.contains(hash) else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + completionHandler(.useCredential, URLCredential(trust: trust)) + } + } + + // MARK: - Pinning helpers + + private static func leafCertificate(from trust: SecTrust) -> SecCertificate? { + if #available(iOS 15.0, *) { + return (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first + } else { + return SecTrustGetCertificateAtIndex(trust, 0) + } + } + + /// Base64 of SHA-256 over the DER-encoded SubjectPublicKeyInfo's key bits. + /// (Capture once with `openssl` and store as a pin; this returns the raw + /// public-key hash for comparison.) + static func publicKeySHA256Base64(for certificate: SecCertificate) -> String? { + guard let key = SecCertificateCopyKey(certificate), + let data = SecKeyCopyExternalRepresentation(key, nil) as Data? else { + return nil + } + return CryptoSHA256.base64(of: data) + } +} + +/// Minimal SHA-256 wrapper using CryptoKit. +enum CryptoSHA256 { + static func base64(of data: Data) -> String { + let digest = SHA256.hash(data: data) + return Data(digest).base64EncodedString() + } +} diff --git a/SessionStore.swift b/SessionStore.swift new file mode 100644 index 0000000..f0b6177 --- /dev/null +++ b/SessionStore.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Holds session state for an authenticated MCMS connection. +/// +/// Cookie handling: `URLSession` stores the `sessionid` and `csrftoken` cookies +/// automatically and replays them. Our job is to (a) read the CSRF cookie value +/// so we can echo it in the `X-CSRFToken` header on writes (Django-style), and +/// (b) clear cookies on logout. +@MainActor +final class SessionStore { + + private(set) var isAuthenticated = false + + /// The cookie storage backing the session. Injected so a proxy deployment + /// can use an isolated storage if needed. + let cookieStorage: HTTPCookieStorage + private let config: APIConfiguration + + init(config: APIConfiguration, cookieStorage: HTTPCookieStorage = .shared) { + self.config = config + self.cookieStorage = cookieStorage + } + + func markAuthenticated() { isAuthenticated = true } + + /// Current CSRF token (the value of the CSRF cookie), to be sent as the + /// `X-CSRFToken` header on mutating requests. Returns nil before login. + func csrfToken() -> String? { + guard let cookies = cookieStorage.cookies(for: config.baseURL) else { return nil } + return cookies.first { Self.matches($0.name, config.csrfCookieName) }?.value + } + + /// Whether a session cookie is currently present. + var hasSessionCookie: Bool { + guard let cookies = cookieStorage.cookies(for: config.baseURL) else { return false } + return cookies.contains { Self.matches($0.name, config.sessionCookieName) } + } + + /// Modern MCMS 6.2 builds emit `__Host-csrftoken` / `__Host-sessionid`; + /// older ones use the bare names. Accept either form (also `__Secure-`). + static func matches(_ cookieName: String, _ base: String) -> Bool { + cookieName == base + || cookieName == "__Host-" + base + || cookieName == "__Secure-" + base + } + + /// Drop all cookies for the configured host (call after logout / on reset). + func clear() { + if let cookies = cookieStorage.cookies(for: config.baseURL) { + cookies.forEach { cookieStorage.deleteCookie($0) } + } + isAuthenticated = false + } +} diff --git a/SettingsView.swift b/SettingsView.swift new file mode 100644 index 0000000..b84b4af --- /dev/null +++ b/SettingsView.swift @@ -0,0 +1,158 @@ +import SwiftUI + +/// Connection configuration. Doubles as initial setup (no config yet) and the +/// in-app Settings tab. Credentials are entered on the Login screen, not here. +struct SettingsView: View { + let isInitialSetup: Bool + @Environment(AppEnvironment.self) private var env + @Environment(\.dismiss) private var dismiss + + @State private var urlString = "https://" + @State private var apiVersion = "v1" + @State private var databaseId = "" + @State private var allowSelfSigned = false + + @State private var testPhase: LoadPhase = .idle + @State private var testResult: String? + @State private var saveError: String? + + var body: some View { + Form { + Section { + TextField("https://host[/api]", text: $urlString) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + TextField("API version", text: $apiVersion) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } header: { + Text("Server") + } footer: { + Text("Host or origin, e.g. https://mcms.lab.svorka.net — “/api” is appended automatically unless you enter a path. Endpoints are v1.") + } + + Section("Database (optional)") { + TextField("Database ID", text: $databaseId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section { + Toggle("Allow self-signed certificate", isOn: $allowSelfSigned) + } header: { + Text("Security") + } footer: { + Text(allowSelfSigned + ? "Trusts the certificate for this host only. Use only on a controlled management network/VPN. For production, install the CA via MDM and leave this off, or add public-key pinning in code." + : "Uses standard system trust evaluation (recommended).") + } + + Section { + Button { + Task { await runTest() } + } label: { + HStack { + Text("Test connection") + Spacer() + switch testPhase { + case .loading: ProgressView() + case .loaded: Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + case .failed: Image(systemName: "xmark.circle.fill").foregroundStyle(.red) + case .idle: EmptyView() + } + } + } + .disabled(!canSave) + + if let testResult { + Text(testResult).font(.footnote).foregroundStyle(.secondary) + } + } footer: { + if case .failed(let msg) = testPhase { + Text(msg).foregroundStyle(.red) + } + } + + if !isInitialSetup { + Section { + Button("Sign out", role: .destructive) { + Task { await env.signOut() } + } + } + } + + if let saveError { + Section { Text(saveError).foregroundStyle(.red) } + } + } + .navigationTitle(isInitialSetup ? "Set up connection" : "Settings") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() }.disabled(!canSave) + } + } + .onAppear(perform: prefill) + } + + private var canSave: Bool { + Self.normalizedBaseURL(urlString) != nil && !apiVersion.trimmingCharacters(in: .whitespaces).isEmpty + } + + private func prefill() { + guard let config = env.configuration else { return } + urlString = config.baseURL.absoluteString + apiVersion = config.apiVersion + databaseId = config.databaseId ?? "" + if case .allowSelfSignedForHost = config.trustPolicy { allowSelfSigned = true } + } + + private func makeConfig() -> APIConfiguration? { + guard let url = Self.normalizedBaseURL(urlString), let host = url.host else { return nil } + let policy: ServerTrustPolicy = allowSelfSigned ? .allowSelfSignedForHost(host) : .system + return APIConfiguration( + baseURL: url, + apiVersion: apiVersion.trimmingCharacters(in: .whitespaces), + databaseId: databaseId.isEmpty ? nil : databaseId, + trustPolicy: policy + ) + } + + /// Normalizes the entered server URL: requires scheme + host, trims any + /// trailing slash, and defaults the path to `/api` when none is given — every + /// MCMS REST path lives under `/api` (dev guide). A reverse proxy mounted + /// elsewhere can still be entered with an explicit path. + static func normalizedBaseURL(_ raw: String) -> URL? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard var comps = URLComponents(string: trimmed), + comps.scheme != nil, let host = comps.host, !host.isEmpty else { return nil } + var path = comps.path + while path.hasSuffix("/") { path.removeLast() } + if path.isEmpty { path = "/api" } + comps.path = path + return comps.url + } + + private func save() { + guard let config = makeConfig() else { + saveError = "Enter a valid URL including scheme (https://)." + return + } + saveError = nil + env.configure(config) + if !isInitialSetup { dismiss() } + } + + private func runTest() async { + guard let config = makeConfig() else { return } + testPhase = .loading + testResult = nil + do { + let result = try await env.probe(config) + testResult = "Connected · \(result)" + testPhase = .loaded + } catch { + testPhase = .failed((error as? APIError)?.localizedDescription ?? error.localizedDescription) + } + } +}