initial commit

This commit is contained in:
Jon Vanvik 2026-05-31 14:11:29 +02:00
commit 8de962eeb8
44 changed files with 4843 additions and 0 deletions

Binary file not shown.

Binary file not shown.

276
APIClient.swift Normal file
View file

@ -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<T: Decodable>(
_ 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": <body> }` per the standard endpoints; pass
/// `wrapInData: false` for action endpoints that take a raw body
/// (e.g. disable-onu `{ "disable": true }`).
@discardableResult
func send<T: Decodable, B: Encodable>(
_ 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<T: Decodable>(
_ 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.33.4.
func getAll<T: Decodable>(
_ 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<T: Decodable>(_ 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<T: Decodable>(_ 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<T>(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<T: Decodable>(_ data: Data) throws -> T {
do {
let envelope = try decoder.decode(APIResponse<T>.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<JSONValue>.self, from: data) {
return env.details
}
return try? decoder.decode(JSONValue.self, from: data)
}
}

68
APIConfiguration.swift Normal file
View file

@ -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 ?? "" }
}

113
APIEnvelope.swift Normal file
View file

@ -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<T: Decodable>: 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": { <document> } }`
struct DataEnvelope<B: Encodable>: 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 }
}

87
APIError.swift Normal file
View file

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

70
APIQuery.swift Normal file
View file

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

107
AppEnvironment.swift Normal file
View file

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

View file

@ -0,0 +1,4 @@
{
"images" : [ { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

39
AuthModels.swift Normal file
View file

@ -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": "<db id>" }` (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.

52
AuthRepository.swift Normal file
View file

@ -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": "<db id>" }` 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)
}
}

126
Components.swift Normal file
View file

@ -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)
}
}
}
}

View file

@ -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")
}
}
}
}

185
DashboardView.swift Normal file
View file

@ -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<Content: View>(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<Destination: View>(
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())
}
}

79
DashboardViewModel.swift Normal file
View file

@ -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)
}
}
}

409
DeviceModels.swift Normal file
View file

@ -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/<id>/` 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/<OLT>/disable-onu/<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" }
}

148
DeviceRepositories.swift Normal file
View file

@ -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/<id>/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/<olt>/disable-onu/<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/<olt>/disable-onu/<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/<olt>/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)
}
}

56
Firmware.swift Normal file
View file

@ -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` 01, 10, 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)
}
}

90
FirmwareUpdateSheet.swift Normal file
View file

@ -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: " · ")
}
}

30
Format.swift Normal file
View file

@ -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)%"
}
}

75
GUI-NOTES.md Normal file
View file

@ -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
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>10.2.10.29</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key><true/>
<key>NSIncludesSubdomains</key><false/>
</dict>
</dict>
</dict>
```
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.

77
HANDOFF.md Normal file
View file

@ -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/<id>/` (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.

54
KeychainStore.swift Normal file
View file

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

74
LoginView.swift Normal file
View file

@ -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)
}
}
}

69
MCMSConnection.swift Normal file
View file

@ -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()
*/

104
MCMSEndpoint.swift Normal file
View file

@ -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/<id>/ (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 }
}

53
MCMSMobileApp.swift Normal file
View file

@ -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") }
}
}
}

758
MCMS_API.md Normal file
View file

@ -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/<id>/` works;
`/api/v1/onus/configs/<id>` 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=<token>; Path=/; Secure; SameSite=Lax
Set-Cookie: __Host-sessionid=<id>; Path=/; Secure; HttpOnly; SameSite=Lax
Set-Cookie: __Host-sessionexpire=<epoch>; 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=<token>; __Host-sessionid=<id>; ...
X-CSRFToken: <token> # the value from __Host-csrftoken
Referer: <baseURL>/ # MCMS rejects POST/PUT/DELETE without this
User-Agent: <anything> # see §3.1 — must not be empty
Accept: application/json
```
For PUT/POST/PATCH also:
```
Content-Type: application/json
Content-Length: <bytes>
```
### 2.3 (Optional) Database selection
If the deployment manages multiple databases:
```
PUT /api/v1/databases/selection/
{ "data": "<database_id>" }
```
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. `<bundle>/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/<id>/` 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": "<OLT MAC>",
"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/<id>/?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/<id>/
→ 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/<id>/` | Get single ONU config |
| `PUT` | `/api/v1/onus/configs/<id>/` | Replace ONU config (full doc) |
| `DELETE` | `/api/v1/onus/configs/<id>/` | Delete ONU (Procedure 43) |
| `GET` | `/api/v3/onus/states/` | List ONU states (paginated) |
| `GET` | `/api/v3/onus/states/<id>/` | Get single ONU state |
| `DELETE` | `/api/v1/onus/states/<id>/` | 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/<name>/` | Upload firmware |
| `PUT` | `/api/v3/tasks/configs/<id>/` | Create bulk upgrade task (Procedure 8) |
| `GET` | `/api/v3/tasks/configs/<id>/` | Poll bulk task config |
| `GET` | `/api/v1/onus/<id>/upgrade/status/` | (Build-dependent) per-ONU status |
### Other endpoints documented but not (yet) used
| Method | Path | Notes |
|---|---|---|
| `GET` | `/api/v1/onus/stats/<id>/?start-time=...` | Time-series ONU PM data |
| `GET` | `/api/v1/olts/stats/<id>/?start-time=...` | OLT PM data |
| `GET` | `/api/v1/onus/logs/<id>/?start-time=...` | Per-ONU syslog |
| `GET` | `/api/v1/olts/logs/<id>/?start-time=...` | Per-OLT syslog |
| `GET` | `/api/v3/tasks/states/<id>/` | 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": "<OLT MAC>",
"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": "<your-task-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/<task-id>/`. 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/<filename>/
{
"data": {
"file": "<base64 binary contents>",
"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/<id>/` → full doc.
2. Compute the inactive bank (see §6.3).
3. Set:
- `ONU["FW Bank Files"][writeSlot] = "<filename>"`
- `ONU["FW Bank Versions"][writeSlot] = "<version>"`
- `ONU["FW Bank Ptr"] = writeSlot` (this initiates the download)
4. `PUT /api/v1/onus/configs/<id>/` 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/<task-id>/
(body shape in §5.4)
```
MCMS owns the rollout: scheduled start, retries, pacing.
Monitor progress (build permitting):
```
GET /api/v3/tasks/states/<task-id>/
```
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 810 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 1050 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.*

52
MongoTimestamp.swift Normal file
View file

@ -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)
}
}

142
OLTDetailView.swift Normal file
View file

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

42
OLTDetailViewModel.swift Normal file
View file

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

75
OLTListView.swift Normal file
View file

@ -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"
}
}

36
OLTListViewModel.swift Normal file
View file

@ -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)
}
}
}

400
ONUDetailView.swift Normal file
View file

@ -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.17.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)
}
}
}
}

125
ONUDetailViewModel.swift Normal file
View file

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

74
ONUListView.swift Normal file
View file

@ -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: " · ")
}
}

54
ONUListViewModel.swift Normal file
View file

@ -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)
}
}
}

118
ONURepository.swift Normal file
View file

@ -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/<id>/`, 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/<id>/reset/`
func reset(id: String) async throws {
let _: JSONValue = try await client.action("PUT", .onuReset(id: id), as: JSONValue.self)
}
/// `DELETE /onus/configs/<id>/`
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)
}
}

73
README.md Normal file
View file

@ -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<N>/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)" \
<core + AppEnvironment + *ViewModel .swift files> /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).

62
ResetConfirmSheet.swift Normal file
View file

@ -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])
}
}

105
ServerTrustEvaluator.swift Normal file
View file

@ -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()
}
}

54
SessionStore.swift Normal file
View file

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

158
SettingsView.swift Normal file
View file

@ -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)
}
}
}