feat: build Sub2API monitor Android prototype
This commit is contained in:
49
app/build.gradle.kts
Normal file
49
app/build.gradle.kts
Normal file
@@ -0,0 +1,49 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.sub2api.monitor"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.sub2api.monitor"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
implementation("androidx.glance:glance-appwidget:1.1.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
|
||||
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
}
|
||||
34
app/src/main/AndroidManifest.xml
Normal file
34
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.Sub2ApiMonitor">
|
||||
<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>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.Sub2ApiMonitorWidgetReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/sub2api_monitor_widget" />
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
60
app/src/main/java/com/sub2api/monitor/MainActivity.kt
Normal file
60
app/src/main/java/com/sub2api/monitor/MainActivity.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
package com.sub2api.monitor
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.sub2api.monitor.data.DataStoreConfigRepository
|
||||
import com.sub2api.monitor.data.MockSub2ApiRepository
|
||||
import com.sub2api.monitor.sync.WorkManagerRefreshScheduler
|
||||
import com.sub2api.monitor.ui.ConfigScreen
|
||||
import com.sub2api.monitor.ui.ConfigViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
val configRepository = remember { DataStoreConfigRepository(applicationContext) }
|
||||
val sub2ApiRepository = remember { MockSub2ApiRepository() }
|
||||
val refreshScheduler = remember { WorkManagerRefreshScheduler(applicationContext) }
|
||||
val configViewModel: ConfigViewModel = viewModel(
|
||||
factory = ConfigViewModelFactory(
|
||||
configRepository = configRepository,
|
||||
sub2ApiRepository = sub2ApiRepository,
|
||||
refreshScheduler = refreshScheduler,
|
||||
),
|
||||
)
|
||||
Sub2ApiMonitorApp(configViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Sub2ApiMonitorApp(configViewModel: ConfigViewModel) {
|
||||
MaterialTheme {
|
||||
ConfigScreen(
|
||||
state = configViewModel.uiState,
|
||||
onBaseUrlChange = configViewModel::updateBaseUrl,
|
||||
onAdminKeyChange = configViewModel::updateAdminKey,
|
||||
onRefreshIntervalChange = configViewModel::updateRefreshInterval,
|
||||
onTestConnection = configViewModel::testConnection,
|
||||
onSave = configViewModel::saveConfig,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ConfigViewModelFactory(
|
||||
private val configRepository: DataStoreConfigRepository,
|
||||
private val sub2ApiRepository: MockSub2ApiRepository,
|
||||
private val refreshScheduler: WorkManagerRefreshScheduler,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return ConfigViewModel(configRepository, sub2ApiRepository, refreshScheduler) as T
|
||||
}
|
||||
}
|
||||
10
app/src/main/java/com/sub2api/monitor/data/AppConfig.kt
Normal file
10
app/src/main/java/com/sub2api/monitor/data/AppConfig.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.sub2api.monitor.data
|
||||
|
||||
data class AppConfig(
|
||||
val baseUrl: String = "",
|
||||
val adminKey: String = "",
|
||||
val refreshIntervalMinutes: Int = 30,
|
||||
) {
|
||||
val isConfigured: Boolean
|
||||
get() = baseUrl.trim().isNotEmpty() && adminKey.trim().isNotEmpty()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.sub2api.monitor.data
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ConfigRepository {
|
||||
val config: Flow<AppConfig>
|
||||
suspend fun save(config: AppConfig)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.sub2api.monitor.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.configDataStore by preferencesDataStore(name = "sub2api_config")
|
||||
|
||||
class DataStoreConfigRepository(
|
||||
private val context: Context,
|
||||
) : ConfigRepository {
|
||||
override val config: Flow<AppConfig> = context.configDataStore.data.map { preferences ->
|
||||
AppConfig(
|
||||
baseUrl = preferences[BASE_URL].orEmpty(),
|
||||
adminKey = preferences[ADMIN_KEY].orEmpty(),
|
||||
refreshIntervalMinutes = preferences[REFRESH_INTERVAL] ?: 30,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun save(config: AppConfig) {
|
||||
context.configDataStore.edit { preferences ->
|
||||
preferences[BASE_URL] = config.baseUrl.trim()
|
||||
preferences[ADMIN_KEY] = config.adminKey.trim()
|
||||
preferences[REFRESH_INTERVAL] = config.refreshIntervalMinutes.coerceAtLeast(15)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val BASE_URL = stringPreferencesKey("base_url")
|
||||
val ADMIN_KEY = stringPreferencesKey("admin_key")
|
||||
val REFRESH_INTERVAL = intPreferencesKey("refresh_interval")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.sub2api.monitor.data
|
||||
|
||||
import com.sub2api.monitor.domain.ModelUsage
|
||||
import com.sub2api.monitor.domain.RecentCall
|
||||
import com.sub2api.monitor.domain.ServiceStatus
|
||||
import com.sub2api.monitor.domain.Sub2ApiSnapshot
|
||||
|
||||
class MockSub2ApiRepository : Sub2ApiRepository {
|
||||
override suspend fun fetchSnapshot(): Sub2ApiSnapshot {
|
||||
val now = System.currentTimeMillis()
|
||||
return Sub2ApiSnapshot(
|
||||
lastUpdatedAtMillis = now,
|
||||
todayTokens = 1_284_920,
|
||||
todayCost = 18.73,
|
||||
todayRequests = 8_426,
|
||||
averageLatencyMs = 248,
|
||||
rpm = 42,
|
||||
tpm = 18_500,
|
||||
serviceStatus = ServiceStatus.Healthy,
|
||||
activeKeyCount = 23,
|
||||
userCount = 156,
|
||||
recentCalls = listOf(
|
||||
RecentCall("gpt-4.1-mini", 200, 214, 1_842, 0.012, now - 60_000),
|
||||
RecentCall("gpt-4o", 200, 352, 4_126, 0.084, now - 180_000),
|
||||
RecentCall("claude-sonnet-4", 200, 441, 6_220, 0.121, now - 260_000),
|
||||
RecentCall("gpt-4.1", 429, 96, 0, 0.0, now - 330_000),
|
||||
RecentCall("gemini-2.5-flash", 200, 188, 2_019, 0.009, now - 420_000),
|
||||
),
|
||||
modelTop = listOf(
|
||||
ModelUsage("gpt-4o", 482_300, 7.82),
|
||||
ModelUsage("claude-sonnet-4", 361_220, 6.13),
|
||||
ModelUsage("gpt-4.1-mini", 288_140, 3.41),
|
||||
ModelUsage("gemini-2.5-flash", 153_260, 1.37),
|
||||
),
|
||||
totalTokens = 92_518_400,
|
||||
totalCost = 1_438.29,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun testConnection(config: AppConfig): Result<Unit> {
|
||||
return if (config.isConfigured) Result.success(Unit) else Result.failure(
|
||||
IllegalStateException("Missing Sub2API URL or admin key"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.sub2api.monitor.data
|
||||
|
||||
import com.sub2api.monitor.domain.Sub2ApiSnapshot
|
||||
|
||||
interface Sub2ApiRepository {
|
||||
suspend fun fetchSnapshot(): Sub2ApiSnapshot
|
||||
suspend fun testConnection(config: AppConfig): Result<Unit>
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.sub2api.monitor.domain
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
fun formatTokens(value: Long): String = compactNumber(value)
|
||||
|
||||
fun formatCurrency(value: Double): String = "$" + String.format(Locale.US, "%.2f", value)
|
||||
|
||||
fun formatLatency(valueMs: Int): String = "$valueMs ms"
|
||||
|
||||
fun formatRpm(value: Int): String = "$value RPM"
|
||||
|
||||
fun formatTpm(value: Long): String = "${compactNumber(value)} TPM"
|
||||
|
||||
fun formatDisplayTime(valueMillis: Long): String {
|
||||
val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
return formatter.format(Date(valueMillis))
|
||||
}
|
||||
|
||||
private fun compactNumber(value: Long): String {
|
||||
val abs = kotlin.math.abs(value)
|
||||
return when {
|
||||
abs >= 1_000_000 -> String.format(Locale.US, "%.1fM", value / 1_000_000.0)
|
||||
abs >= 1_000 -> String.format(Locale.US, "%.1fK", value / 1_000.0)
|
||||
else -> value.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.sub2api.monitor.domain
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Sub2ApiSnapshot(
|
||||
val lastUpdatedAtMillis: Long,
|
||||
val todayTokens: Long,
|
||||
val todayCost: Double,
|
||||
val todayRequests: Int,
|
||||
val averageLatencyMs: Int,
|
||||
val rpm: Int,
|
||||
val tpm: Long,
|
||||
val serviceStatus: ServiceStatus,
|
||||
val activeKeyCount: Int,
|
||||
val userCount: Int,
|
||||
val recentCalls: List<RecentCall>,
|
||||
val modelTop: List<ModelUsage>,
|
||||
val totalTokens: Long,
|
||||
val totalCost: Double,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RecentCall(
|
||||
val model: String,
|
||||
val statusCode: Int,
|
||||
val latencyMs: Int,
|
||||
val tokens: Long,
|
||||
val cost: Double,
|
||||
val createdAtMillis: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelUsage(
|
||||
val model: String,
|
||||
val tokens: Long,
|
||||
val cost: Double,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class ServiceStatus {
|
||||
Healthy,
|
||||
Degraded,
|
||||
Down,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.sub2api.monitor.sync
|
||||
|
||||
interface RefreshScheduler {
|
||||
fun schedule(intervalMinutes: Int)
|
||||
}
|
||||
|
||||
fun sanitizeRefreshIntervalMinutes(intervalMinutes: Int): Int = intervalMinutes.coerceAtLeast(15)
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.sub2api.monitor.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.sub2api.monitor.data.DataStoreConfigRepository
|
||||
import com.sub2api.monitor.data.MockSub2ApiRepository
|
||||
import com.sub2api.monitor.widget.Sub2ApiMonitorWidget
|
||||
import com.sub2api.monitor.widget.WidgetStateRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class WidgetRefreshWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val configRepository = DataStoreConfigRepository(applicationContext)
|
||||
val config = configRepository.config.first()
|
||||
if (!config.isConfigured) {
|
||||
Sub2ApiMonitorWidget().updateAll(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val widgetStateRepository = WidgetStateRepository(applicationContext)
|
||||
val repository = MockSub2ApiRepository()
|
||||
|
||||
return runCatching {
|
||||
val snapshot = repository.fetchSnapshot()
|
||||
widgetStateRepository.saveSuccess(snapshot)
|
||||
Sub2ApiMonitorWidget().updateAll(applicationContext)
|
||||
}.fold(
|
||||
onSuccess = { Result.success() },
|
||||
onFailure = { error ->
|
||||
widgetStateRepository.saveError(error.message ?: "刷新失败")
|
||||
Sub2ApiMonitorWidget().updateAll(applicationContext)
|
||||
Result.retry()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sub2api.monitor.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class WorkManagerRefreshScheduler(
|
||||
context: Context,
|
||||
) : RefreshScheduler {
|
||||
private val workManager = WorkManager.getInstance(context.applicationContext)
|
||||
|
||||
override fun schedule(intervalMinutes: Int) {
|
||||
val sanitized = sanitizeRefreshIntervalMinutes(intervalMinutes)
|
||||
val request = PeriodicWorkRequestBuilder<WidgetRefreshWorker>(
|
||||
sanitized.toLong(),
|
||||
TimeUnit.MINUTES,
|
||||
).build()
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val WORK_NAME = "sub2api_widget_refresh"
|
||||
}
|
||||
}
|
||||
149
app/src/main/java/com/sub2api/monitor/ui/ConfigScreen.kt
Normal file
149
app/src/main/java/com/sub2api/monitor/ui/ConfigScreen.kt
Normal file
@@ -0,0 +1,149 @@
|
||||
package com.sub2api.monitor.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ConfigScreen(
|
||||
state: ConfigUiState,
|
||||
onBaseUrlChange: (String) -> Unit,
|
||||
onAdminKeyChange: (String) -> Unit,
|
||||
onRefreshIntervalChange: (String) -> Unit,
|
||||
onTestConnection: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
color = Color(0xFFE8EEF7),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFFE8EEF7), Color(0xFFF7FAFC), Color(0xFFEFF6F2)),
|
||||
),
|
||||
)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Sub2API Monitor",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF172033),
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.baseUrl,
|
||||
onValueChange = onBaseUrlChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("Sub2API 地址") },
|
||||
placeholder = { Text("https://sub2api.example.com") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.adminKey,
|
||||
onValueChange = onAdminKeyChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("Admin Key") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.refreshIntervalMinutes,
|
||||
onValueChange = onRefreshIntervalChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("自动刷新间隔(分钟)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = onTestConnection,
|
||||
enabled = !state.isTesting,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2563EB)),
|
||||
) {
|
||||
Text(if (state.isTesting) "测试中" else "测试连接")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Button(
|
||||
onClick = onSave,
|
||||
enabled = !state.isSaving,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0F766E)),
|
||||
) {
|
||||
Text(if (state.isSaving) "保存中" else "保存配置")
|
||||
}
|
||||
}
|
||||
|
||||
state.statusMessage?.let { message ->
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF0F766E),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfigScreenPreviewState() {
|
||||
var state by remember { mutableStateOf(ConfigUiState()) }
|
||||
ConfigScreen(
|
||||
state = state,
|
||||
onBaseUrlChange = { state = state.withBaseUrl(it) },
|
||||
onAdminKeyChange = { state = state.withAdminKey(it) },
|
||||
onRefreshIntervalChange = { state = state.withRefreshInterval(it) },
|
||||
onTestConnection = {},
|
||||
onSave = {},
|
||||
)
|
||||
}
|
||||
88
app/src/main/java/com/sub2api/monitor/ui/ConfigViewModel.kt
Normal file
88
app/src/main/java/com/sub2api/monitor/ui/ConfigViewModel.kt
Normal file
@@ -0,0 +1,88 @@
|
||||
package com.sub2api.monitor.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.sub2api.monitor.data.AppConfig
|
||||
import com.sub2api.monitor.data.ConfigRepository
|
||||
import com.sub2api.monitor.data.Sub2ApiRepository
|
||||
import com.sub2api.monitor.sync.RefreshScheduler
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ConfigUiState(
|
||||
val baseUrl: String = "",
|
||||
val adminKey: String = "",
|
||||
val refreshIntervalMinutes: String = "30",
|
||||
val isSaving: Boolean = false,
|
||||
val isTesting: Boolean = false,
|
||||
val statusMessage: String? = null,
|
||||
) {
|
||||
fun withBaseUrl(value: String): ConfigUiState = copy(baseUrl = value)
|
||||
fun withAdminKey(value: String): ConfigUiState = copy(adminKey = value)
|
||||
fun withRefreshInterval(value: String): ConfigUiState = copy(refreshIntervalMinutes = value.filter { it.isDigit() })
|
||||
|
||||
fun toAppConfig(): AppConfig = AppConfig(
|
||||
baseUrl = baseUrl,
|
||||
adminKey = adminKey,
|
||||
refreshIntervalMinutes = refreshIntervalMinutes.toIntOrNull()?.coerceAtLeast(15) ?: 30,
|
||||
)
|
||||
}
|
||||
|
||||
class ConfigViewModel(
|
||||
private val configRepository: ConfigRepository,
|
||||
private val sub2ApiRepository: Sub2ApiRepository,
|
||||
private val refreshScheduler: RefreshScheduler,
|
||||
) : ViewModel() {
|
||||
var uiState by mutableStateOf(ConfigUiState())
|
||||
private set
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
configRepository.config.collect { config ->
|
||||
uiState = uiState.copy(
|
||||
baseUrl = config.baseUrl,
|
||||
adminKey = config.adminKey,
|
||||
refreshIntervalMinutes = config.refreshIntervalMinutes.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBaseUrl(value: String) {
|
||||
uiState = uiState.withBaseUrl(value)
|
||||
}
|
||||
|
||||
fun updateAdminKey(value: String) {
|
||||
uiState = uiState.withAdminKey(value)
|
||||
}
|
||||
|
||||
fun updateRefreshInterval(value: String) {
|
||||
uiState = uiState.withRefreshInterval(value)
|
||||
}
|
||||
|
||||
fun testConnection() {
|
||||
viewModelScope.launch {
|
||||
uiState = uiState.copy(isTesting = true, statusMessage = null)
|
||||
val result = sub2ApiRepository.testConnection(uiState.toAppConfig())
|
||||
uiState = uiState.copy(
|
||||
isTesting = false,
|
||||
statusMessage = result.fold(
|
||||
onSuccess = { "连接测试成功" },
|
||||
onFailure = { it.message ?: "连接测试失败" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveConfig() {
|
||||
viewModelScope.launch {
|
||||
uiState = uiState.copy(isSaving = true, statusMessage = null)
|
||||
val config = uiState.toAppConfig()
|
||||
configRepository.save(config)
|
||||
refreshScheduler.schedule(config.refreshIntervalMinutes)
|
||||
uiState = uiState.copy(isSaving = false, statusMessage = "配置已保存")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.sub2api.monitor.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.action.ActionCallback
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.appwidget.update
|
||||
import com.sub2api.monitor.data.DataStoreConfigRepository
|
||||
import com.sub2api.monitor.data.MockSub2ApiRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class RefreshWidgetAction : ActionCallback {
|
||||
override suspend fun onAction(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
parameters: ActionParameters,
|
||||
) {
|
||||
val config = DataStoreConfigRepository(context).config.first()
|
||||
val widgetStateRepository = WidgetStateRepository(context)
|
||||
|
||||
if (!config.isConfigured) {
|
||||
Sub2ApiMonitorWidget().update(context, glanceId)
|
||||
return
|
||||
}
|
||||
|
||||
runCatching {
|
||||
MockSub2ApiRepository().fetchSnapshot()
|
||||
}.fold(
|
||||
onSuccess = { snapshot ->
|
||||
widgetStateRepository.saveSuccess(snapshot)
|
||||
},
|
||||
onFailure = { error ->
|
||||
widgetStateRepository.saveError(error.message ?: "刷新失败")
|
||||
},
|
||||
)
|
||||
|
||||
Sub2ApiMonitorWidget().update(context, glanceId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.sub2api.monitor.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.Button
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.actionRunCallback
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.defaultWeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import com.sub2api.monitor.R
|
||||
import com.sub2api.monitor.data.DataStoreConfigRepository
|
||||
import com.sub2api.monitor.domain.ModelUsage
|
||||
import com.sub2api.monitor.domain.RecentCall
|
||||
import com.sub2api.monitor.domain.ServiceStatus
|
||||
import com.sub2api.monitor.domain.formatCurrency
|
||||
import com.sub2api.monitor.domain.formatDisplayTime
|
||||
import com.sub2api.monitor.domain.formatLatency
|
||||
import com.sub2api.monitor.domain.formatRpm
|
||||
import com.sub2api.monitor.domain.formatTokens
|
||||
import com.sub2api.monitor.domain.formatTpm
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class Sub2ApiMonitorWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val config = DataStoreConfigRepository(context).config.first()
|
||||
val stateRepository = WidgetStateRepository(context)
|
||||
val snapshot = stateRepository.lastSuccessfulSnapshot.first()
|
||||
val error = stateRepository.lastError.first()
|
||||
val state = buildWidgetState(config, snapshot, error)
|
||||
|
||||
provideContent {
|
||||
WidgetContent(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetContent(state: Sub2ApiWidgetState) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(ColorProvider(Color(0xCCFFFFFF)))
|
||||
.cornerRadius(24.dp)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
if (!state.isConfigured) {
|
||||
EmptyState()
|
||||
} else {
|
||||
DashboardState(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState() {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_launcher),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(36.dp),
|
||||
)
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
Text(
|
||||
text = "请先配置 Sub2API",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(Color(0xFF172033)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardState(state: Sub2ApiWidgetState) {
|
||||
val snapshot = state.snapshot
|
||||
if (snapshot == null) {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("暂无监控数据", style = titleStyle())
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
Button(text = "刷新", onClick = actionRunCallback<RefreshWidgetAction>())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text("Sub2API", style = titleStyle())
|
||||
Text(
|
||||
text = "更新 ${formatDisplayTime(snapshot.lastUpdatedAtMillis)}",
|
||||
style = mutedStyle(),
|
||||
)
|
||||
}
|
||||
Button(text = "刷新", onClick = actionRunCallback<RefreshWidgetAction>())
|
||||
}
|
||||
|
||||
state.errorMessage?.let {
|
||||
Spacer(modifier = GlanceModifier.height(6.dp))
|
||||
Text("错误:$it", style = errorStyle())
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
MetricBlock("今日 Token", formatTokens(snapshot.todayTokens), Color(0xFF2563EB), GlanceModifier.defaultWeight())
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
MetricBlock("今日消耗", formatCurrency(snapshot.todayCost), Color(0xFF0F766E), GlanceModifier.defaultWeight())
|
||||
}
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
MetricBlock("请求次数", snapshot.todayRequests.toString(), Color(0xFF7C3AED), GlanceModifier.defaultWeight())
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
MetricBlock("服务状态", statusText(snapshot.serviceStatus), statusColor(snapshot.serviceStatus), GlanceModifier.defaultWeight())
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
TinyMetric("平均", formatLatency(snapshot.averageLatencyMs), GlanceModifier.defaultWeight())
|
||||
TinyMetric("RPM", formatRpm(snapshot.rpm), GlanceModifier.defaultWeight())
|
||||
TinyMetric("TPM", formatTpm(snapshot.tpm), GlanceModifier.defaultWeight())
|
||||
TinyMetric("Key", snapshot.activeKeyCount.toString(), GlanceModifier.defaultWeight())
|
||||
TinyMetric("用户", snapshot.userCount.toString(), GlanceModifier.defaultWeight())
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(8.dp))
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
SectionTitle("最近调用")
|
||||
snapshot.recentCalls.take(5).forEach { RecentCallRow(it) }
|
||||
}
|
||||
Spacer(modifier = GlanceModifier.width(10.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
SectionTitle("模型 TOP4")
|
||||
snapshot.modelTop.take(4).forEach { ModelUsageRow(it) }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = GlanceModifier.height(6.dp))
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
Text("累计 ${formatTokens(snapshot.totalTokens)}", style = mutedStyle(), modifier = GlanceModifier.defaultWeight())
|
||||
Text("累计 ${formatCurrency(snapshot.totalCost)}", style = mutedStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetricBlock(label: String, value: String, color: Color, modifier: GlanceModifier = GlanceModifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(ColorProvider(Color(0x26FFFFFF)))
|
||||
.cornerRadius(8.dp)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Text(label, style = mutedStyle())
|
||||
Text(
|
||||
value,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(color),
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TinyMetric(label: String, value: String, modifier: GlanceModifier = GlanceModifier) {
|
||||
Column(modifier = modifier.padding(end = 4.dp)) {
|
||||
Text(label, style = mutedStyle())
|
||||
Text(value, style = smallStrongStyle())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(text: String) {
|
||||
Text(text, style = smallStrongStyle())
|
||||
Spacer(modifier = GlanceModifier.height(3.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentCallRow(call: RecentCall) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
Text(call.model, style = mutedStyle(), modifier = GlanceModifier.defaultWeight())
|
||||
Text("${call.statusCode}", style = mutedStyle())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelUsageRow(model: ModelUsage) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
Text(model.model, style = mutedStyle(), modifier = GlanceModifier.defaultWeight())
|
||||
Text(formatCurrency(model.cost), style = mutedStyle())
|
||||
}
|
||||
}
|
||||
|
||||
private fun titleStyle(): TextStyle = TextStyle(
|
||||
color = ColorProvider(Color(0xFF172033)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
private fun smallStrongStyle(): TextStyle = TextStyle(
|
||||
color = ColorProvider(Color(0xFF172033)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
private fun mutedStyle(): TextStyle = TextStyle(color = ColorProvider(Color(0xFF5F6B7A)))
|
||||
|
||||
private fun errorStyle(): TextStyle = TextStyle(
|
||||
color = ColorProvider(Color(0xFFB42318)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
private fun statusText(status: ServiceStatus): String = when (status) {
|
||||
ServiceStatus.Healthy -> "正常"
|
||||
ServiceStatus.Degraded -> "降级"
|
||||
ServiceStatus.Down -> "异常"
|
||||
}
|
||||
|
||||
private fun statusColor(status: ServiceStatus): Color = when (status) {
|
||||
ServiceStatus.Healthy -> Color(0xFF0F766E)
|
||||
ServiceStatus.Degraded -> Color(0xFFB45309)
|
||||
ServiceStatus.Down -> Color(0xFFB42318)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.sub2api.monitor.widget
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
class Sub2ApiMonitorWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = Sub2ApiMonitorWidget()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sub2api.monitor.widget
|
||||
|
||||
import com.sub2api.monitor.data.AppConfig
|
||||
import com.sub2api.monitor.domain.Sub2ApiSnapshot
|
||||
|
||||
fun buildWidgetState(
|
||||
config: AppConfig,
|
||||
lastSuccessfulSnapshot: Sub2ApiSnapshot?,
|
||||
errorMessage: String?,
|
||||
): Sub2ApiWidgetState {
|
||||
if (!config.isConfigured) {
|
||||
return Sub2ApiWidgetState(
|
||||
isConfigured = false,
|
||||
snapshot = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
|
||||
return Sub2ApiWidgetState(
|
||||
isConfigured = true,
|
||||
snapshot = lastSuccessfulSnapshot,
|
||||
errorMessage = errorMessage,
|
||||
)
|
||||
}
|
||||
12
app/src/main/java/com/sub2api/monitor/widget/WidgetState.kt
Normal file
12
app/src/main/java/com/sub2api/monitor/widget/WidgetState.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.sub2api.monitor.widget
|
||||
|
||||
import com.sub2api.monitor.domain.Sub2ApiSnapshot
|
||||
|
||||
data class Sub2ApiWidgetState(
|
||||
val isConfigured: Boolean,
|
||||
val snapshot: Sub2ApiSnapshot?,
|
||||
val errorMessage: String?,
|
||||
) {
|
||||
val hasError: Boolean = errorMessage != null
|
||||
val primaryMessage: String = if (isConfigured) "Sub2API" else "请先配置 Sub2API"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.sub2api.monitor.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.sub2api.monitor.domain.Sub2ApiSnapshot
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private val Context.widgetStateDataStore by preferencesDataStore(name = "sub2api_widget_state")
|
||||
|
||||
class WidgetStateRepository(
|
||||
private val context: Context,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
) {
|
||||
val lastSuccessfulSnapshot: Flow<Sub2ApiSnapshot?> = context.widgetStateDataStore.data.map { preferences ->
|
||||
preferences[SNAPSHOT]?.let { value ->
|
||||
runCatching { json.decodeFromString<Sub2ApiSnapshot>(value) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
val lastError: Flow<String?> = context.widgetStateDataStore.data.map { preferences ->
|
||||
preferences[ERROR]
|
||||
}
|
||||
|
||||
suspend fun saveSuccess(snapshot: Sub2ApiSnapshot) {
|
||||
context.widgetStateDataStore.edit { preferences ->
|
||||
preferences[SNAPSHOT] = json.encodeToString(snapshot)
|
||||
preferences.remove(ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveError(message: String) {
|
||||
context.widgetStateDataStore.edit { preferences ->
|
||||
preferences[ERROR] = message
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SNAPSHOT = stringPreferencesKey("snapshot")
|
||||
val ERROR = stringPreferencesKey("error")
|
||||
}
|
||||
}
|
||||
13
app/src/main/res/drawable/ic_launcher.xml
Normal file
13
app/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="#F7FAFC"
|
||||
android:pathData="M4,4h40v40h-40z" />
|
||||
<path
|
||||
android:fillColor="#2563EB"
|
||||
android:pathData="M14,30h4v6h-4zM22,20h4v16h-4zM30,12h4v24h-4z" />
|
||||
</vector>
|
||||
9
app/src/main/res/layout/widget_loading.xml
Normal file
9
app/src/main/res/layout/widget_loading.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:padding="16dp"
|
||||
android:text="@string/app_name"
|
||||
android:textColor="#172033"
|
||||
android:textStyle="bold" />
|
||||
5
app/src/main/res/values/strings.xml
Normal file
5
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Sub2API Monitor</string>
|
||||
<string name="widget_description">Sub2API monitoring dashboard widget</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/styles.xml
Normal file
4
app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Sub2ApiMonitor" parent="android:style/Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
12
app/src/main/res/xml/sub2api_monitor_widget.xml
Normal file
12
app/src/main/res/xml/sub2api_monitor_widget.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_description"
|
||||
android:initialLayout="@layout/widget_loading"
|
||||
android:minWidth="280dp"
|
||||
android:minHeight="220dp"
|
||||
android:previewImage="@drawable/ic_launcher"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="3"
|
||||
android:updatePeriodMillis="0"
|
||||
android:widgetCategory="home_screen" />
|
||||
11
app/src/test/java/com/sub2api/monitor/SmokeTest.kt
Normal file
11
app/src/test/java/com/sub2api/monitor/SmokeTest.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.sub2api.monitor
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SmokeTest {
|
||||
@Test
|
||||
fun projectRunsUnitTests() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.sub2api.monitor.data
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConfigRepositoryTest {
|
||||
@Test
|
||||
fun detectsMissingConfiguration() {
|
||||
assertFalse(AppConfig().isConfigured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detectsSavedConfiguration() {
|
||||
val config = AppConfig(
|
||||
baseUrl = "https://sub2api.example.com",
|
||||
adminKey = "secret",
|
||||
refreshIntervalMinutes = 30,
|
||||
)
|
||||
|
||||
assertTrue(config.isConfigured)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.sub2api.monitor.data
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MockSub2ApiRepositoryTest {
|
||||
@Test
|
||||
fun returnsCompleteSnapshot() = runTest {
|
||||
val snapshot = MockSub2ApiRepository().fetchSnapshot()
|
||||
|
||||
assertTrue(snapshot.todayTokens > 0)
|
||||
assertTrue(snapshot.todayCost > 0.0)
|
||||
assertEquals(5, snapshot.recentCalls.size)
|
||||
assertEquals(4, snapshot.modelTop.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sub2api.monitor.domain
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MetricFormattersTest {
|
||||
@Test
|
||||
fun formatsLargeTokenCounts() {
|
||||
assertEquals("12.3K", formatTokens(12_345))
|
||||
assertEquals("9.9M", formatTokens(9_876_543))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatsCurrencyWithDollarPrefix() {
|
||||
assertEquals("$3.21", formatCurrency(3.214))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatsLatencyAndRates() {
|
||||
assertEquals("248 ms", formatLatency(248))
|
||||
assertEquals("42 RPM", formatRpm(42))
|
||||
assertEquals("18.5K TPM", formatTpm(18_500))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.sub2api.monitor.sync
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RefreshSchedulerTest {
|
||||
@Test
|
||||
fun clampsRefreshIntervalToWorkManagerMinimum() {
|
||||
assertEquals(15, sanitizeRefreshIntervalMinutes(5))
|
||||
assertEquals(30, sanitizeRefreshIntervalMinutes(30))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.sub2api.monitor.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ConfigViewModelTest {
|
||||
@Test
|
||||
fun updatesFormFields() {
|
||||
val state = ConfigUiState()
|
||||
.withBaseUrl("https://sub2api.example.com")
|
||||
.withAdminKey("secret")
|
||||
.withRefreshInterval("30")
|
||||
|
||||
assertEquals("https://sub2api.example.com", state.baseUrl)
|
||||
assertEquals("secret", state.adminKey)
|
||||
assertEquals("30", state.refreshIntervalMinutes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sub2api.monitor.widget
|
||||
|
||||
import com.sub2api.monitor.data.AppConfig
|
||||
import com.sub2api.monitor.data.MockSub2ApiRepository
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class Sub2ApiWidgetViewModelTest {
|
||||
@Test
|
||||
fun missingConfigShowsSetupPrompt() = runTest {
|
||||
val state = buildWidgetState(AppConfig(), null, null)
|
||||
assertFalse(state.isConfigured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedRefreshPreservesLastSuccessfulSnapshot() = runTest {
|
||||
val previous = MockSub2ApiRepository().fetchSnapshot()
|
||||
val state = buildWidgetState(
|
||||
config = AppConfig("https://example.com", "secret", 30),
|
||||
lastSuccessfulSnapshot = previous,
|
||||
errorMessage = "Network error",
|
||||
)
|
||||
|
||||
assertTrue(state.isConfigured)
|
||||
assertNotNull(state.snapshot)
|
||||
assertTrue(state.hasError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sub2api.monitor.widget
|
||||
|
||||
import com.sub2api.monitor.data.AppConfig
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class WidgetStateTextTest {
|
||||
@Test
|
||||
fun missingConfigUsesSetupPrompt() {
|
||||
val state = buildWidgetState(AppConfig(), null, null)
|
||||
assertEquals("请先配置 Sub2API", state.primaryMessage)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user