Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@
<string name="timeline_filters_current_session">Session en cours uniquement</string>
<string name="timeline_filters_toggle">Filtres</string>
<string name="timeline_filters_no_result">Aucun filtre ne correspond</string>
<string name="timeline_filters_reset">Réinitialiser</string>
<string name="timeline_filters_only">Seul</string>
<string name="timeline_filters_none">Aucun</string>
<string name="timeline_filters_rule_one">1 règle</string>
<string name="timeline_filters_rule_other">%1$d règles</string>
<string name="timeline_filters_host_count">%1$d hôtes</string>
<string name="timeline_filters_hosts_matching">%1$d hôtes correspondent</string>
<string name="timeline_filters_status_placeholder">Code ou plage : 404, 500-503</string>
<string name="timeline_filters_host_placeholder">Mot dans l\'hôte, Entrée</string>
<string name="timeline_filters_pending">%1$d requêtes en cours, sans durée</string>
<string name="timeline_facet_network_method">Méthode</string>
<string name="timeline_facet_network_status">Statut</string>
<string name="timeline_facet_network_state">État</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@
<string name="timeline_filters_current_session">Current session only</string>
<string name="timeline_filters_toggle">Filters</string>
<string name="timeline_filters_no_result">No filter matches</string>
<string name="timeline_filters_reset">Reset</string>
<string name="timeline_filters_only">Only</string>
<string name="timeline_filters_none">None</string>
<string name="timeline_filters_rule_one">1 rule</string>
<string name="timeline_filters_rule_other">%1$d rules</string>
<string name="timeline_filters_host_count">%1$d hosts</string>
<string name="timeline_filters_hosts_matching">%1$d hosts match</string>
<string name="timeline_filters_status_placeholder">Code or range: 404, 500-503</string>
<string name="timeline_filters_host_placeholder">Word in the host, Enter</string>
<string name="timeline_filters_pending">%1$d requests in progress, no duration</string>
<string name="timeline_facet_network_method">Method</string>
<string name="timeline_facet_network_status">Status</string>
<string name="timeline_facet_network_state">State</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TimelineSummaryUiModel> = 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<Int?> = filterStore.filter
.distinctUntilChanged()
.flatMapLatest { filter ->
observeTimelineSummaryUseCase(filter).map { it.sources[TimelineSource.Network]?.count ?: 0 }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

val panel: StateFlow<TimelineFilterPanelUiModel> = 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)
Expand All @@ -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()) }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<TimelineRuleDomainModel> = emptyList(),
val hostRules: List<TimelineRuleDomainModel> = 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.
Expand All @@ -36,6 +51,10 @@ internal class TimelineFilterStore(
val facets: StateFlow<Map<TimelineFacet, Set<String>>>
field = MutableStateFlow<Map<TimelineFacet, Set<String>>>(emptyMap())

// the status, host and duration rules of the network calls
val network: StateFlow<TimelineNetworkRules>
field = MutableStateFlow(TimelineNetworkRules())

// the events of the latest session only
val currentSessionOnly: StateFlow<Boolean>
field = MutableStateFlow(false)
Expand All @@ -52,12 +71,17 @@ internal class TimelineFilterStore(
storedSources,
facets,
currentSessionOnly,
) { text, sources, facets, currentSessionOnly ->
network,
) { text, sources, facets, currentSessionOnly, network ->
TimelineFilterDomainModel(
sources = sources,
text = text,
facets = facets,
currentSessionOnly = currentSessionOnly,
networkEnabled = network.enabled,
networkStatusRules = network.statusRules,
networkHostRules = network.hostRules,
networkDuration = network.duration,
)
}
.shareIn(scope, SharingStarted.WhileSubscribed(5_000), replay = 1)
Expand All @@ -74,6 +98,42 @@ internal class TimelineFilterStore(
}
}

// the values hidden of a facet at once, ex: only one host, or none
fun setHiddenValues(facet: TimelineFacet, values: Set<String>) {
facets.update { if (values.isEmpty()) it - facet else it + (facet to values) }
}

// the values hidden of the network facets and the rules, the master switch is kept
fun resetNetwork() {
facets.update { facets -> facets.filterKeys { it.source != TimelineSource.Network } }
network.update { TimelineNetworkRules(enabled = it.enabled) }
}

fun setNetworkEnabled(value: Boolean) {
network.update { it.copy(enabled = value) }
}

// a rule is told by its text: adding it again only changes its side, include or exclude
fun addStatusRule(rule: TimelineRuleDomainModel) {
network.update { it.copy(statusRules = it.statusRules.filterNot { existing -> existing.value == rule.value } + rule) }
}

fun removeStatusRule(value: String) {
network.update { it.copy(statusRules = it.statusRules.filterNot { existing -> existing.value == value }) }
}

fun addHostRule(rule: TimelineRuleDomainModel) {
network.update { it.copy(hostRules = it.hostRules.filterNot { existing -> existing.value == rule.value } + rule) }
}

fun removeHostRule(value: String) {
network.update { it.copy(hostRules = it.hostRules.filterNot { existing -> existing.value == value }) }
}

fun setNetworkDuration(value: TimelineDurationRangeDomainModel?) {
network.update { it.copy(duration = value) }
}

fun setCurrentSessionOnly(value: Boolean) {
currentSessionOnly.update { value }
}
Expand All @@ -82,6 +142,7 @@ internal class TimelineFilterStore(
fun clear() {
text.update { "" }
facets.update { emptyMap() }
network.update { TimelineNetworkRules() }
currentSessionOnly.update { false }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ internal class TimelineViewModel(
filterStore.facets,
filterStore.storedSources,
filterStore.currentSessionOnly,
) { facets, sources, currentSessionOnly ->
facets.values.sumOf { it.size } + TimelineSource.entries.count { it !in sources } + if (currentSessionOnly) 1 else 0
filterStore.network,
) { facets, sources, currentSessionOnly, network ->
facets.values.sumOf { it.size } + TimelineSource.entries.count { it !in sources } + network.ruleCount +
if (currentSessionOnly) 1 else 0
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package io.github.openflocon.flocondesktop.features.timeline.mapper

import io.github.openflocon.domain.timeline.models.TIMELINE_DURATION_BUCKETS_MS
import io.github.openflocon.domain.timeline.models.TimelineFacet
import io.github.openflocon.domain.timeline.models.TimelineRuleDomainModel
import io.github.openflocon.domain.timeline.models.TimelineSource
import io.github.openflocon.flocondesktop.features.network.list.mapper.toHttpMethodUi
import io.github.openflocon.flocondesktop.features.network.list.model.NetworkMethodUi
import io.github.openflocon.flocondesktop.features.timeline.TimelineNetworkRules
import io.github.openflocon.flocondesktop.features.timeline.model.TimelineFacetGroupUiModel
import io.github.openflocon.flocondesktop.features.timeline.model.TimelineFacetValueUiModel
import io.github.openflocon.flocondesktop.features.timeline.model.TimelineFilterPanelUiModel
import io.github.openflocon.flocondesktop.features.timeline.model.TimelineFilterSectionUiModel
import io.github.openflocon.flocondesktop.features.timeline.model.TimelineNetworkFilterUiModel
import io.github.openflocon.flocondesktop.features.timeline.model.TimelineRuleUiModel
import io.github.openflocon.flocondesktop.features.timeline.model.TimelineSummaryUiModel
import kotlinx.collections.immutable.toImmutableList

Expand All @@ -19,6 +24,8 @@ internal fun TimelineSummaryUiModel.toFilterPanel(
selectedSources: Set<TimelineSource>,
facets: Map<TimelineFacet, Set<String>>, // the values hidden, by facet
currentSessionOnly: Boolean = false,
network: TimelineNetworkRules = TimelineNetworkRules(),
matchingNetworkCount: Int? = null, // the network calls displayed, null: all of them
): TimelineFilterPanelUiModel {
val sections = TimelineSource.entries
.map { source ->
Expand All @@ -36,16 +43,66 @@ internal fun TimelineSummaryUiModel.toFilterPanel(
.sortedByDescending { it.count > 0 } // stable: the other of the sources is kept

val hiddenSources = TimelineSource.entries.count { it !in selectedSources }
val activeFilterCount = facets.values.sumOf { it.size } + hiddenSources + if (currentSessionOnly) 1 else 0
val activeFilterCount = facets.values.sumOf { it.size } + hiddenSources + network.ruleCount + if (currentSessionOnly) 1 else 0

return TimelineFilterPanelUiModel(
sections = sections.toImmutableList(),
network = toNetworkFilter(facets, network, matchingNetworkCount),
currentSessionOnly = currentSessionOnly,
hasActiveFilters = activeFilterCount > 0,
activeFilterCount = activeFilterCount,
)
}

private fun TimelineSummaryUiModel.toNetworkFilter(
facets: Map<TimelineFacet, Set<String>>,
network: TimelineNetworkRules,
matchingNetworkCount: Int?,
): TimelineNetworkFilterUiModel {
fun values(facet: TimelineFacet) = toGroup(facet, hidden = facets[facet].orEmpty())?.values.orEmpty()

val total = sourceCounts[TimelineSource.Network] ?: 0
val rules = network.hostRules
val allHosts = values(TimelineFacet.NetworkHost)
// the hosts matching the words typed, the others are not offered
val hosts = allHosts.filter { it.value.matchesHostRules(rules) }
val buckets = TIMELINE_DURATION_BUCKETS_MS
val durationCounts = facetCounts[TimelineFacet.NetworkSpeed].orEmpty()
val duration = network.duration
val start = duration?.let { buckets.indexOf(it.minMs).coerceAtLeast(0) } ?: 0
val end = duration?.maxMs?.let { buckets.indexOf(it).takeIf { index -> index >= 0 } } ?: buckets.size

return TimelineNetworkFilterUiModel(
enabled = network.enabled,
isFiltered = network.ruleCount > 0 || TimelineFacet.entries.any { it.source == TimelineSource.Network && facets[it].orEmpty().isNotEmpty() },
matchingCount = if (network.enabled) matchingNetworkCount ?: total else total,
totalCount = total,
methods = values(TimelineFacet.NetworkMethod).toImmutableList(),
statusClasses = values(TimelineFacet.NetworkStatus).toImmutableList(),
statusRules = network.statusRules.map { TimelineRuleUiModel(it.value, it.exclude) }.toImmutableList(),
histogram = buckets.map { durationCounts[it.toString()] ?: 0 }.toImmutableList(),
pendingCount = facetCounts[TimelineFacet.NetworkState]?.get("PENDING") ?: 0,
durationStart = start,
durationEnd = end,
durationLabel = duration?.let { "${formatDuration(it.minMs)} - ${it.maxMs?.let(::formatDuration) ?: "∞"}" },
content = values(TimelineFacet.NetworkContent).toImmutableList(),
hosts = hosts.toImmutableList(),
hostRules = rules.map { TimelineRuleUiModel(it.value, it.exclude) }.toImmutableList(),
hiddenHostCount = allHosts.count { !it.selected },
hostCount = hosts.size,
)
}

// a host matches when it has one of the included words, if any, and none of the excluded ones
private fun String.matchesHostRules(rules: List<TimelineRuleDomainModel>): Boolean {
val includes = rules.filter { !it.exclude }
val excludes = rules.filter { it.exclude }
return (includes.isEmpty() || includes.any { contains(it.value, ignoreCase = true) }) &&
excludes.none { contains(it.value, ignoreCase = true) }
}

private fun formatDuration(ms: Long): String = if (ms >= 1_000 && ms % 1_000 == 0L) "${ms / 1_000} s" else "$ms ms"

// the methods of the facet are the ones of the timeline query: WS and GRPC for the calls that are not http
private fun String.toMethodUi(): NetworkMethodUi = when (this) {
"WS" -> NetworkMethodUi.WebSocket
Expand Down
Loading
Loading