From 156151a8f14673acf254ddaa420d2302747edf5b Mon Sep 17 00:00:00 2001 From: doTTTTT Date: Sat, 31 Oct 2026 00:30:00 +0100 Subject: [PATCH 1/4] refact: [TIMELINE] Network status, host and duration rules in the filter --- .../datasource/TimelineQueryBuilder.kt | 93 ++++++++++++++- .../datasource/TimelineQueryBuilderTest.kt | 106 +++++++++++++++++- .../domain/timeline/models/TimelineFacet.kt | 11 +- .../models/TimelineFilterDomainModel.kt | 8 ++ .../models/TimelineRuleDomainModel.kt | 52 +++++++++ .../models/TimelineStatusRulesTest.kt | 43 +++++++ 6 files changed, 300 insertions(+), 13 deletions(-) create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineRuleDomainModel.kt create mode 100644 FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/timeline/models/TimelineStatusRulesTest.kt diff --git a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/timeline/datasource/TimelineQueryBuilder.kt b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/timeline/datasource/TimelineQueryBuilder.kt index 1a2e32a4c..0e30f8bc0 100644 --- a/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/timeline/datasource/TimelineQueryBuilder.kt +++ b/FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/timeline/datasource/TimelineQueryBuilder.kt @@ -1,8 +1,10 @@ package io.github.openflocon.data.local.timeline.datasource +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.TimelineFilterDomainModel import io.github.openflocon.domain.timeline.models.TimelineSource +import io.github.openflocon.domain.timeline.models.TimelineStatusMatcher internal const val KIND_NETWORK = "network" internal const val KIND_NETWORK_RESPONSE = "network_response" // the response of a network call, at the time it arrived @@ -61,11 +63,81 @@ private const val NETWORK_STATE = "CASE WHEN response_durationMs IS NULL THEN 'P "WHEN $NETWORK_STATUS_CODE IS NULL THEN 'OTHER' ELSE 'SUCCESS' END" // null while waiting for the response -private const val NETWORK_SPEED = "CASE WHEN response_durationMs IS NULL THEN NULL " + - "WHEN response_durationMs < 100 THEN '<100ms' WHEN response_durationMs < 500 THEN '100-500ms' " + - "WHEN response_durationMs < 1000 THEN '500ms-1s' ELSE '>1s' END" +private val NETWORK_SPEED = buildString { + append("CASE WHEN response_durationMs IS NULL THEN NULL") + // the bucket is told by its start, from the longest: WHEN duration >= 5000 THEN '5000' WHEN duration >= 2000 ... + TIMELINE_DURATION_BUCKETS_MS.reversed().forEach { start -> + append(" WHEN response_durationMs >= $start THEN '$start'") + } + append(" ELSE '0' END") +} + +private const val NETWORK_CONTENT = "CASE WHEN type = 'WEBSOCKET' THEN 'WEBSOCKET' " + + "WHEN COALESCE(response_isImage, 0) = 1 THEN 'IMAGE' ELSE 'OTHER' END" + +private const val NETWORK_HOST = "request_domainFormatted" + +private const val NETWORK_DURATION = "CAST(response_durationMs AS INTEGER)" + +/** + * The conditions of the status, host and duration rules of the network calls, each starting with " AND ". + * The rules of an ignored facet are not applied, to count its values. + */ +private fun networkRuleConditions( + filter: TimelineFilterDomainModel, + ignoredFacets: Set, +): Pair> { + if (!filter.networkEnabled) return "" to emptyList() + val sql = StringBuilder() + val params = ArrayList() + val code = "CAST($NETWORK_STATUS_CODE AS INTEGER)" + + if (TimelineFacet.NetworkStatus !in ignoredFacets) { + val matchers = filter.networkStatusRules.mapNotNull { rule -> + TimelineStatusMatcher.parse(rule.value)?.let { rule.exclude to it } + } + val includes = matchers.filter { !it.first }.map { it.second } + val excludes = matchers.filter { it.first }.map { it.second } + if (includes.isNotEmpty()) { + sql.append(includes.joinToString(prefix = " AND (", separator = " OR ", postfix = ")") { "$code BETWEEN ? AND ?" }) + includes.forEach { + params.add(it.from) + params.add(it.to) + } + } + excludes.forEach { + // a call without code yet is not one of the codes hidden + sql.append(" AND ($code IS NULL OR NOT ($code BETWEEN ? AND ?))") + params.add(it.from) + params.add(it.to) + } + } + + if (TimelineFacet.NetworkHost !in ignoredFacets) { + val includes = filter.networkHostRules.filter { !it.exclude && it.value.isNotBlank() } + val excludes = filter.networkHostRules.filter { it.exclude && it.value.isNotBlank() } + if (includes.isNotEmpty()) { + sql.append(includes.joinToString(prefix = " AND (", separator = " OR ", postfix = ")") { "$NETWORK_HOST LIKE ?" }) + includes.forEach { params.add("%${it.value}%") } + } + excludes.forEach { + sql.append(" AND COALESCE($NETWORK_HOST, '') NOT LIKE ?") + params.add("%${it.value}%") + } + } -private const val NETWORK_CONTENT = "CASE WHEN COALESCE(response_isImage, 0) = 1 THEN 'IMAGE' ELSE 'OTHER' END" + val duration = filter.networkDuration + if (duration != null && TimelineFacet.NetworkSpeed !in ignoredFacets) { + // a range hides the calls without duration yet + sql.append(" AND $NETWORK_DURATION >= ?") + params.add(duration.minMs) + duration.maxMs?.let { maxMs -> + sql.append(" AND $NETWORK_DURATION <= ?") + params.add(maxMs) + } + } + return sql.toString() to params +} internal class TimelineQuery( val sql: String, @@ -102,6 +174,7 @@ private fun buildUnion( extraCondition: String? = null, timeColumn: String? = null, // the time of the event, to keep the current session only facetExpressions: Map = emptyMap(), // what each facet of the source filters on + networkRules: Boolean = false, // the status, host and duration rules of the network calls ): String { val where = StringBuilder("WHERE deviceId = ?") params.add(deviceId) @@ -120,13 +193,19 @@ private fun buildUnion( where.append(" AND $timeColumn >= ${latestSessionSql(deviceId, packageName, params)}") } facetExpressions.forEach { (facet, expression) -> - val values = filter.facets[facet]?.takeIf { it.isNotEmpty() && facet !in ignoredFacets } + val ignored = facet in ignoredFacets || (facet.source == TimelineSource.Network && !filter.networkEnabled) + val values = filter.facets[facet]?.takeIf { it.isNotEmpty() && !ignored } if (values != null) { // an event without value (ex: a call without http code yet) is not one of the values hidden where.append(" AND ($expression IS NULL OR $expression NOT IN (${values.joinToString { "?" }}))") params.addAll(values) } } + if (networkRules) { + val (sql, ruleParams) = networkRuleConditions(filter, ignoredFacets) + where.append(sql) + params.addAll(ruleParams) + } if (text != null) { where.append(textColumns.joinToString(prefix = " AND (", separator = " OR ", postfix = ")") { "$it LIKE ?" }) repeat(textColumns.size) { params.add(text) } @@ -144,7 +223,7 @@ private fun buildUnion( TimelineFacet.NetworkState to NETWORK_STATE, TimelineFacet.NetworkSpeed to NETWORK_SPEED, TimelineFacet.NetworkContent to NETWORK_CONTENT, - TimelineFacet.NetworkHost to "request_domainFormatted", + TimelineFacet.NetworkHost to NETWORK_HOST, ) val branches = buildList { @@ -161,6 +240,7 @@ private fun buildUnion( textColumns = listOf("request_url", "request_method"), timeColumn = "request_startTime", facetExpressions = networkFacets, + networkRules = true, ) ) } @@ -178,6 +258,7 @@ private fun buildUnion( textColumns = listOf("request_url", "request_method"), timeColumn = "request_startTime", facetExpressions = networkFacets, + networkRules = true, // a call without response yet has no response event extraCondition = "response_durationMs IS NOT NULL", ) diff --git a/FloconDesktop/data/local/src/commonTest/kotlin/io/github/openflocon/data/local/timeline/datasource/TimelineQueryBuilderTest.kt b/FloconDesktop/data/local/src/commonTest/kotlin/io/github/openflocon/data/local/timeline/datasource/TimelineQueryBuilderTest.kt index ffceca66f..556d92ee6 100644 --- a/FloconDesktop/data/local/src/commonTest/kotlin/io/github/openflocon/data/local/timeline/datasource/TimelineQueryBuilderTest.kt +++ b/FloconDesktop/data/local/src/commonTest/kotlin/io/github/openflocon/data/local/timeline/datasource/TimelineQueryBuilderTest.kt @@ -1,7 +1,9 @@ package io.github.openflocon.data.local.timeline.datasource +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 kotlin.test.Test import kotlin.test.assertEquals @@ -261,7 +263,7 @@ class TimelineQueryBuilderTest { sources = setOf(TimelineSource.Network), facets = mapOf( TimelineFacet.NetworkState to setOf("PENDING"), - TimelineFacet.NetworkSpeed to setOf(">1s"), + TimelineFacet.NetworkSpeed to setOf("1000"), TimelineFacet.NetworkContent to setOf("IMAGE"), TimelineFacet.NetworkHost to setOf("ads.acme.io"), ), @@ -269,15 +271,111 @@ class TimelineQueryBuilderTest { ) assertTrue(query.sql.contains("WHEN response_durationMs IS NULL THEN 'PENDING'")) - assertTrue(query.sql.contains("WHEN response_durationMs < 100 THEN '<100ms'")) - assertTrue(query.sql.contains("THEN 'IMAGE' ELSE 'OTHER' END")) + assertTrue(query.sql.contains("WHEN response_durationMs >= 1000 THEN '1000'")) + assertTrue(query.sql.contains("THEN 'WEBSOCKET' WHEN COALESCE(response_isImage, 0) = 1 THEN 'IMAGE' ELSE 'OTHER' END")) assertTrue(query.sql.contains("request_domainFormatted NOT IN (?)")) // a call still waiting for its response has no duration: it is not hidden by the duration assertTrue(query.sql.contains("IS NULL OR CASE WHEN response_durationMs IS NULL THEN NULL")) - val branch = listOf("device", "com.app", "PENDING", ">1s", "IMAGE", "ads.acme.io") + val branch = listOf("device", "com.app", "PENDING", "1000", "IMAGE", "ads.acme.io") assertEquals(sessionParams + branch + branch, query.params) } + @Test + fun `the status rules keep the included codes and hide the excluded ones`() { + val query = build( + TimelineFilterDomainModel( + sources = setOf(TimelineSource.Network), + networkStatusRules = listOf( + TimelineRuleDomainModel("101", exclude = false), + TimelineRuleDomainModel("500-503", exclude = true), + ), + ) + ) + + assertTrue(query.sql.contains("AND (CAST(COALESCE(response_http_responseHttpCode, response_graphql_responseHttpCode) AS INTEGER) BETWEEN ? AND ?)")) + assertTrue(query.sql.contains("IS NULL OR NOT (CAST(COALESCE(response_http_responseHttpCode, response_graphql_responseHttpCode) AS INTEGER) BETWEEN ? AND ?)")) + val branch = listOf("device", "com.app", 101, 101, 500, 503) + assertEquals(sessionParams + branch + branch, query.params) + } + + @Test + fun `the host rules are words of the host`() { + val query = build( + TimelineFilterDomainModel( + sources = setOf(TimelineSource.Network), + networkHostRules = listOf( + TimelineRuleDomainModel("postman", exclude = false), + TimelineRuleDomainModel("analytics", exclude = true), + ), + ) + ) + + assertTrue(query.sql.contains("AND (request_domainFormatted LIKE ?)")) + assertTrue(query.sql.contains("AND COALESCE(request_domainFormatted, '') NOT LIKE ?")) + val branch = listOf("device", "com.app", "%postman%", "%analytics%") + assertEquals(sessionParams + branch + branch, query.params) + } + + @Test + fun `a duration range hides the calls without duration`() { + val query = build( + TimelineFilterDomainModel( + sources = setOf(TimelineSource.Network), + networkDuration = TimelineDurationRangeDomainModel(minMs = 100, maxMs = 1_000), + ) + ) + + assertTrue(query.sql.contains("AND CAST(response_durationMs AS INTEGER) >= ? AND CAST(response_durationMs AS INTEGER) <= ?")) + val branch = listOf("device", "com.app", 100L, 1_000L) + assertEquals(sessionParams + branch + branch, query.params) + } + + @Test + fun `a duration range without maximum has no upper bound`() { + val query = build( + TimelineFilterDomainModel( + sources = setOf(TimelineSource.Network), + networkDuration = TimelineDurationRangeDomainModel(minMs = 1_000, maxMs = null), + ) + ) + + assertFalse(query.sql.contains("<= ?")) + } + + @Test + fun `the network filters are ignored when they are disabled`() { + val query = build( + TimelineFilterDomainModel( + sources = setOf(TimelineSource.Network), + networkEnabled = false, + facets = mapOf(TimelineFacet.NetworkMethod to setOf("GET")), + networkStatusRules = listOf(TimelineRuleDomainModel("404", exclude = false)), + networkHostRules = listOf(TimelineRuleDomainModel("acme", exclude = true)), + networkDuration = TimelineDurationRangeDomainModel(minMs = 100, maxMs = null), + ) + ) + + assertFalse(query.params.contains("GET")) + assertFalse(query.sql.contains("BETWEEN")) + assertFalse(query.sql.contains("NOT LIKE")) + assertFalse(query.sql.contains(">= ?")) + } + + @Test + fun `the durations are counted without the duration range`() { + val query = buildTimelineFacetCountsQuery( + deviceId = "device", + packageName = "com.app", + filter = TimelineFilterDomainModel( + networkDuration = TimelineDurationRangeDomainModel(minMs = 100, maxMs = 1_000), + ), + ) + + // only the other network facets (the counts of status, host...) apply the range + val otherNetworkFacets = TimelineFacet.entries.count { it.source == TimelineSource.Network } - 1 + assertEquals(otherNetworkFacets, query.params.count { it == 100L }) + } + @Test fun `the statements unchecked are hidden`() { val query = build( diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineFacet.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineFacet.kt index bc956c8a6..3d811cbd0 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineFacet.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineFacet.kt @@ -3,6 +3,11 @@ package io.github.openflocon.domain.timeline.models // the kinds of SQL statement, TRANSACTION being BEGIN, COMMIT, END, ROLLBACK, SAVEPOINT and RELEASE, SCHEMA being CREATE, ALTER and DROP private val DATABASE_STATEMENTS = listOf("SELECT", "INSERT", "UPDATE", "DELETE", "SCHEMA", "PRAGMA", "TRANSACTION", "OTHER") +/** + * The start of each bucket of the duration histogram, in ms. A bucket ends where the next one starts, the last one is open. + */ +val TIMELINE_DURATION_BUCKETS_MS = listOf(0L, 50L, 100L, 200L, 300L, 500L, 750L, 1_000L, 2_000L, 5_000L) + /** * A way to narrow the events of one [source] down: every value is shown by default and the filter keeps the values * that are not hidden, ex: hiding the GET calls of the network. @@ -27,14 +32,14 @@ enum class TimelineFacet( knownValues = listOf("SUCCESS", "ERROR", "PENDING", "OTHER"), ), - // the time the response took, nothing while waiting for it + // the time the response took, nothing while waiting for it. A value is the start of a bucket of the histogram, in ms NetworkSpeed( source = TimelineSource.Network, - knownValues = listOf("<100ms", "100-500ms", "500ms-1s", ">1s"), + knownValues = TIMELINE_DURATION_BUCKETS_MS.map { it.toString() }, ), NetworkContent( source = TimelineSource.Network, - knownValues = listOf("IMAGE", "OTHER"), + knownValues = listOf("WEBSOCKET", "IMAGE", "OTHER"), ), NetworkHost(TimelineSource.Network), DatabaseName(TimelineSource.Database), diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineFilterDomainModel.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineFilterDomainModel.kt index 2d3e68fe4..468e81be7 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineFilterDomainModel.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineFilterDomainModel.kt @@ -10,4 +10,12 @@ data class TimelineFilterDomainModel( val currentSessionOnly: Boolean = false, // the values hidden by facet, a facet without value hides nothing. The facets of an unselected source are ignored val facets: Map> = emptyMap(), + // false: the filters of the network calls are all ignored + val networkEnabled: Boolean = true, + // codes or ranges ("404", "500-503") of the network calls to keep (include) or hide (exclude) + val networkStatusRules: List = emptyList(), + // words of the host of the network calls to keep (include) or hide (exclude) + val networkHostRules: List = emptyList(), + // null: every duration, the calls without duration yet are kept. A range hides them + val networkDuration: TimelineDurationRangeDomainModel? = null, ) diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineRuleDomainModel.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineRuleDomainModel.kt new file mode 100644 index 000000000..30eaaef16 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineRuleDomainModel.kt @@ -0,0 +1,52 @@ +package io.github.openflocon.domain.timeline.models + +/** + * An include / exclude rule typed by the user: a word for a host, a code or a range of codes ("404", "500-503") for a status. + */ +data class TimelineRuleDomainModel( + val value: String, + val exclude: Boolean, +) + +/** + * A range of milliseconds, without upper bound when [maxMs] is null. + */ +data class TimelineDurationRangeDomainModel( + val minMs: Long, + val maxMs: Long?, +) + +/** + * The status codes matched by a rule: one code, or an inclusive range. + */ +data class TimelineStatusMatcher( + val from: Int, + val to: Int, +) { + companion object { + /** + * "404" or "500-503" (spaces allowed). Null for anything else: unknown text, reversed range. + */ + fun parse(text: String): TimelineStatusMatcher? { + val parts = text.trim().split("-").map { it.trim() } + val numbers = parts.map { it.toIntOrNull() ?: return null } + return when (numbers.size) { + 1 -> TimelineStatusMatcher(numbers[0], numbers[0]) + 2 -> numbers.takeIf { it[0] <= it[1] }?.let { TimelineStatusMatcher(it[0], it[1]) } + else -> null + } + } + } +} + +private val DASH_WITH_SPACES = Regex("\\s*-\\s*") + +/** + * "404, 500-503" into matchers, comma or space separated. Null when a part is not a status. + */ +fun parseTimelineStatusRules(text: String): List? { + // "500 - 503" is one range, not three parts + val parts = text.replace(DASH_WITH_SPACES, "-").split(",", " ").map { it.trim() }.filter { it.isNotEmpty() } + if (parts.isEmpty()) return null + return parts.map { TimelineStatusMatcher.parse(it) ?: return null } +} diff --git a/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/timeline/models/TimelineStatusRulesTest.kt b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/timeline/models/TimelineStatusRulesTest.kt new file mode 100644 index 000000000..59d8a5763 --- /dev/null +++ b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/timeline/models/TimelineStatusRulesTest.kt @@ -0,0 +1,43 @@ +package io.github.openflocon.domain.timeline.models + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TimelineStatusRulesTest { + + @Test + fun `a code is a range of one`() { + assertEquals(TimelineStatusMatcher(404, 404), TimelineStatusMatcher.parse("404")) + } + + @Test + fun `a range keeps both ends`() { + assertEquals(TimelineStatusMatcher(500, 503), TimelineStatusMatcher.parse(" 500 - 503 ")) + } + + @Test + fun `a reversed range or a word is not a status`() { + assertNull(TimelineStatusMatcher.parse("503-500")) + assertNull(TimelineStatusMatcher.parse("abc")) + assertNull(TimelineStatusMatcher.parse("1-2-3")) + } + + @Test + fun `codes and ranges are split on commas and spaces`() { + assertEquals( + listOf(TimelineStatusMatcher(404, 404), TimelineStatusMatcher(500, 503)), + parseTimelineStatusRules("404, 500-503"), + ) + assertEquals( + listOf(TimelineStatusMatcher(500, 503)), + parseTimelineStatusRules("500 - 503"), + ) + } + + @Test + fun `one invalid part rejects the whole text`() { + assertNull(parseTimelineStatusRules("404, foo")) + assertNull(parseTimelineStatusRules(" ")) + } +} From 64ca43c5af4cc836cec40aad6e8d4f3a9c413c85 Mon Sep 17 00:00:00 2001 From: doTTTTT Date: Sat, 31 Oct 2026 00:37:35 +0100 Subject: [PATCH 2/4] refact: [TIMELINE] Network filter card with status rules, duration range and host rules --- .../composeResources/values-fr/strings.xml | 10 + .../composeResources/values/strings.xml | 10 + .../timeline/TimelineFilterPanelViewModel.kt | 60 +- .../features/timeline/TimelineFilterStore.kt | 63 +- .../features/timeline/TimelineViewModel.kt | 6 +- .../timeline/mapper/FilterPanelMapper.kt | 59 +- .../model/TimelineFilterPanelUiModel.kt | 98 +++ .../timeline/model/TimelineUiModel.kt | 18 + .../timeline/view/NetworkFilterCard.kt | 618 ++++++++++++++++++ .../timeline/view/TimelineFilterPanel.kt | 23 +- .../timeline/mapper/FilterPanelMapperTest.kt | 81 +++ .../models/TimelineRuleDomainModel.kt | 3 + .../components/FloconFilterChip.kt | 91 +++ .../components/FloconHistogram.kt | 77 +++ .../components/FloconIncludeExcludeInput.kt | 159 +++++ .../components/FloconRangeSlider.kt | 146 +++++ .../designsystem/components/FloconRuleChip.kt | 84 +++ 17 files changed, 1593 insertions(+), 13 deletions(-) create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/NetworkFilterCard.kt create mode 100644 FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconFilterChip.kt create mode 100644 FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt create mode 100644 FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconIncludeExcludeInput.kt create mode 100644 FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconRangeSlider.kt create mode 100644 FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconRuleChip.kt 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>> field = MutableStateFlow>>(emptyMap()) + // the status, host and duration rules of the network calls + val network: StateFlow + field = MutableStateFlow(TimelineNetworkRules()) + // the events of the latest session only val currentSessionOnly: StateFlow field = MutableStateFlow(false) @@ -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) @@ -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) { + 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 } } @@ -82,6 +142,7 @@ internal class TimelineFilterStore( fun clear() { text.update { "" } facets.update { emptyMap() } + network.update { TimelineNetworkRules() } currentSessionOnly.update { false } } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineViewModel.kt index 1d039c3b2..1ca286717 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/TimelineViewModel.kt @@ -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) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/mapper/FilterPanelMapper.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/mapper/FilterPanelMapper.kt index 7edb75a9e..e75d77099 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/mapper/FilterPanelMapper.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/mapper/FilterPanelMapper.kt @@ -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 @@ -19,6 +24,8 @@ internal fun TimelineSummaryUiModel.toFilterPanel( selectedSources: Set, facets: Map>, // 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 -> @@ -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>, + 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): 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 diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/model/TimelineFilterPanelUiModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/model/TimelineFilterPanelUiModel.kt index dffa022d0..9f95f612a 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/model/TimelineFilterPanelUiModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/model/TimelineFilterPanelUiModel.kt @@ -34,9 +34,106 @@ internal data class TimelineFilterSectionUiModel( val groups: ImmutableList, ) +// an include or exclude rule typed by the user, displayed as a chip +@Immutable +internal data class TimelineRuleUiModel( + val value: String, + val exclude: Boolean, +) + +/** + * The network card of the filter panel: the filters of the network calls, side by side instead of in dropdowns. + * The duration is told by positions of the slider: a position is the start of a bucket of the histogram, the last one is "no limit". + */ +@Immutable +internal data class TimelineNetworkFilterUiModel( + val enabled: Boolean, + val isFiltered: Boolean, // a value is hidden or a rule is set: offers to reset + val matchingCount: Int, // the network calls displayed + val totalCount: Int, // the network calls received, with the text of the filter + val methods: ImmutableList, + val statusClasses: ImmutableList, + val statusRules: ImmutableList, + val histogram: ImmutableList, // calls of each bucket of duration + val pendingCount: Int, // calls without duration yet + val durationStart: Int, + val durationEnd: Int, + val durationLabel: String?, // null: every duration + val content: ImmutableList, + val hosts: ImmutableList, // the hosts matching the rules + val hostRules: ImmutableList, + val hiddenHostCount: Int, + val hostCount: Int, +) { + val hasHiddenStatus: Boolean get() = statusClasses.any { !it.selected } + val maxDurationPosition: Int get() = histogram.size + + companion object { + val Empty = TimelineNetworkFilterUiModel( + enabled = true, + isFiltered = false, + matchingCount = 0, + totalCount = 0, + methods = persistentListOf(), + statusClasses = persistentListOf(), + statusRules = persistentListOf(), + histogram = persistentListOf(), + pendingCount = 0, + durationStart = 0, + durationEnd = 0, + durationLabel = null, + content = persistentListOf(), + hosts = persistentListOf(), + hostRules = persistentListOf(), + hiddenHostCount = 0, + hostCount = 0, + ) + } +} + +internal fun previewTimelineNetworkFilterUiModel() = TimelineNetworkFilterUiModel( + enabled = true, + isFiltered = true, + matchingCount = 8, + totalCount = 22, + methods = persistentListOf( + TimelineFacetValueUiModel("GET", count = 5, selected = true, method = NetworkMethodUi.Http.GET), + TimelineFacetValueUiModel("POST", count = 2, selected = true, method = NetworkMethodUi.Http.POST), + TimelineFacetValueUiModel("DELETE", count = 0, selected = false, method = NetworkMethodUi.Http.DELETE), + TimelineFacetValueUiModel("WS", count = 1, selected = true, method = NetworkMethodUi.WebSocket), + TimelineFacetValueUiModel("GRPC", count = 0, selected = true, method = NetworkMethodUi.Grpc), + ), + statusClasses = persistentListOf( + TimelineFacetValueUiModel("1xx", count = 1, selected = true, httpFamily = 1), + TimelineFacetValueUiModel("2xx", count = 5, selected = true, httpFamily = 2), + TimelineFacetValueUiModel("3xx", count = 0, selected = true, httpFamily = 3), + TimelineFacetValueUiModel("4xx", count = 0, selected = true, httpFamily = 4), + TimelineFacetValueUiModel("5xx", count = 2, selected = true, httpFamily = 5), + ), + statusRules = persistentListOf(TimelineRuleUiModel("101", exclude = false), TimelineRuleUiModel("500-503", exclude = true)), + histogram = persistentListOf(0, 1, 4, 8, 6, 2, 3, 1, 0, 0), + pendingCount = 14, + durationStart = 2, + durationEnd = 7, + durationLabel = "100 ms - 1 s", + content = persistentListOf( + TimelineFacetValueUiModel("WEBSOCKET", count = 22, selected = true), + TimelineFacetValueUiModel("IMAGE", count = 0, selected = true), + TimelineFacetValueUiModel("OTHER", count = 0, selected = true), + ), + hosts = persistentListOf( + TimelineFacetValueUiModel("ws.postman-echo.com:443", count = 14, selected = true), + TimelineFacetValueUiModel("ws.postman-echo.com", count = 8, selected = true), + ), + hostRules = persistentListOf(TimelineRuleUiModel("postman", exclude = false), TimelineRuleUiModel("analytics", exclude = true)), + hiddenHostCount = 0, + hostCount = 2, +) + @Immutable internal data class TimelineFilterPanelUiModel( val sections: ImmutableList, + val network: TimelineNetworkFilterUiModel, val currentSessionOnly: Boolean, // something is filtered: used to offer to clear it val hasActiveFilters: Boolean, @@ -46,6 +143,7 @@ internal data class TimelineFilterPanelUiModel( companion object { val Empty = TimelineFilterPanelUiModel( sections = persistentListOf(), + network = TimelineNetworkFilterUiModel.Empty, currentSessionOnly = false, hasActiveFilters = false, activeFilterCount = 0, diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/model/TimelineUiModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/model/TimelineUiModel.kt index 31c364fb2..c9c819c13 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/model/TimelineUiModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/model/TimelineUiModel.kt @@ -80,6 +80,24 @@ internal sealed interface TimelineFilterAction { data class ToggleSource(val source: TimelineSource) : TimelineFilterAction data class ToggleFacet(val facet: TimelineFacet, val value: String) : TimelineFilterAction data class SetCurrentSessionOnly(val value: Boolean) : TimelineFilterAction + + // the network card + data class SetNetworkEnabled(val value: Boolean) : TimelineFilterAction + + // shows every network call again: the values hidden and the rules are cleared + data object ResetNetwork : TimelineFilterAction + + // the values hidden of a facet at once: only one host (all the others hidden), none, or all of them + data class SetHiddenValues(val facet: TimelineFacet, val values: Set) : TimelineFilterAction + + // "404, 500-503": a code or a range, several of them separated by commas + data class AddStatusRules(val text: String, val exclude: Boolean) : TimelineFilterAction + data class RemoveStatusRule(val value: String) : TimelineFilterAction + data class AddHostRule(val text: String, val exclude: Boolean) : TimelineFilterAction + data class RemoveHostRule(val value: String) : TimelineFilterAction + + // positions of the slider, see TimelineNetworkFilterUiModel + data class SetDurationRange(val start: Int, val end: Int) : TimelineFilterAction data object ClearAll : TimelineFilterAction } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/NetworkFilterCard.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/NetworkFilterCard.kt new file mode 100644 index 000000000..b75ea7c78 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/NetworkFilterCard.kt @@ -0,0 +1,618 @@ +package io.github.openflocon.flocondesktop.features.timeline.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.KeyboardArrowDown +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.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import flocondesktop.composeapp.generated.resources.Res +import flocondesktop.composeapp.generated.resources.filter_exclude +import flocondesktop.composeapp.generated.resources.filter_include +import flocondesktop.composeapp.generated.resources.remove +import flocondesktop.composeapp.generated.resources.timeline_facet_network_content +import flocondesktop.composeapp.generated.resources.timeline_facet_network_host +import flocondesktop.composeapp.generated.resources.timeline_facet_network_method +import flocondesktop.composeapp.generated.resources.timeline_facet_network_speed +import flocondesktop.composeapp.generated.resources.timeline_facet_network_status +import flocondesktop.composeapp.generated.resources.timeline_filters_any +import flocondesktop.composeapp.generated.resources.timeline_filters_host_count +import flocondesktop.composeapp.generated.resources.timeline_filters_host_placeholder +import flocondesktop.composeapp.generated.resources.timeline_filters_hosts_matching +import flocondesktop.composeapp.generated.resources.timeline_filters_none +import flocondesktop.composeapp.generated.resources.timeline_filters_only +import flocondesktop.composeapp.generated.resources.timeline_filters_pending +import flocondesktop.composeapp.generated.resources.timeline_filters_reset +import flocondesktop.composeapp.generated.resources.timeline_filters_rule_one +import flocondesktop.composeapp.generated.resources.timeline_filters_rule_other +import flocondesktop.composeapp.generated.resources.timeline_filters_status_placeholder +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.TimelineSource +import io.github.openflocon.flocondesktop.features.network.list.view.components.MethodTag +import io.github.openflocon.flocondesktop.features.timeline.model.TimelineFacetValueUiModel +import io.github.openflocon.flocondesktop.features.timeline.model.TimelineFilterAction +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.previewTimelineNetworkFilterUiModel +import io.github.openflocon.library.designsystem.FloconTheme +import io.github.openflocon.library.designsystem.components.FloconCheckbox +import io.github.openflocon.library.designsystem.components.FloconDot +import io.github.openflocon.library.designsystem.components.FloconDropdownMenu +import io.github.openflocon.library.designsystem.components.FloconFilterChip +import io.github.openflocon.library.designsystem.components.FloconHistogram +import io.github.openflocon.library.designsystem.components.FloconIcon +import io.github.openflocon.library.designsystem.components.FloconIncludeExcludeInput +import io.github.openflocon.library.designsystem.components.FloconRangeSlider +import io.github.openflocon.library.designsystem.components.FloconRangeSliderInset +import io.github.openflocon.library.designsystem.components.FloconRuleChip +import io.github.openflocon.library.designsystem.components.FloconStatusPill +import io.github.openflocon.library.designsystem.components.FloconToggleSwitch +import org.jetbrains.compose.resources.stringResource + +private const val DisabledCardAlpha = 0.5f +private const val HiddenTagAlpha = 0.3f +private const val ExpandedChevronRotation = 180f +private const val HostRowsMaxCount = 200 + +// the labels under the slider, where they are on its positions: [position in the buckets] to [label] +private val DurationTicks = listOf(0L to "0", 100L to "100ms", 500L to "500ms", 1_000L to "1s") + +/** + * The filters of the network calls, side by side instead of behind dropdowns: the methods, the status, the duration, + * the content and the hosts. The master switch keeps the filters but ignores them. + */ +@Composable +internal fun NetworkFilterCard( + network: TimelineNetworkFilterUiModel, + onAction: (TimelineFilterAction) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = FloconTheme.colors + val (categoryColor, _) = TimelineSource.Network.categoryColors() + var expanded by remember { mutableStateOf(true) } + val chevronRotation by animateFloatAsState(if (expanded) 0f else -ExpandedChevronRotation / 2) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(FloconTheme.spacing.medium) + .clip(FloconTheme.shapes.medium) + .background(colors.bg200) + .border(1.dp, colors.line, FloconTheme.shapes.medium) + .animateContentSize() + .padding(FloconTheme.spacing.medium), + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.medium), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + Row( + modifier = Modifier + .weight(1f) + .clip(FloconTheme.shapes.small) + .clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + FloconIcon(imageVector = Icons.Outlined.ExpandMore, modifier = Modifier.rotate(chevronRotation)) + CategoryDot(color = categoryColor, filled = network.enabled) + Text( + text = stringResource(TimelineSource.Network.label()), + style = FloconTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = colors.ink, + maxLines = 1, + ) + } + if (network.isFiltered) { + Text( + text = stringResource(Res.string.timeline_filters_reset), + style = FloconTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colors.accent, + modifier = Modifier + .clip(FloconTheme.shapes.small) + .clickable { onAction(TimelineFilterAction.ResetNetwork) } + .padding(horizontal = FloconTheme.spacing.extraSmall, vertical = FloconTheme.spacing.tiny), + ) + } + Text( + text = "${network.matchingCount} / ${network.totalCount}", + style = FloconTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = if (network.isFiltered) colors.accent else colors.inkMuted, + maxLines = 1, + ) + FloconToggleSwitch( + checked = network.enabled, + onCheckedChange = { onAction(TimelineFilterAction.SetNetworkEnabled(it)) }, + ) + } + AnimatedVisibility(visible = expanded) { + // the filters of a card switched off are still there, to be set before switching it on + Column( + modifier = Modifier.alpha(if (network.enabled) 1f else DisabledCardAlpha), + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.large), + ) { + MethodSection(network, onAction) + StatusSection(network, onAction) + DurationSection(network, onAction) + ContentSection(network, onAction) + HostSection(network, onAction) + } + } + } +} + +@Composable +private fun SectionTitle( + title: String, + summary: String, + modifier: Modifier = Modifier, + summaryActive: Boolean = false, +) { + val colors = FloconTheme.colors + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = FloconTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colors.inkMuted, + modifier = Modifier.weight(1f), + ) + Text( + text = summary, + style = FloconTheme.typography.labelMedium, + color = if (summaryActive) colors.accent else colors.inkMuted, + maxLines = 1, + ) + } +} + +@Composable +private fun ruleCountText(count: Int): String = if (count == 1) { + stringResource(Res.string.timeline_filters_rule_one) +} else { + stringResource(Res.string.timeline_filters_rule_other, count) +} + +// "All" or "2/5", then the rules: "All · 2 rules" +@Composable +private fun summaryText( + shown: Int, + total: Int, + ruleCount: Int, +): String { + val values = if (shown == total) stringResource(Res.string.timeline_filters_any) else "$shown/$total" + return if (ruleCount > 0) "$values · ${ruleCountText(ruleCount)}" else values +} + +@Composable +private fun MethodSection( + network: TimelineNetworkFilterUiModel, + onAction: (TimelineFilterAction) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small)) { + SectionTitle( + title = stringResource(Res.string.timeline_facet_network_method), + summary = summaryText(network.methods.count { it.selected }, network.methods.size, ruleCount = 0), + summaryActive = network.methods.any { !it.selected }, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + network.methods.forEach { item -> + val alpha by animateFloatAsState(if (item.selected) 1f else HiddenTagAlpha) + item.method?.let { method -> + Box( + modifier = Modifier + .alpha(alpha) + .clip(FloconTheme.shapes.small) + .clickable { onAction(TimelineFilterAction.ToggleFacet(TimelineFacet.NetworkMethod, item.value)) }, + ) { + MethodTag(method = method) + } + } + } + } + } +} + +@Composable +private fun StatusSection( + network: TimelineNetworkFilterUiModel, + onAction: (TimelineFilterAction) -> Unit, +) { + val colors = FloconTheme.colors + Column(verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small)) { + SectionTitle( + title = stringResource(Res.string.timeline_facet_network_status), + summary = summaryText(network.statusClasses.count { it.selected }, network.statusClasses.size, network.statusRules.size), + summaryActive = network.hasHiddenStatus || network.statusRules.isNotEmpty(), + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + network.statusClasses.forEach { item -> + FloconFilterChip( + text = item.value, + selected = item.selected, + onClick = { onAction(TimelineFilterAction.ToggleFacet(TimelineFacet.NetworkStatus, item.value)) }, + monospace = true, + leading = { FloconDot(color = colors.httpStatus((item.httpFamily ?: 0) * 100)) }, + ) + } + } + FloconIncludeExcludeInput( + includeLabel = stringResource(Res.string.filter_include), + excludeLabel = stringResource(Res.string.filter_exclude), + placeholder = stringResource(Res.string.timeline_filters_status_placeholder), + onSubmit = { text, exclude -> onAction(TimelineFilterAction.AddStatusRules(text, exclude)) }, + ) + RuleChips( + rules = network.statusRules, + onRemove = { onAction(TimelineFilterAction.RemoveStatusRule(it)) }, + ) + } +} + +@Composable +private fun RuleChips( + rules: List, + onRemove: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (rules.isEmpty()) return + val removeDescription = stringResource(Res.string.remove) + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + rules.forEach { rule -> + FloconRuleChip( + text = rule.value, + exclude = rule.exclude, + onRemove = { onRemove(rule.value) }, + removeContentDescription = removeDescription, + ) + } + } +} + +@Composable +private fun DurationSection( + network: TimelineNetworkFilterUiModel, + onAction: (TimelineFilterAction) -> Unit, +) { + val colors = FloconTheme.colors + val steps = network.histogram.size + if (steps == 0) return + Column(verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small)) { + SectionTitle( + title = stringResource(Res.string.timeline_facet_network_speed), + summary = network.durationLabel ?: stringResource(Res.string.timeline_filters_any), + summaryActive = network.durationLabel != null, + ) + FloconHistogram( + values = network.histogram, + start = network.durationStart, + end = network.durationEnd, + ) + FloconRangeSlider( + start = network.durationStart, + end = network.durationEnd, + max = steps, + onRangeChange = { start, end -> onAction(TimelineFilterAction.SetDurationRange(start, end)) }, + ) + // the ticks are where their duration is on the slider; the last position is "no limit" + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = FloconRangeSliderInset), + ) { + val ticks = DurationTicks.map { (ms, label) -> TIMELINE_DURATION_BUCKETS_MS.indexOf(ms) to label } + (steps to "∞") + ticks.forEach { (position, label) -> + if (position >= 0) { + Text( + text = label, + style = FloconTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = colors.inkFaint, + maxLines = 1, + modifier = Modifier.align(BiasAlignment(horizontalBias = position.toFloat() / steps * 2 - 1, verticalBias = -1f)), + ) + } + } + // keeps the height of the labels + Text(text = " ", style = FloconTheme.typography.labelSmall) + } + if (network.pendingCount > 0) { + Text( + text = stringResource(Res.string.timeline_filters_pending, network.pendingCount), + style = FloconTheme.typography.bodySmall, + color = colors.inkMuted, + ) + } + } +} + +@Composable +private fun ContentSection( + network: TimelineNetworkFilterUiModel, + onAction: (TimelineFilterAction) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small)) { + SectionTitle( + title = stringResource(Res.string.timeline_facet_network_content), + summary = summaryText(network.content.count { it.selected }, network.content.size, ruleCount = 0), + summaryActive = network.content.any { !it.selected }, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + network.content.forEach { item -> + FloconFilterChip( + text = item.value, + selected = item.selected, + count = item.count, + monospace = true, + onClick = { onAction(TimelineFilterAction.ToggleFacet(TimelineFacet.NetworkContent, item.value)) }, + ) + } + } + } +} + +@Composable +private fun HostSection( + network: TimelineNetworkFilterUiModel, + onAction: (TimelineFilterAction) -> Unit, +) { + val colors = FloconTheme.colors + var open by remember { mutableStateOf(false) } + val summary = summaryText( + shown = network.hosts.count { it.selected }, + total = network.hosts.size, + ruleCount = network.hostRules.size, + ) + Column(verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small)) { + SectionTitle( + title = stringResource(Res.string.timeline_facet_network_host), + summary = stringResource(Res.string.timeline_filters_any).takeIf { network.hiddenHostCount == 0 && network.hostRules.isEmpty() } + ?: summary, + summaryActive = network.hiddenHostCount > 0 || network.hostRules.isNotEmpty(), + ) + Box { + Row( + modifier = Modifier + .fillMaxWidth() + .height(FloconTheme.sizes.pageBarControlHeight) + .clip(FloconTheme.shapes.medium) + .border( + width = 1.dp, + color = if (network.hostRules.isNotEmpty() || network.hiddenHostCount > 0) colors.accent else colors.line, + shape = FloconTheme.shapes.medium, + ) + .clickable { open = true } + .padding(horizontal = FloconTheme.spacing.medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = hostSelectorText(network), + style = FloconTheme.typography.bodyMedium, + color = colors.ink, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + FloconIcon( + imageVector = Icons.Outlined.KeyboardArrowDown, + modifier = Modifier + .size(HostChevronSize) + .rotate(if (open) ExpandedChevronRotation else 0f), + ) + } + FloconDropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + modifier = Modifier.width(FloconTheme.sizes.sidePanelWidth), + ) { + HostMenu(network = network, onAction = onAction) + } + } + } +} + +private val HostChevronSize = 16.dp + +// "All · 2 hosts · 2 rules" +@Composable +private fun hostSelectorText(network: TimelineNetworkFilterUiModel): String { + val all = stringResource(Res.string.timeline_filters_any) + val selected = network.hosts.count { it.selected } + val values = if (network.hiddenHostCount == 0) all else "$selected/${network.hosts.size}" + val hosts = stringResource(Res.string.timeline_filters_host_count, network.hostCount) + return buildString { + append(values) + append(" · ") + append(hosts) + if (network.hostRules.isNotEmpty()) { + append(" · ") + append(ruleCountText(network.hostRules.size)) + } + } +} + +@Composable +private fun HostMenu( + network: TimelineNetworkFilterUiModel, + onAction: (TimelineFilterAction) -> Unit, +) { + val colors = FloconTheme.colors + val anySelected = network.hosts.any { it.selected } + Column( + modifier = Modifier.padding(vertical = FloconTheme.spacing.small), + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + Column( + modifier = Modifier.padding(horizontal = FloconTheme.spacing.medium), + verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + FloconIncludeExcludeInput( + includeLabel = stringResource(Res.string.filter_include), + excludeLabel = stringResource(Res.string.filter_exclude), + placeholder = stringResource(Res.string.timeline_filters_host_placeholder), + onSubmit = { text, exclude -> onAction(TimelineFilterAction.AddHostRule(text, exclude)) }, + ) + RuleChips( + rules = network.hostRules, + onRemove = { onAction(TimelineFilterAction.RemoveHostRule(it)) }, + ) + } + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(colors.line), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = FloconTheme.spacing.medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.timeline_filters_hosts_matching, network.hostCount).uppercase(), + style = FloconTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = colors.inkMuted, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(if (anySelected) Res.string.timeline_filters_none else Res.string.timeline_filters_any), + style = FloconTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colors.inkMuted, + modifier = Modifier + .clip(FloconTheme.shapes.small) + .clickable { + // none: every host shown here is hidden, all: nothing is hidden + val hidden = if (anySelected) network.hosts.map { it.value }.toSet() else emptySet() + onAction(TimelineFilterAction.SetHiddenValues(TimelineFacet.NetworkHost, hidden)) + } + .padding(horizontal = FloconTheme.spacing.small, vertical = FloconTheme.spacing.tiny), + ) + } + network.hosts.take(HostRowsMaxCount).forEach { item -> + HostRow( + item = item, + onToggle = { onAction(TimelineFilterAction.ToggleFacet(TimelineFacet.NetworkHost, item.value)) }, + // only this one: the other hosts shown are hidden + onOnly = { + onAction(TimelineFilterAction.SetHiddenValues(TimelineFacet.NetworkHost, network.hosts.map { it.value }.toSet() - item.value)) + }, + ) + } + } +} + +@Composable +private fun HostRow( + item: TimelineFacetValueUiModel, + onToggle: () -> Unit, + onOnly: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = FloconTheme.colors + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(horizontal = FloconTheme.spacing.medium, vertical = FloconTheme.spacing.extraSmall), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + FloconCheckbox( + checked = item.selected, + onCheckedChange = { onToggle() }, + ) + Text( + text = item.value, + style = FloconTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = colors.ink, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + FloconStatusPill( + text = stringResource(Res.string.timeline_filters_only), + color = colors.inkMuted, + modifier = Modifier.clickable(onClick = onOnly), + ) + Text( + text = item.count.toString(), + style = FloconTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = colors.inkFaint, + ) + } +} + +@Composable +private fun NetworkFilterCardPreview(isDarkTheme: Boolean) { + FloconTheme(isDarkTheme = isDarkTheme) { + NetworkFilterCard( + network = remember { previewTimelineNetworkFilterUiModel() }, + onAction = {}, + modifier = Modifier + .width(PreviewCardWidth) + .background(FloconTheme.colors.bg100), + ) + } +} + +private val PreviewCardWidth = 420.dp + +@Preview +@Composable +private fun NetworkFilterCardDarkPreview() = NetworkFilterCardPreview(isDarkTheme = true) + +@Preview +@Composable +private fun NetworkFilterCardLightPreview() = NetworkFilterCardPreview(isDarkTheme = false) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/TimelineFilterPanel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/TimelineFilterPanel.kt index ee3bb9ec8..c07c14332 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/TimelineFilterPanel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/TimelineFilterPanel.kt @@ -147,13 +147,22 @@ internal fun TimelineFilterPanel( modifier = Modifier.fillMaxSize(), ) { items(items = sections, key = { it.source }) { section -> - FilterSection( - section = section, - label = sourceLabels.getValue(section.source), - onAction = onAction, - // the sections slide when the order changes: a source gets its first event - modifier = Modifier.animateItem(), - ) + if (section.source == TimelineSource.Network) { + // the network calls have their own card, the filters side by side + NetworkFilterCard( + network = panel.network, + onAction = onAction, + modifier = Modifier.animateItem(), + ) + } else { + FilterSection( + section = section, + label = sourceLabels.getValue(section.source), + onAction = onAction, + // the sections slide when the order changes: a source gets its first event + modifier = Modifier.animateItem(), + ) + } } } } diff --git a/FloconDesktop/composeApp/src/commonTest/kotlin/io/github/openflocon/flocondesktop/features/timeline/mapper/FilterPanelMapperTest.kt b/FloconDesktop/composeApp/src/commonTest/kotlin/io/github/openflocon/flocondesktop/features/timeline/mapper/FilterPanelMapperTest.kt index 63d15520b..b5e4ed35e 100644 --- a/FloconDesktop/composeApp/src/commonTest/kotlin/io/github/openflocon/flocondesktop/features/timeline/mapper/FilterPanelMapperTest.kt +++ b/FloconDesktop/composeApp/src/commonTest/kotlin/io/github/openflocon/flocondesktop/features/timeline/mapper/FilterPanelMapperTest.kt @@ -1,8 +1,12 @@ 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.TimelineDurationRangeDomainModel 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.model.NetworkMethodUi +import io.github.openflocon.flocondesktop.features.timeline.TimelineNetworkRules import io.github.openflocon.flocondesktop.features.timeline.model.TimelineSummaryUiModel import kotlin.test.Test import kotlin.test.assertEquals @@ -128,4 +132,81 @@ class FilterPanelMapperTest { assertEquals(2, some.sections.first { it.source == TimelineSource.Network }.activeFacetCount) assertFalse(some.sections.first { it.source == TimelineSource.Crash }.enabled) } + + @Test + fun `the network card counts the calls displayed over the calls received`() { + val card = summary.toFilterPanel(all, emptyMap(), matchingNetworkCount = 2).network + + assertEquals(2, card.matchingCount) + assertEquals(3, card.totalCount) + assertFalse(card.isFiltered) + // every call is displayed when the filters are not counted yet + assertEquals(3, summary.toFilterPanel(all, emptyMap()).network.matchingCount) + } + + @Test + fun `a rule, a hidden value or a duration range is a filter to reset`() { + val rule = TimelineNetworkRules(statusRules = listOf(TimelineRuleDomainModel("404", exclude = false))) + assertTrue(summary.toFilterPanel(all, emptyMap(), network = rule).network.isFiltered) + assertTrue(summary.toFilterPanel(all, mapOf(TimelineFacet.NetworkContent to setOf("IMAGE"))).network.isFiltered) + // a facet of another source is not one of the network + assertFalse(summary.toFilterPanel(all, mapOf(TimelineFacet.DatabaseName to setOf("a.db"))).network.isFiltered) + // the rules count in the badge of the filter button + assertEquals(1, summary.toFilterPanel(all, emptyMap(), network = rule).activeFilterCount) + } + + @Test + fun `the duration range is told by positions of the buckets`() { + val range = TimelineNetworkRules(duration = TimelineDurationRangeDomainModel(minMs = 100, maxMs = 1_000)) + val card = summary.toFilterPanel(all, emptyMap(), network = range).network + + assertEquals(2, card.durationStart) + assertEquals(7, card.durationEnd) + assertEquals("100 ms - 1 s", card.durationLabel) + + val open = TimelineNetworkRules(duration = TimelineDurationRangeDomainModel(minMs = 1_000, maxMs = null)) + val openCard = summary.toFilterPanel(all, emptyMap(), network = open).network + assertEquals(TIMELINE_DURATION_BUCKETS_MS.size, openCard.durationEnd) + assertEquals("1 s - ∞", openCard.durationLabel) + + val none = summary.toFilterPanel(all, emptyMap()).network + assertEquals(0, none.durationStart) + assertEquals(none.histogram.size, none.durationEnd) + assertEquals(null, none.durationLabel) + } + + @Test + fun `the histogram and the pending calls come from the counts`() { + val counted = summary.copy( + facetCounts = summary.facetCounts + mapOf( + TimelineFacet.NetworkSpeed to mapOf("0" to 4, "100" to 2), + TimelineFacet.NetworkState to mapOf("PENDING" to 5), + ), + ) + val card = counted.toFilterPanel(all, emptyMap()).network + + assertEquals(4, card.histogram[0]) + assertEquals(2, card.histogram[TIMELINE_DURATION_BUCKETS_MS.indexOf(100L)]) + assertEquals(5, card.pendingCount) + } + + @Test + fun `the hosts offered are the ones matching the rules`() { + val hosts = summary.copy( + facetCounts = summary.facetCounts + (TimelineFacet.NetworkHost to mapOf("ws.postman-echo.com" to 8, "ads.acme.io" to 1, "analytics.postman.io" to 2)), + ) + val rules = TimelineNetworkRules( + hostRules = listOf( + TimelineRuleDomainModel("postman", exclude = false), + TimelineRuleDomainModel("analytics", exclude = true), + ), + ) + + val card = hosts.toFilterPanel(all, emptyMap(), network = rules).network + + assertEquals(listOf("ws.postman-echo.com"), card.hosts.map { it.value }) + assertEquals(1, card.hostCount) + // all the hosts are offered without rule + assertEquals(3, hosts.toFilterPanel(all, emptyMap()).network.hostCount) + } } diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineRuleDomainModel.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineRuleDomainModel.kt index 30eaaef16..d4ff5c6ac 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineRuleDomainModel.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/timeline/models/TimelineRuleDomainModel.kt @@ -23,6 +23,9 @@ data class TimelineStatusMatcher( val from: Int, val to: Int, ) { + // "404" or "500-503", what the user typed and what the rule stores + fun label(): String = if (from == to) "$from" else "$from-$to" + companion object { /** * "404" or "500-503" (spaces allowed). Null for anything else: unknown text, reversed range. diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconFilterChip.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconFilterChip.kt new file mode 100644 index 000000000..0d6ae090a --- /dev/null +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconFilterChip.kt @@ -0,0 +1,91 @@ +package io.github.openflocon.library.designsystem.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.github.openflocon.library.designsystem.FloconTheme + +private val ChipShape = RoundedCornerShape(50) +private const val EmptyChipAlpha = 0.55f + +/** + * A pill that is ticked or not, ex: a class of http status or a type of content. The values of a filter are side by side. + * A [count] of 0 fades the chip: nothing to show for it yet, it can still be ticked. + */ +@Composable +fun FloconFilterChip( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + count: Int? = null, + monospace: Boolean = false, + leading: (@Composable () -> Unit)? = null, +) { + val colors = FloconTheme.colors + val background by animateColorAsState(if (selected) colors.bg300 else colors.bg000) + val border by animateColorAsState(if (selected) colors.lineStrong else colors.line) + Row( + modifier = modifier + .then(if (count == 0) Modifier.alpha(EmptyChipAlpha) else Modifier) + .height(FloconTheme.sizes.pageBarControlHeight) + .clip(ChipShape) + .background(background) + .border(1.dp, border, ChipShape) + .clickable(onClick = onClick) + .padding(horizontal = FloconTheme.spacing.medium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + leading?.invoke() + Text( + text = text, + style = FloconTheme.typography.bodyMedium.let { if (monospace) it.copy(fontFamily = FontFamily.Monospace) else it }, + fontWeight = FontWeight.SemiBold, + color = if (selected) colors.ink else colors.inkMuted, + maxLines = 1, + ) + if (count != null) { + Text( + text = count.toString(), + style = FloconTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = colors.inkFaint, + maxLines = 1, + ) + } + } +} + +@Preview +@Composable +private fun FloconFilterChipPreview() { + FloconTheme { + Row( + modifier = Modifier + .background(FloconTheme.colors.bg100) + .padding(FloconTheme.spacing.medium), + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + FloconFilterChip(text = "2xx", selected = true, onClick = {}, leading = { FloconDot(color = FloconTheme.colors.statusSuccess) }) + FloconFilterChip(text = "WEBSOCKET", selected = true, onClick = {}, count = 22, monospace = true) + FloconFilterChip(text = "IMAGE", selected = false, onClick = {}, count = 0, monospace = true) + } + } +} diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt new file mode 100644 index 000000000..5a6a67371 --- /dev/null +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt @@ -0,0 +1,77 @@ +package io.github.openflocon.library.designsystem.components + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.github.openflocon.library.designsystem.FloconTheme + +private val BarsHeight = 40.dp +private const val OutsideRangeAlpha = 0.18f +private const val InsideRangeAlpha = 0.55f + +/** + * One bar per bucket, as high as its value. The buckets from [start] (included) to [end] (excluded), the positions of a + * [FloconRangeSlider] of the same size, are the ones in the range: the other bars are faded. + */ +@Composable +fun FloconHistogram( + values: List, + start: Int, + end: Int, + modifier: Modifier = Modifier, +) { + val colors = FloconTheme.colors + val highest = (values.maxOrNull() ?: 0).coerceAtLeast(1) + Row( + modifier = modifier + .fillMaxWidth() + .height(BarsHeight) + // like the slider, to be aligned with its positions + .padding(horizontal = FloconRangeSliderInset), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(0.dp), + ) { + values.forEachIndexed { index, value -> + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + contentAlignment = Alignment.BottomCenter, + ) { + if (value > 0) { + Box( + Modifier + .fillMaxWidth() + .fillMaxHeight(value.toFloat() / highest) + .background(colors.accent.copy(alpha = if (index in start until end) InsideRangeAlpha else OutsideRangeAlpha)), + ) + } + } + } + } +} + +@Preview +@Composable +private fun FloconHistogramPreview() { + FloconTheme { + FloconHistogram( + values = listOf(0, 1, 4, 8, 6, 2, 3, 1, 0, 0), + start = 2, + end = 7, + modifier = Modifier + .background(FloconTheme.colors.bg100) + .padding(FloconTheme.spacing.medium), + ) + } +} diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconIncludeExcludeInput.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconIncludeExcludeInput.kt new file mode 100644 index 000000000..e077febf4 --- /dev/null +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconIncludeExcludeInput.kt @@ -0,0 +1,159 @@ +package io.github.openflocon.library.designsystem.components + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import io.github.openflocon.library.designsystem.FloconTheme + +/** + * Adds include / exclude rules: two segments to pick the side, then a text field where Enter adds the rule. + */ +@Composable +fun FloconIncludeExcludeInput( + includeLabel: String, + excludeLabel: String, + placeholder: String, + onSubmit: (text: String, exclude: Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = FloconTheme.colors + var text by remember { mutableStateOf("") } + var exclude by remember { mutableStateOf(false) } + fun submit() { + if (text.isNotBlank()) { + onSubmit(text.trim(), exclude) + text = "" + } + } + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .height(FloconTheme.sizes.pageBarControlHeight) + .clip(FloconTheme.shapes.medium) + .border(1.dp, colors.line, FloconTheme.shapes.medium) + .padding(FloconTheme.spacing.tiny), + ) { + Segment(label = includeLabel, sign = "+", signColor = colors.statusSuccess, selected = !exclude, onClick = { exclude = false }) + Segment(label = excludeLabel, sign = "−", signColor = colors.statusError, selected = exclude, onClick = { exclude = true }) + } + Box( + modifier = Modifier + .weight(1f) + .height(FloconTheme.sizes.pageBarControlHeight) + .clip(FloconTheme.shapes.medium) + .border(1.dp, colors.line, FloconTheme.shapes.medium) + .padding(horizontal = FloconTheme.spacing.medium), + contentAlignment = Alignment.CenterStart, + ) { + if (text.isEmpty()) { + Text( + text = placeholder, + style = FloconTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = colors.inkFaint, + maxLines = 1, + ) + } + BasicTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + textStyle = FloconTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace, color = colors.ink), + cursorBrush = SolidColor(colors.accent), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { submit() }), + modifier = Modifier + .fillMaxWidth() + .onPreviewKeyEvent { + if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + submit() + true + } else { + false + } + }, + ) + } + } +} + +@Composable +private fun Segment( + label: String, + sign: String, + signColor: Color, + selected: Boolean, + onClick: () -> Unit, +) { + val colors = FloconTheme.colors + Row( + modifier = Modifier + .fillMaxHeight() + .clip(FloconTheme.shapes.small) + .then(if (selected) Modifier.background(colors.bg300) else Modifier) + .clickable(onClick = onClick) + .padding(horizontal = FloconTheme.spacing.medium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + Text(text = sign, style = FloconTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = signColor) + Text( + text = label, + style = FloconTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (selected) colors.ink else colors.inkMuted, + maxLines = 1, + ) + } +} + +@Preview +@Composable +private fun FloconIncludeExcludeInputPreview() { + FloconTheme { + FloconIncludeExcludeInput( + includeLabel = "Include", + excludeLabel = "Exclude", + placeholder = "Code or range: 404, 500-503", + onSubmit = { _, _ -> }, + modifier = Modifier + .background(FloconTheme.colors.bg100) + .padding(FloconTheme.spacing.medium), + ) + } +} diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconRangeSlider.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconRangeSlider.kt new file mode 100644 index 000000000..a8d6591da --- /dev/null +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconRangeSlider.kt @@ -0,0 +1,146 @@ +package io.github.openflocon.library.designsystem.components + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import io.github.openflocon.library.designsystem.FloconTheme +import kotlin.math.abs +import kotlin.math.roundToInt + +private val ThumbSize = 16.dp +private val TrackHeight = 4.dp + +/** + * The width kept on each side of the track for the thumbs: what a chart above or below the slider + * ([FloconHistogram]) has to keep too, to be aligned with the positions of the slider. + */ +val FloconRangeSliderInset = ThumbSize / 2 + +/** + * Two thumbs on the positions 0..[max]: the range goes from [start] to [end]. A position is a step, not a value, + * the caller knows what it stands for. + */ +@Composable +fun FloconRangeSlider( + start: Int, + end: Int, + max: Int, + onRangeChange: (start: Int, end: Int) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = FloconTheme.colors + val density = LocalDensity.current + var widthPx by remember { mutableStateOf(0) } + val insetPx = with(density) { FloconRangeSliderInset.toPx() } + val trackPx = (widthPx - insetPx * 2).coerceAtLeast(1f) + val currentStart by rememberUpdatedState(start) + val currentEnd by rememberUpdatedState(end) + val currentOnChange by rememberUpdatedState(onRangeChange) + var dragging by remember { mutableStateOf(null) } // true: the start thumb + + fun positionAt(x: Float): Int = (((x - insetPx) / trackPx) * max).roundToInt().coerceIn(0, max) + fun xOf(position: Int): Float = insetPx + trackPx * position / max.coerceAtLeast(1) + + Box( + modifier = modifier + .fillMaxWidth() + .height(ThumbSize) + .onSizeChanged { widthPx = it.width } + .pointerHoverIcon(PointerIcon.Hand) + .pointerInput(max) { + detectDragGestures( + onDragStart = { offset -> + // the closest thumb moves, the end one when both are on the same position and the click is after it + val toStart = abs(offset.x - xOf(currentStart)) + val toEnd = abs(offset.x - xOf(currentEnd)) + dragging = toStart < toEnd || (toStart == toEnd && offset.x < xOf(currentStart)) + }, + onDragEnd = { dragging = null }, + onDragCancel = { dragging = null }, + ) { change, _ -> + val position = positionAt(change.position.x) + when (dragging) { + true -> currentOnChange(position.coerceAtMost(currentEnd), currentEnd) + false -> currentOnChange(currentStart, position.coerceAtLeast(currentStart)) + null -> Unit + } + } + }, + contentAlignment = Alignment.CenterStart, + ) { + Box( + Modifier + .padding(horizontal = FloconRangeSliderInset) + .fillMaxWidth() + .height(TrackHeight) + .background(colors.bg300, RoundedCornerShape(50)), + ) + val startDp = with(density) { xOf(start).toDp() } + val endDp = with(density) { xOf(end).toDp() } + Box( + Modifier + .offset(x = startDp) + .width((endDp - startDp).coerceAtLeast(0.dp)) + .height(TrackHeight) + .background(colors.accent), + ) + Thumb(offsetPx = xOf(start) - with(density) { ThumbSize.toPx() } / 2) + Thumb(offsetPx = xOf(end) - with(density) { ThumbSize.toPx() } / 2) + } +} + +@Composable +private fun Thumb(offsetPx: Float) { + Box( + Modifier + .offset { IntOffset(offsetPx.roundToInt(), 0) } + .size(ThumbSize) + .background(FloconTheme.colors.accent, CircleShape), + ) +} + +@Preview +@Composable +private fun FloconRangeSliderPreview() { + FloconTheme { + var start by remember { mutableStateOf(2) } + var end by remember { mutableStateOf(7) } + FloconRangeSlider( + start = start, + end = end, + max = 10, + onRangeChange = { s, e -> + start = s + end = e + }, + modifier = Modifier + .background(FloconTheme.colors.bg100) + .padding(FloconTheme.spacing.medium), + ) + } +} diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconRuleChip.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconRuleChip.kt new file mode 100644 index 000000000..fc4aaeeaf --- /dev/null +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconRuleChip.kt @@ -0,0 +1,84 @@ +package io.github.openflocon.library.designsystem.components + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.github.openflocon.library.designsystem.FloconTheme + +private val RemoveIconSize = 14.dp + +/** + * A rule typed by the user, that keeps ([exclude] false, green +) or hides ([exclude] true, red −) what matches [text]. + */ +@Composable +fun FloconRuleChip( + text: String, + exclude: Boolean, + onRemove: () -> Unit, + removeContentDescription: String, + modifier: Modifier = Modifier, +) { + val colors = FloconTheme.colors + Row( + modifier = modifier + .clip(FloconTheme.shapes.medium) + .background(colors.bg300) + .border(1.dp, colors.line, FloconTheme.shapes.medium) + .padding(horizontal = FloconTheme.spacing.medium, vertical = FloconTheme.spacing.small), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + Text( + text = if (exclude) "−" else "+", + style = FloconTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = if (exclude) colors.statusError else colors.statusSuccess, + ) + Text( + text = text, + style = FloconTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = colors.ink, + maxLines = 1, + ) + FloconIcon( + imageVector = Icons.Outlined.Close, + modifier = Modifier + .size(RemoveIconSize) + .semantics { contentDescription = removeContentDescription } + .clickable(onClick = onRemove), + ) + } +} + +@Preview +@Composable +private fun FloconRuleChipPreview() { + FloconTheme { + Row( + modifier = Modifier + .background(FloconTheme.colors.bg100) + .padding(FloconTheme.spacing.medium), + horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small), + ) { + FloconRuleChip(text = "101", exclude = false, onRemove = {}, removeContentDescription = "Remove") + FloconRuleChip(text = "500-503", exclude = true, onRemove = {}, removeContentDescription = "Remove") + } + } +} From fd6fbd32c9546a96ff433cd62bc36b2b68f3a128 Mon Sep 17 00:00:00 2001 From: doTTTTT Date: Sat, 31 Oct 2026 00:41:06 +0100 Subject: [PATCH 3/4] refact: [TIMELINE] Full width host dropdown, clip the duration bars out of the range --- .../features/timeline/view/NetworkFilterCard.kt | 8 +++++++- .../designsystem/components/FloconHistogram.kt | 13 +++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/NetworkFilterCard.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/NetworkFilterCard.kt index b75ea7c78..a3623bafd 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/NetworkFilterCard.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/timeline/view/NetworkFilterCard.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -32,6 +33,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -407,6 +410,7 @@ private fun HostSection( ) { val colors = FloconTheme.colors var open by remember { mutableStateOf(false) } + var selectorWidth by remember { mutableIntStateOf(0) } val summary = summaryText( shown = network.hosts.count { it.selected }, total = network.hosts.size, @@ -423,6 +427,7 @@ private fun HostSection( Row( modifier = Modifier .fillMaxWidth() + .onSizeChanged { selectorWidth = it.width } .height(FloconTheme.sizes.pageBarControlHeight) .clip(FloconTheme.shapes.medium) .border( @@ -452,7 +457,8 @@ private fun HostSection( FloconDropdownMenu( expanded = open, onDismissRequest = { open = false }, - modifier = Modifier.width(FloconTheme.sizes.sidePanelWidth), + // as wide as the selector + modifier = Modifier.width(with(LocalDensity.current) { selectorWidth.toDp() }), ) { HostMenu(network = network, onAction = onAction) } diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt index 5a6a67371..b67d279ed 100644 --- a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt @@ -16,12 +16,12 @@ import androidx.compose.ui.unit.dp import io.github.openflocon.library.designsystem.FloconTheme private val BarsHeight = 40.dp -private const val OutsideRangeAlpha = 0.18f -private const val InsideRangeAlpha = 0.55f +private const val BarAlpha = 0.55f /** - * One bar per bucket, as high as its value. The buckets from [start] (included) to [end] (excluded), the positions of a - * [FloconRangeSlider] of the same size, are the ones in the range: the other bars are faded. + * One bar per bucket, as high as its value. Only the buckets from [start] (included) to [end] (excluded), the positions + * of a [FloconRangeSlider] of the same size, are drawn: the bars out of the range are clipped. + * The bars keep the height of the highest bucket of all, so they don't grow when the range gets smaller. */ @Composable fun FloconHistogram( @@ -48,12 +48,13 @@ fun FloconHistogram( .fillMaxHeight(), contentAlignment = Alignment.BottomCenter, ) { - if (value > 0) { + // the bars out of the range are clipped, not faded + if (value > 0 && index in start until end) { Box( Modifier .fillMaxWidth() .fillMaxHeight(value.toFloat() / highest) - .background(colors.accent.copy(alpha = if (index in start until end) InsideRangeAlpha else OutsideRangeAlpha)), + .background(colors.accent.copy(alpha = BarAlpha)), ) } } From 4369d2b8d7169409259d4ac63d76e8adee61dc42 Mon Sep 17 00:00:00 2001 From: doTTTTT Date: Sat, 31 Oct 2026 00:44:08 +0100 Subject: [PATCH 4/4] refact: [TIMELINE] Fade the duration bars out of the range --- .../designsystem/components/FloconHistogram.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt index b67d279ed..122c18cbf 100644 --- a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconHistogram.kt @@ -16,11 +16,11 @@ import androidx.compose.ui.unit.dp import io.github.openflocon.library.designsystem.FloconTheme private val BarsHeight = 40.dp -private const val BarAlpha = 0.55f +private const val OutsideRangeAlpha = 0.5f /** - * One bar per bucket, as high as its value. Only the buckets from [start] (included) to [end] (excluded), the positions - * of a [FloconRangeSlider] of the same size, are drawn: the bars out of the range are clipped. + * One bar per bucket, as high as its value. The buckets from [start] (included) to [end] (excluded), the positions + * of a [FloconRangeSlider] of the same size, are the ones in the range: the other bars are drawn at half the opacity. * The bars keep the height of the highest bucket of all, so they don't grow when the range gets smaller. */ @Composable @@ -48,13 +48,12 @@ fun FloconHistogram( .fillMaxHeight(), contentAlignment = Alignment.BottomCenter, ) { - // the bars out of the range are clipped, not faded - if (value > 0 && index in start until end) { + if (value > 0) { Box( Modifier .fillMaxWidth() .fillMaxHeight(value.toFloat() / highest) - .background(colors.accent.copy(alpha = BarAlpha)), + .background(colors.accent.copy(alpha = if (index in start until end) 1f else OutsideRangeAlpha)), ) } }