initial commit

This commit is contained in:
Jon Vanvik 2026-05-31 14:09:00 +02:00
commit ca3d7d05e0
56 changed files with 3875 additions and 0 deletions

61
app/build.gradle.kts Normal file
View file

@ -0,0 +1,61 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "no.svorka.mcms"
compileSdk = 36
defaultConfig {
applicationId = "no.svorka.mcms"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.02")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6")
implementation("androidx.navigation:navigation-compose:2.8.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}

4
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,4 @@
# Keep kotlinx.serialization metadata.
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class kotlinx.serialization.json.** { *; }

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MCMS">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,107 @@
package no.svorka.mcms
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import no.svorka.mcms.core.McmsConnection
import no.svorka.mcms.core.OltStateDoc
import no.svorka.mcms.core.OnuStateDoc
import no.svorka.mcms.ui.*
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val app = remember { AppState(applicationContext) }
PonGoTheme { RootApp(app) }
}
}
}
private sealed interface Screen {
data object Main : Screen
data class OnuDetail(val id: String) : Screen
data class OltDetail(val id: String, val title: String) : Screen
data class AbnormalRx(val onus: List<OnuStateDoc>) : Screen
data class LaserOff(val olts: List<OltStateDoc>) : Screen
}
@Composable
private fun RootApp(app: AppState) {
when {
!app.isConfigured -> SettingsScreen(app, initialSetup = true, onClose = {})
!app.isAuthenticated -> {
var showSettings by remember { mutableStateOf(false) }
if (showSettings) SettingsScreen(app, initialSetup = false, onClose = { showSettings = false })
else LoginScreen(app, onOpenSettings = { showSettings = true })
}
else -> {
val connection = app.connection
if (connection == null) { SettingsScreen(app, initialSetup = true, onClose = {}); return }
var stack by remember { mutableStateOf<List<Screen>>(listOf(Screen.Main)) }
BackHandler(enabled = stack.size > 1) { stack = stack.dropLast(1) }
when (val top = stack.last()) {
Screen.Main -> MainScaffold(
app, connection,
openOnu = { stack = stack + Screen.OnuDetail(it) },
openOlt = { id, t -> stack = stack + Screen.OltDetail(id, t) },
openAbnormalRx = { stack = stack + Screen.AbnormalRx(it) },
openLaserOff = { stack = stack + Screen.LaserOff(it) },
)
is Screen.OnuDetail -> OnuDetailScreen(connection, top.id, onBack = { stack = stack.dropLast(1) })
is Screen.OltDetail -> OltDetailScreen(connection, top.id, top.title, onBack = { stack = stack.dropLast(1) }, openOnu = { stack = stack + Screen.OnuDetail(it) })
is Screen.AbnormalRx -> AbnormalRxScreen(top.onus, onBack = { stack = stack.dropLast(1) }, openOnu = { stack = stack + Screen.OnuDetail(it) })
is Screen.LaserOff -> LaserOffScreen(top.olts, onBack = { stack = stack.dropLast(1) }, openOlt = { id, t -> stack = stack + Screen.OltDetail(id, t) })
}
}
}
}
@Composable
private fun MainScaffold(
app: AppState,
connection: McmsConnection,
openOnu: (String) -> Unit,
openOlt: (String, String) -> Unit,
openAbnormalRx: (List<OnuStateDoc>) -> Unit,
openLaserOff: (List<OltStateDoc>) -> Unit,
) {
var tab by rememberSaveable { mutableStateOf(0) }
Scaffold(bottomBar = {
NavigationBar {
NavigationBarItem(selected = tab == 0, onClick = { tab = 0 }, icon = { Icon(Icons.Filled.Dashboard, null) }, label = { Text("Dashboard") })
NavigationBarItem(selected = tab == 1, onClick = { tab = 1 }, icon = { Icon(Icons.Filled.Hub, null) }, label = { Text("ONUs") })
NavigationBarItem(selected = tab == 2, onClick = { tab = 2 }, icon = { Icon(Icons.Filled.Router, null) }, label = { Text("OLTs") })
NavigationBarItem(selected = tab == 3, onClick = { tab = 3 }, icon = { Icon(Icons.Filled.Settings, null) }, label = { Text("Settings") })
}
}) { padding ->
Box(Modifier.padding(padding)) {
when (tab) {
0 -> DashboardScreen(connection, openAbnormalRx, openLaserOff)
1 -> OnuListScreen(connection, openOnu)
2 -> OltListScreen(connection, openOlt)
else -> SettingsScreen(app, initialSetup = false, onClose = { tab = 0 })
}
}
}
}

View file

@ -0,0 +1,151 @@
package no.svorka.mcms.core
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** Suspend bridge for an OkHttp call, with coroutine cancellation. */
suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) { cont.resume(response) }
override fun onFailure(call: Call, e: IOException) {
if (!cont.isCancelled) cont.resumeWithException(e)
}
})
cont.invokeOnCancellation { runCatching { cancel() } }
}
/**
* Transport for the MCMS REST API. Builds versioned requests, injects auth
* headers, unwraps the `{status,data}` envelope, and maps status codes to
* [ApiError]. Returns raw [JsonElement] trees that the repositories wrap.
*/
class ApiClient(
private val config: ApiConfiguration,
private val session: SessionStore,
) {
/** Fired on 401 so the app can route back to login. */
var onAuthExpired: (() -> Unit)? = null
private val jsonMedia = "application/json".toMediaType()
private val client: OkHttpClient = OkHttpClient.Builder().apply {
cookieJar(session.cookieJar)
callTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
ServerTrust.apply(this, config.trustPolicy)
}.build()
suspend fun getElement(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY, version: String? = null): JsonElement =
unwrapData(perform("GET", endpoint, query, null, version))
suspend fun getList(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY, version: String? = null): List<JsonElement> =
getElement(endpoint, query, version).array ?: emptyList()
/** Raw body, bypassing the envelope — for the bare-array CPE helper. */
suspend fun getRaw(endpoint: McmsEndpoint, version: String? = null): JsonElement {
val resp = perform("GET", endpoint, ApiQuery.EMPTY, null, version)
return if (resp.body.isBlank()) JsonNull else MCMSJson.parseToJsonElement(resp.body)
}
suspend fun send(method: String, endpoint: McmsEndpoint, body: JsonElement, wrapInData: Boolean = true, version: String? = null): JsonElement {
val payload = if (wrapInData) buildJsonObject { put("data", body) } else body
return unwrapData(perform(method, endpoint, ApiQuery.EMPTY, payload.toString(), version))
}
suspend fun action(method: String, endpoint: McmsEndpoint, version: String? = null): JsonElement =
unwrapData(perform(method, endpoint, ApiQuery.EMPTY, null, version))
suspend fun delete(endpoint: McmsEndpoint, version: String? = null) {
perform("DELETE", endpoint, ApiQuery.EMPTY, null, version)
}
/** Paginates by the `next` cursor; page size 100 (field guide §3.4). */
suspend fun getAll(
endpoint: McmsEndpoint,
baseQuery: ApiQuery = ApiQuery.EMPTY,
pageLimit: Int = 100,
version: String? = null,
idOf: (JsonElement) -> String?,
): List<JsonElement> {
val all = mutableListOf<JsonElement>()
var cursor: String? = null
do {
val q = baseQuery.copy(limit = pageLimit, next = cursor)
val page = getList(endpoint, q, version)
all += page
cursor = if (page.size == pageLimit) page.lastOrNull()?.let(idOf) else null
} while (cursor != null)
return all
}
private data class Resp(val code: Int, val body: String)
private fun buildUrl(endpoint: McmsEndpoint, query: ApiQuery, version: String?): HttpUrl {
val base = config.baseUrl.toHttpUrlOrNull() ?: throw ApiError.InvalidUrl
val builder = base.newBuilder()
// Raw ids; addPathSegments encodes once and keeps ':' in MAC ids raw.
builder.addPathSegments(endpoint.path(version ?: config.apiVersion).removePrefix("/"))
for ((name, value) in query.items()) builder.addQueryParameter(name, value)
return builder.build()
}
private suspend fun perform(method: String, endpoint: McmsEndpoint, query: ApiQuery, bodyJson: String?, version: String?): Resp {
val url = buildUrl(endpoint, query, version)
val reqBody = when {
bodyJson != null -> bodyJson.toRequestBody(jsonMedia)
method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody()
else -> null
}
val builder = Request.Builder().url(url)
.header("Accept", "application/json")
.header("Referer", config.refererValue)
.method(method, reqBody)
if (bodyJson != null) builder.header("Content-Type", "application/json")
if (method != "GET") session.csrfToken()?.let { builder.header("X-CSRFToken", it) }
val response = try {
client.newCall(builder.build()).await()
} catch (e: SSLException) {
throw ApiError.ServerTrust
} catch (e: IOException) {
throw ApiError.Transport(e.message ?: "request failed")
}
val code = response.code
val body = response.body?.string() ?: ""
response.close()
if (code !in 200..299) {
val details = runCatching { MCMSJson.parseToJsonElement(body)["details"] }.getOrNull()
val error = ApiError.fromStatus(code, details)
if (error.requiresReauthentication) onAuthExpired?.invoke()
throw error
}
return Resp(code, body)
}
private fun unwrapData(resp: Resp): JsonElement {
if (resp.body.isBlank()) return JsonNull
val root = MCMSJson.parseToJsonElement(resp.body)
val status = root["status"].string
if (status != null && !status.equals("success", ignoreCase = true)) {
throw ApiError.ApiFailure(root["details"])
}
return root["data"] ?: JsonNull
}
}

View file

@ -0,0 +1,59 @@
package no.svorka.mcms.core
import kotlinx.serialization.Serializable
import java.net.URI
/** How to evaluate the server's TLS certificate. */
@Serializable
sealed class ServerTrustPolicy {
/** Default platform trust evaluation. */
@Serializable
data object System : ServerTrustPolicy()
/** Accept any certificate presented by this exact host (self-signed lab boxes). */
@Serializable
data class AllowSelfSignedForHost(val host: String) : ServerTrustPolicy()
/** Trust only if the leaf public-key SHA-256 (base64) matches one of these. */
@Serializable
data class PinPublicKeySha256(val pins: List<String>, val host: String) : ServerTrustPolicy()
}
/**
* Connection configuration for the MCMS REST API. URL-driven so the same build
* works direct or via reverse proxy. `baseUrl` includes the `/api` prefix, e.g.
* `https://mcms.lab.svorka.net/api`.
*/
@Serializable
data class ApiConfiguration(
val baseUrl: String,
val apiVersion: String = "v1",
val databaseId: String? = null,
val refererOverride: String? = null,
val sessionCookieName: String = "sessionid",
val csrfCookieName: String = "csrftoken",
val trustPolicy: ServerTrustPolicy = ServerTrustPolicy.System,
val timeoutSeconds: Long = 30,
) {
val refererValue: String get() = refererOverride ?: baseUrl
val host: String? get() = runCatching { URI(baseUrl).host }.getOrNull()
companion object {
/**
* Normalises a user-entered URL: requires scheme + host, trims a trailing
* slash, and defaults the path to `/api` when none is given. Returns null
* if it isn't a usable URL.
*/
fun normalizedBaseUrl(raw: String): String? {
val trimmed = raw.trim()
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
val scheme = uri.scheme ?: return null
val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null
var path = uri.path ?: ""
while (path.endsWith("/")) path = path.dropLast(1)
if (path.isEmpty()) path = "/api"
val port = if (uri.port >= 0) ":${uri.port}" else ""
return "$scheme://$host$port$path"
}
}
}

View file

@ -0,0 +1,38 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.JsonElement
/** Typed errors surfaced by [ApiClient]. */
sealed class ApiError(message: String) : Exception(message) {
data object InvalidUrl : ApiError("The request URL was invalid.")
data class Transport(val detail: String) : ApiError("Network error: $detail")
data object ServerTrust : ApiError("The server's TLS certificate could not be trusted.")
data class Encoding(val detail: String) : ApiError("Failed to encode request: $detail")
data class Decoding(val detail: String) : ApiError("Failed to read server response: $detail")
data class BadRequest(val details: JsonElement?) : ApiError("Request rejected (400). ${details.compactDescription()}")
data object Unauthorized : ApiError("Not authenticated (401). Please sign in again.")
data class Forbidden(val details: JsonElement?) : ApiError("Forbidden (403) — your account may not have permission for this action. ${details.compactDescription()}")
data object NotFound : ApiError("Resource not found (404).")
data class Conflict(val details: JsonElement?) : ApiError("Conflict (409) — ID already in use. ${details.compactDescription()}")
data object UnsupportedMediaType : ApiError("Unsupported media type (415).")
data class Server(val details: JsonElement?) : ApiError("Server error (500). ${details.compactDescription()}")
data class ApiFailure(val details: JsonElement?) : ApiError("Request failed. ${details.compactDescription()}")
data class UnexpectedStatus(val code: Int, val details: JsonElement?) : ApiError("Unexpected response ($code). ${details.compactDescription()}")
/** Only 401 should bounce to login; a 403 is usually a permission/CSRF issue. */
val requiresReauthentication: Boolean get() = this is Unauthorized
companion object {
fun fromStatus(code: Int, details: JsonElement?): ApiError = when (code) {
400 -> BadRequest(details)
401 -> Unauthorized
403 -> Forbidden(details)
404 -> NotFound
409 -> Conflict(details)
415 -> UnsupportedMediaType
500 -> Server(details)
else -> UnexpectedStatus(code, details)
}
}
}

View file

@ -0,0 +1,48 @@
package no.svorka.mcms.core
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Builder for the MCMS list-endpoint query parameters. `query`/`projection`/
* `sort` join as comma-separated `key=value` strings; `start-time`/`end-time`
* use the UTC `yyyy-MM-dd HH:mm:ss` format.
*/
data class ApiQuery(
val query: Map<String, String> = emptyMap(),
val projection: Map<String, Int> = emptyMap(),
val sort: Map<String, Int> = emptyMap(),
val limit: Int? = null,
val skip: Int? = null,
val next: String? = null,
val distinct: String? = null,
val startTime: Date? = null,
val endTime: Date? = null,
) {
fun items(): List<Pair<String, String>> {
val out = mutableListOf<Pair<String, String>>()
if (query.isNotEmpty()) out += "query" to query.entries.joinToString(",") { "${it.key}=${it.value}" }
if (projection.isNotEmpty()) out += "projection" to projection.entries.joinToString(",") { "${it.key}=${it.value}" }
if (sort.isNotEmpty()) out += "sort" to sort.entries.joinToString(",") { "${it.key}=${it.value}" }
limit?.let { out += "limit" to it.toString() }
skip?.let { out += "skip" to it.toString() }
next?.let { out += "next" to it }
distinct?.let { out += "distinct" to it }
startTime?.let { out += "start-time" to timeFormatter.format(it) }
endTime?.let { out += "end-time" to timeFormatter.format(it) }
return out
}
companion object {
val EMPTY = ApiQuery()
private val timeFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
fun window(lastHours: Int, limit: Int? = null): ApiQuery =
ApiQuery(startTime = Date(System.currentTimeMillis() - lastHours * 3_600_000L), limit = limit)
}
}

View file

@ -0,0 +1,9 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.JsonElement
/** `GET /ponmgr/version/` — best-effort display version. */
class VersionInfo(val raw: JsonElement) {
val displayVersion: String?
get() = raw["version"].string ?: raw["Version"].string ?: raw["ponmgr"]["version"].string
}

View file

@ -0,0 +1,62 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/** One uploaded ONU firmware image from `GET /files/onu-firmware/`. */
class FirmwareFile(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: filename ?: "?"
val filename: String? get() = raw["filename"].string ?: raw["_id"].string
val version: String? get() = raw["metadata"]["Version"].string
val manufacturer: String? get() = raw["metadata"]["Compatible Manufacturer"].string
val models: List<String> get() = raw["metadata"]["Compatible Model"].array?.mapNotNull { it.string } ?: emptyList()
val sizeBytes: Double? get() = raw["length"].double
val uploadDate: String? get() = raw["uploadDate"].string
/** Manufacturer must match the ONU vendor and the equipment id must be an
* exact (not substring) member of Compatible Model. */
fun isCompatible(vendor: String?, equipmentId: String?): Boolean {
val mfg = manufacturer
if (vendor != null && !mfg.isNullOrEmpty() && !mfg.equals(vendor, ignoreCase = true)) return false
if (equipmentId == null || models.isEmpty()) return true
return models.any { it.equals(equipmentId, ignoreCase = true) }
}
}
/** Procedure 7 (per-ONU firmware upgrade) as a pure transform. */
object FirmwareUpgrade {
data class Result(val document: JsonElement, val slot: Int)
/** Stage `filename`/`version` in the ONU's INACTIVE bank and point at it,
* preserving every other field (full-doc PUT). Ptr 01, 10, else1. */
fun apply(config: JsonElement, filename: String, version: String): Result? {
val root = config as? JsonObject ?: return null
val onu = root["ONU"] as? JsonObject ?: return null
val currentPtr = onu["FW Bank Ptr"].int ?: 65535
val slot = if (currentPtr == 1) 0 else 1
val files = (onu["FW Bank Files"] as? JsonArray)?.toMutableList() ?: mutableListOf()
val versions = (onu["FW Bank Versions"] as? JsonArray)?.toMutableList() ?: mutableListOf()
while (files.size <= slot) files.add(JsonPrimitive(""))
while (versions.size <= slot) versions.add(JsonPrimitive(""))
files[slot] = JsonPrimitive(filename)
versions[slot] = JsonPrimitive(version)
val newOnu = buildJsonObject {
onu.forEach { (k, v) -> put(k, v) }
put("FW Bank Files", JsonArray(files))
put("FW Bank Versions", JsonArray(versions))
put("FW Bank Ptr", JsonPrimitive(slot))
}
val newRoot = buildJsonObject {
root.forEach { (k, v) -> put(k, v) }
put("ONU", newOnu)
}
return Result(newRoot, slot)
}
}

View file

@ -0,0 +1,55 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
/**
* Shared lenient JSON. MCMS documents are heterogeneous Mongo docs, so we work
* with raw [JsonElement] trees (the Kotlin analogue of the iOS `JSONValue`) and
* read fields through the accessors below rather than modelling every field.
*/
val MCMSJson: Json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
}
/** Object-member access that tolerates a null receiver and non-object values. */
operator fun JsonElement?.get(key: String): JsonElement? =
(this as? JsonObject)?.get(key)
val JsonElement?.string: String?
get() = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
val JsonElement?.int: Int?
get() = (this as? JsonPrimitive)?.let { it.intOrNull ?: it.doubleOrNull?.toInt() }
val JsonElement?.double: Double?
get() = (this as? JsonPrimitive)?.let { it.doubleOrNull ?: it.intOrNull?.toDouble() }
val JsonElement?.bool: Boolean?
get() = (this as? JsonPrimitive)?.booleanOrNull
val JsonElement?.array: List<JsonElement>?
get() = this as? JsonArray
val JsonElement?.obj: Map<String, JsonElement>?
get() = this as? JsonObject
val JsonElement?.isObject: Boolean
get() = this is JsonObject
/** A compact human rendering for error/detail surfaces. */
fun JsonElement?.compactDescription(): String = when (val e = this) {
null, JsonNull -> "null"
is JsonPrimitive -> e.content
is JsonArray -> e.joinToString(", ") { it.compactDescription() }
is JsonObject -> e.entries.joinToString("; ") { "${it.key}: ${it.value.compactDescription()}" }
}

View file

@ -0,0 +1,29 @@
package no.svorka.mcms.core
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Encrypted credential storage (Android Keystore-backed), the analogue of the
* iOS Keychain wrapper. Never store secrets in plain SharedPreferences.
*/
class KeychainStore(context: Context) {
private val prefs: SharedPreferences = run {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"mcms_credentials",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun get(key: String): String? = prefs.getString(key, null)
fun set(key: String, value: String) { prefs.edit().putString(key, value).apply() }
fun delete(key: String) { prefs.edit().remove(key).apply() }
}

View file

@ -0,0 +1,17 @@
package no.svorka.mcms.core
/** Builds and holds the networking stack for one MCMS connection. */
class McmsConnection(val config: ApiConfiguration) {
val session = SessionStore(config)
val client = ApiClient(config, session)
val auth = AuthRepository(client, session, config)
val onu = OnuRepository(client)
val olt = OltRepository(client)
val controller = ControllerRepository(client)
/** Fired when a request returns 401, so the app can route back to login. */
var onAuthExpired: (() -> Unit)?
get() = client.onAuthExpired
set(value) { client.onAuthExpired = value }
}

View file

@ -0,0 +1,74 @@
package no.svorka.mcms.core
/**
* Versioned endpoint paths (relative to `baseUrl`). IDs are kept RAW here the
* URL builder in [ApiClient] percent-encodes the whole path exactly once.
* Pre-encoding double-encoded MAC colons (`:` `%3A` `%253A`), which MCMS
* rejected as an invalid id.
*/
sealed class McmsEndpoint {
data object Authenticate : McmsEndpoint()
data object Logout : McmsEndpoint()
data object DatabaseSelection : McmsEndpoint()
data object Version : McmsEndpoint()
data object ControllerConfigs : McmsEndpoint()
data class ControllerConfig(val id: String) : McmsEndpoint()
data class ControllerStats(val id: String) : McmsEndpoint()
data class ControllerLogs(val id: String) : McmsEndpoint()
data object OltConfigs : McmsEndpoint()
data class OltConfig(val id: String) : McmsEndpoint()
data object OltStates : McmsEndpoint()
data class OltState(val id: String) : McmsEndpoint()
data class OltStats(val id: String) : McmsEndpoint()
data class OltLogs(val id: String) : McmsEndpoint()
data class OltReset(val id: String) : McmsEndpoint()
data object OnuConfigs : McmsEndpoint()
data class OnuConfig(val id: String) : McmsEndpoint()
data object OnuStates : McmsEndpoint()
data class OnuState(val id: String) : McmsEndpoint()
data class OnuStats(val id: String) : McmsEndpoint()
data class OnuLogs(val id: String) : McmsEndpoint()
data object OnuAlarmHistories : McmsEndpoint()
data object OnuCpeStates : McmsEndpoint()
data class OnuReset(val id: String) : McmsEndpoint()
data object OnuFirmwareFiles : McmsEndpoint()
data class OnuCpe(val id: String) : McmsEndpoint() // versionless web helper
fun path(version: String): String {
val v = "/$version"
return when (this) {
Authenticate -> "$v/users/authenticate/"
Logout -> "$v/users/logout/"
DatabaseSelection -> "$v/databases/selection/"
Version -> "$v/ponmgr/version/"
ControllerConfigs -> "$v/controllers/configs/"
is ControllerConfig -> "$v/controllers/configs/$id/"
is ControllerStats -> "$v/controllers/stats/$id/"
is ControllerLogs -> "$v/controllers/logs/$id/"
OltConfigs -> "$v/olts/configs/"
is OltConfig -> "$v/olts/configs/$id/"
OltStates -> "$v/olts/states/"
is OltState -> "$v/olts/states/$id/"
is OltStats -> "$v/olts/stats/$id/"
is OltLogs -> "$v/olts/logs/$id/"
is OltReset -> "$v/olts/$id/reset/"
OnuConfigs -> "$v/onus/configs/"
is OnuConfig -> "$v/onus/configs/$id/"
OnuStates -> "$v/onus/states/"
is OnuState -> "$v/onus/states/$id/"
is OnuStats -> "$v/onus/stats/$id/"
is OnuLogs -> "$v/onus/logs/$id/"
OnuAlarmHistories -> "$v/onus/alarm-histories/"
OnuCpeStates -> "$v/onus/cpe-states/"
is OnuReset -> "$v/onus/$id/reset/"
OnuFirmwareFiles -> "$v/files/onu-firmware/"
is OnuCpe -> "/cpe/onu/$id/"
}
}
}

View file

@ -0,0 +1,212 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.JsonElement
// ───────────────────────── Enums ─────────────────────────
/** ONU registration buckets from OLT-STATE `"ONU States"` (dev guide Procedure 2). */
enum class OnuLifecycleState(val label: String) {
REGISTERED("Registered"),
UNSPECIFIED("Unspecified"),
UNPROVISIONED("Unprovisioned"),
DEREGISTERED("Deregistered"),
DYING_GASP("Dying Gasp"),
DISABLED("Disabled"),
DISALLOWED_ADMIN("Disallowed Admin"),
DISALLOWED_ERROR("Disallowed Error"),
DISALLOWED_REG_ID("Disallowed Reg ID"),
UNKNOWN("Unknown");
/** Registered or Unspecified counts as online (dev guide Procedure 2). */
val isOnline: Boolean get() = this == REGISTERED || this == UNSPECIFIED
companion object {
fun fromBucket(name: String): OnuLifecycleState =
entries.firstOrNull { it.label.equals(name, ignoreCase = true) } ?: UNKNOWN
}
}
enum class AlarmSeverity(val rank: Int, val label: String) {
EMERGENCY(0, "emergency"), ALERT(1, "alert"), CRITICAL(2, "critical"),
ERROR(3, "error"), WARNING(4, "warning"), NOTICE(5, "notice"),
INFO(6, "info"), DEBUG(7, "debug"), UNKNOWN(8, "unknown");
companion object {
fun from(raw: String?): AlarmSeverity =
entries.firstOrNull { it.label.equals(raw, ignoreCase = true) } ?: UNKNOWN
}
}
// ───────────────────────── ONU-STATE ─────────────────────────
class OnuStateDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val serialNumber: String? get() = raw["_id"].string
/** Parent OLT MAC, from `OLT.MAC Address` (dev guide Procedure 2). */
val parentOlt: String? get() = raw["OLT"]["MAC Address"].string
val lastUpdate: String? get() = raw["Time"].string
/** Registration lives on OLT-STATE; resolved + stamped by the list/dashboard VMs. */
var resolvedLifecycle: OnuLifecycleState? = null
val lifecycle: OnuLifecycleState get() = resolvedLifecycle ?: OnuLifecycleState.UNKNOWN
/** Operator name from ONU-CFG; stamped by the VM. */
var resolvedName: String? = null
val displayName: String get() = resolvedName?.takeIf { it.isNotEmpty() } ?: id
private val onu get() = raw["ONU"]
val registrationState: String? get() = onu["Registration State"].string
val equipmentId: String? get() = onu["Equipment ID"].string
val vendor: String? get() = onu["Vendor"].string
val ponMode: String? get() = onu["PON Mode"].string
val onlineTime: String? get() = onu["Online Time"].string
val temperatureC: Double? get() = raw["GPON"]["Temperature"].double
val fwVersion: String? get() = onu["FW Version"].string
val fwBankVersions: List<String> get() = onu["FW Bank Versions"].array?.mapNotNull { it.string } ?: emptyList()
val fwBankPtr: Int? get() = onu["FW Bank Ptr"].int
val fwUpgradeStatus: JsonElement? get() = onu["FW Upgrade Status"]?.takeIf { it.obj?.isNotEmpty() == true }
private fun ponStat(pon: String, key: String): Double? = raw["STATS"][pon][key].double
val rxOpticalDBm: Double? get() = ponStat("ONU-PON", "RX Optical Level")
val txOpticalDBm: Double? get() = ponStat("ONU-PON", "TX Optical Level")
val onuPreFec: Double? get() = ponStat("ONU-PON", "RX Pre-FEC BER")
val onuPostFec: Double? get() = ponStat("ONU-PON", "RX Post-FEC BER")
val oltPreFec: Double? get() = ponStat("OLT-PON", "RX Pre-FEC BER")
val oltPostFec: Double? get() = ponStat("OLT-PON", "RX Post-FEC BER")
val uniPorts: List<UniPort> get() = (raw.obj ?: emptyMap()).entries
.filter { it.key.startsWith("UNI-") }.sortedBy { it.key }
.map { UniPort(it.key, it.value) }
val ponServices: List<PonServiceStats> get() = (raw["STATS"].obj ?: emptyMap()).entries
.filter { it.key.startsWith("OLT-PON Service ") }.sortedBy { it.key }
.map { PonServiceStats(it.key, it.value) }
}
class UniPort(val name: String, val raw: JsonElement) {
val isEthernet: Boolean get() = name.contains("ETH", ignoreCase = true)
val enabled: Boolean? get() = raw["Enable"].bool
val speed: String? get() = raw["Speed"].string
val duplex: String? get() = raw["Duplex"].string
val state: String? get() = raw["State"].string
}
class PonServiceStats(val name: String, val raw: JsonElement) {
val rxRateBps: Double? get() = raw["RX Rate bps"].double
val txRateBps: Double? get() = raw["TX Rate bps"].double
val rxTotalOctets: Double? get() = raw["RX Total Octets"].double
val txTotalOctets: Double? get() = raw["TX Total Octets"].double
}
// ───────────────────────── ONU-CFG ─────────────────────────
class OnuConfigDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val name: String? get() = raw["ONU"]["Name"].string
}
// ───────────────────────── OLT-STATE / OLT-CFG ─────────────────────────
class OltStateDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val lastUpdate: String? get() = raw["Time"].string
var resolvedName: String? = null
val displayName: String get() = resolvedName?.takeIf { it.isNotEmpty() } ?: id
val onuStatesByBucket: Map<String, List<String>> get() {
val o = raw["ONU States"].obj ?: return emptyMap()
return o.mapValues { (_, v) -> v.array?.mapNotNull { it.string } ?: emptyList() }
}
val totalOnus: Int get() = onuStatesByBucket.values.sumOf { it.size }
val onlineOnus: Int get() = onuStatesByBucket.entries.sumOf { (bucket, ids) ->
if (OnuLifecycleState.fromBucket(bucket).isOnline) ids.size else 0
}
val populatedBuckets: List<Pair<OnuLifecycleState, List<String>>> get() = onuStatesByBucket
.filterValues { it.isNotEmpty() }
.map { OnuLifecycleState.fromBucket(it.key) to it.value.sorted() }
.sortedBy { it.first.ordinal }
private val olt get() = raw["OLT"]
private val ponStats get() = raw["STATS"]["OLT-PON"]
val onlineTime: String? get() = olt["Online Time"].string
val firmwareVersion: String? get() = olt["FW Version"].string
val model: String? get() = olt["Model"].string
val ponMode: String? get() = olt["PON Mode"].string
val statsTotalOnus: Int? get() = ponStats["Total ONUs Count"].int
val statsOnlineOnus: Int? get() = ponStats["Online ONUs Count"].int
val rxUtilPercent: Int? get() = ponStats["RX BW Total Util"].int
val txUtilPercent: Int? get() = ponStats["TX BW Total Util"].int
val rxRateBps: Double? get() = ponStats["RX BW Ethernet Rate bps"].double
val txRateBps: Double? get() = ponStats["TX BW Ethernet Rate bps"].double
val asicTempC: Int? get() = raw["STATS"]["OLT-TEMP"]["ASIC"].int
val voltage: Double? get() = raw["STATS"]["OLT-ENV"]["Voltage"].double
val laserShutdown: String? get() = olt["Laser Shutdown"].string
val isLaserOff: Boolean get() = laserShutdown?.let { !it.equals("Laser ON", ignoreCase = true) } ?: false
val switchName: String? get() = raw["Switch"]["System Name"].string?.takeIf { it.isNotEmpty() }
val switchPort: String? get() = raw["Switch"]["Port ID"].string?.takeIf { it.isNotEmpty() }
val switchAddress: String? get() = raw["Switch"]["IPv4 Address"].string?.takeIf { it.isNotEmpty() }
}
class OltConfigDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
/** Operator label `OLT.Name` (NOT `NETCONF.Name`, which defaults to the MAC). */
val name: String? get() = raw["OLT"]["Name"].string?.takeIf { it.isNotEmpty() }
}
class ControllerConfigDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
}
// ───────────────────────── Alarms / logs / stats / CPE ─────────────────────────
class AlarmHistoryDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val severity: AlarmSeverity get() = AlarmSeverity.from(raw["Severity"].string ?: raw["severity"].string)
val message: String? get() = raw["Message"].string ?: raw["message"].string
val raisedAt: String? get() = raw["Time"].string ?: raw["timestamp"].string
}
class LogEntry(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val severity: AlarmSeverity get() = AlarmSeverity.from(raw["Severity"].string ?: raw["severity"].string)
val message: String? get() = raw["Message"].string ?: raw["message"].string
val time: String? get() = raw["Time"].string ?: raw["timestamp"].string
}
class StatSample(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val timestamp: String? get() = raw["Time"].string ?: raw["timestamp"].string
val numericMetrics: List<Pair<String, Double>> get() = (raw.obj ?: emptyMap())
.mapNotNull { (k, v) -> v.double?.let { k to it } }.sortedBy { it.first }
}
/** A CPE behind an ONU with its DHCP lease — from the `/cpe/onu/<id>/` helper. */
class CpeState(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val mac: String? get() = raw["_id"].string
val dhcpState: String? get() = raw["DHCP"]["Client State"].string?.takeIf { it.isNotEmpty() }
val ipv4: String? get() = raw["DHCP"]["Client IP Addr"].string?.takeIf { it.isNotEmpty() }
val dhcpServer: String? get() = raw["DHCP"]["Server ID"].string?.takeIf { it.isNotEmpty() }
val dhcpLeaseSeconds: Int? get() = raw["DHCP"]["Lease Time"].int
val circuitId: String? get() = raw["DHCP"]["Circuit ID"].string?.takeIf { it.isNotEmpty() }
val dhcpLastSuccess: String? get() = raw["DHCP"]["Last Success Time"].string?.takeIf { it.isNotEmpty() }
val dhcpv6State: String? get() = raw["DHCPV6"]["Client State"].string?.takeIf { it.isNotEmpty() }
val ipv6: String? get() = raw["DHCPV6"]["Client IP Addr"].string?.takeIf { it.isNotEmpty() }
val ipv6ValidLifetime: String? get() = raw["DHCPV6"]["Valid Lifetime"].string?.takeIf { it.isNotEmpty() }
val ipv6Prefix: String? get() {
val entry = raw["DHCPV6"]["Prefix Delegation"].array?.firstOrNull() ?: return null
val prefixObj = entry["Prefixes"].array?.firstOrNull() ?: return null
val prefix = prefixObj["Prefix"].string ?: return null
val len = prefixObj["Prefix Length"].int ?: return null
return "$prefix/$len"
}
val hasLease: Boolean get() = ipv4 != null || ipv6 != null
}

View file

@ -0,0 +1,90 @@
package no.svorka.mcms.core
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
/**
* In-memory cookie jar (per host), so we can both replay cookies and read the
* CSRF cookie value. Sessions are discarded on logout/app-kill.
*/
class SessionCookieJar : CookieJar {
private val store = mutableMapOf<String, MutableList<Cookie>>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val list = store.getOrPut(url.host) { mutableListOf() }
cookies.forEach { c ->
list.removeAll { it.name == c.name }
list.add(c)
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
store[url.host]?.filter { it.matches(url) } ?: emptyList()
fun valueMatching(host: String, base: String): String? =
store[host]?.firstOrNull { matches(it.name, base) }?.value
fun hasCookie(host: String, base: String): Boolean =
store[host]?.any { matches(it.name, base) } == true
fun clear(host: String) { store.remove(host) }
// Modern MCMS builds emit `__Host-`/`__Secure-` prefixed cookies; accept either.
private fun matches(name: String, base: String) =
name == base || name == "__Host-$base" || name == "__Secure-$base"
}
/** Session state for one connection: the cookie jar + CSRF token access. */
class SessionStore(private val config: ApiConfiguration) {
val cookieJar = SessionCookieJar()
var isAuthenticated = false
private set
fun markAuthenticated() { isAuthenticated = true }
/** CSRF token to echo as the `X-CSRFToken` header. Null before login. */
fun csrfToken(): String? = config.host?.let { cookieJar.valueMatching(it, config.csrfCookieName) }
val hasSessionCookie: Boolean
get() = config.host?.let { cookieJar.hasCookie(it, config.sessionCookieName) } == true
fun clear() {
config.host?.let { cookieJar.clear(it) }
isAuthenticated = false
}
}
/** Applies a [ServerTrustPolicy] to an OkHttp client builder. */
object ServerTrust {
fun apply(builder: OkHttpClient.Builder, policy: ServerTrustPolicy) {
when (policy) {
is ServerTrustPolicy.System -> Unit // default platform trust
is ServerTrustPolicy.AllowSelfSignedForHost -> {
// Accept ANY certificate, but the hostname verifier restricts it to
// the one allowed host (matches the iOS self-signed escape hatch).
val trustAll = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val ctx = SSLContext.getInstance("TLS").apply {
init(null, arrayOf<javax.net.ssl.TrustManager>(trustAll), SecureRandom())
}
builder.sslSocketFactory(ctx.socketFactory, trustAll)
builder.hostnameVerifier { hostname, _ -> hostname.equals(policy.host, ignoreCase = true) }
}
is ServerTrustPolicy.PinPublicKeySha256 -> {
val pinner = okhttp3.CertificatePinner.Builder().apply {
policy.pins.forEach { add(policy.host, "sha256/$it") }
}.build()
builder.certificatePinner(pinner)
}
}
}
}

View file

@ -0,0 +1,132 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class AuthRepository(
private val client: ApiClient,
private val session: SessionStore,
private val config: ApiConfiguration,
) {
suspend fun login(email: String, password: String) {
val body = buildJsonObject { put("email", email); put("password", password) }
// Login body is unwrapped (matches the official 6.2 dev guide).
client.send("POST", McmsEndpoint.Authenticate, body, wrapInData = false)
session.markAuthenticated()
config.databaseId?.let { selectDatabase(it) }
}
suspend fun selectDatabase(id: String) {
// `{ "data": "<db id>" }` — a bare string.
client.send("PUT", McmsEndpoint.DatabaseSelection, JsonPrimitive(id), wrapInData = true)
}
suspend fun logout() {
runCatching { client.action("GET", McmsEndpoint.Logout) }
session.clear()
}
suspend fun version(): VersionInfo = VersionInfo(client.getElement(McmsEndpoint.Version))
}
class OnuRepository(private val client: ApiClient) {
suspend fun states(query: ApiQuery = ApiQuery.EMPTY): List<OnuStateDoc> =
client.getList(McmsEndpoint.OnuStates, query).map { OnuStateDoc(it) }
suspend fun allStates(): List<OnuStateDoc> =
client.getAll(McmsEndpoint.OnuStates) { it["_id"].string }.map { OnuStateDoc(it) }
suspend fun state(id: String): OnuStateDoc = OnuStateDoc(client.getElement(McmsEndpoint.OnuState(id)))
suspend fun config(id: String): OnuConfigDoc = OnuConfigDoc(client.getElement(McmsEndpoint.OnuConfig(id)))
/** id → operator name via projection (only `ONU.Name` comes back). */
suspend fun nameMap(): Map<String, String> {
val q = ApiQuery(projection = mapOf("ONU.Name" to 1))
return client.getAll(McmsEndpoint.OnuConfigs, baseQuery = q) { it["_id"].string }
.map { OnuConfigDoc(it) }
.mapNotNull { c -> c.name?.takeIf { it.isNotEmpty() }?.let { c.id to it } }
.toMap()
}
suspend fun stats(id: String, lastHours: Int = 24): List<StatSample> =
client.getList(McmsEndpoint.OnuStats(id), ApiQuery.window(lastHours)).map { StatSample(it) }
suspend fun logs(id: String, lastHours: Int = 24): List<LogEntry> =
client.getList(McmsEndpoint.OnuLogs(id), ApiQuery.window(lastHours)).map { LogEntry(it) }
suspend fun alarmHistories(query: ApiQuery = ApiQuery.EMPTY): List<AlarmHistoryDoc> =
client.getList(McmsEndpoint.OnuAlarmHistories, query).map { AlarmHistoryDoc(it) }
/** CPE DHCP state from the `/cpe/onu/<id>/` helper — a bare `[code, cpe…]` array. */
suspend fun cpes(id: String): List<CpeState> =
client.getRaw(McmsEndpoint.OnuCpe(id)).array?.filter { it.isObject }?.map { CpeState(it) } ?: emptyList()
suspend fun reset(id: String) { client.action("PUT", McmsEndpoint.OnuReset(id)) }
suspend fun firmwareFiles(): List<FirmwareFile> =
client.getList(McmsEndpoint.OnuFirmwareFiles).map { FirmwareFile(it) }
/** Procedure 7: stage firmware in the inactive bank. Returns the slot. Service-affecting. */
suspend fun upgradeFirmware(id: String, file: FirmwareFile): Int {
val filename = file.filename ?: throw ApiError.Encoding("Firmware file is missing a filename.")
val version = file.version ?: throw ApiError.Encoding("Firmware file is missing a version.")
val current = config(id)
val result = FirmwareUpgrade.apply(current.raw, filename, version)
?: throw ApiError.Encoding("Unexpected ONU-CFG shape — firmware write aborted.")
client.send("PUT", McmsEndpoint.OnuConfig(id), result.document, wrapInData = true)
return result.slot
}
}
class OltRepository(private val client: ApiClient) {
suspend fun states(query: ApiQuery = ApiQuery.EMPTY): List<OltStateDoc> =
client.getList(McmsEndpoint.OltStates, query).map { OltStateDoc(it) }
suspend fun allStates(): List<OltStateDoc> =
client.getAll(McmsEndpoint.OltStates) { it["_id"].string }.map { OltStateDoc(it) }
suspend fun state(id: String): OltStateDoc = OltStateDoc(client.getElement(McmsEndpoint.OltState(id)))
suspend fun allConfigs(): List<OltConfigDoc> =
client.getAll(McmsEndpoint.OltConfigs) { it["_id"].string }.map { OltConfigDoc(it) }
suspend fun nameMap(): Map<String, String> {
val q = ApiQuery(projection = mapOf("OLT.Name" to 1))
return client.getAll(McmsEndpoint.OltConfigs, baseQuery = q) { it["_id"].string }
.map { OltConfigDoc(it) }
.mapNotNull { c -> c.name?.takeIf { it.isNotEmpty() && it != c.id }?.let { c.id to it } }
.toMap()
}
suspend fun reset(id: String) { client.action("PUT", McmsEndpoint.OltReset(id)) }
suspend fun registrationMap(): Map<String, OnuLifecycleState> =
OltRepository.registrationMap(allStates())
companion object {
/** Invert OLT-STATE buckets → onuId: state; prefer non-Registered on duplicate (§3.11). */
fun registrationMap(olts: List<OltStateDoc>): Map<String, OnuLifecycleState> {
val map = mutableMapOf<String, OnuLifecycleState>()
for (olt in olts) {
for ((bucket, ids) in olt.onuStatesByBucket) {
val state = OnuLifecycleState.fromBucket(bucket)
for (id in ids) {
val existing = map[id]
when {
existing == null -> map[id] = state
existing == OnuLifecycleState.REGISTERED && state != OnuLifecycleState.REGISTERED -> map[id] = state
}
}
}
}
return map
}
}
}
class ControllerRepository(private val client: ApiClient) {
suspend fun allConfigs(): List<ControllerConfigDoc> =
client.getAll(McmsEndpoint.ControllerConfigs) { it["_id"].string }.map { ControllerConfigDoc(it) }
}

View file

@ -0,0 +1,70 @@
package no.svorka.mcms.core
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Parses/renders MCMS timestamps: UTC with a literal space, e.g.
* `2026-05-04 21:51:57.394127`. Try microsecond precision first, then seconds.
*/
object MongoTimestamp {
private val withMicros = utc("yyyy-MM-dd HH:mm:ss.SSSSSS")
private val seconds = utc("yyyy-MM-dd HH:mm:ss")
private val display = SimpleDateFormat("d MMM yyyy, HH:mm", Locale.getDefault())
private fun utc(pattern: String) = SimpleDateFormat(pattern, Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
isLenient = false
}
fun parse(s: String?): Date? {
if (s == null) return null
return runCatching { withMicros.parse(s) }.getOrNull()
?: runCatching { seconds.parse(s) }.getOrNull()
}
fun displayString(s: String?): String? {
if (s == null) return null
val d = parse(s) ?: return s
return display.format(d)
}
/** Uptime etc., computed from two MCMS timestamps so it uses the device clock. */
fun duration(from: String?, to: String?): String? {
val start = parse(from) ?: return null
val end = parse(to) ?: return null
val total = (end.time - start.time) / 1000
if (total < 0) return null
val days = total / 86400
val hours = (total % 86400) / 3600
val mins = (total % 3600) / 60
return when {
days > 0 -> "${days}d ${hours}h"
hours > 0 -> "${hours}h ${mins}m"
else -> "${mins}m"
}
}
}
/** Compact human formatting for counters in the UI. */
object Format {
fun bps(value: Double?): String {
if (value == null || value < 0) return ""
val units = listOf("bps", "kbps", "Mbps", "Gbps", "Tbps")
var x = value; var i = 0
while (x >= 1000 && i < units.size - 1) { x /= 1000; i++ }
return if (i == 0 || x >= 100) "${x.toLong()} ${units[i]}" else "${"%.1f".format(x)} ${units[i]}"
}
fun bytes(value: Double?): String {
if (value == null || value < 0) return ""
val units = listOf("B", "KB", "MB", "GB", "TB", "PB")
var x = value; var i = 0
while (x >= 1024 && i < units.size - 1) { x /= 1024; i++ }
return if (i == 0 || x >= 100) "${x.toLong()} ${units[i]}" else "${"%.1f".format(x)} ${units[i]}"
}
fun percent(value: Int?): String = value?.let { "$it%" } ?: ""
}

View file

@ -0,0 +1,83 @@
package no.svorka.mcms.ui
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import no.svorka.mcms.core.ApiConfiguration
import no.svorka.mcms.core.ApiError
import no.svorka.mcms.core.KeychainStore
import no.svorka.mcms.core.MCMSJson
import no.svorka.mcms.core.McmsConnection
/**
* Top-level app state: owns the active [McmsConnection], persists the (non-secret)
* [ApiConfiguration] to SharedPreferences, and keeps credentials in the Keystore.
*/
class AppState(context: Context) {
private val prefs = context.getSharedPreferences("mcms.app", Context.MODE_PRIVATE)
private val keychain = KeychainStore(context)
var configuration by mutableStateOf<ApiConfiguration?>(null)
private set
var connection by mutableStateOf<McmsConnection?>(null)
private set
var isAuthenticated by mutableStateOf(false)
val isConfigured: Boolean get() = configuration != null
init { loadConfiguration() }
fun configure(config: ApiConfiguration) {
configuration = config
connection = makeConnection(config)
isAuthenticated = false
prefs.edit().putString("config", MCMSJson.encodeToString(ApiConfiguration.serializer(), config)).apply()
}
private fun makeConnection(config: ApiConfiguration): McmsConnection {
val conn = McmsConnection(config)
conn.onAuthExpired = { isAuthenticated = false }
return conn
}
private fun loadConfiguration() {
val json = prefs.getString("config", null) ?: return
runCatching { MCMSJson.decodeFromString(ApiConfiguration.serializer(), json) }.getOrNull()?.let {
configuration = it
connection = makeConnection(it)
}
}
val savedEmail: String? get() = keychain.get("email")
val savedPassword: String? get() = keychain.get("password")
suspend fun signIn(email: String, password: String, remember: Boolean) {
val conn = connection ?: throw ApiError.Transport("No connection configured")
conn.auth.login(email, password)
if (remember) {
keychain.set("email", email)
keychain.set("password", password)
}
isAuthenticated = true
}
suspend fun signOut() {
connection?.auth?.logout()
isAuthenticated = false
}
/** Reachability probe: a 401/403/404 means the host answered, which is success. */
suspend fun probe(config: ApiConfiguration): String {
val conn = McmsConnection(config)
return try {
val version = conn.auth.version()
version.displayVersion?.let { "PON Manager $it" } ?: "Reachable"
} catch (e: ApiError) {
when (e) {
is ApiError.Unauthorized, is ApiError.Forbidden, ApiError.NotFound -> "Reachable — sign in to continue"
else -> throw e
}
}
}
}

View file

@ -0,0 +1,171 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import no.svorka.mcms.core.ApiConfiguration
import no.svorka.mcms.core.ServerTrustPolicy
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(app: AppState, onOpenSettings: () -> Unit) {
var email by rememberSaveable { mutableStateOf(app.savedEmail ?: "") }
var password by rememberSaveable { mutableStateOf(app.savedPassword ?: "") }
var remember by rememberSaveable { mutableStateOf(true) }
var phase by remember { mutableStateOf<LoadPhase>(LoadPhase.Idle) }
val scope = rememberCoroutineScope()
Scaffold(topBar = {
TopAppBar(
title = { Text("PON Go") },
actions = { IconButton(onClick = onOpenSettings) { Icon(Icons.Filled.Settings, contentDescription = "Settings") } },
)
}) { padding ->
Column(Modifier.padding(padding).padding(16.dp).fillMaxSize()) {
OutlinedTextField(
value = email, onValueChange = { email = it }, label = { Text("Email") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password, onValueChange = { password = it }, label = { Text("Password") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = remember, onCheckedChange = { remember = it })
Text("Remember me")
}
app.configuration?.host?.let {
Text("Connecting to $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.height(16.dp))
Button(
onClick = {
phase = LoadPhase.Loading
scope.launch {
phase = try { app.signIn(email, password, remember); LoadPhase.Loaded }
catch (e: Throwable) { LoadPhase.Failed(e.uiMessage()) }
}
},
enabled = email.isNotBlank() && password.isNotBlank() && phase != LoadPhase.Loading,
modifier = Modifier.fillMaxWidth(),
) {
if (phase == LoadPhase.Loading) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
else Text("Sign in")
}
(phase as? LoadPhase.Failed)?.let {
Spacer(Modifier.height(8.dp))
Text(it.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) {
var url by rememberSaveable { mutableStateOf(app.configuration?.baseUrl ?: "https://") }
var apiVersion by rememberSaveable { mutableStateOf(app.configuration?.apiVersion ?: "v1") }
var dbId by rememberSaveable { mutableStateOf(app.configuration?.databaseId ?: "") }
var allowSelfSigned by rememberSaveable {
mutableStateOf(app.configuration?.trustPolicy is ServerTrustPolicy.AllowSelfSignedForHost)
}
var testPhase by remember { mutableStateOf<LoadPhase>(LoadPhase.Idle) }
var testResult by remember { mutableStateOf<String?>(null) }
var saveError by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
fun makeConfig(): ApiConfiguration? {
val base = ApiConfiguration.normalizedBaseUrl(url) ?: return null
val host = ApiConfiguration(baseUrl = base).host
val policy = if (allowSelfSigned && host != null) ServerTrustPolicy.AllowSelfSignedForHost(host)
else ServerTrustPolicy.System
return ApiConfiguration(
baseUrl = base, apiVersion = apiVersion.trim(),
databaseId = dbId.ifBlank { null }, trustPolicy = policy,
)
}
val canSave = ApiConfiguration.normalizedBaseUrl(url) != null && apiVersion.isNotBlank()
Scaffold(topBar = {
TopAppBar(
title = { Text(if (initialSetup) "Set up connection" else "Settings") },
navigationIcon = { if (!initialSetup) TextButton(onClick = onClose) { Text("Close") } },
actions = {
TextButton(
enabled = canSave,
onClick = {
val config = makeConfig()
if (config == null) { saveError = "Enter a valid URL including scheme (https://)." }
else { saveError = null; app.configure(config); if (!initialSetup) onClose() }
},
) { Text("Save") }
},
)
}) { padding ->
Column(
Modifier.padding(padding).padding(16.dp).fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = url, onValueChange = { url = it }, label = { Text("Server URL") }, singleLine = true,
supportingText = { Text("Host or origin, e.g. https://mcms.lab.svorka.net — “/api” is appended automatically.") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = apiVersion, onValueChange = { apiVersion = it }, label = { Text("API version") }, singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = dbId, onValueChange = { dbId = it }, label = { Text("Database ID (optional)") }, singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = allowSelfSigned, onCheckedChange = { allowSelfSigned = it })
Spacer(Modifier.width(8.dp))
Text("Allow self-signed certificate")
}
Button(
enabled = canSave && testPhase != LoadPhase.Loading,
onClick = {
val config = makeConfig() ?: return@Button
testPhase = LoadPhase.Loading; testResult = null
scope.launch {
try { testResult = app.probe(config); testPhase = LoadPhase.Loaded }
catch (e: Throwable) { testResult = e.uiMessage(); testPhase = LoadPhase.Failed(e.uiMessage()) }
}
},
) {
if (testPhase == LoadPhase.Loading) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
else Text("Test connection")
}
testResult?.let {
val isErr = testPhase is LoadPhase.Failed
Text(it, color = if (isErr) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall)
}
saveError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (!initialSetup) {
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = { scope.launch { app.signOut() } }) { Text("Sign out") }
}
}
}
}

View file

@ -0,0 +1,118 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun StateBadge(text: String, color: Color) {
Surface(color = color.copy(alpha = 0.18f), shape = RoundedCornerShape(50)) {
Text(
text,
color = color,
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
@Composable
fun LoadingView(label: String = "Loading…") {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
fun ErrorState(message: String, onRetry: (() -> Unit)? = null) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Filled.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
Text("Something went wrong", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Text(message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (onRetry != null) {
Spacer(Modifier.height(16.dp))
Button(onClick = onRetry) { Text("Retry") }
}
}
}
}
@Composable
fun CountTile(title: String, value: Int, modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Column(Modifier.padding(14.dp)) {
Text(title, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(6.dp))
Text("$value", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
}
}
}
@Composable
fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Column {
Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp))
Card { Column(Modifier.padding(14.dp), content = content) }
}
}
@Composable
fun KeyValueRow(label: String, value: String) {
Row(
Modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
fun BackButton(onBack: () -> Unit) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }
}
@Composable
fun ListRow(onClick: (() -> Unit)? = null, content: @Composable RowScope.() -> Unit) {
Row(
Modifier.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}

View file

@ -0,0 +1,232 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import no.svorka.mcms.core.*
class DashboardModel(private val connection: McmsConnection) {
var phase by mutableStateOf<LoadPhase>(LoadPhase.Idle)
var onuTotal by mutableStateOf(0)
var oltCount by mutableStateOf(0)
var controllerCount by mutableStateOf(0)
var onuCounts by mutableStateOf<List<Pair<OnuLifecycleState, Int>>>(emptyList())
var severityCounts by mutableStateOf<List<Pair<AlarmSeverity, Int>>>(emptyList())
var totalDownBps by mutableStateOf(0.0)
var totalUpBps by mutableStateOf(0.0)
var abnormalRx by mutableStateOf<List<OnuStateDoc>>(emptyList())
var lasersOff by mutableStateOf<List<OltStateDoc>>(emptyList())
var opticalAvailable by mutableStateOf(false)
suspend fun load(extraStats: Boolean) {
phase = LoadPhase.Loading
try {
val olts = connection.olt.allStates()
oltCount = olts.size
val reg = OltRepository.registrationMap(olts)
onuTotal = reg.size
onuCounts = reg.values.groupingBy { it }.eachCount().toList().sortedByDescending { it.second }
totalDownBps = olts.mapNotNull { it.txRateBps }.sum()
totalUpBps = olts.mapNotNull { it.rxRateBps }.sum()
lasersOff = olts.filter { it.isLaserOff }
controllerCount = runCatching { connection.controller.allConfigs().size }.getOrDefault(0)
val alarms = runCatching { connection.onu.alarmHistories() }.getOrDefault(emptyList())
severityCounts = alarms.groupingBy { it.severity }.eachCount().toList().sortedBy { it.first.rank }
if (extraStats) {
val onus = connection.onu.allStates()
opticalAvailable = onus.any { it.rxOpticalDBm != null }
abnormalRx = onus.filter { val rx = it.rxOpticalDBm; rx != null && (rx < -28 || rx > -10) }
} else {
opticalAvailable = false; abnormalRx = emptyList()
}
phase = LoadPhase.Loaded
} catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage())
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
connection: McmsConnection,
openAbnormalRx: (List<OnuStateDoc>) -> Unit,
openLaserOff: (List<OltStateDoc>) -> Unit,
) {
val model = remember(connection) { DashboardModel(connection) }
var extraStats by rememberSaveable { mutableStateOf(false) }
var autoRefresh by rememberSaveable { mutableStateOf(false) }
var menuOpen by remember { mutableStateOf(false) }
LaunchedEffect(extraStats, autoRefresh) {
while (true) {
model.load(extraStats)
if (!autoRefresh) break
delay(30_000)
}
}
Scaffold(topBar = {
TopAppBar(
title = { Text("Dashboard") },
actions = {
IconButton(onClick = { menuOpen = true }) { Icon(Icons.Filled.MoreVert, "Options") }
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("Detailed stats (Rx scan)") },
trailingIcon = { Switch(checked = extraStats, onCheckedChange = { extraStats = it }) },
onClick = { extraStats = !extraStats },
)
DropdownMenuItem(
text = { Text("Auto-refresh · 30s") },
trailingIcon = { Switch(checked = autoRefresh, onCheckedChange = { autoRefresh = it }) },
onClick = { autoRefresh = !autoRefresh },
)
}
},
)
}) { padding ->
Box(Modifier.padding(padding).fillMaxSize()) {
when (val p = model.phase) {
is LoadPhase.Idle, is LoadPhase.Loading -> if (model.onuTotal == 0 && model.oltCount == 0) LoadingView("Loading network…") else content(model, openAbnormalRx, openLaserOff)
is LoadPhase.Failed -> ErrorState(p.message)
is LoadPhase.Loaded -> content(model, openAbnormalRx, openLaserOff)
}
}
}
}
@Composable
private fun content(
model: DashboardModel,
openAbnormalRx: (List<OnuStateDoc>) -> Unit,
openLaserOff: (List<OltStateDoc>) -> Unit,
) {
LazyColumn(
Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
CountTile("ONUs", model.onuTotal, Modifier.weight(1f))
CountTile("OLTs", model.oltCount, Modifier.weight(1f))
CountTile("Controllers", model.controllerCount, Modifier.weight(1f))
}
}
if (model.totalDownBps > 0 || model.totalUpBps > 0) {
item {
SectionCard("Traffic · all OLTs") {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text("↓ Downstream", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(Format.bps(model.totalDownBps), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
Column(horizontalAlignment = Alignment.End) {
Text("↑ Upstream", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(Format.bps(model.totalUpBps), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
}
}
}
}
if (model.opticalAvailable || model.lasersOff.isNotEmpty()) {
item {
SectionCard("Health") {
if (model.opticalAvailable) {
HealthRow("ONUs with abnormal Rx", "< 28 or > 10 dBm", model.abnormalRx.size) {
if (model.abnormalRx.isNotEmpty()) openAbnormalRx(model.abnormalRx)
}
}
HealthRow("OLTs with laser off", null, model.lasersOff.size) {
if (model.lasersOff.isNotEmpty()) openLaserOff(model.lasersOff)
}
}
}
}
if (model.onuCounts.isNotEmpty()) {
item {
SectionCard("ONU states") {
model.onuCounts.forEach { (state, count) ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
StateBadge(state.label, state.color())
Spacer(Modifier.weight(1f))
Text("$count")
}
}
}
}
}
}
}
@Composable
private fun HealthRow(title: String, detail: String?, count: Int, onClick: () -> Unit) {
Row(
Modifier.fillMaxWidth().clickable(enabled = count > 0, onClick = onClick).padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(title)
if (detail != null) Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text("$count", fontWeight = FontWeight.SemiBold, color = if (count > 0) Color(0xFFEF6C00) else Color(0xFF2E7D32))
if (count > 0) Icon(Icons.Filled.ChevronRight, null, tint = MaterialTheme.colorScheme.outline)
}
}
// ── Drill-down screens ──
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AbnormalRxScreen(onus: List<OnuStateDoc>, onBack: () -> Unit, openOnu: (String) -> Unit) {
Scaffold(topBar = { TopAppBar(title = { Text("Abnormal Rx") }, navigationIcon = { BackButton(onBack) }) }) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
items(onus) { onu ->
ListRow(onClick = { openOnu(onu.id) }) {
Column(Modifier.weight(1f)) {
Text(onu.displayName, fontWeight = FontWeight.Medium)
onu.parentOlt?.let { Text("OLT $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
onu.rxOpticalDBm?.let { rx ->
Column(horizontalAlignment = Alignment.End) {
Text("${"%.1f".format(rx)} dBm", color = Color(0xFFEF6C00))
Text(if (rx < -28) "weak" else "strong", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
HorizontalDivider()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LaserOffScreen(olts: List<OltStateDoc>, onBack: () -> Unit, openOlt: (String, String) -> Unit) {
Scaffold(topBar = { TopAppBar(title = { Text("Laser off") }, navigationIcon = { BackButton(onBack) }) }) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
items(olts) { olt ->
ListRow(onClick = { openOlt(olt.id, olt.displayName) }) {
Column(Modifier.weight(1f)) {
Text(olt.displayName, fontWeight = FontWeight.Medium)
Text(olt.laserShutdown ?: "Laser off", style = MaterialTheme.typography.bodySmall, color = Color(0xFFEF6C00))
}
}
HorizontalDivider()
}
}
}
}

View file

@ -0,0 +1,202 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import no.svorka.mcms.core.*
class OltListModel(private val connection: McmsConnection) {
var phase by mutableStateOf<LoadPhase>(LoadPhase.Idle)
var olts by mutableStateOf<List<OltStateDoc>>(emptyList())
var search by mutableStateOf("")
val filtered: List<OltStateDoc>
get() {
val q = search.trim()
if (q.isEmpty()) return olts
return olts.filter { it.id.contains(q, true) || (it.resolvedName?.contains(q, true) == true) }
}
suspend fun load() {
phase = LoadPhase.Loading
try {
val fetched = connection.olt.allStates()
val names = runCatching { connection.olt.nameMap() }.getOrDefault(emptyMap())
fetched.forEach { it.resolvedName = names[it.id] }
olts = fetched.sortedBy { it.displayName.lowercase() }
phase = LoadPhase.Loaded
} catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage())
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OltListScreen(connection: McmsConnection, openOlt: (String, String) -> Unit) {
val model = remember(connection) { OltListModel(connection) }
LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() }
Scaffold(topBar = { TopAppBar(title = { Text("OLTs") }) }) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
OutlinedTextField(
value = model.search, onValueChange = { model.search = it },
placeholder = { Text("OLT name or MAC") }, singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
)
when (val p = model.phase) {
is LoadPhase.Idle, is LoadPhase.Loading -> if (model.olts.isEmpty()) LoadingView("Loading OLTs…") else OltList(model.filtered, openOlt)
is LoadPhase.Failed -> ErrorState(p.message)
is LoadPhase.Loaded -> OltList(model.filtered, openOlt)
}
}
}
}
@Composable
private fun OltList(olts: List<OltStateDoc>, openOlt: (String, String) -> Unit) {
LazyColumn(Modifier.fillMaxSize()) {
items(olts) { olt ->
ListRow(onClick = { openOlt(olt.id, olt.displayName) }) {
Column(Modifier.weight(1f)) {
Text(olt.displayName, fontWeight = FontWeight.Medium)
Text("${olt.totalOnus} ONUs · ${olt.onlineOnus} online", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
val down = olt.totalOnus - olt.onlineOnus
when {
olt.totalOnus == 0 -> StateBadge("no ONUs", Color(0xFF9E9E9E))
down > 0 -> StateBadge("$down down", Color(0xFFEF6C00))
else -> StateBadge("all up", Color(0xFF2E7D32))
}
}
HorizontalDivider()
}
}
}
// ───────────────────────── Detail ─────────────────────────
class OltDetailModel(private val connection: McmsConnection, val oltId: String) {
var phase by mutableStateOf<LoadPhase>(LoadPhase.Idle)
var olt by mutableStateOf<OltStateDoc?>(null)
var actionMessage by mutableStateOf<String?>(null)
var busy by mutableStateOf(false)
suspend fun load() {
phase = LoadPhase.Loading
try { olt = connection.olt.state(oltId); phase = LoadPhase.Loaded }
catch (e: Throwable) { phase = LoadPhase.Failed(e.uiMessage()) }
}
suspend fun reset() {
busy = true; actionMessage = null
try { connection.olt.reset(oltId); actionMessage = "Reset requested." } catch (e: Throwable) { actionMessage = e.uiMessage() }
busy = false
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OltDetailScreen(connection: McmsConnection, oltId: String, title: String, onBack: () -> Unit, openOnu: (String) -> Unit) {
val model = remember(oltId) { OltDetailModel(connection, oltId) }
val scope = rememberCoroutineScope()
var showReset by remember { mutableStateOf(false) }
var menu by remember { mutableStateOf(false) }
LaunchedEffect(oltId) { model.load() }
Scaffold(topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = { BackButton(onBack) },
actions = {
IconButton(onClick = { menu = true }) { Icon(Icons.Filled.MoreVert, "Actions") }
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(text = { Text("Reset OLT", color = MaterialTheme.colorScheme.error) },
enabled = !model.busy, onClick = { menu = false; showReset = true })
}
},
)
}) { padding ->
Box(Modifier.padding(padding).fillMaxSize()) {
when (val p = model.phase) {
is LoadPhase.Idle, is LoadPhase.Loading -> if (model.olt == null) LoadingView() else OltContent(model.olt!!, openOnu)
is LoadPhase.Failed -> ErrorState(p.message) { scope.launch { model.load() } }
is LoadPhase.Loaded -> model.olt?.let { OltContent(it, openOnu) }
}
}
}
model.actionMessage?.let { msg ->
AlertDialog(onDismissRequest = { model.actionMessage = null },
confirmButton = { TextButton(onClick = { model.actionMessage = null }) { Text("OK") } },
title = { Text("Operation") }, text = { Text(msg) })
}
if (showReset) ResetConfirmDialog("OLT", title, onDismiss = { showReset = false }) { scope.launch { model.reset() } }
}
@Composable
private fun OltContent(olt: OltStateDoc, openOnu: (String) -> Unit) {
LazyColumn(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
item {
SectionCard("Status") {
val down = olt.totalOnus - olt.onlineOnus
KeyValueRow("ONUs", "${olt.totalOnus} · ${olt.onlineOnus} online" + if (down > 0) " · $down down" else "")
olt.model?.let { KeyValueRow("Model", it) }
olt.ponMode?.let { KeyValueRow("PON mode", it) }
olt.firmwareVersion?.let { KeyValueRow("Firmware", it) }
olt.onlineTime?.let {
MongoTimestamp.duration(it, olt.lastUpdate)?.let { up -> KeyValueRow("Uptime", up) }
KeyValueRow("Online since", MongoTimestamp.displayString(it) ?: it)
}
}
}
if (olt.rxRateBps != null || olt.txRateBps != null) {
item {
SectionCard("PON traffic") {
KeyValueRow("Downstream", "${Format.bps(olt.txRateBps)} · ${Format.percent(olt.txUtilPercent)} util")
KeyValueRow("Upstream", "${Format.bps(olt.rxRateBps)} · ${Format.percent(olt.rxUtilPercent)} util")
}
}
}
if (olt.asicTempC != null || olt.voltage != null) {
item {
SectionCard("Environment") {
olt.asicTempC?.let { KeyValueRow("ASIC temp", "$it °C") }
olt.voltage?.let { KeyValueRow("Voltage", "%.2f V".format(it)) }
}
}
}
olt.switchName?.let { sw ->
item {
SectionCard("Uplink") {
KeyValueRow("Switch", sw)
olt.switchPort?.let { KeyValueRow("Port", it) }
olt.switchAddress?.let { KeyValueRow("Address", it) }
}
}
}
olt.populatedBuckets.forEach { (state, ids) ->
item {
Row(verticalAlignment = Alignment.CenterVertically) {
StateBadge(state.label, state.color())
Spacer(Modifier.weight(1f))
Text("${ids.size}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
items(ids) { onuId ->
ListRow(onClick = { openOnu(onuId) }) { Text(onuId) }
HorizontalDivider()
}
}
}
}

View file

@ -0,0 +1,453 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import no.svorka.mcms.core.*
// ───────────────────────── List ─────────────────────────
class OnuListModel(private val connection: McmsConnection) {
var phase by mutableStateOf<LoadPhase>(LoadPhase.Idle)
var onus by mutableStateOf<List<OnuStateDoc>>(emptyList())
var search by mutableStateOf("")
val filtered: List<OnuStateDoc>
get() {
val q = search.trim()
if (q.isEmpty()) return onus
return onus.filter {
it.id.contains(q, true) ||
(it.resolvedName?.contains(q, true) == true) ||
(it.parentOlt?.contains(q, true) == true)
}
}
suspend fun load() {
phase = LoadPhase.Loading
try {
val fetched = connection.onu.allStates()
val reg = runCatching { connection.olt.registrationMap() }.getOrDefault(emptyMap())
val names = runCatching { connection.onu.nameMap() }.getOrDefault(emptyMap())
fetched.forEach { it.resolvedLifecycle = reg[it.id]; it.resolvedName = names[it.id] }
onus = fetched.sortedBy { it.displayName.lowercase() }
phase = LoadPhase.Loaded
} catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage())
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OnuListScreen(connection: McmsConnection, openOnu: (String) -> Unit) {
val model = remember(connection) { OnuListModel(connection) }
LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() }
Scaffold(topBar = { TopAppBar(title = { Text("ONUs") }) }) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
OutlinedTextField(
value = model.search, onValueChange = { model.search = it },
placeholder = { Text("Name, serial, or OLT") }, singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
)
when (val p = model.phase) {
is LoadPhase.Idle, is LoadPhase.Loading -> if (model.onus.isEmpty()) LoadingView("Loading ONUs…") else OnuList(model.filtered, openOnu)
is LoadPhase.Failed -> ErrorState(p.message) { /* retry */ }
is LoadPhase.Loaded -> OnuList(model.filtered, openOnu)
}
}
}
}
@Composable
private fun OnuList(onus: List<OnuStateDoc>, openOnu: (String) -> Unit) {
LazyColumn(Modifier.fillMaxSize()) {
items(onus) { onu ->
ListRow(onClick = { openOnu(onu.id) }) {
Column(Modifier.weight(1f)) {
Text(onu.displayName, fontWeight = FontWeight.Medium)
subtitle(onu)?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
StateBadge(onu.lifecycle.label, onu.lifecycle.color())
}
HorizontalDivider()
}
}
}
private fun subtitle(onu: OnuStateDoc): String? {
val parts = buildList {
if (onu.resolvedName != null) add(onu.id)
onu.parentOlt?.let { add("OLT $it") }
}
return parts.joinToString(" · ").ifEmpty { null }
}
// ───────────────────────── Detail ─────────────────────────
private val prettyJson = Json { prettyPrint = true }
class OnuDetailModel(private val connection: McmsConnection, val onuId: String) {
var tab by mutableStateOf(0) // 0 overview · 1 cpe · 2 logs · 3 config
var phase by mutableStateOf<LoadPhase>(LoadPhase.Idle)
var liveState by mutableStateOf<OnuStateDoc?>(null)
var cpes by mutableStateOf<List<CpeState>>(emptyList())
var logs by mutableStateOf<List<LogEntry>>(emptyList())
var config by mutableStateOf<OnuConfigDoc?>(null)
var firmwareFiles by mutableStateOf<List<FirmwareFile>>(emptyList())
var firmwareError by mutableStateOf<String?>(null)
var actionMessage by mutableStateOf<String?>(null)
var busy by mutableStateOf(false)
suspend fun loadTab() { phase = LoadPhase.Loading; reload() }
suspend fun refresh() { reload() }
private suspend fun reload() {
try {
when (tab) {
0 -> liveState = connection.onu.state(onuId)
1 -> cpes = connection.onu.cpes(onuId)
2 -> logs = connection.onu.logs(onuId)
3 -> config = connection.onu.config(onuId)
}
phase = LoadPhase.Loaded
} catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage())
}
}
suspend fun loadFirmware() {
firmwareError = null
try { firmwareFiles = connection.onu.firmwareFiles() } catch (e: Throwable) { firmwareError = e.uiMessage() }
}
suspend fun reset() {
busy = true; actionMessage = null
try { connection.onu.reset(onuId); actionMessage = "Reset requested." } catch (e: Throwable) { actionMessage = e.uiMessage() }
busy = false
}
suspend fun upgrade(file: FirmwareFile) {
busy = true; actionMessage = null
try {
val slot = connection.onu.upgradeFirmware(onuId, file)
actionMessage = "Firmware ${file.version ?: "image"} staged to bank $slot. The ONU starts it after its next reboot."
} catch (e: Throwable) { actionMessage = e.uiMessage() }
busy = false
}
}
private val onuTabs = listOf("Overview", "CPE", "Logs", "Config")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OnuDetailScreen(connection: McmsConnection, onuId: String, onBack: () -> Unit) {
val model = remember(onuId) { OnuDetailModel(connection, onuId) }
val scope = rememberCoroutineScope()
var showReset by remember { mutableStateOf(false) }
var showFirmware by remember { mutableStateOf(false) }
var menu by remember { mutableStateOf(false) }
LaunchedEffect(model.tab) { model.loadTab() }
Scaffold(topBar = {
TopAppBar(
title = { Text(onuId) },
navigationIcon = { BackButton(onBack) },
actions = {
IconButton(onClick = { menu = true }) { Icon(Icons.Filled.MoreVert, "Actions") }
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(text = { Text("Reset ONU", color = MaterialTheme.colorScheme.error) },
enabled = !model.busy, onClick = { menu = false; showReset = true })
}
},
)
}) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = model.tab) {
onuTabs.forEachIndexed { i, title ->
Tab(selected = model.tab == i, onClick = { model.tab = i }, text = { Text(title) })
}
}
Box(Modifier.fillMaxSize()) {
when (val p = model.phase) {
is LoadPhase.Loading, is LoadPhase.Idle -> LoadingView()
is LoadPhase.Failed -> ErrorState(p.message) { scope.launch { model.loadTab() } }
is LoadPhase.Loaded -> when (model.tab) {
0 -> OverviewTab(model) { showFirmware = true }
1 -> CpeTab(model.cpes)
2 -> LogsTab(model.logs)
else -> ConfigTab(model.config)
}
}
}
}
}
model.actionMessage?.let { msg ->
AlertDialog(
onDismissRequest = { model.actionMessage = null },
confirmButton = { TextButton(onClick = { model.actionMessage = null }) { Text("OK") } },
title = { Text("Operation") }, text = { Text(msg) },
)
}
if (showReset) ResetConfirmDialog("ONU", onuId, onDismiss = { showReset = false }) { scope.launch { model.reset() } }
if (showFirmware) FirmwareDialog(model, onDismiss = { showFirmware = false })
}
@Composable
private fun OverviewTab(model: OnuDetailModel, onUpdateFirmware: () -> Unit) {
val s = model.liveState ?: return
LazyColumn(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
item {
SectionCard("Status") {
s.registrationState?.let { KeyValueRow("Registration", it) }
s.equipmentId?.let { KeyValueRow("Equipment", it) }
s.ponMode?.let { KeyValueRow("PON mode", it) }
s.temperatureC?.let { KeyValueRow("Temperature", "%.1f °C".format(it)) }
s.onlineTime?.let {
KeyValueRow("Online since", MongoTimestamp.displayString(it) ?: it)
MongoTimestamp.duration(it, s.lastUpdate)?.let { up -> KeyValueRow("Uptime", up) }
}
}
}
item {
SectionCard("Firmware") {
s.fwVersion?.let { KeyValueRow("Active version", it) }
s.fwBankVersions.forEachIndexed { i, v ->
KeyValueRow("Bank $i" + if (s.fwBankPtr == i) " · active" else "", v.ifEmpty { "" })
}
Spacer(Modifier.height(8.dp))
Button(onClick = onUpdateFirmware, enabled = !model.busy) { Text("Update firmware…") }
}
}
item {
SectionCard("Live optical") {
OpticalRow("RX optical", s.rxOpticalDBm, green = -28.0, warn = -30.0)
OpticalRow("TX optical", s.txOpticalDBm, green = 3.0, warn = 3.0)
FecRow("ONU post-FEC", s.onuPostFec, isPost = true)
FecRow("OLT post-FEC", s.oltPostFec, isPost = true)
}
}
if (s.uniPorts.isNotEmpty()) {
item {
SectionCard("UNI ports") {
s.uniPorts.forEach { port ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(port.name)
(port.state ?: listOfNotNull(port.speed, port.duplex).joinToString(" · ").ifEmpty { null })
?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
port.enabled?.let { StateBadge(if (it) "enabled" else "disabled", if (it) Color(0xFF2E7D32) else Color(0xFF757575)) }
}
}
}
}
}
if (s.ponServices.isNotEmpty()) {
item {
SectionCard("Traffic") {
s.ponServices.forEach { svc ->
Text(svc.name, fontWeight = FontWeight.Medium)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${Format.bps(svc.rxRateBps)}")
Text("${Format.bps(svc.txRateBps)}")
}
Spacer(Modifier.height(6.dp))
}
}
}
}
}
}
@Composable
private fun OpticalRow(title: String, value: Double?, green: Double, warn: Double) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(title)
val color = when {
value == null -> MaterialTheme.colorScheme.onSurfaceVariant
value >= green -> Color(0xFF2E7D32)
value >= warn -> Color(0xFFF9A825)
else -> Color(0xFFC62828)
}
Text(value?.let { "%.2f dBm".format(it) } ?: "", color = color)
}
}
@Composable
private fun FecRow(title: String, value: Double?, isPost: Boolean) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(title)
val color = when {
value == null -> MaterialTheme.colorScheme.onSurfaceVariant
isPost && value > 0 -> Color(0xFFC62828)
value > 0 -> MaterialTheme.colorScheme.onSurfaceVariant
else -> Color(0xFF2E7D32)
}
Text(value?.let { it.toLong().toString() } ?: "", color = color)
}
}
@Composable
private fun CpeTab(cpes: List<CpeState>) {
if (cpes.isEmpty()) { EmptyState("No CPEs"); return }
LazyColumn(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
items(cpes) { cpe ->
SectionCard(cpe.mac ?: cpe.id) {
cpe.ipv4?.let {
KeyValueRow("IPv4", it + (cpe.dhcpState?.let { s -> " ($s)" } ?: ""))
cpe.dhcpServer?.let { s -> KeyValueRow("DHCP server", s) }
cpe.dhcpLeaseSeconds?.let { l -> KeyValueRow("Lease", "${l / 3600}h") }
}
cpe.ipv6?.let {
KeyValueRow("IPv6", it)
cpe.ipv6Prefix?.let { p -> KeyValueRow("Delegated prefix", p) }
}
cpe.circuitId?.let { KeyValueRow("Circuit ID", it) }
if (!cpe.hasLease) Text("No active lease.", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun LogsTab(logs: List<LogEntry>) {
if (logs.isEmpty()) { EmptyState("No logs"); return }
LazyColumn(Modifier.fillMaxSize()) {
items(logs) { log ->
Column(Modifier.fillMaxWidth().padding(16.dp, 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
StateBadge(log.severity.label, log.severity.color())
Spacer(Modifier.weight(1f))
MongoTimestamp.displayString(log.time)?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
Text(log.message ?: log.id, style = MaterialTheme.typography.bodySmall)
}
HorizontalDivider()
}
}
}
@Composable
private fun ConfigTab(config: OnuConfigDoc?) {
if (config == null) { EmptyState("No configuration"); return }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
config.name?.let { KeyValueRow("Name", it); Spacer(Modifier.height(8.dp)) }
SelectionContainer {
Text(prettyJson.encodeToString(JsonElement.serializer(), config.raw),
fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun EmptyState(text: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
// ───────────────────────── Reset + Firmware dialogs ─────────────────────────
@Composable
fun ResetConfirmDialog(kind: String, name: String, onDismiss: () -> Unit, onConfirm: () -> Unit) {
var ack by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Reset $kind?") },
text = {
Column {
Text("Resetting $kind$name” reboots it and interrupts service for everything connected to it. This can't be undone.")
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = ack, onCheckedChange = { ack = it })
Text("I understand this will interrupt service")
}
}
},
confirmButton = {
TextButton(enabled = ack, onClick = { onConfirm(); onDismiss() }) {
Text("Reset $kind", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FirmwareDialog(model: OnuDetailModel, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
var showAll by remember { mutableStateOf(false) }
var pending by remember { mutableStateOf<FirmwareFile?>(null) }
val state = model.liveState
LaunchedEffect(Unit) { if (model.firmwareFiles.isEmpty()) model.loadFirmware() }
val compatible = model.firmwareFiles.filter { it.isCompatible(state?.vendor, state?.equipmentId) }
val visible = if (showAll) model.firmwareFiles else compatible
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Update firmware") },
text = {
Column {
state?.fwVersion?.let { Text("Current: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
Spacer(Modifier.height(8.dp))
when {
model.firmwareError != null -> Text(model.firmwareError!!, color = MaterialTheme.colorScheme.error)
model.firmwareFiles.isEmpty() -> Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp); Spacer(Modifier.width(8.dp)); Text("Loading…") }
else -> Column {
visible.forEach { file ->
ListRow(onClick = { pending = file }) {
Column(Modifier.weight(1f)) {
Text(file.version ?: file.filename ?: "Unknown", fontWeight = FontWeight.Medium)
Text(listOfNotNull(file.models.firstOrNull(), file.sizeBytes?.let { Format.bytes(it) }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
if (model.firmwareFiles.size > compatible.size) {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = showAll, onCheckedChange = { showAll = it }); Spacer(Modifier.width(8.dp)); Text("Show all firmware")
}
}
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
pending?.let { file ->
AlertDialog(
onDismissRequest = { pending = null },
title = { Text("Stage firmware?") },
text = { Text("Stage ${file.version ?: "this image"} on ${model.onuId} in the standby bank. It becomes active after the ONU's next reboot — this is service-affecting.") },
confirmButton = {
TextButton(onClick = { pending = null; onDismiss(); scope.launch { model.upgrade(file) } }) {
Text("Download ${file.version ?: "firmware"}", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { pending = null }) { Text("Cancel") } },
)
}
}

View file

@ -0,0 +1,31 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val Orange = Color(0xFFF39200)
private val Navy = Color(0xFF0B2A4A)
private val LightColors = lightColorScheme(
primary = Orange,
onPrimary = Color.White,
secondary = Navy,
)
private val DarkColors = darkColorScheme(
primary = Orange,
onPrimary = Color.Black,
secondary = Color(0xFF9FB8D6),
)
@Composable
fun PonGoTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors,
content = content,
)
}

View file

@ -0,0 +1,35 @@
package no.svorka.mcms.ui
import androidx.compose.ui.graphics.Color
import no.svorka.mcms.core.AlarmSeverity
import no.svorka.mcms.core.OnuLifecycleState
/** Screen load state. */
sealed interface LoadPhase {
data object Idle : LoadPhase
data object Loading : LoadPhase
data object Loaded : LoadPhase
data class Failed(val message: String) : LoadPhase
}
fun OnuLifecycleState.color(): Color = when (this) {
OnuLifecycleState.REGISTERED -> Color(0xFF2E7D32)
OnuLifecycleState.UNSPECIFIED -> Color(0xFF26A69A)
OnuLifecycleState.UNPROVISIONED -> Color(0xFF1565C0)
OnuLifecycleState.DEREGISTERED -> Color(0xFF757575)
OnuLifecycleState.DYING_GASP, OnuLifecycleState.DISALLOWED_ERROR -> Color(0xFFC62828)
OnuLifecycleState.DISABLED, OnuLifecycleState.DISALLOWED_ADMIN, OnuLifecycleState.DISALLOWED_REG_ID -> Color(0xFFEF6C00)
OnuLifecycleState.UNKNOWN -> Color(0xFF9E9E9E)
}
fun AlarmSeverity.color(): Color = when (this) {
AlarmSeverity.EMERGENCY, AlarmSeverity.ALERT, AlarmSeverity.CRITICAL -> Color(0xFFC62828)
AlarmSeverity.ERROR -> Color(0xFFEF6C00)
AlarmSeverity.WARNING -> Color(0xFFF9A825)
AlarmSeverity.NOTICE, AlarmSeverity.INFO -> Color(0xFF1565C0)
AlarmSeverity.DEBUG -> Color(0xFF757575)
AlarmSeverity.UNKNOWN -> Color(0xFF9E9E9E)
}
/** Maps a thrown error to a user message. */
fun Throwable.uiMessage(): String = message ?: "Something went wrong"

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#002049</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PON Go</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Window theme only; the UI itself is drawn by Jetpack Compose / Material 3. -->
<style name="Theme.MCMS" parent="android:Theme.Material.Light.NoActionBar" />
</resources>