diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml
index b57be726d..64032d6d0 100644
--- a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml
+++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml
@@ -64,6 +64,16 @@
Session en cours uniquement
Filtres
Aucun filtre ne correspond
+ Réinitialiser
+ Seul
+ Aucun
+ 1 règle
+ %1$d règles
+ %1$d hôtes
+ %1$d hôtes correspondent
+ Code ou plage : 404, 500-503
+ Mot dans l\'hôte, Entrée
+ %1$d requêtes en cours, sans durée
Méthode
Statut
État
diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml
index b087e2808..f590def34 100644
--- a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -72,6 +72,16 @@
Current session only
Filters
No filter matches
+ Reset
+ Only
+ None
+ 1 rule
+ %1$d rules
+ %1$d hosts
+ %1$d hosts match
+ Code or range: 404, 500-503
+ Word in the host, Enter
+ %1$d requests in progress, no duration
Method
Status
State
diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineFilterPanelViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineFilterPanelViewModel.kt
index b3c8f8dd4..a15416ed4 100644
--- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineFilterPanelViewModel.kt
+++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineFilterPanelViewModel.kt
@@ -3,7 +3,11 @@ package io.github.openflocon.flocondesktop.features.timeline
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.github.openflocon.domain.common.DispatcherProvider
+import io.github.openflocon.domain.timeline.models.TIMELINE_DURATION_BUCKETS_MS
+import io.github.openflocon.domain.timeline.models.TimelineDurationRangeDomainModel
+import io.github.openflocon.domain.timeline.models.TimelineRuleDomainModel
import io.github.openflocon.domain.timeline.models.TimelineSource
+import io.github.openflocon.domain.timeline.models.parseTimelineStatusRules
import io.github.openflocon.domain.timeline.usecase.ObserveTimelineSummaryUseCase
import io.github.openflocon.domain.timeline.usecase.SetTimelineSelectedSourcesUseCase
import io.github.openflocon.flocondesktop.features.timeline.mapper.toFilterPanel
@@ -31,23 +35,41 @@ internal class TimelineFilterPanelViewModel(
// the counts follow the text but not the facets: ticking a value must not make the others, or the sources, move or disappear
private val summary: StateFlow = filterStore.filter
- .map { it.copy(facets = emptyMap()) }
+ .map {
+ it.copy(
+ facets = emptyMap(),
+ networkStatusRules = emptyList(),
+ networkHostRules = emptyList(),
+ networkDuration = null,
+ )
+ }
.distinctUntilChanged()
.flatMapLatest { filter ->
observeTimelineSummaryUseCase(filter).map { it.toUi(selected = filter.sources) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TimelineSummaryUiModel.Empty)
+ // the network calls the filters let through, for the counter of the network card
+ private val matchingNetworkCount: StateFlow = filterStore.filter
+ .distinctUntilChanged()
+ .flatMapLatest { filter ->
+ observeTimelineSummaryUseCase(filter).map { it.sources[TimelineSource.Network]?.count ?: 0 }
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
+
val panel: StateFlow = combine(
summary,
filterStore.storedSources,
filterStore.facets,
filterStore.currentSessionOnly,
- ) { summary, sources, facets, currentSessionOnly ->
+ combine(filterStore.network, matchingNetworkCount) { network, matching -> network to matching },
+ ) { summary, sources, facets, currentSessionOnly, (network, matching) ->
summary.toFilterPanel(
selectedSources = sources,
facets = facets,
currentSessionOnly = currentSessionOnly,
+ network = network,
+ matchingNetworkCount = matching,
)
}
.flowOn(dispatcherProvider.viewModel)
@@ -65,6 +87,40 @@ internal class TimelineFilterPanelViewModel(
is TimelineFilterAction.SetCurrentSessionOnly -> filterStore.setCurrentSessionOnly(action.value)
+ is TimelineFilterAction.SetNetworkEnabled -> filterStore.setNetworkEnabled(action.value)
+
+ is TimelineFilterAction.ResetNetwork -> filterStore.resetNetwork()
+
+ is TimelineFilterAction.SetHiddenValues -> filterStore.setHiddenValues(action.facet, action.values)
+
+ is TimelineFilterAction.AddStatusRules -> {
+ // a text with a part that is not a status is not a rule
+ parseTimelineStatusRules(action.text)?.let { matchers ->
+ matchers.forEach { filterStore.addStatusRule(TimelineRuleDomainModel(it.label(), action.exclude)) }
+ }
+ }
+
+ is TimelineFilterAction.RemoveStatusRule -> filterStore.removeStatusRule(action.value)
+
+ is TimelineFilterAction.AddHostRule -> {
+ action.text.trim().takeIf { it.isNotEmpty() }?.let { filterStore.addHostRule(TimelineRuleDomainModel(it, action.exclude)) }
+ }
+
+ is TimelineFilterAction.RemoveHostRule -> filterStore.removeHostRule(action.value)
+
+ is TimelineFilterAction.SetDurationRange -> {
+ val buckets = TIMELINE_DURATION_BUCKETS_MS
+ val range = if (action.start <= 0 && action.end >= buckets.size) {
+ null // every duration
+ } else {
+ TimelineDurationRangeDomainModel(
+ minMs = buckets[action.start.coerceIn(0, buckets.lastIndex)],
+ maxMs = buckets.getOrNull(action.end),
+ )
+ }
+ filterStore.setNetworkDuration(range)
+ }
+
is TimelineFilterAction.ClearAll -> {
filterStore.clear()
viewModelScope.launch { setTimelineSelectedSourcesUseCase(TimelineSource.entries.toSet()) }
diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineFilterStore.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineFilterStore.kt
index 400b02026..f9766fb0a 100644
--- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineFilterStore.kt
+++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineFilterStore.kt
@@ -1,7 +1,10 @@
package io.github.openflocon.flocondesktop.features.timeline
+import io.github.openflocon.domain.timeline.models.TimelineDurationRangeDomainModel
import io.github.openflocon.domain.timeline.models.TimelineFacet
import io.github.openflocon.domain.timeline.models.TimelineFilterDomainModel
+import io.github.openflocon.domain.timeline.models.TimelineRuleDomainModel
+import io.github.openflocon.domain.timeline.models.TimelineSource
import io.github.openflocon.domain.timeline.usecase.ObserveTimelineSettingsUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -18,6 +21,18 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
+/**
+ * What the user typed or moved in the network card of the filter panel, next to the values hidden by facet.
+ */
+internal data class TimelineNetworkRules(
+ val enabled: Boolean = true,
+ val statusRules: List = emptyList(),
+ val hostRules: List = emptyList(),
+ val duration: TimelineDurationRangeDomainModel? = null,
+) {
+ val ruleCount: Int get() = statusRules.size + hostRules.size + if (duration != null) 1 else 0
+}
+
/**
* The filter of the timeline, shared by the screen and by its filter panel: they are two destinations, so two view models.
* The sources are a setting of the timeline, the text and the facets are not saved.
@@ -36,6 +51,10 @@ internal class TimelineFilterStore(
val facets: StateFlow