Add admin docs and Android admin scaffold
This commit is contained in:
10
android-admin/.gitignore
vendored
Normal file
10
android-admin/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.idea/
|
||||
.gradle/
|
||||
local.properties
|
||||
*.iml
|
||||
build/
|
||||
**/build/
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
keystore.jks
|
||||
18
android-admin/README.md
Normal file
18
android-admin/README.md
Normal 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).
|
||||
60
android-admin/app/build.gradle.kts
Normal file
60
android-admin/app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
21
android-admin/app/src/main/AndroidManifest.xml
Normal file
21
android-admin/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
21
android-admin/app/src/main/java/local/admin/MainActivity.kt
Normal file
21
android-admin/app/src/main/java/local/admin/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
android-admin/app/src/main/java/local/admin/ui/AdminApp.kt
Normal file
19
android-admin/app/src/main/java/local/admin/ui/AdminApp.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 ?: ""}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
154
android-admin/app/src/main/java/local/admin/ui/HomeScreen.kt
Normal file
154
android-admin/app/src/main/java/local/admin/ui/HomeScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
android-admin/build.gradle.kts
Normal file
5
android-admin/build.gradle.kts
Normal 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
|
||||
}
|
||||
4
android-admin/gradle.properties
Normal file
4
android-admin/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
5
android-admin/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
android-admin/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
25
android-admin/gradlew
vendored
Executable 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
21
android-admin/gradlew.bat
vendored
Normal 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
|
||||
18
android-admin/settings.gradle.kts
Normal file
18
android-admin/settings.gradle.kts
Normal 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")
|
||||
Reference in New Issue
Block a user