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

5
.gitignore vendored
View File

@@ -7,3 +7,8 @@ data/
*.sqlite *.sqlite
*.sqlite-wal *.sqlite-wal
*.sqlite-shm *.sqlite-shm
# Android
android-admin/.gradle/
android-admin/**/build/
android-admin/local.properties

View File

@@ -6,8 +6,10 @@
## Android admin app ## Android admin app
- [ ] Kotlin + Compose app - [x] Kotlin + Compose app (project scaffold)
- [ ] Store `ADMIN_TOKEN` securely (EncryptedSharedPreferences/Keystore) - [ ] Add gradle wrapper jar (or generate via Android Studio)
- [ ] Logs list/search/detail using `/api/logs` + `/api/logs/:request_id` - [x] Store `ADMIN_TOKEN` securely (EncryptedSharedPreferences)
- [ ] Stats views using `/api/stats/*` - [x] Logs list/search/detail using `/api/logs` + `/api/logs/:request_id`
- [ ] Base URL config for local network host (e.g. `http://192.168.88.2:8787`) - [x] Stats summary view using `/api/stats/summary`
- [x] Base URL config for local network host (e.g. `http://192.168.88.2:8787`)
- [ ] Timeseries + model stats screens

View File

@@ -29,6 +29,11 @@ export FIRMWARE_API_KEY=...
Note: `.env` is loaded by `./run.sh` for non-secret config (like `ADMIN_TOKEN`, `PORT`). Note: `.env` is loaded by `./run.sh` for non-secret config (like `ADMIN_TOKEN`, `PORT`).
## Docs
- `docs/USER.md`
- `docs/ADMIN.md`
## Run (dev) ## Run (dev)
```bash ```bash

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")

94
docs/ADMIN.md Normal file
View File

@@ -0,0 +1,94 @@
# Admin
There are two admin surfaces:
1) Browser admin UI: `/admin`
2) Admin APIs: `/api/logs` and `/api/stats/*`
Admin is intended for local-network use.
## Browser admin UI
1. Start the server (serves both chat UI and admin UI):
```bash
export FIRMWARE_API_KEY=...
./run.sh build
./run.sh server
```
2. Open the admin UI:
- `http://<HOST>:<PORT>/admin` (example: `http://192.168.88.2:8787/admin`)
3. Login:
- Enter `ADMIN_TOKEN` once.
- The server sets an `admin_token` httpOnly cookie.
- The UI then calls admin APIs without needing custom headers.
Logout clears the cookie.
## Admin APIs
Admin APIs are read-only and require authentication.
Auth methods:
- Header: `x-admin-token: $ADMIN_TOKEN`
- Cookie session: `admin_token` (set by `POST /api/admin/session`)
### List/search logs
`GET /api/logs`
Query params:
- `q`: full-text search across prompt+answer (SQLite FTS5)
- `status`: `ok|error|aborted|started`
- `model`: model id
- `from`, `to`: timestamps (ms epoch)
- `limit` (1..200), `offset`
Example:
```bash
curl -H "x-admin-token: $ADMIN_TOKEN" \
"http://192.168.88.2:8787/api/logs?q=xin%20chao&limit=50"
```
### Log detail
`GET /api/logs/:request_id`
Returns full record including:
- `messages_json` (full conversation context)
- `assistant_text` (final/partial answer)
- timestamps and status
### Stats
- `GET /api/stats/summary`
- `GET /api/stats/models`
- `GET /api/stats/timeseries?bucket=hour|day`
Example:
```bash
curl -H "x-admin-token: $ADMIN_TOKEN" \
"http://192.168.88.2:8787/api/stats/summary"
```
## Data logging
SQLite file (default): `./data/chatlog.sqlite`
Each `/api/chat` request inserts a log row at start and updates it when finished.
Stored fields include:
- timestamps: `ts_request`, `ts_first_token`, `ts_done`
- full context: `messages_json`
- quick fields: `user_text`, `assistant_text`
- `model`, `status`, optional `usage` tokens

33
docs/USER.md Normal file
View File

@@ -0,0 +1,33 @@
# User
## Start
```bash
export FIRMWARE_API_KEY=...
cp .env.example .env
```
Edit `.env`:
```bash
ADMIN_TOKEN=...
HOST=0.0.0.0
PORT=8787
```
Then:
```bash
./run.sh build
./run.sh server
```
Open:
- `http://192.168.88.2:8787/`
## Chat UI
- Works in mobile Safari/iPhone.
- Streams responses.
- Renders assistant responses as Markdown (sanitized).