feat: build Sub2API monitor Android prototype
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
|
.learnings/
|
||||||
|
|||||||
44
README.md
Normal file
44
README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Sub2API Monitor
|
||||||
|
|
||||||
|
Android home-screen widget prototype for monitoring Sub2API usage and service status.
|
||||||
|
|
||||||
|
## Current Scope
|
||||||
|
|
||||||
|
- Native Android app in Kotlin.
|
||||||
|
- Jetpack Compose configuration screen.
|
||||||
|
- Jetpack Glance home-screen widget.
|
||||||
|
- Local configuration with DataStore.
|
||||||
|
- Mock Sub2API monitoring data.
|
||||||
|
- Manual widget refresh action.
|
||||||
|
- WorkManager refresh scheduling boundary.
|
||||||
|
- Last successful snapshot is preserved when refresh fails.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The app lets the user enter:
|
||||||
|
|
||||||
|
- Sub2API base URL.
|
||||||
|
- Admin key.
|
||||||
|
- Automatic refresh interval in minutes.
|
||||||
|
|
||||||
|
The admin key is stored locally through DataStore and is not rendered in the widget.
|
||||||
|
|
||||||
|
## Widget States
|
||||||
|
|
||||||
|
- Missing configuration: shows `请先配置 Sub2API`.
|
||||||
|
- Configured with data: shows dashboard metrics, recent calls, model TOP4, and lifetime totals.
|
||||||
|
- Refresh failure: stores the error while keeping the last successful snapshot visible.
|
||||||
|
|
||||||
|
## Replacing Mock Data
|
||||||
|
|
||||||
|
The first real integration should replace `MockSub2ApiRepository` with a network-backed implementation of `Sub2ApiRepository`. Keep the widget and Compose UI consuming `Sub2ApiSnapshot` so the rendering layer does not need structural changes.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Open this folder in Android Studio or run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\gradlew :app:assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
This workspace currently does not include a Gradle wrapper jar. If Android Studio is used, let it sync the Gradle project and generate/use its configured Gradle installation.
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
build.gradle.kts
Normal file
6
build.gradle.kts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
|
||||||
|
}
|
||||||
4
gradle.properties
Normal file
4
gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
18
settings.gradle.kts
Normal file
18
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 = "Sub2API Monitor"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user