diff --git a/client/app/build.gradle b/client/app/build.gradle index d21dd3d..8449598 100644 --- a/client/app/build.gradle +++ b/client/app/build.gradle @@ -73,6 +73,9 @@ dependencies { def nav_version = "2.5.3" implementation("androidx.navigation:navigation-compose:$nav_version") + // custom - work manager + implementation 'androidx.work:work-runtime-ktx:2.7.1' + implementation 'androidx.core:core-ktx:1.10.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' @@ -86,8 +89,4 @@ dependencies { androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version" -} - -dependencies { - } \ No newline at end of file diff --git a/client/app/src/main/AndroidManifest.xml b/client/app/src/main/AndroidManifest.xml index eface89..950f2e9 100644 --- a/client/app/src/main/AndroidManifest.xml +++ b/client/app/src/main/AndroidManifest.xml @@ -16,7 +16,6 @@ @@ -24,6 +23,12 @@ + + + \ No newline at end of file diff --git a/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/ConfigObject.kt b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/ConfigObject.kt new file mode 100644 index 0000000..af7260d --- /dev/null +++ b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/ConfigObject.kt @@ -0,0 +1,29 @@ +package com.birdthedeveloper.prometheus.android.prometheus.android.exporter + +import androidx.work.Data + +data class PushProxConfig( + val pushProxUrl : String, + val pushProxFqdn : String, +){ + companion object{ + fun fromData(data : Data) : PushProxConfig{ + return PushProxConfig( + data.getString("0")!!, + data.getString("1")!!, + ) + } + } + + fun toData() : Data { + return Data.Builder() + .putString("0", pushProxUrl) + .putString("1", pushProxFqdn) + .build() + } +} + +data class PromServerConfig( + //TODO implement this + val dummy : String, +) diff --git a/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/HomeActivity.kt b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/HomeActivity.kt index dac29a1..bd68aa9 100644 --- a/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/HomeActivity.kt +++ b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/HomeActivity.kt @@ -159,10 +159,10 @@ private fun onCheckedChangePushProx( value : Boolean, promViewModel: PromViewModel, showDialog : MutableState -){ +) { if (value) { - val result : String? = promViewModel.turnPushProxOn() - if(result != null){ + val result: String? = promViewModel.turnPushProxOn() + if (result != null) { showDialog.value = result } } else { @@ -176,7 +176,7 @@ private fun PushProxPage( ){ val uiState : PromUiState by promViewModel.uiState.collectAsState() - // if showDialogText == "", do not display alert dialog + // if showDialogText is empty string, do not display alert dialog val showDialogText : MutableState = remember { mutableStateOf("") } Column( @@ -193,8 +193,20 @@ private fun PushProxPage( modifier = Modifier.padding(bottom = 30.dp) ) + if(uiState.pushProxTurnedOn){ + Text( + text = """ + To edit PushProx proxy URL or FQDN, turn it off first. + """.trimIndent(), + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 12.dp), + ) + } + TextField( value = uiState.fqdn, + singleLine = true, + enabled = !uiState.pushProxTurnedOn, onValueChange = { promViewModel.updatePushProxFQDN(it) }, @@ -206,6 +218,8 @@ private fun PushProxPage( TextField( value = uiState.pushProxURL, + singleLine = true, + enabled = !uiState.pushProxTurnedOn, onValueChange = { promViewModel.updatePushProxURL(it) }, diff --git a/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/MainActivity.kt b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/MainActivity.kt index d4713cc..d296dc4 100644 --- a/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/MainActivity.kt +++ b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/MainActivity.kt @@ -1,34 +1,19 @@ package com.birdthedeveloper.prometheus.android.prometheus.android.exporter import android.os.Bundle -import android.text.BoringLayout.Metrics import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Button import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface -import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier -import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.birdthedeveloper.prometheus.android.prometheus.android.exporter.ui.theme.PrometheusAndroidExporterTheme -import io.prometheus.client.Collector -import io.prometheus.client.CollectorRegistry -import io.prometheus.client.exporter.common.TextFormat -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.io.StringWriter //TODO //https://www.geeksforgeeks.org/how-to-launch-an-application-automatically-on-system-boot-up-in-android/ @@ -39,7 +24,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) val promViewModel: PromViewModel = ViewModelProvider(this)[PromViewModel::class.java] - promViewModel.setApplicationContext { this.applicationContext } + promViewModel.initializeWithApplicationContext { this.applicationContext } setContent { PrometheusAndroidExporterTheme { diff --git a/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PromServerWorker.kt b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PromServerWorker.kt new file mode 100644 index 0000000..0d845f8 --- /dev/null +++ b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PromServerWorker.kt @@ -0,0 +1,2 @@ +package com.birdthedeveloper.prometheus.android.prometheus.android.exporter + diff --git a/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PromViewModel.kt b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PromViewModel.kt index f257b62..015620d 100644 --- a/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PromViewModel.kt +++ b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PromViewModel.kt @@ -4,9 +4,16 @@ import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager import io.prometheus.client.CollectorRegistry import io.prometheus.client.exporter.common.TextFormat -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,28 +34,28 @@ data class PromUiState( val serverTurnedOn : Boolean = false, val pushProxTurnedOn : Boolean = false, val serverPort : Int? = null, // if null, use default port - val fqdn : String = "", - val pushProxURL : String = "", + val fqdn : String = "test.example.com", + val pushProxURL : String = "143.42.59.63:8080", val configFileState : ConfigFileState = ConfigFileState.LOADING, ) private val TAG : String = "PROMVIEWMODEL" class PromViewModel(): ViewModel() { + // constants private val DEFAULT_SERVER_PORT : Int = 10101 //TODO register with prometheus community + private val PROM_UNIQUE_WORK : String = "prom_unique_job" + + private val _uiState = MutableStateFlow(PromUiState()) val uiState : StateFlow = _uiState.asStateFlow() - // app - level components - private val collectorRegistry: CollectorRegistry = CollectorRegistry() - private lateinit var pushProxClient : PushProxClient - private val prometheusServer : PrometheusServer = PrometheusServer() - private lateinit var metricsEngine: MetricsEngine - private lateinit var androidCustomExporter: AndroidCustomExporter + private lateinit var getContext: () -> Context init { Log.v(TAG, "Checking for configuration file") viewModelScope.launch { + //TODO check for configuration file delay(1000) _uiState.update { current -> current.copy(configFileState = ConfigFileState.MISSING) @@ -60,13 +67,8 @@ class PromViewModel(): ViewModel() { return DEFAULT_SERVER_PORT } - fun setApplicationContext(getContext : () -> Context){ - // initalize app - level components - metricsEngine = MetricsEngine(getContext()) - androidCustomExporter = AndroidCustomExporter(metricsEngine).register(collectorRegistry) - pushProxClient = PushProxClient(collectorRegistry) - //TODO somehow this PushProx definition does not work here - // -> fix it + fun initializeWithApplicationContext(getContext : () -> Context){ + this.getContext = getContext } fun updateTabIndex(index : Int){ @@ -85,22 +87,16 @@ class PromViewModel(): ViewModel() { } } - private suspend fun performScrape() : String { - val writer : StringWriter = StringWriter() - TextFormat.write004(writer, collectorRegistry.metricFamilySamples()) - - return writer.toString() - } - // if result is not null, it contains an error message fun turnServerOn() : String?{ try{ - prometheusServer.startInBackground( - PrometheusServerConfig( - getPromServerPort(), - ::performScrape - ) - ) + //TODO rewrite asap +// prometheusServer.startInBackground( +// PrometheusServerConfig( +// getPromServerPort(), +// ::performScrape +// ) +// ) }catch(e : Exception){ Log.v(TAG, e.toString()) return "Prometheus server failed!" @@ -119,8 +115,8 @@ class PromViewModel(): ViewModel() { } private fun validatePushProxSettings() : String? { - val fqdn = _uiState.value.fqdn.trim() - val url = _uiState.value.pushProxURL.trim() + val fqdn = _uiState.value.fqdn.trim().trim('\n') + val url = _uiState.value.pushProxURL.trim().trim('\n') if( fqdn.isEmpty() ) return "Fully Qualified Domain Name cannot be empty!" if( url.isEmpty() ) return "PushProx URL cannot be empty!" @@ -128,25 +124,42 @@ class PromViewModel(): ViewModel() { return null } + private fun launchPushProxUsingWorkManager(){ + val workManagerInstance = WorkManager.getInstance(getContext()) + + // worker configuration + val inputData : Data = PushProxConfig( + pushProxFqdn = _uiState.value.fqdn, + pushProxUrl = _uiState.value.pushProxURL, + ).toData() + + // constraints + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.NOT_REQUIRED) + .build() + + // setup worker request + val workerRequest = OneTimeWorkRequestBuilder() + .setInputData(inputData) + .setConstraints(constraints) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + + // enqueue + workManagerInstance.beginUniqueWork( + PROM_UNIQUE_WORK, + ExistingWorkPolicy.KEEP, + workerRequest, + ).enqueue() + } + // if result is not null, it contains an error message fun turnPushProxOn() : String?{ - val error : String? = validatePushProxSettings() - if(error != null){ - return error - } + if(error != null){ return error } - try{ - pushProxClient.startBackground( - PushProxConfig( - performScrape = ::performScrape, - fqdn = _uiState.value.fqdn.trim(), - proxyUrl = _uiState.value.pushProxURL.trim(), - ) - ) - }catch(e : Exception){ - return "PushProx client failed!" - } + // idempotent call + launchPushProxUsingWorkManager() _uiState.update { current -> current.copy( @@ -158,7 +171,14 @@ class PromViewModel(): ViewModel() { } fun turnPushProxOff(){ - //TODO implement + val workerManagerInstance = WorkManager.getInstance(getContext()) + workerManagerInstance.cancelUniqueWork(PROM_UNIQUE_WORK) + + _uiState.update {current -> + current.copy( + pushProxTurnedOn = false + ) + } } fun updatePushProxURL(url : String){ diff --git a/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PushProxClient.kt b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PushProxClient.kt index e859393..3cfabac 100644 --- a/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PushProxClient.kt +++ b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PushProxClient.kt @@ -12,6 +12,7 @@ import io.ktor.client.statement.HttpResponse import io.ktor.http.HttpMethod import io.prometheus.client.CollectorRegistry import io.prometheus.client.Counter +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -51,13 +52,6 @@ private class Counters(collectorRegistry: CollectorRegistry) { fun pollError(){ pollErrorCounter.inc() } } -// Configuration object for this pushprox client -data class PushProxConfig( - val fqdn: String, - val proxyUrl: String, - val performScrape: suspend () -> String, -) - // Error in parsing HTTP header "Id" from HTTP request from Prometheus class PushProxIdParseException(message: String) : Exception(message) @@ -68,31 +62,26 @@ data class PushProxContext( val pushUrl : String, val backoff : StrategyBackoff, val fqdn : String, - val performScrape: suspend () -> String, ) // This is a stripped down kotlin implementation of github.com/prometheus-community/PushProx client class PushProxClient( - private val collectorRegistry: CollectorRegistry + collectorRegistry: CollectorRegistry, + private val performScrape: suspend () -> String ) { private val counters : Counters = Counters(collectorRegistry) private val retryInitialWaitSeconds : Int = 1 private val retryMaxWaitSeconds : Int = 5 - private var isRunning : Boolean = false // Use this function to start exporting metrics to pushprox in the background - fun startBackground(config: PushProxConfig) { - if(!isRunning){ - isRunning = true - + suspend fun startBackground(config: PushProxConfig) { val client : HttpClient = HttpClient() //TODO close this thing val context : PushProxContext = processConfig(client, config) loop(context) - } } private fun processConfig(client : HttpClient, config : PushProxConfig) : PushProxContext { - var modifiedProxyURL = config.proxyUrl.trim('/') + var modifiedProxyURL = config.pushProxUrl.trim('/') if( !modifiedProxyURL.startsWith("http://") && @@ -109,8 +98,7 @@ class PushProxClient( pollURL, pushURL, newBackoffFromFlags(), - config.fqdn, - config.performScrape, + config.pushProxFqdn, ) } @@ -143,7 +131,6 @@ class PushProxClient( "X-Prometheus-Scrape-Timeout: 9.5\r\n" val result : String = httpHeaders + "\r\n" + scrapedMetrics - log("result", result) return result } @@ -152,7 +139,7 @@ class PushProxClient( // perform scrape lateinit var scrapedMetrics : String try { - scrapedMetrics = context.performScrape() + scrapedMetrics = performScrape() }catch(e : Exception){ counters.scrapeError() log("scrape exception", e.toString()) @@ -173,7 +160,6 @@ class PushProxClient( log("push exception", e.toString()) return } - } private fun newBackoffFromFlags() : StrategyBackoff { @@ -187,27 +173,27 @@ class PushProxClient( } //TODO migrate to work manager - private fun loop(context : PushProxContext) { - // fire and forget a new coroutine - GlobalScope.launch { - launch { - while (true) { - val job = launch { - log("pushprox main loop", "loop start") - var result = context.backoff.withRetries { - // register poll error using try-catch block - try { - doPoll(context) - } catch (e: Exception) { - log("exception encountered!", e.toString()) - counters.pollError() - throw e - } - } - } - job.join() // wait for the job to finish - } + private suspend fun loop(context : PushProxContext) { + var shouldContinue : Boolean = true + while (shouldContinue) { + log("pushprox main loop", "loop start") + // register poll error using try-catch block + try { + doPoll(context) + }catch(e : CancellationException){ + shouldContinue = false } + catch (e: Exception) { + for(exception in e.suppressed){ + if(exception is CancellationException){ + shouldContinue = false + } + } + log("exception encountered!", e.toString()) + counters.pollError() + throw e + } + log("pushprox main loop", "loop end") } } diff --git a/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PushProxWorker.kt b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PushProxWorker.kt new file mode 100644 index 0000000..be1b2ae --- /dev/null +++ b/client/app/src/main/java/com/birdthedeveloper/prometheus/android/prometheus/android/exporter/PushProxWorker.kt @@ -0,0 +1,81 @@ +package com.birdthedeveloper.prometheus.android.prometheus.android.exporter + +import android.content.Context +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import io.prometheus.client.CollectorRegistry +import io.prometheus.client.exporter.common.TextFormat +import kotlinx.coroutines.delay +import java.io.StringWriter + +private val TAG = "PUSH_PROX_WORKER" + +class PushProxWorker( + private val context : Context, + parameters : WorkerParameters +): CoroutineWorker(context, parameters){ + + override suspend fun doWork():Result { + //TODO implement this + val cache: PushProxWorkerCache = PushProxWorkerCache.getInstance { + return@getInstance context + } + + try{ + val pushProxConfig : PushProxConfig = PushProxConfig.fromData(inputData) + + cache.startBackground(pushProxConfig) + + }catch(e : Exception){ + Log.v(TAG, e.toString()) + return Result.failure() + } + + return Result.success() + } +} + +// thread-safe singleton +class PushProxWorkerCache private constructor( + private val getContext: () -> Context +){ + private val collectorRegistry: CollectorRegistry = CollectorRegistry() + private val metricsEngine : MetricsEngine = MetricsEngine(getContext()) + private val pushProxClient = PushProxClient(collectorRegistry, ::performScrape) + private lateinit var androidCustomExporter : AndroidCustomExporter + + init { + Log.v(TAG, "Initializing WorkerCache") + androidCustomExporter = AndroidCustomExporter(metricsEngine).register(collectorRegistry) + } + + private fun performScrape() : String{ + val writer = StringWriter() + TextFormat.write004(writer, collectorRegistry.metricFamilySamples()) + return writer.toString() + } + + suspend fun startBackground(pushProxConfig : PushProxConfig){ + pushProxClient.startBackground(pushProxConfig) + } + + companion object { + private var instance : PushProxWorkerCache? = null + + fun getInstance(getContext: () -> Context) : PushProxWorkerCache { + if(instance == null){ + synchronized(PushProxWorkerCache::class.java){ + if (instance == null){ + instance = PushProxWorkerCache(getContext) + } + } + } + return instance!! + } + } +} \ No newline at end of file