initial commit
92
HANDOFF.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Handoff — PON Go Android
|
||||
|
||||
Context for the next session/developer. Pairs with [`README.md`](README.md) and
|
||||
[`docs/MCMS_API.md`](docs/MCMS_API.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`core/`** — pure Kotlin, no Android UI deps. Mirrors the iOS networking core.
|
||||
- JSON: MCMS docs are heterogeneous Mongo documents, so we keep raw
|
||||
`kotlinx.serialization` `JsonElement` trees and read fields via the accessors
|
||||
in `Json.kt` (`el["key"]`, `.string/.int/.double/.array/.obj`) — the analogue
|
||||
of the iOS `JSONValue`. Models (`Models.kt`) are thin wrappers over a
|
||||
`JsonElement` with computed properties.
|
||||
- `ApiClient` (OkHttp): builds versioned requests, injects `Referer` +
|
||||
`X-CSRFToken` (writes), unwraps the `{status,data}` envelope, maps status
|
||||
codes to `ApiError`, paginates via the `next` cursor. `Call.await()` bridges
|
||||
OkHttp to coroutines with cancellation.
|
||||
- Repositories return model wrappers; `McmsConnection` wires it all; auth/cookies
|
||||
in `Networking.kt` (`SessionCookieJar` + `SessionStore`), TLS policy in
|
||||
`ServerTrust`, secrets in `KeychainStore` (EncryptedSharedPreferences).
|
||||
- **`ui/`** — Jetpack Compose. Navigation is **state-based** in `MainActivity`
|
||||
(a `List<Screen>` back stack + `BackHandler`), not Navigation-Compose — so
|
||||
detail screens and the dashboard drill-downs can take in-memory objects
|
||||
directly. Each screen has a plain **state-holder class** (`…Model`) exposing
|
||||
`mutableStateOf` fields + `suspend load()/refresh()` — the analogue of the iOS
|
||||
`@Observable` view models. No androidx `ViewModel`/DI.
|
||||
|
||||
## Build / verify loop (this matters)
|
||||
|
||||
The dev machine had **no `kotlinc`/`gradle` on PATH**, but Android Studio provides
|
||||
everything. Two loops:
|
||||
|
||||
- **Full build (authoritative):**
|
||||
```
|
||||
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
./gradlew :app:assembleDebug --no-daemon --console=plain
|
||||
```
|
||||
Needs `local.properties` (`sdk.dir=…`, git-ignored). First run downloads deps
|
||||
to `~/.gradle`; afterwards it's ~20–60 s. This is what proves the whole app
|
||||
(UI + resources + manifest + icons) compiles.
|
||||
- **Quick core-only check (no Gradle), when iterating on `core/`:** the bundled
|
||||
compiler at `…/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc`
|
||||
with `-Xplugin=…/kotlinc/lib/kotlinx-serialization-compiler-plugin.jar` and a
|
||||
classpath of Maven jars (kotlinx-serialization-json/core, okhttp, okio-jvm,
|
||||
kotlinx-coroutines-core-jvm). Compiles every `core/*.kt` except
|
||||
`KeychainStore.kt` (androidx). Faster than a Gradle round-trip.
|
||||
|
||||
## API gotchas (all handled in code — don't regress)
|
||||
|
||||
- **Single-encode URLs.** Ids stay RAW in `McmsEndpoint`; `ApiClient` encodes the
|
||||
path once via `HttpUrl.addPathSegments`. Pre-encoding double-encoded MAC colons
|
||||
(`:` → `%3A` → `%253A`) → MCMS "invalid id".
|
||||
- **401 bounces to login; 403 does NOT** (permission/CSRF — `requiresReauthentication`
|
||||
is 401-only).
|
||||
- **Writes return `{"status":"success"}` with no `data`** — the unwrap tolerates it.
|
||||
- **ONU registration lives on OLT-STATE buckets** (`OltRepository.registrationMap`),
|
||||
not ONU-STATE. Friendly names are `ONU.Name` / `OLT.Name` (NOT `NETCONF.Name`).
|
||||
- **CPE DHCP** comes from the web helper `GET /api/cpe/onu/<id>/` — a versionless,
|
||||
bare `[code, cpe…]` array (use `ApiClient.getRaw`).
|
||||
- **Firmware** = Procedure 7: GET ONU-CFG → write filename/version into the
|
||||
**inactive** bank → full-doc PUT (`FirmwareUpgrade.apply`). Inactive-bank only,
|
||||
so service stays up until the ONU reboots. Still worth testing on a spare ONU.
|
||||
- Field paths were confirmed against live JSON; see `docs/MCMS_API.md` and the
|
||||
model accessors.
|
||||
|
||||
## Trimmed vs iOS (intentional, easy to restore)
|
||||
|
||||
- ONU detail tabs are **Overview / CPE / Logs / Config** — no separate Alarms tab.
|
||||
Current alarms live on ONU-STATE `"Alarm"` buckets if you want to add it.
|
||||
- **Config** shows pretty-printed JSON text, not the collapsible tree the iOS app
|
||||
has.
|
||||
- Dashboard **ONU-state rows aren't tappable** (the Health rows are). The iOS app
|
||||
drills ONU-state → filtered ONU list.
|
||||
- Toggles use `rememberSaveable` (survive rotation, not process death). Persist in
|
||||
prefs if you want parity with iOS `@AppStorage`.
|
||||
- Self-signed/cleartext: the TLS trust policy is in `ServerTrust`. Plain-HTTP hosts
|
||||
also need a `usesCleartextTraffic`/network-security-config exception (Android's
|
||||
ATS analogue) — add when needed.
|
||||
|
||||
## Repo split
|
||||
|
||||
This folder is self-contained (has its own `docs/MCMS_API.md`). To make it a git
|
||||
repo: `git init` here; the included `.gitignore` already excludes `build/`,
|
||||
`.gradle/`, `local.properties`, `.idea/`. Commit the Gradle wrapper
|
||||
(`gradlew`, `gradle/wrapper/*`). The full dev-guide PDFs (`323-1961-30x`) live in
|
||||
the iOS repo if you ever need the deep source.
|
||||
|
||||
## Possible next steps
|
||||
|
||||
Restore the trimmed items above; add OLT firmware (Procedure 8 bulk task);
|
||||
per-ONU live-alarms view; charts for PM history; pull-to-refresh on lists;
|
||||
dark-theme polish; a proper app theme from the brand colours.
|
||||
54
README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# PON Go — Android
|
||||
|
||||
Native Android app for Ciena **MCMS 6.2 PON Manager** basics on the go — dashboard,
|
||||
ONU and OLT browsing/diagnostics, firmware updates, resets. Kotlin + Jetpack
|
||||
Compose + OkHttp. Sibling of the iOS app (`../mcms_ios`); the REST behaviour and
|
||||
field paths are identical and documented in [`docs/MCMS_API.md`](docs/MCMS_API.md).
|
||||
|
||||
**Status: complete and building.** `./gradlew :app:assembleDebug` → `BUILD
|
||||
SUCCESSFUL`, runs in the emulator. Package `no.svorka.mcms`, app name **PON Go**.
|
||||
|
||||
## Build & run
|
||||
|
||||
1. Open the folder in **Android Studio** → let it sync → Run on a device/emulator
|
||||
that can reach your MCMS host.
|
||||
2. Or CLI: `./gradlew :app:assembleDebug` (needs `local.properties` with
|
||||
`sdk.dir=…`, and a JDK 17+ — Android Studio's bundled JBR works).
|
||||
|
||||
Verified toolchain on the dev machine: **AGP 8.11.0, Kotlin 2.0.21, Gradle 8.13,
|
||||
compileSdk 36, minSdk 26, JBR 21**.
|
||||
|
||||
## What it does (feature parity with iOS)
|
||||
|
||||
- **Login / Settings** — URL normalisation (`/api` auto-appended), self-signed
|
||||
TLS toggle, test-connection probe, remember-me (Android Keystore), sign out.
|
||||
- **Dashboard** — ONU/OLT/controller counts; total OLT traffic; a **Health**
|
||||
section (abnormal-Rx ONUs, laser-off OLTs) that drills into the affected
|
||||
devices; ONU-state breakdown; **Detailed stats** (opt-in Rx scan) and
|
||||
**Auto-refresh 30 s** toggles.
|
||||
- **ONUs** — searchable list; detail with **Overview** (status, firmware +
|
||||
update picker, optical/FEC, UNI ports, traffic), **CPE** (DHCPv4/v6 leases),
|
||||
**Logs**, **Config** (raw JSON). Reset + firmware behind hardened,
|
||||
acknowledge-to-enable, red confirmations.
|
||||
- **OLTs** — list with up/down summary; detail with status/uptime, PON traffic,
|
||||
environment, uplink (LLDP), registration buckets (→ ONU detail), reset.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
app/src/main/java/no/svorka/mcms/
|
||||
MainActivity.kt root: state-based nav + bottom bar
|
||||
core/ networking / models / repositories (≈ iOS core)
|
||||
Json, ApiConfiguration, ApiError, ApiQuery, McmsEndpoint, Util,
|
||||
Models, AuthModels, Firmware, Networking, ApiClient, Repositories,
|
||||
McmsConnection, KeychainStore
|
||||
ui/ Jetpack Compose
|
||||
Theme, Types, Components, AppState,
|
||||
AuthScreens (Login+Settings), DashboardScreen (+drill-downs),
|
||||
OnuScreens (list+detail+firmware+reset), OltScreens (list+detail)
|
||||
app/src/main/res/ adaptive launcher icon, strings (app_name), colors
|
||||
docs/MCMS_API.md the MCMS REST field guide (single source of truth)
|
||||
```
|
||||
|
||||
See [`HANDOFF.md`](HANDOFF.md) for architecture notes, the build/verify loop, the
|
||||
API gotchas, and what's deliberately trimmed vs iOS.
|
||||
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>
|
||||
7
build.gradle.kts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Top-level build file. Plugin versions are declared here and applied per-module.
|
||||
plugins {
|
||||
id("com.android.application") version "8.11.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
|
||||
}
|
||||
758
docs/MCMS_API.md
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
# Ciena MCMS 6.2 REST API — Implementer's Field Guide
|
||||
|
||||
Reference for building an HTTP client against a Ciena MicroClimate
|
||||
Management System (MCMS) 6.2 PON Manager. Distilled from the official
|
||||
dev guide (`323-1961-306`) plus empirical findings against a working
|
||||
deployment with GNXS / Tibit MicroPlug ONUs on Interos Everest 5.x
|
||||
firmware.
|
||||
|
||||
Language-agnostic — every quirk listed here was hit in production. If
|
||||
you skip the gotchas in §3, you will lose an afternoon to one of them.
|
||||
|
||||
---
|
||||
|
||||
## 1. Connection basics
|
||||
|
||||
- **Base URL.** The root of the PON Manager web UI (e.g.
|
||||
`https://mcms.example.net`). All REST paths are prefixed with `/api`.
|
||||
- **Transport.** HTTPS. Internal deployments commonly use self-signed
|
||||
certificates on private IPs — your client needs an opt-in to skip
|
||||
TLS verification for that case (do **not** disable globally).
|
||||
- **HTTP version.** HTTP/1.1 works. The server also negotiates HTTP/2
|
||||
but the dev-guide examples are 1.1 and the only behavioural
|
||||
difference observed is irrelevant to clients.
|
||||
- **Path normalisation.** The dev guide writes paths as
|
||||
`/v1/users/authenticate/` — the real wire path is
|
||||
`/api/v1/users/authenticate/`. Normalise once in your client.
|
||||
- **API versions.** Both `/api/v1/...` and `/api/v3/...` exist and serve
|
||||
different endpoints. **Use whichever the dev guide lists for that
|
||||
resource — they are not interchangeable.** See §4 endpoint table.
|
||||
- **Trailing slashes.** Required. `/api/v1/onus/configs/<id>/` works;
|
||||
`/api/v1/onus/configs/<id>` does not (Django's `APPEND_SLASH` is
|
||||
on, but a few endpoints 500 instead of redirecting).
|
||||
|
||||
---
|
||||
|
||||
## 2. Login & session
|
||||
|
||||
MCMS uses Django session cookies + a CSRF token. Sessions do **not**
|
||||
auto-expire on idle — log out explicitly when done.
|
||||
|
||||
### 2.1 Authenticate
|
||||
|
||||
```
|
||||
POST /api/v1/users/authenticate/
|
||||
Content-Type: application/json
|
||||
|
||||
{ "data": { "email": "user@example.net", "password": "..." } }
|
||||
```
|
||||
|
||||
Response on success (`HTTP 200`):
|
||||
|
||||
```json
|
||||
{ "status": "success" }
|
||||
```
|
||||
|
||||
Set-Cookie headers carry the session. On observed MCMS 6.2 builds:
|
||||
|
||||
```
|
||||
Set-Cookie: __Host-csrftoken=<token>; Path=/; Secure; SameSite=Lax
|
||||
Set-Cookie: __Host-sessionid=<id>; Path=/; Secure; HttpOnly; SameSite=Lax
|
||||
Set-Cookie: __Host-sessionexpire=<epoch>; Path=/; Secure; SameSite=Lax
|
||||
```
|
||||
|
||||
→ **The `__Host-` prefix is significant.** Some MCMS builds (older
|
||||
ones, mostly) use bare `csrftoken` / `sessionid`. Your cookie reader
|
||||
should accept either prefix.
|
||||
|
||||
→ Some build variants accept an unwrapped body too:
|
||||
`{"email": ..., "password": ...}` with no `data` envelope. If wrapped
|
||||
returns HTTP 400, retry unwrapped.
|
||||
|
||||
### 2.2 Subsequent requests
|
||||
|
||||
Every authenticated request must send:
|
||||
|
||||
```
|
||||
Cookie: __Host-csrftoken=<token>; __Host-sessionid=<id>; ...
|
||||
X-CSRFToken: <token> # the value from __Host-csrftoken
|
||||
Referer: <baseURL>/ # MCMS rejects POST/PUT/DELETE without this
|
||||
User-Agent: <anything> # see §3.1 — must not be empty
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
For PUT/POST/PATCH also:
|
||||
|
||||
```
|
||||
Content-Type: application/json
|
||||
Content-Length: <bytes>
|
||||
```
|
||||
|
||||
### 2.3 (Optional) Database selection
|
||||
|
||||
If the deployment manages multiple databases:
|
||||
|
||||
```
|
||||
PUT /api/v1/databases/selection/
|
||||
{ "data": "<database_id>" }
|
||||
```
|
||||
|
||||
Most deployments only have one and skip this step.
|
||||
|
||||
### 2.4 Logout
|
||||
|
||||
```
|
||||
GET /api/v1/users/logout/
|
||||
```
|
||||
|
||||
Best-effort — don't fail your client if this errors.
|
||||
|
||||
---
|
||||
|
||||
## 3. The gotchas (read this section twice)
|
||||
|
||||
### 3.1 User-Agent header is REQUIRED
|
||||
|
||||
If you don't send a `User-Agent`, some MCMS middleware dereferences
|
||||
`HTTP_USER_AGENT` without a null check and returns **HTTP 500** with
|
||||
body:
|
||||
|
||||
```json
|
||||
{"status": "fail", "details": {"message": "Internal server error. See server logs for details."}}
|
||||
```
|
||||
|
||||
Reproduce: `curl -A '' https://mcms/api/v3/onus/configs/`. Any non-empty
|
||||
string works (`PonFleetClient/1.0`, etc.).
|
||||
|
||||
**For iOS/Swift**: `URLSession` sets a default User-Agent automatically
|
||||
(e.g. `<bundle>/1 CFNetwork/... Darwin/...`), so this is unlikely to
|
||||
bite you there — but if you ever set a custom `URLRequest` with
|
||||
`setValue(nil, forHTTPHeaderField: "User-Agent")`, the 500s will
|
||||
return.
|
||||
|
||||
### 3.2 CSRF token field name
|
||||
|
||||
The token cookie is `__Host-csrftoken` on new builds, bare `csrftoken`
|
||||
on older. The header you send back is always `X-CSRFToken` (no
|
||||
prefix, capital `T` in `Token`). Both Django case-fold the header
|
||||
name, so casing is fine — but the body name is exact.
|
||||
|
||||
### 3.3 Pagination: use `next`, NOT `skip`
|
||||
|
||||
`/api/v3/onus/configs/?skip=N` has been observed returning HTTP 500.
|
||||
The working approach uses the last document's `_id` as a cursor:
|
||||
|
||||
```
|
||||
GET /api/v3/onus/configs/?limit=100
|
||||
→ returns 100 docs, the last with _id = "X"
|
||||
GET /api/v3/onus/configs/?limit=100&next=X
|
||||
→ returns the next 100, EXCLUDING all _ids <= X
|
||||
```
|
||||
|
||||
Loop until you get a short page (< limit) or an empty page. Most list
|
||||
endpoints accept this pattern.
|
||||
|
||||
### 3.4 Page size 100 is the safe default
|
||||
|
||||
`?limit=1000` against `/api/v3/onus/configs/` on a ~1500-ONU fleet
|
||||
times out at the Apache front-end before Django logs the request.
|
||||
Full `ONU-CFG` documents are ~30 KB each, so 1000 × 30 KB = 30 MB,
|
||||
which exceeds the proxy timeout. **100 per page keeps each response
|
||||
~3 MB** and well under any default proxy budget.
|
||||
|
||||
This applies to all bulk list endpoints (configs, states, OLT states).
|
||||
|
||||
### 3.5 Request body envelope: `{"data": payload}`
|
||||
|
||||
PUT/POST/PATCH bodies are wrapped:
|
||||
|
||||
```json
|
||||
{ "data": { "_id": "...", "ONU": { ... } } }
|
||||
```
|
||||
|
||||
Response envelope is similar:
|
||||
|
||||
```json
|
||||
{ "status": "success", "data": { ... } }
|
||||
{ "status": "fail", "details": { "message": "..." } }
|
||||
{ "status": "warning", "data": { ... }, "details": { ... } }
|
||||
```
|
||||
|
||||
`"warning"` is treated as success by MCMS (the write happened) but
|
||||
indicates a schema mismatch you should log.
|
||||
|
||||
### 3.6 PUT is full-document replace, NOT patch
|
||||
|
||||
`PUT /api/v1/onus/configs/<id>/` replaces the entire `ONU-CFG`
|
||||
document. You **must** GET the current doc, mutate fields, and PUT
|
||||
the entire result. There is no PATCH equivalent that works reliably
|
||||
on MCMS 6.2.
|
||||
|
||||
Forgetting this turns into "I PUT'd just the FW Bank fields and now
|
||||
the ONU has no service config" — the dev guide is explicit but easy
|
||||
to skim past.
|
||||
|
||||
### 3.7 Timestamp format
|
||||
|
||||
MCMS emits and accepts timestamps as:
|
||||
|
||||
```
|
||||
"YYYY-MM-DD HH:MM:SS" # second precision
|
||||
"YYYY-MM-DD HH:MM:SS.ffffff" # microsecond precision (for STATE.Time)
|
||||
```
|
||||
|
||||
Always **UTC**, always with a **literal space** between date and time
|
||||
(not ISO-8601 `T`). Used on:
|
||||
|
||||
- `ONU-STATE.Time`
|
||||
- `OLT-STATE.Time`
|
||||
- Alarm timestamps
|
||||
- `AUTO-TASK-CFG.Task["Scheduled Start Time"]`
|
||||
- `start-time` / `end-time` URL params on stats endpoints
|
||||
|
||||
**For Swift**: `ISO8601DateFormatter` will NOT parse this. Use a
|
||||
plain `DateFormatter` with:
|
||||
|
||||
```swift
|
||||
let fmt = DateFormatter()
|
||||
fmt.timeZone = TimeZone(identifier: "UTC")
|
||||
fmt.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS" // or "yyyy-MM-dd HH:mm:ss"
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
```
|
||||
|
||||
The `en_US_POSIX` locale matters — without it, devices set to a
|
||||
24-hour-format locale will still parse fine but devices set to
|
||||
12-hour-format locales will fail. POSIX locale forces parse rules.
|
||||
|
||||
### 3.8 Server-side query/projection filters are brittle
|
||||
|
||||
The dev guide documents `query`, `projection`, `sort`, `limit`,
|
||||
`skip`, `next`, `distinct` URL parameters. In practice:
|
||||
|
||||
- **`query`** (Mongo-style filter) works for simple equality on
|
||||
bare field names. Anything with **spaces in field names**, quoted
|
||||
values with non-ASCII characters, or projection paths that don't
|
||||
exist on every document version is unreliable — you'll get either
|
||||
empty results or 400s.
|
||||
- **`projection`** is *very* picky about paths. Better to fetch full
|
||||
docs and project client-side.
|
||||
|
||||
**Recommended approach**: load the full fleet (paginated by `next`)
|
||||
and filter client-side. The data volume is fine for any device with
|
||||
enough RAM to hold a few thousand 30 KB documents.
|
||||
|
||||
### 3.9 URL encoding
|
||||
|
||||
Use percent-encoding (`encodeURIComponent` equivalent). MCMS does NOT
|
||||
accept `+` for spaces in URL parameters — use `%20`.
|
||||
|
||||
This matters for query params like `start-time` which carry literal
|
||||
spaces in the timestamp.
|
||||
|
||||
### 3.10 ONU IDs
|
||||
|
||||
The `_id` field of an ONU document is:
|
||||
|
||||
- The **serial number** for XGS-PON / GPON ONUs (e.g. `GNXS05057fee`)
|
||||
- The **MAC address** for 10G-EPON ONUs
|
||||
|
||||
Treat as opaque strings. They're URL-safe in observed deployments
|
||||
but always `encodeURIComponent` to be defensive.
|
||||
|
||||
### 3.11 Registration status lives on OLT-STATE, not ONU-STATE
|
||||
|
||||
This is non-obvious. The 9 registration buckets live as arrays on the
|
||||
OLT's state document:
|
||||
|
||||
```json
|
||||
{
|
||||
"_id": "<OLT MAC>",
|
||||
"ONU States": {
|
||||
"Registered": ["GNXS01", "GNXS02", ...],
|
||||
"Deregistered": ["GNXS09", ...],
|
||||
"Dying Gasp": [...],
|
||||
"Disabled": [...],
|
||||
"Disallowed Admin": [...],
|
||||
"Disallowed Error": [...],
|
||||
"Disallowed Reg ID": [...],
|
||||
"Unspecified": [...],
|
||||
"Unprovisioned": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To resolve "what's the status of ONU X?", you walk every OLT-STATE
|
||||
doc, find which bucket contains X, and that's your answer. See
|
||||
Procedure 2 in the dev guide.
|
||||
|
||||
An ONU should appear in exactly one bucket on exactly one OLT. In the
|
||||
rare duplicate case, prefer the non-`Registered` bucket — that's the
|
||||
interesting signal.
|
||||
|
||||
### 3.12 FEC counters live on ONU-STATE.STATS, not on `/onus/stats/`
|
||||
|
||||
`GET /api/v1/onus/stats/<id>/?start-time=...` returns historical PM
|
||||
samples (time-series). For the *current* FEC counters and optical
|
||||
levels, read the live `ONU-STATE` document:
|
||||
|
||||
```
|
||||
GET /api/v3/onus/states/<id>/
|
||||
→ data.STATS["ONU-PON"]["RX Pre-FEC BER"]
|
||||
→ data.STATS["ONU-PON"]["RX Post-FEC BER"]
|
||||
→ data.STATS["ONU-PON"]["RX Optical Level"] (dBm)
|
||||
→ data.STATS["ONU-PON"]["TX Optical Level"] (dBm)
|
||||
→ data.STATS["OLT-PON"]["RX Pre-FEC BER"]
|
||||
→ data.STATS["OLT-PON"]["RX Post-FEC BER"]
|
||||
```
|
||||
|
||||
The MCMS Web UI's `/api/onu/summary` helper returns the same data
|
||||
under `data.state_collection.STATS.*` (wrapper shape).
|
||||
|
||||
### 3.13 Bulk endpoints don't return everything in one shot
|
||||
|
||||
Even on a small fleet, MCMS may silently truncate large list
|
||||
responses. Always paginate — don't trust `?limit=large_number` to be
|
||||
authoritative.
|
||||
|
||||
### 3.14 Verbose error logging matters
|
||||
|
||||
When MCMS 500s, the response body sometimes contains useful detail
|
||||
(missing required param, schema validation failure, etc.). Always log
|
||||
both the response headers and the FULL body on non-2xx during
|
||||
development — most "mysterious 500" cases resolve to a one-line hint
|
||||
in the body.
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints used by the fleet-upgrade app
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/v1/users/authenticate/` | Login |
|
||||
| `GET` | `/api/v1/users/logout/` | Logout |
|
||||
| `PUT` | `/api/v1/databases/selection/` | (Optional) DB selection |
|
||||
| `GET` | `/api/v3/onus/configs/` | List ONU configs (paginated) |
|
||||
| `GET` | `/api/v1/onus/configs/<id>/` | Get single ONU config |
|
||||
| `PUT` | `/api/v1/onus/configs/<id>/` | Replace ONU config (full doc) |
|
||||
| `DELETE` | `/api/v1/onus/configs/<id>/` | Delete ONU (Procedure 43) |
|
||||
| `GET` | `/api/v3/onus/states/` | List ONU states (paginated) |
|
||||
| `GET` | `/api/v3/onus/states/<id>/` | Get single ONU state |
|
||||
| `DELETE` | `/api/v1/onus/states/<id>/` | Delete ONU state (best-effort) |
|
||||
| `GET` | `/api/v3/olts/states/` | List OLT states (paginated; registration) |
|
||||
| `GET` | `/api/v1/files/onu-firmware/` | List uploaded firmware files |
|
||||
| `POST` | `/api/v1/files/onu-firmware/<name>/` | Upload firmware |
|
||||
| `PUT` | `/api/v3/tasks/configs/<id>/` | Create bulk upgrade task (Procedure 8) |
|
||||
| `GET` | `/api/v3/tasks/configs/<id>/` | Poll bulk task config |
|
||||
| `GET` | `/api/v1/onus/<id>/upgrade/status/` | (Build-dependent) per-ONU status |
|
||||
|
||||
### Other endpoints documented but not (yet) used
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/onus/stats/<id>/?start-time=...` | Time-series ONU PM data |
|
||||
| `GET` | `/api/v1/olts/stats/<id>/?start-time=...` | OLT PM data |
|
||||
| `GET` | `/api/v1/onus/logs/<id>/?start-time=...` | Per-ONU syslog |
|
||||
| `GET` | `/api/v1/olts/logs/<id>/?start-time=...` | Per-OLT syslog |
|
||||
| `GET` | `/api/v3/tasks/states/<id>/` | Bulk task progress (good follow-up) |
|
||||
| `GET` | `/api/v1/onus/automation/states/` | PON automation state |
|
||||
| `GET` | `/api/v1/onus/cpe-states/` | ONU CPE states (downstream devices) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Data shapes (observed)
|
||||
|
||||
These are not formally specified in the dev guide for MCMS 6.2 — they
|
||||
were extracted from live traffic.
|
||||
|
||||
### 5.1 ONU-CFG
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"_id": "GNXS05057fee",
|
||||
"ONU": {
|
||||
"Name": "Operator-set label",
|
||||
"Address": "Street, city",
|
||||
"PON Mode": "GPON", // GPON | XGS-PON | 10G-EPON
|
||||
"Equipment ID": "FT-XGS2110",
|
||||
|
||||
"FW Bank Files": ["...bin", "...bin"], // [slot 0, slot 1]
|
||||
"FW Bank Versions": ["EV05110R", "EV05100R"],
|
||||
"FW Bank Ptr": 0, // 0 | 1 | 65535 (unset)
|
||||
|
||||
"ONU-ALARM-CFG": "Default",
|
||||
"Vendor": "GNXS",
|
||||
"Serial Number": "GNXS05057fee",
|
||||
"Host MAC Address": "58:00:32:05:7f:ee",
|
||||
// ...many more fields - preserve all of them when PUTing
|
||||
},
|
||||
"OLT-Service 0": { ... }, // service config slots
|
||||
"OLT-Service 1": { ... },
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Critical**: when mutating for an upgrade, only change `ONU["FW Bank
|
||||
Files"]`, `ONU["FW Bank Versions"]`, and `ONU["FW Bank Ptr"]`. Pass
|
||||
through everything else unchanged.
|
||||
|
||||
### 5.2 ONU-STATE
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"_id": "GNXS05057fee",
|
||||
"Time": "2026-05-04 21:51:57.394127", // UTC, last update
|
||||
"ONU": {
|
||||
"Equipment ID": "FT-XGS2110",
|
||||
"FW Bank Files": [...],
|
||||
"FW Bank Versions": [...],
|
||||
"FW Bank Ptr": 0,
|
||||
"FW Version": "EV05110R", // resolved active version
|
||||
"FW Upgrade Status": {
|
||||
"Bank": 0,
|
||||
"Status": "Success", // status string
|
||||
"Progress": 100, // percent
|
||||
"Upgrade Time": "2026-03-18 17:12:43.093893",
|
||||
"Total Blocks": 482915,
|
||||
"Sent Blocks": 482915,
|
||||
"Failures": 0,
|
||||
// ...
|
||||
},
|
||||
"PON Mode": "GPON",
|
||||
"Online Time": "2026-03-18 17:13:42.285965",
|
||||
// ...
|
||||
},
|
||||
"STATS": {
|
||||
"ONU-PON": {
|
||||
"RX Pre-FEC BER": 22388041291, // cumulative since reset
|
||||
"RX Post-FEC BER": 22379111198,
|
||||
"RX Optical Level": -17.544, // dBm
|
||||
"TX Optical Level": 5.532 // dBm
|
||||
},
|
||||
"OLT-PON": {
|
||||
"RX Pre-FEC BER": 0, // upstream view
|
||||
"RX Post-FEC BER": 0,
|
||||
"TX Optical Level": 5.6,
|
||||
"RX Optical Level": -18.3
|
||||
},
|
||||
"ONU-EnhancedFecPmHistData-32769": { // NOT the same as the BER fields above
|
||||
"uncorrectable_code_words": 1829, // (these are codeword counts,
|
||||
"corrected_bytes": 181, // not bit-error-rate ratios)
|
||||
"corrected_code_words": 181
|
||||
},
|
||||
// ...many more
|
||||
},
|
||||
"Alarm": {
|
||||
"0-EMERG": [], "1-ALERT": [], "2-CRIT": [],
|
||||
"3-ERROR": [], "4-WARNING": [], "5-NOTICE": [],
|
||||
"6-INFO": [], "7-DEBUG": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 OLT-STATE (relevant subset)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"_id": "<OLT MAC>",
|
||||
"Time": "2026-05-04 21:51:57.394127",
|
||||
"ONU States": {
|
||||
"Registered": ["onu1", "onu2", ...],
|
||||
"Deregistered": [...],
|
||||
"Dying Gasp": [...],
|
||||
"Disabled": [...],
|
||||
"Disallowed Admin": [...],
|
||||
"Disallowed Error": [...],
|
||||
"Disallowed Reg ID": [...],
|
||||
"Unspecified": [...],
|
||||
"Unprovisioned": [...]
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 AUTO-TASK-CFG (bulk firmware task, Procedure 8)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"_id": "<your-task-id>", // unique, caller-generated
|
||||
"Task": {
|
||||
"Device Type": "ONU",
|
||||
"Operation": "Firmware Upgrade",
|
||||
"Scheduled Start Time": "2026-05-12 03:00:00" // UTC space format
|
||||
},
|
||||
"Task Details": {
|
||||
"Devices": ["GNXS01", "GNXS02", ...], // ONU IDs (serial / MAC)
|
||||
"ONU": {
|
||||
"FW Bank Ptr": 1, // target slot (single value!)
|
||||
"FW Bank Files": { "0": "old.bin", "1": "new.bin" },
|
||||
"FW Bank Versions": { "0": "EV05110R", "1": "EV05120R" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
PUT to `/api/v3/tasks/configs/<task-id>/`. Note: `FW Bank Files` and
|
||||
`FW Bank Versions` here are **objects keyed by slot index as strings**
|
||||
(`"0"`, `"1"`), not arrays. This is different from the `ONU-CFG`
|
||||
shape where the same fields are arrays — almost certainly a Mongo
|
||||
serialisation quirk. Match it as-is.
|
||||
|
||||
**Single bank per task.** Because `FW Bank Ptr` accepts one slot, if
|
||||
your selection spans ONUs with different active banks, bucket them
|
||||
by target slot and create one task per bucket.
|
||||
|
||||
### 5.5 Firmware file entry (from `/api/v1/files/onu-firmware/`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"_id": "...",
|
||||
"filename": "Interos-Everest-5.12.0-R-EV05120R.bin",
|
||||
"metadata": {
|
||||
"Compatible Manufacturer": "TIBITCOM",
|
||||
"Compatible Model": ["MicroPlug ONU"],
|
||||
"Version": "EV05120R"
|
||||
},
|
||||
// GridFS metadata...
|
||||
}
|
||||
```
|
||||
|
||||
To upload:
|
||||
|
||||
```
|
||||
POST /api/v1/files/onu-firmware/<filename>/
|
||||
{
|
||||
"data": {
|
||||
"file": "<base64 binary contents>",
|
||||
"metadata": {
|
||||
"Compatible Manufacturer": "TIBITCOM",
|
||||
"Compatible Model": ["MicroPlug ONU"],
|
||||
"Version": "EV05120R"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Firmware upgrade flow
|
||||
|
||||
Two methods documented:
|
||||
|
||||
### 6.1 Procedure 7 — per-ONU PUT
|
||||
|
||||
For each ONU:
|
||||
|
||||
1. `GET /api/v1/onus/configs/<id>/` → full doc.
|
||||
2. Compute the inactive bank (see §6.3).
|
||||
3. Set:
|
||||
- `ONU["FW Bank Files"][writeSlot] = "<filename>"`
|
||||
- `ONU["FW Bank Versions"][writeSlot] = "<version>"`
|
||||
- `ONU["FW Bank Ptr"] = writeSlot` (this initiates the download)
|
||||
4. `PUT /api/v1/onus/configs/<id>/` with the entire mutated document.
|
||||
|
||||
Pros: see errors per ONU live. Cons: slow for large fleets, you own
|
||||
retries.
|
||||
|
||||
### 6.2 Procedure 8 — bulk task (recommended)
|
||||
|
||||
Build one `AUTO-TASK-CFG` per (target slot) bucket:
|
||||
|
||||
```
|
||||
PUT /api/v3/tasks/configs/<task-id>/
|
||||
(body shape in §5.4)
|
||||
```
|
||||
|
||||
MCMS owns the rollout: scheduled start, retries, pacing.
|
||||
Monitor progress (build permitting):
|
||||
|
||||
```
|
||||
GET /api/v3/tasks/states/<task-id>/
|
||||
```
|
||||
|
||||
Or just poll the ONU configs and watch `FW Bank Versions` /
|
||||
`FW Bank Ptr` flip.
|
||||
|
||||
### 6.3 Inactive-bank selection
|
||||
|
||||
`FW Bank Ptr` identifies the active bank (0, 1, or 65535 = unset).
|
||||
**Always write to the inactive bank** so you don't blow away the
|
||||
running image:
|
||||
|
||||
| Current `FW Bank Ptr` | Write slot | Why |
|
||||
|---|---|---|
|
||||
| 0 | 1 | preserve active |
|
||||
| 1 | 0 | preserve active |
|
||||
| 65535 (unset) | 1 | matches Procedure 7 example; slot 0 stays as factory fallback |
|
||||
|
||||
MCMS flips `FW Bank Ptr` to the new slot when the download completes.
|
||||
The ONU reboots into the new image on its next reboot.
|
||||
|
||||
---
|
||||
|
||||
## 7. FEC + optical interpretation
|
||||
|
||||
### 7.1 Four FEC counters
|
||||
|
||||
Per the user guide green-LED criteria (`323-1961-302` p149):
|
||||
|
||||
| Field | Path | Green | Red |
|
||||
|---|---|---|---|
|
||||
| ONU Pre-FEC BER | `STATS["ONU-PON"]["RX Pre-FEC BER"]` | 0 | (informational) |
|
||||
| ONU Post-FEC BER | `STATS["ONU-PON"]["RX Post-FEC BER"]` | 0 | > 0 |
|
||||
| OLT Pre-FEC BER | `STATS["OLT-PON"]["RX Pre-FEC BER"]` | 0 | (informational) |
|
||||
| OLT Post-FEC BER | `STATS["OLT-PON"]["RX Post-FEC BER"]` | 0 | > 0 |
|
||||
|
||||
Note: the field name is "BER" but the magnitudes are big integers
|
||||
(cumulative error byte counts since reset), not ratios in [0,1].
|
||||
Treat them as counts.
|
||||
|
||||
### 7.2 Health bucket rules
|
||||
|
||||
Suggested 4-state reduction:
|
||||
|
||||
- **OK**: all four counters present and == 0.
|
||||
- **Pre-FEC errors**: any pre > 0, all post == 0. (Marginal link;
|
||||
FEC is correcting.)
|
||||
- **Post-FEC errors**: any post > 0. (Uncorrected bits reaching the
|
||||
user.)
|
||||
- **Buggy (pre ≈ post)**: ONU pre > 0 AND post > 0 AND
|
||||
`post / pre >= 0.95`. Real FEC reduces errors; if post is within
|
||||
5% of pre, the firmware is mirroring the counter. Observed on
|
||||
Interos Everest ONUs.
|
||||
|
||||
### 7.3 Optical levels
|
||||
|
||||
```
|
||||
STATS["ONU-PON"]["RX Optical Level"] // ONU receive power, dBm
|
||||
STATS["ONU-PON"]["TX Optical Level"] // ONU transmit power, dBm
|
||||
```
|
||||
|
||||
User guide thresholds:
|
||||
|
||||
| | Green | Red |
|
||||
|---|---|---|
|
||||
| RX | ≥ −30 dBm | < −30 dBm |
|
||||
| TX | ≥ 3 dBm | < 3 dBm |
|
||||
|
||||
Operators usually want a margin warning band — e.g. yellow when RX
|
||||
is between −28 and −30 dBm.
|
||||
|
||||
---
|
||||
|
||||
## 8. iOS-specific notes
|
||||
|
||||
### 8.1 Library stack suggestions
|
||||
|
||||
- **HTTP**: `URLSession` is fine. The features you need are standard
|
||||
cookie storage, custom headers, and the option to bypass cert
|
||||
validation. Don't reach for Alamofire just for this.
|
||||
- **Cookies**: `HTTPCookieStorage` handles `__Host-` prefixes
|
||||
correctly. Get the CSRF value via:
|
||||
```swift
|
||||
HTTPCookieStorage.shared.cookies(for: baseURL)?
|
||||
.first { $0.name == "__Host-csrftoken" || $0.name == "csrftoken" }?.value
|
||||
```
|
||||
- **Self-signed certs**: Implement
|
||||
`URLSessionDelegate.urlSession(_:didReceive:completionHandler:)`
|
||||
and accept `serverTrust` only when the user has explicitly toggled
|
||||
"accept self-signed" for the configured host. Never default-on.
|
||||
- **JSON**: `JSONSerialization` is simpler than `Codable` for the
|
||||
envelope pattern (`{status, data, details}`) because the `data`
|
||||
payload is heterogeneous. `Codable` works if you model each
|
||||
endpoint individually.
|
||||
|
||||
### 8.2 Concurrency
|
||||
|
||||
The fleet load is N HTTPS round-trips for per-ONU state (the bulk
|
||||
endpoint is the optimisation but the per-ID fallback may also be
|
||||
needed). Use Swift Concurrency (`TaskGroup`) with a concurrency cap
|
||||
of 8–10 to avoid stampeding the server. The desktop client uses 5
|
||||
for deletes and 8 for state fetches.
|
||||
|
||||
### 8.3 Background uploads
|
||||
|
||||
Firmware `.bin` files are 10–50 MB base64-encoded. If you implement
|
||||
uploads:
|
||||
|
||||
- Don't block the main thread.
|
||||
- Consider `URLSession` background configuration so an upload can
|
||||
survive an app suspend.
|
||||
- The upload is a single POST; no chunking required.
|
||||
|
||||
### 8.4 Storing the session
|
||||
|
||||
For UX: persist the base URL and username in `UserDefaults`, never
|
||||
the password. Re-prompt for password on launch.
|
||||
|
||||
The session cookie itself goes in the per-session cookie storage
|
||||
and is automatically discarded — don't try to persist it.
|
||||
|
||||
### 8.5 Date handling
|
||||
|
||||
See §3.7. Default `DateFormatter` with `en_US_POSIX` locale + UTC
|
||||
timezone, format `"yyyy-MM-dd HH:mm:ss.SSSSSS"` (microseconds) or
|
||||
`"yyyy-MM-dd HH:mm:ss"` (seconds). Try the microsecond format
|
||||
first, fall back to second if it fails.
|
||||
|
||||
### 8.6 Cell-vs-Wi-Fi
|
||||
|
||||
MCMS APIs are typically reachable only from the operator's internal
|
||||
network. Test on cellular: many internal hosts won't be reachable,
|
||||
and your error UI needs to communicate that clearly.
|
||||
|
||||
### 8.7 Self-signed cert prompt
|
||||
|
||||
Make this a first-class onboarding step. The lab MCMS at
|
||||
`https://mcms.lab.svorka.net/` uses a self-signed cert, and any
|
||||
auto-rejection will silently break login.
|
||||
|
||||
---
|
||||
|
||||
## 9. Procedures referenced in the dev guide
|
||||
|
||||
| # | Title | Notes |
|
||||
|---|---|---|
|
||||
| 2 | Determining ONU registration status | OLT-STATE["ONU States"] buckets |
|
||||
| 7 | Per-ONU firmware upgrade | Full-doc PUT to ONU-CFG |
|
||||
| 8 | Bulk firmware upgrade | AUTO-TASK-CFG via PUT |
|
||||
| 38 | Resetting bit error rate values | Names the four FEC fields |
|
||||
| 43 | Deleting an ONU | DELETE on ONU-CFG |
|
||||
|
||||
(Page numbers vary by document revision; cite by procedure number.)
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick implementation checklist for a new client
|
||||
|
||||
A correct first POST-login GET against `/api/v3/onus/configs/?limit=1`
|
||||
returning HTTP 200 means you've got the eight critical pieces right:
|
||||
|
||||
- [ ] HTTPS reachable (with cert exception if needed)
|
||||
- [ ] Path prefix `/api` added
|
||||
- [ ] User-Agent set to non-empty value
|
||||
- [ ] Cookie jar storing `__Host-sessionid` + `__Host-csrftoken`
|
||||
- [ ] `X-CSRFToken` header pulled from `__Host-csrftoken` cookie value
|
||||
- [ ] `Referer` header set to base URL
|
||||
- [ ] `Accept: application/json`
|
||||
- [ ] Trailing slash on the path
|
||||
|
||||
Add for write operations:
|
||||
|
||||
- [ ] `Content-Type: application/json`
|
||||
- [ ] Body wrapped: `{"data": ...}`
|
||||
- [ ] Full-document PUT (never partial)
|
||||
|
||||
Add for list endpoints:
|
||||
|
||||
- [ ] Page size ≤ 100
|
||||
- [ ] `next` cursor (not `skip`)
|
||||
- [ ] Loop until short page
|
||||
|
||||
Once those land, the API is comfortable to work with — the hard parts
|
||||
are upfront.
|
||||
|
||||
---
|
||||
|
||||
*Compiled from `323-1961-306` (REST API dev guide), `323-1961-302`
|
||||
(PON Manager User Guide), and empirical testing against MCMS 6.2 with
|
||||
Interos Everest 5.11/5.12 firmware on GNXS / Tibit MicroPlug ONUs.*
|
||||
4
gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
gradlew
vendored
Executable file
|
|
@ -0,0 +1,251 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
17
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "MCMS"
|
||||
include(":app")
|
||||