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

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