Add admin docs and Android admin scaffold

This commit is contained in:
kdusek
2026-01-27 02:34:21 +01:00
parent c716699a66
commit a36707c8aa
21 changed files with 664 additions and 5 deletions

10
android-admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.idea/
.gradle/
local.properties
*.iml
build/
**/build/
captures/
.externalNativeBuild/
.cxx/
keystore.jks

18
android-admin/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Admin Android App
Android (Kotlin + Jetpack Compose) admin client for the local-network Firmware Chat server.
## Features
- Configure `Base URL` (e.g. `http://192.168.88.2:8787`)
- Store `ADMIN_TOKEN` securely (EncryptedSharedPreferences)
- View/search logs, open log detail
- View stats summary
## Build / Run
Open `android-admin/` in Android Studio and run the `app` configuration.
## Note
This repo does not include `gradle-wrapper.jar` (Android Studio will generate/sync it).

View File

@@ -0,0 +1,60 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "local.admin"
compileSdk = 34
defaultConfig {
applicationId = "local.admin"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.activity:activity-compose:1.9.1")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.material3:material3")
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}

View File

@@ -0,0 +1,21 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="Admin"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,21 @@
package local.admin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import local.admin.ui.AdminApp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface {
AdminApp()
}
}
}
}
}

View File

@@ -0,0 +1,19 @@
package local.admin.ui
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@Composable
fun AdminApp() {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = "home") {
composable("home") { HomeScreen(nav) }
composable("detail/{id}") { backStack ->
val id = backStack.arguments?.getString("id") ?: ""
DetailScreen(nav, id)
}
composable("settings") { SettingsScreen(nav) }
}
}

View File

@@ -0,0 +1,11 @@
package local.admin.ui
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
fun launchIo(block: suspend () -> Unit) {
CoroutineScope(Dispatchers.IO).launch {
block()
}
}

View File

@@ -0,0 +1,67 @@
package local.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import kotlinx.serialization.json.Json
import local.admin.data.Api
import local.admin.data.LogDetail
import local.admin.data.SecurePrefs
import okhttp3.OkHttpClient
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailScreen(nav: NavController, id: String) {
val ctx = LocalContext.current
val prefs = remember { SecurePrefs(ctx) }
val detail = remember { mutableStateOf<LogDetail?>(null) }
val err = remember { mutableStateOf<String?>(null) }
LaunchedEffect(id) {
err.value = null
try {
val api = Api(OkHttpClient(), Json { ignoreUnknownKeys = true }, prefs.getBaseUrl(), prefs.getAdminToken())
detail.value = api.getLogDetail(id)
} catch (e: Exception) {
err.value = e.message
}
}
Scaffold(
topBar = { TopAppBar(title = { Text("Detail") }) }
) { pad ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(pad)
.padding(12.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
err.value?.let { Text("Error: $it") }
val d = detail.value
if (d != null) {
Text("id=${d.request_id}", fontFamily = FontFamily.Monospace)
Text("model=${d.model} status=${d.status}", fontFamily = FontFamily.Monospace)
Text("messages_json:\n${d.messages_json}")
Text("assistant_text:\n${d.assistant_text ?: ""}")
}
}
}
}

View File

@@ -0,0 +1,154 @@
package local.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import local.admin.data.Api
import local.admin.data.LogRow
import local.admin.data.SecurePrefs
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(nav: NavController) {
val ctx = LocalContext.current
val prefs = remember { SecurePrefs(ctx) }
val q = remember { mutableStateOf("") }
val model = remember { mutableStateOf("") }
val status = remember { mutableStateOf("") }
val rows = remember { mutableStateOf<List<LogRow>>(emptyList()) }
val err = remember { mutableStateOf<String?>(null) }
val stats = remember { mutableStateOf<String>("") }
fun api(): Api {
val http = OkHttpClient()
val json = Json { ignoreUnknownKeys = true }
return Api(http, json, prefs.getBaseUrl(), prefs.getAdminToken())
}
suspend fun refresh() {
err.value = null
try {
val a = api()
val s = a.getStatsSummary()
stats.value = "total=${s.total} ok=${s.ok} error=${s.error} aborted=${s.aborted} p95=${s.p95LatencyMs ?: "-"}ms"
rows.value = a.getLogs(q.value.ifBlank { null }, status.value.ifBlank { null }, model.value.ifBlank { null }, 50, 0).rows
} catch (e: Exception) {
err.value = e.message
}
}
LaunchedEffect(Unit) {
refresh()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Admin") },
actions = {
IconButton(onClick = { nav.navigate("settings") }) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
)
}
) { pad ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(pad)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(stats.value, fontFamily = FontFamily.Monospace)
err.value?.let { Text("Error: $it") }
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = q.value,
onValueChange = { q.value = it },
modifier = Modifier.weight(1f),
label = { Text("Search") },
singleLine = true,
)
Button(onClick = { launchIo { refresh() } }) {
Text("Go")
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = status.value,
onValueChange = { status.value = it },
modifier = Modifier.weight(1f),
label = { Text("Status") },
singleLine = true,
)
OutlinedTextField(
value = model.value,
onValueChange = { model.value = it },
modifier = Modifier.weight(1f),
label = { Text("Model") },
singleLine = true,
)
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(rows.value) { r ->
LogCard(r) { nav.navigate("detail/${r.requestId}") }
Spacer(Modifier.height(10.dp))
}
}
}
}
}
@Composable
private fun LogCard(r: LogRow, onOpen: () -> Unit) {
Card(
onClick = onOpen,
colors = CardDefaults.cardColors(),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(r.status)
Text(r.model, fontFamily = FontFamily.Monospace, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text("Prompt: ${r.userPreview}", maxLines = 2, overflow = TextOverflow.Ellipsis)
Text("Answer: ${r.assistantPreview}", maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
}

View File

@@ -0,0 +1,61 @@
package local.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import local.admin.data.SecurePrefs
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(nav: NavController) {
val ctx = LocalContext.current
val prefs = remember { SecurePrefs(ctx) }
val baseUrl = remember { mutableStateOf(prefs.getBaseUrl()) }
val token = remember { mutableStateOf(prefs.getAdminToken()) }
Scaffold(
topBar = { TopAppBar(title = { Text("Settings") }) }
) { pad ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(pad)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = baseUrl.value,
onValueChange = { baseUrl.value = it },
label = { Text("Base URL") },
singleLine = true,
)
OutlinedTextField(
value = token.value,
onValueChange = { token.value = it },
label = { Text("ADMIN_TOKEN") },
singleLine = true,
)
Button(onClick = {
prefs.setBaseUrl(baseUrl.value.trim())
prefs.setAdminToken(token.value)
nav.popBackStack()
}) {
Text("Save")
}
}
}
}

View File

@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

25
android-admin/gradlew vendored Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for POSIX generated by Gradle.
##
##############################################################################
app_path=$0
APP_HOME=${app_path%"${app_path##*/}"}
APP_HOME=$(cd "${APP_HOME:-./}" && pwd -P) || exit
DEFAULT_JVM_OPTS='-Xmx64m -Xms64m'
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "${JAVA_HOME:-}" ] ; then
JAVACMD="$JAVA_HOME/bin/java"
else
JAVACMD=java
fi
exec "$JAVACMD" $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \
-classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

21
android-admin/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,21 @@
@rem
@rem Gradle startup script for Windows
@rem
@if "%DEBUG%"=="" @echo off
setlocal
set APP_HOME=%~dp0
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
if defined JAVA_HOME (
set JAVACMD=%JAVA_HOME%\bin\java.exe
) else (
set JAVACMD=java.exe
)
%JAVACMD% %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
endlocal

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Admin"
include(":app")