initial commit
This commit is contained in:
commit
8de962eeb8
44 changed files with 4843 additions and 0 deletions
BIN
323-1961-302_mcms_6_2_pon_manager_user_guide.pdf
Normal file
BIN
323-1961-302_mcms_6_2_pon_manager_user_guide.pdf
Normal file
Binary file not shown.
BIN
323-1961-306_mcms_6_2_rest_api_developer_guide.pdf
Normal file
BIN
323-1961-306_mcms_6_2_rest_api_developer_guide.pdf
Normal file
Binary file not shown.
276
APIClient.swift
Normal file
276
APIClient.swift
Normal 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.3–3.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
68
APIConfiguration.swift
Normal 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
113
APIEnvelope.swift
Normal 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
87
APIError.swift
Normal 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
70
APIQuery.swift
Normal 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
107
AppEnvironment.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
4
Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"images" : [ { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
BIN
Assets.xcassets/AppIcon.appiconset/icon-1024.png
Normal file
BIN
Assets.xcassets/AppIcon.appiconset/icon-1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 756 KiB |
39
AuthModels.swift
Normal file
39
AuthModels.swift
Normal 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
52
AuthRepository.swift
Normal 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
126
Components.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
DashboardDrilldownViews.swift
Normal file
70
DashboardDrilldownViews.swift
Normal 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
185
DashboardView.swift
Normal 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
79
DashboardViewModel.swift
Normal 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
409
DeviceModels.swift
Normal 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
148
DeviceRepositories.swift
Normal 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
56
Firmware.swift
Normal 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` 0→1, 1→0, anything
|
||||||
|
/// else (incl. 65535 unset) → 1. Returns the new document + the slot written,
|
||||||
|
/// or nil if the document isn't a recognizable ONU-CFG.
|
||||||
|
static func apply(to config: JSONValue, filename: String, version: String) -> (document: JSONValue, slot: Int)? {
|
||||||
|
guard var root = config.objectValue, var onu = root["ONU"]?.objectValue else { return nil }
|
||||||
|
|
||||||
|
let currentPtr = onu["FW Bank Ptr"]?.intValue ?? 65535
|
||||||
|
let slot = (currentPtr == 1) ? 0 : 1
|
||||||
|
|
||||||
|
var files = onu["FW Bank Files"]?.arrayValue ?? []
|
||||||
|
var versions = onu["FW Bank Versions"]?.arrayValue ?? []
|
||||||
|
while files.count <= slot { files.append(.string("")) }
|
||||||
|
while versions.count <= slot { versions.append(.string("")) }
|
||||||
|
files[slot] = .string(filename)
|
||||||
|
versions[slot] = .string(version)
|
||||||
|
|
||||||
|
onu["FW Bank Files"] = .array(files)
|
||||||
|
onu["FW Bank Versions"] = .array(versions)
|
||||||
|
onu["FW Bank Ptr"] = .int(slot)
|
||||||
|
root["ONU"] = .object(onu)
|
||||||
|
return (.object(root), slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
FirmwareUpdateSheet.swift
Normal file
90
FirmwareUpdateSheet.swift
Normal 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
30
Format.swift
Normal 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
75
GUI-NOTES.md
Normal 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
77
HANDOFF.md
Normal 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
54
KeychainStore.swift
Normal 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
74
LoginView.swift
Normal 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
69
MCMSConnection.swift
Normal 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
104
MCMSEndpoint.swift
Normal 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
53
MCMSMobileApp.swift
Normal 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
758
MCMS_API.md
Normal 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 8–10 to avoid stampeding the server. The desktop client uses 5
|
||||||
|
for deletes and 8 for state fetches.
|
||||||
|
|
||||||
|
### 8.3 Background uploads
|
||||||
|
|
||||||
|
Firmware `.bin` files are 10–50 MB base64-encoded. If you implement
|
||||||
|
uploads:
|
||||||
|
|
||||||
|
- Don't block the main thread.
|
||||||
|
- Consider `URLSession` background configuration so an upload can
|
||||||
|
survive an app suspend.
|
||||||
|
- The upload is a single POST; no chunking required.
|
||||||
|
|
||||||
|
### 8.4 Storing the session
|
||||||
|
|
||||||
|
For UX: persist the base URL and username in `UserDefaults`, never
|
||||||
|
the password. Re-prompt for password on launch.
|
||||||
|
|
||||||
|
The session cookie itself goes in the per-session cookie storage
|
||||||
|
and is automatically discarded — don't try to persist it.
|
||||||
|
|
||||||
|
### 8.5 Date handling
|
||||||
|
|
||||||
|
See §3.7. Default `DateFormatter` with `en_US_POSIX` locale + UTC
|
||||||
|
timezone, format `"yyyy-MM-dd HH:mm:ss.SSSSSS"` (microseconds) or
|
||||||
|
`"yyyy-MM-dd HH:mm:ss"` (seconds). Try the microsecond format
|
||||||
|
first, fall back to second if it fails.
|
||||||
|
|
||||||
|
### 8.6 Cell-vs-Wi-Fi
|
||||||
|
|
||||||
|
MCMS APIs are typically reachable only from the operator's internal
|
||||||
|
network. Test on cellular: many internal hosts won't be reachable,
|
||||||
|
and your error UI needs to communicate that clearly.
|
||||||
|
|
||||||
|
### 8.7 Self-signed cert prompt
|
||||||
|
|
||||||
|
Make this a first-class onboarding step. The lab MCMS at
|
||||||
|
`https://mcms.lab.svorka.net/` uses a self-signed cert, and any
|
||||||
|
auto-rejection will silently break login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Procedures referenced in the dev guide
|
||||||
|
|
||||||
|
| # | Title | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| 2 | Determining ONU registration status | OLT-STATE["ONU States"] buckets |
|
||||||
|
| 7 | Per-ONU firmware upgrade | Full-doc PUT to ONU-CFG |
|
||||||
|
| 8 | Bulk firmware upgrade | AUTO-TASK-CFG via PUT |
|
||||||
|
| 38 | Resetting bit error rate values | Names the four FEC fields |
|
||||||
|
| 43 | Deleting an ONU | DELETE on ONU-CFG |
|
||||||
|
|
||||||
|
(Page numbers vary by document revision; cite by procedure number.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Quick implementation checklist for a new client
|
||||||
|
|
||||||
|
A correct first POST-login GET against `/api/v3/onus/configs/?limit=1`
|
||||||
|
returning HTTP 200 means you've got the eight critical pieces right:
|
||||||
|
|
||||||
|
- [ ] HTTPS reachable (with cert exception if needed)
|
||||||
|
- [ ] Path prefix `/api` added
|
||||||
|
- [ ] User-Agent set to non-empty value
|
||||||
|
- [ ] Cookie jar storing `__Host-sessionid` + `__Host-csrftoken`
|
||||||
|
- [ ] `X-CSRFToken` header pulled from `__Host-csrftoken` cookie value
|
||||||
|
- [ ] `Referer` header set to base URL
|
||||||
|
- [ ] `Accept: application/json`
|
||||||
|
- [ ] Trailing slash on the path
|
||||||
|
|
||||||
|
Add for write operations:
|
||||||
|
|
||||||
|
- [ ] `Content-Type: application/json`
|
||||||
|
- [ ] Body wrapped: `{"data": ...}`
|
||||||
|
- [ ] Full-document PUT (never partial)
|
||||||
|
|
||||||
|
Add for list endpoints:
|
||||||
|
|
||||||
|
- [ ] Page size ≤ 100
|
||||||
|
- [ ] `next` cursor (not `skip`)
|
||||||
|
- [ ] Loop until short page
|
||||||
|
|
||||||
|
Once those land, the API is comfortable to work with — the hard parts
|
||||||
|
are upfront.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Compiled from `323-1961-306` (REST API dev guide), `323-1961-302`
|
||||||
|
(PON Manager User Guide), and empirical testing against MCMS 6.2 with
|
||||||
|
Interos Everest 5.11/5.12 firmware on GNXS / Tibit MicroPlug ONUs.*
|
||||||
52
MongoTimestamp.swift
Normal file
52
MongoTimestamp.swift
Normal 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
142
OLTDetailView.swift
Normal 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
42
OLTDetailViewModel.swift
Normal 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
75
OLTListView.swift
Normal 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
36
OLTListViewModel.swift
Normal 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
400
ONUDetailView.swift
Normal 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.1–7.2).
|
||||||
|
private func fecRow(_ title: String, _ value: Double?, isPost: Bool) -> some View {
|
||||||
|
LabeledContent(title) {
|
||||||
|
if let value {
|
||||||
|
Text(Int(value).formatted())
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(isPost && value > 0 ? .red : (value > 0 ? .secondary : .green))
|
||||||
|
} else {
|
||||||
|
Text("—").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: CPE (DHCP leases)
|
||||||
|
|
||||||
|
private var cpeView: some View {
|
||||||
|
Group {
|
||||||
|
if vm.cpes.isEmpty {
|
||||||
|
ContentUnavailableView("No CPEs", systemImage: "laptopcomputer.slash")
|
||||||
|
} else {
|
||||||
|
List(vm.cpes) { cpe in
|
||||||
|
Section {
|
||||||
|
if let ip = cpe.ipv4 {
|
||||||
|
LabeledContent("IPv4") {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(ip).monospacedDigit()
|
||||||
|
if let state = cpe.dhcpState { dhcpBadge(state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let server = cpe.dhcpServer { LabeledContent("DHCP server", value: server) }
|
||||||
|
if let lease = cpe.dhcpLeaseSeconds { LabeledContent("Lease", value: leaseText(lease)) }
|
||||||
|
if let renew = MongoTimestamp.displayString(cpe.dhcpLastSuccess) {
|
||||||
|
LabeledContent("Last renew", value: renew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let ip6 = cpe.ipv6 {
|
||||||
|
LabeledContent("IPv6") {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(ip6).font(.callout.monospaced())
|
||||||
|
if let state = cpe.dhcpv6State { dhcpBadge(state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let prefix = cpe.ipv6Prefix { LabeledContent("Delegated prefix", value: prefix) }
|
||||||
|
if let valid = MongoTimestamp.displayString(cpe.ipv6ValidLifetime) {
|
||||||
|
LabeledContent("Valid until", value: valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let circuit = cpe.circuitId { LabeledContent("Circuit ID", value: circuit) }
|
||||||
|
if !cpe.hasLease { Text("No active lease.").foregroundStyle(.secondary) }
|
||||||
|
} header: {
|
||||||
|
Text(cpe.mac ?? cpe.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dhcpBadge(_ state: String) -> some View {
|
||||||
|
let ok = state.lowercased().contains("bound") || state.lowercased() == "ack"
|
||||||
|
return StateBadge(text: state, color: ok ? .green : .orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func leaseText(_ seconds: Int) -> String {
|
||||||
|
if seconds % 86400 == 0 { return "\(seconds / 86400)d" }
|
||||||
|
if seconds % 3600 == 0 { return "\(seconds / 3600)h" }
|
||||||
|
return "\(seconds / 60)m"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Config
|
||||||
|
|
||||||
|
private var configView: some View {
|
||||||
|
List {
|
||||||
|
if let config = vm.config {
|
||||||
|
if let name = config.netconfName {
|
||||||
|
Section("Identity") { LabeledContent("NETCONF name", value: name) }
|
||||||
|
}
|
||||||
|
Section("Raw document") {
|
||||||
|
JSONTreeView(value: config.raw)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("No configuration loaded.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Alarms
|
||||||
|
|
||||||
|
private var alarmsView: some View {
|
||||||
|
Group {
|
||||||
|
if vm.alarms.isEmpty {
|
||||||
|
ContentUnavailableView("No alarms", systemImage: "bell.slash")
|
||||||
|
} else {
|
||||||
|
List(vm.alarms) { alarm in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
SeverityBadge(severity: alarm.severity)
|
||||||
|
Spacer()
|
||||||
|
if let at = MongoTimestamp.displayString(alarm.raisedAt) {
|
||||||
|
Text(at).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(alarm.message ?? alarm.id).font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Logs
|
||||||
|
|
||||||
|
private var logsView: some View {
|
||||||
|
Group {
|
||||||
|
if vm.logs.isEmpty {
|
||||||
|
ContentUnavailableView("No logs", systemImage: "doc.text")
|
||||||
|
} else {
|
||||||
|
List(vm.logs) { log in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
SeverityBadge(severity: log.severity)
|
||||||
|
Spacer()
|
||||||
|
if let t = MongoTimestamp.displayString(log.time) {
|
||||||
|
Text(t).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(log.message ?? log.id).font(.caption).foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal recursive viewer for a `JSONValue` (read-only). Renders nested
|
||||||
|
/// objects/arrays as disclosure groups so the full Mongo document is browsable
|
||||||
|
/// before specific fields are typed.
|
||||||
|
struct JSONTreeView: View {
|
||||||
|
let value: JSONValue
|
||||||
|
var key: String? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch value {
|
||||||
|
case .object(let dict):
|
||||||
|
DisclosureGroup(key ?? "{ }") {
|
||||||
|
ForEach(dict.keys.sorted(), id: \.self) { k in
|
||||||
|
JSONTreeView(value: dict[k]!, key: k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .array(let arr):
|
||||||
|
DisclosureGroup(key.map { "\($0) [\(arr.count)]" } ?? "[ \(arr.count) ]") {
|
||||||
|
ForEach(Array(arr.enumerated()), id: \.offset) { idx, item in
|
||||||
|
JSONTreeView(value: item, key: "\(idx)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
LabeledContent(key ?? "value") {
|
||||||
|
Text(value.compactDescription)
|
||||||
|
.font(.callout.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
ONUDetailViewModel.swift
Normal file
125
ONUDetailViewModel.swift
Normal 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
74
ONUListView.swift
Normal 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
54
ONUListViewModel.swift
Normal 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
118
ONURepository.swift
Normal 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
73
README.md
Normal 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
62
ResetConfirmSheet.swift
Normal 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
105
ServerTrustEvaluator.swift
Normal 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
54
SessionStore.swift
Normal 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
158
SettingsView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue