initial commit
61
app/build.gradle.kts
Normal 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
|
|
@ -0,0 +1,4 @@
|
|||
# Keep kotlinx.serialization metadata.
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.**
|
||||
-keepclassmembers class kotlinx.serialization.json.** { *; }
|
||||
25
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
107
app/src/main/java/no/svorka/mcms/MainActivity.kt
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
151
app/src/main/java/no/svorka/mcms/core/ApiClient.kt
Normal 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
|
||||
}
|
||||
}
|
||||
59
app/src/main/java/no/svorka/mcms/core/ApiConfiguration.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
38
app/src/main/java/no/svorka/mcms/core/ApiError.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/src/main/java/no/svorka/mcms/core/ApiQuery.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
9
app/src/main/java/no/svorka/mcms/core/AuthModels.kt
Normal 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
|
||||
}
|
||||
62
app/src/main/java/no/svorka/mcms/core/Firmware.kt
Normal 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 0→1, 1→0, else→1. */
|
||||
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)
|
||||
}
|
||||
}
|
||||
55
app/src/main/java/no/svorka/mcms/core/Json.kt
Normal 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()}" }
|
||||
}
|
||||
29
app/src/main/java/no/svorka/mcms/core/KeychainStore.kt
Normal 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() }
|
||||
}
|
||||
17
app/src/main/java/no/svorka/mcms/core/McmsConnection.kt
Normal 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 }
|
||||
}
|
||||
74
app/src/main/java/no/svorka/mcms/core/McmsEndpoint.kt
Normal 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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
212
app/src/main/java/no/svorka/mcms/core/Models.kt
Normal 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
|
||||
}
|
||||
90
app/src/main/java/no/svorka/mcms/core/Networking.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
132
app/src/main/java/no/svorka/mcms/core/Repositories.kt
Normal 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) }
|
||||
}
|
||||
70
app/src/main/java/no/svorka/mcms/core/Util.kt
Normal 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%" } ?: "—"
|
||||
}
|
||||
83
app/src/main/java/no/svorka/mcms/ui/AppState.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
171
app/src/main/java/no/svorka/mcms/ui/AuthScreens.kt
Normal 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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
app/src/main/java/no/svorka/mcms/ui/Components.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
232
app/src/main/java/no/svorka/mcms/ui/DashboardScreen.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
202
app/src/main/java/no/svorka/mcms/ui/OltScreens.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
453
app/src/main/java/no/svorka/mcms/ui/OnuScreens.kt
Normal 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") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
31
app/src/main/java/no/svorka/mcms/ui/Theme.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
35
app/src/main/java/no/svorka/mcms/ui/Types.kt
Normal 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"
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
4
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#002049</color>
|
||||
</resources>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">PON Go</string>
|
||||
</resources>
|
||||
5
app/src/main/res/values/themes.xml
Normal 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>
|
||||