Add admin docs and Android admin scaffold
This commit is contained in:
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user