From 52a02af60b91958f94a092d9757b2c717a412649 Mon Sep 17 00:00:00 2001 From: Dave <62414029+yimjr@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:42:15 +0800 Subject: [PATCH 1/4] Refactor timeline filters with draft apply controls --- src/views/Timeline.vue | 672 ++++++++++++++++++++++++++++++++----- test/unit/Timeline.test.js | 280 ++++++++++++++++ 2 files changed, 863 insertions(+), 89 deletions(-) create mode 100644 test/unit/Timeline.test.js diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index 06cb4a842..b72b97622 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -7,66 +7,100 @@ div // Toolbar: filters (primary), display kebab (swimlanes etc.), event count, // and keyboard hint. Flex-wrap so it doesn't overlap at narrow widths. div.timeline-toolbar.d-flex.flex-wrap.align-items-center - details.timeline-filters.mr-2(ref="filtersDetails") + details.timeline-filters.mr-2(ref="filtersDetails", @toggle="onFiltersToggle") summary.timeline-chip.timeline-chip--clickable icon.mr-1(name="filter") b Filters: {{ filter_summary }} div.timeline-filters-panel.shadow-sm + div.timeline-filter-actions + button.btn.btn-outline-secondary.btn-sm.timeline-filter-reset(type="button", @click.stop.prevent="resetFilterChanges") Reset + button.btn.btn-primary.btn-sm(type="button", @click.stop.prevent="applyFilterChanges") Confirm + button.btn.btn-outline-secondary.btn-sm(type="button", @click.stop.prevent="cancelFilterChanges") Cancel table tr - th.pt-2.pr-3 - label(for="timeline-filter-host") Host: + th.pr-3 + label(for="timeline-filter-duration") Duration: td - select#timeline-filter-host.form-control.form-control-sm(v-model="filter_hostname") - option(:value='null') All - option(v-for="host in hosts", :value="host") {{ host }} + div.timeline-duration-control + div.timeline-duration-inputs + div.timeline-duration-field + input#timeline-filter-duration-min.form-control.form-control-sm( + type="number" + min="0" + step="any" + v-model.number="pending_filter_duration_min" + placeholder="Min" + aria-label="Minimum duration" + @input="clearDurationRangeError" + ) + select.form-control.form-control-sm( + v-model="pending_filter_duration_min_unit" + aria-label="Minimum duration unit" + @change="clearDurationRangeError" + ) + option(v-for="unit in durationUnitOptions", :key="unit.value", :value="unit.value") {{ unit.text }} + span.timeline-duration-separator – + div.timeline-duration-field + input#timeline-filter-duration-max.form-control.form-control-sm( + type="number" + min="0" + step="any" + v-model.number="pending_filter_duration_max" + placeholder="Max" + aria-label="Maximum duration" + @input="clearDurationRangeError" + ) + select.form-control.form-control-sm( + v-model="pending_filter_duration_max_unit" + aria-label="Maximum duration unit" + @change="clearDurationRangeError" + ) + option(v-for="unit in durationUnitOptions", :key="unit.value", :value="unit.value") {{ unit.text }} + small.timeline-duration-error.text-danger(v-if="duration_range_error_visible && duration_range_invalid") + | Minimum duration cannot exceed maximum duration. tr - th.pt-2.pr-3 - label(for="timeline-filter-client") Client: + th td - select#timeline-filter-client.form-control.form-control-sm(v-model="filter_client") - option(:value='null') All - option(v-for="client in clients", :value="client") {{ client }} + div.timeline-filter-toggles + label.timeline-filter-toggle + input(type="checkbox", v-model="pending_filter_afk") + span {{ $t('timeline.filterAfk') }} + label.timeline-filter-toggle + input(type="checkbox", v-model="pending_filter_merge_similar") + span {{ $t('timeline.mergeByApp') }} tr - th.pt-2.pr-3 - label(for="timeline-filter-duration") Duration: + th.pr-3 + label Host: td - select#timeline-filter-duration.form-control.form-control-sm(v-model="filter_duration") - option(:value='null') All - option(:value='2') 2+ secs - option(:value='5') 5+ secs - option(:value='10') 10+ secs - option(:value='30') 30+ sec - option(:value='1 * 60') 1+ mins - option(:value='2 * 60') 2+ mins - option(:value='3 * 60') 3+ mins - option(:value='10 * 60') 10+ mins - option(:value='30 * 60') 30+ mins - option(:value='1 * 60 * 60') 1+ hrs - option(:value='2 * 60 * 60') 2+ hrs + div.timeline-filter-options(v-if="hosts.length > 0") + label.timeline-filter-option(:title="'ALL'") + input(type="checkbox", :checked="all_pending_hosts_selected", @change="toggleAllPendingHosts") + span.timeline-filter-option-label ALL + label.timeline-filter-option(v-for="host in hosts", :key="host", :title="host") + input(type="checkbox", v-model="pending_filter_hostnames", :value="host") + span.timeline-filter-option-label {{ host }} tr - th.pt-2.pr-3 - label AFK: + th.pr-3 + label Client: td - b-form-checkbox(v-model="filter_afk" size="sm" switch) - | {{ $t('timeline.filterAfk') }} + div.timeline-filter-options(v-if="clients.length > 0") + label.timeline-filter-option(:title="'ALL'") + input(type="checkbox", :checked="all_pending_clients_selected", @change="toggleAllPendingClients") + span.timeline-filter-option-label ALL + label.timeline-filter-option(v-for="client in clients", :key="client", :title="client") + input(type="checkbox", v-model="pending_filter_clients", :value="client") + span.timeline-filter-option-label {{ client }} tr - th.pt-2.pr-3 - label Merge: + th.pr-3 + label Categories: td - b-form-checkbox(v-model="filter_merge_similar" size="sm" switch) - | {{ $t('timeline.mergeByApp') }} - tr - th.pt-2.pr-3 - label(for="timeline-filter-categories") Categories: - td - select#timeline-filter-categories.form-control.form-control-sm(@change="onCategorySelect($event)", :value="''") - option(value="" disabled) {{ filter_categories.length > 0 ? 'Add category...' : 'All' }} - option(v-for="cat in category_options", :key="cat.text", :value="cat.text") {{ cat.text }} - div.mt-1(v-if="filter_categories.length > 0") - span.badge.badge-info.mr-1(v-for="(cat, idx) in filter_categories", :key="idx") - | {{ cat.join(' > ') }} - button.ml-1.close.small(@click="removeCategory(idx)", type="button", aria-label="Remove category", style="font-size: 0.85rem; line-height: 1") × + div.timeline-filter-options(v-if="category_options.length > 0") + label.timeline-filter-option(:title="'ALL'") + input(type="checkbox", :checked="all_pending_categories_selected", @change="toggleAllPendingCategories") + span.timeline-filter-option-label ALL + label.timeline-filter-option(v-for="cat in category_options", :key="cat.text", :title="cat.text") + input(type="checkbox", :checked="isPendingCategorySelected(cat.value)", @change="togglePendingCategory(cat.value)") + span.timeline-filter-option-label {{ cat.text }} // Display options (swimlanes, future visual toggles) tucked behind a // ghost kebab so they don't compete visually with Filters. @@ -79,7 +113,7 @@ div title="Display options" aria-label="Display options" ) - template(v-slot:button-content) + template(v-slot:button-content="slotProps") icon(name="ellipsis-v") b-dropdown-header Swimlanes b-dropdown-item-button( @@ -126,17 +160,39 @@ export default { data() { return { all_buckets: null, - hosts: null, + hosts: [], buckets: null, - clients: null, + clients: [], daterange: null, maxDuration: 31 * 24 * 60 * 60, - filter_hostname: null, - filter_client: null, - filter_duration: null, + filter_hostnames: [], + filter_clients: [], + pending_filter_hostnames: [], + pending_filter_clients: [], + host_filter_initialized: false, + client_filter_initialized: false, + filter_duration_min: null, + filter_duration_max: null, + filter_duration_min_unit: 'seconds', + filter_duration_max_unit: 'seconds', + pending_filter_duration_min: null, + pending_filter_duration_max: null, + pending_filter_duration_min_unit: 'seconds', + pending_filter_duration_max_unit: 'seconds', + duration_range_error_visible: false, + durationUnitOptions: [ + { value: 'seconds', text: 'seconds' }, + { value: 'minutes', text: 'minutes' }, + { value: 'hours', text: 'hours' }, + ], filter_afk: false, + pending_filter_afk: false, filter_merge_similar: false, + pending_filter_merge_similar: false, filter_categories: [], + pending_filter_categories: [], + category_filter_initialized: false, + buckets_refresh_scheduled: false, swimlane: null, swimlaneOptions: [ { value: null, text: 'None' }, @@ -160,16 +216,77 @@ export default { const categoryStore = useCategoryStore(); return categoryStore.allCategoriesSelect; }, + all_hosts_selected() { + return ( + this.hosts.length > 0 && + this.filter_hostnames.length === this.hosts.length && + this.hosts.every(host => this.filter_hostnames.includes(host)) + ); + }, + all_clients_selected() { + return ( + this.clients.length > 0 && + this.filter_clients.length === this.clients.length && + this.clients.every(client => this.filter_clients.includes(client)) + ); + }, + all_categories_selected() { + return ( + this.category_options.length > 0 && + this.filter_categories.length === this.category_options.length && + this.category_options.every(category => this.isCategorySelected(category.value)) + ); + }, + all_pending_hosts_selected() { + return ( + this.hosts.length > 0 && + this.pending_filter_hostnames.length === this.hosts.length && + this.hosts.every(host => this.pending_filter_hostnames.includes(host)) + ); + }, + all_pending_clients_selected() { + return ( + this.clients.length > 0 && + this.pending_filter_clients.length === this.clients.length && + this.clients.every(client => this.pending_filter_clients.includes(client)) + ); + }, + all_pending_categories_selected() { + return ( + this.category_options.length > 0 && + this.pending_filter_categories.length === this.category_options.length && + this.category_options.every(category => this.isPendingCategorySelected(category.value)) + ); + }, + duration_range_invalid() { + const min = this.normalizeDuration( + this.pending_filter_duration_min, + this.pending_filter_duration_min_unit + ); + const max = this.normalizeDuration( + this.pending_filter_duration_max, + this.pending_filter_duration_max_unit + ); + return min !== null && max !== null && min > max; + }, filter_summary() { const desc = []; - if (this.filter_hostname) { - desc.push(this.filter_hostname); + if (this.filter_hostnames.length > 0 && !this.all_hosts_selected) { + desc.push( + this.filter_hostnames.length > 1 + ? `${this.filter_hostnames.length} Hosts` + : this.filter_hostnames[0] + ); } - if (this.filter_client) { - desc.push(this.filter_client); + if (this.filter_clients.length > 0 && !this.all_clients_selected) { + desc.push( + this.filter_clients.length > 1 + ? `${this.filter_clients.length} Clients` + : this.filter_clients[0] + ); } - if (this.filter_duration > 0) { - desc.push(seconds_to_duration(this.filter_duration)); + if (this.duration_filter_summary) { + desc.push(this.duration_filter_summary); } if (this.filter_afk) { desc.push('AFK filtered'); @@ -177,11 +294,11 @@ export default { if (this.filter_merge_similar) { desc.push('merged by app'); } - if (this.filter_categories.length > 0) { + if (this.filter_categories.length > 0 && !this.all_categories_selected) { desc.push( - this.filter_categories.length + - ' categor' + - (this.filter_categories.length === 1 ? 'y' : 'ies') + this.filter_categories.length > 1 + ? `${this.filter_categories.length} Categories` + : '1 category' ); } @@ -190,53 +307,222 @@ export default { } return 'none'; }, + duration_filter_summary() { + const min = this.filter_duration_min; + const max = this.filter_duration_max; + if (min !== null && max !== null) { + return `${seconds_to_duration(min)} - ${seconds_to_duration(max)}`; + } + if (min !== null) { + return `>= ${seconds_to_duration(min)}`; + } + if (max !== null) { + return `<= ${seconds_to_duration(max)}`; + } + return null; + }, }, watch: { daterange() { this.updateTimelineWindow = true; this.getBuckets(); }, - filter_hostname() { + filter_hostnames() { this.updateTimelineWindow = false; - this.getBuckets(); + this.scheduleBucketsRefresh(); }, - filter_client() { + filter_clients() { this.updateTimelineWindow = false; - this.getBuckets(); + this.scheduleBucketsRefresh(); }, - filter_duration() { + filter_duration_min() { this.updateTimelineWindow = false; - this.getBuckets(); + this.scheduleBucketsRefresh(); + }, + filter_duration_max() { + this.updateTimelineWindow = false; + this.scheduleBucketsRefresh(); }, filter_afk() { this.updateTimelineWindow = false; - this.getBuckets(); + this.scheduleBucketsRefresh(); }, filter_merge_similar() { this.updateTimelineWindow = false; - this.getBuckets(); + this.scheduleBucketsRefresh(); }, filter_categories() { this.updateTimelineWindow = false; - this.getBuckets(); + this.scheduleBucketsRefresh(); + }, + category_options: { + immediate: true, + handler(options, previousOptions) { + if (options.length === 0) return; + + const previousAllSelected = + this.category_filter_initialized && + previousOptions && + previousOptions.length > 0 && + this.filter_categories.length === previousOptions.length && + previousOptions.every(category => this.isCategorySelected(category.value)); + + if (!this.category_filter_initialized || previousAllSelected) { + this.filter_categories = options.map(category => category.value); + this.category_filter_initialized = true; + return; + } + + this.filter_categories = this.filter_categories.filter(selectedCategory => + options.some(category => _.isEqual(category.value, selectedCategory)) + ); + }, }, swimlane() { this.updateTimelineWindow = false; - this.getBuckets(); + this.scheduleBucketsRefresh(); }, }, + mounted() { + const categoryStore = useCategoryStore(); + // Timeline can be opened directly, before another view has initialized + // the category store. Avoid reloading it when it already has state, since + // that could overwrite in-memory category edits from another view. + if (categoryStore.category_sets.length === 0 && categoryStore.classes.length === 0) { + categoryStore.load(); + } + }, methods: { - onCategorySelect(event) { - const text = event.target.value; - if (!text) return; - const cat = this.category_options.find(c => c.text === text); - if (cat && !this.filter_categories.some(fc => _.isEqual(fc, cat.value))) { - this.filter_categories = [...this.filter_categories, cat.value]; + onFiltersToggle(event) { + if (event.target.open) { + this.syncFilterDrafts(); + } + }, + syncFilterDrafts() { + this.duration_range_error_visible = false; + this.pending_filter_hostnames = [...this.filter_hostnames]; + this.pending_filter_clients = [...this.filter_clients]; + this.pending_filter_duration_min_unit = this.filter_duration_min_unit; + this.pending_filter_duration_max_unit = this.filter_duration_max_unit; + this.pending_filter_duration_min = this.durationInUnit( + this.filter_duration_min, + this.filter_duration_min_unit + ); + this.pending_filter_duration_max = this.durationInUnit( + this.filter_duration_max, + this.filter_duration_max_unit + ); + this.pending_filter_afk = this.filter_afk; + this.pending_filter_merge_similar = this.filter_merge_similar; + this.pending_filter_categories = this.filter_categories.map(category => [...category]); + }, + applyFilterChanges() { + this.duration_range_error_visible = false; + if (this.duration_range_invalid) { + this.duration_range_error_visible = true; + return; + } + + if (!_.isEqual(this.filter_hostnames, this.pending_filter_hostnames)) { + this.filter_hostnames = [...this.pending_filter_hostnames]; + } + if (!_.isEqual(this.filter_clients, this.pending_filter_clients)) { + this.filter_clients = [...this.pending_filter_clients]; + } + const pendingDurationMin = this.normalizeDuration( + this.pending_filter_duration_min, + this.pending_filter_duration_min_unit + ); + const pendingDurationMax = this.normalizeDuration( + this.pending_filter_duration_max, + this.pending_filter_duration_max_unit + ); + if (this.filter_duration_min !== pendingDurationMin) { + this.filter_duration_min = pendingDurationMin; + } + if (this.filter_duration_max !== pendingDurationMax) { + this.filter_duration_max = pendingDurationMax; } - event.target.value = ''; + this.filter_duration_min_unit = this.pending_filter_duration_min_unit; + this.filter_duration_max_unit = this.pending_filter_duration_max_unit; + if (this.filter_afk !== this.pending_filter_afk) { + this.filter_afk = this.pending_filter_afk; + } + if (this.filter_merge_similar !== this.pending_filter_merge_similar) { + this.filter_merge_similar = this.pending_filter_merge_similar; + } + if (!_.isEqual(this.filter_categories, this.pending_filter_categories)) { + this.filter_categories = this.pending_filter_categories.map(category => [...category]); + } + this.$refs.filtersDetails.open = false; + }, + resetFilterChanges() { + this.pending_filter_hostnames = [...this.hosts]; + this.pending_filter_clients = [...this.clients]; + this.pending_filter_duration_min = null; + this.pending_filter_duration_max = null; + this.pending_filter_duration_min_unit = 'seconds'; + this.pending_filter_duration_max_unit = 'seconds'; + this.pending_filter_afk = false; + this.pending_filter_merge_similar = false; + this.pending_filter_categories = this.category_options.map(category => [...category.value]); + this.duration_range_error_visible = false; + }, + cancelFilterChanges() { + this.syncFilterDrafts(); + this.$refs.filtersDetails.open = false; + }, + clearDurationRangeError() { + this.duration_range_error_visible = false; + }, + scheduleBucketsRefresh() { + if (this.buckets_refresh_scheduled) return; + + this.buckets_refresh_scheduled = true; + this.$nextTick(() => { + this.buckets_refresh_scheduled = false; + this.getBuckets(); + }); }, - removeCategory(idx) { - this.filter_categories = this.filter_categories.filter((_cat, i) => i !== idx); + durationUnitFactor(unit) { + return { seconds: 1, minutes: 60, hours: 60 * 60 }[unit] || 1; + }, + durationInUnit(seconds, unit) { + if (seconds === null || seconds === undefined) return null; + return seconds / this.durationUnitFactor(unit); + }, + normalizeDuration(value, unit) { + if (value === '' || value === null || value === undefined) return null; + const duration = Number(value) * this.durationUnitFactor(unit); + return Number.isFinite(duration) && duration >= 0 ? duration : null; + }, + toggleAllPendingHosts() { + this.pending_filter_hostnames = this.all_pending_hosts_selected ? [] : [...this.hosts]; + }, + toggleAllPendingClients() { + this.pending_filter_clients = this.all_pending_clients_selected ? [] : [...this.clients]; + }, + isCategorySelected(category) { + return this.filter_categories.some(filterCategory => _.isEqual(filterCategory, category)); + }, + isPendingCategorySelected(category) { + return this.pending_filter_categories.some(filterCategory => + _.isEqual(filterCategory, category) + ); + }, + toggleAllPendingCategories() { + this.pending_filter_categories = this.all_pending_categories_selected + ? [] + : this.category_options.map(category => category.value); + }, + togglePendingCategory(category) { + if (this.isPendingCategorySelected(category)) { + this.pending_filter_categories = this.pending_filter_categories.filter( + filterCategory => !_.isEqual(filterCategory, category) + ); + } else { + this.pending_filter_categories = [...this.pending_filter_categories, category]; + } }, getBuckets: async function () { if (this.daterange == null) return; @@ -248,6 +534,17 @@ export default { }) ); + const previousAllHostsSelected = + this.host_filter_initialized && + this.hosts.length > 0 && + this.filter_hostnames.length === this.hosts.length && + this.hosts.every(host => this.filter_hostnames.includes(host)); + const previousAllClientsSelected = + this.client_filter_initialized && + this.clients.length > 0 && + this.filter_clients.length === this.clients.length && + this.clients.every(client => this.filter_clients.includes(client)); + this.hosts = this.all_buckets .map(a => a.hostname) .filter((value, index, array) => array.indexOf(value) === index); @@ -255,24 +552,83 @@ export default { .map(a => a.client) .filter((value, index, array) => array.indexOf(value) === index); + if (this.hosts.length > 0) { + if (!this.host_filter_initialized || previousAllHostsSelected) { + const nextHostnames = [...this.hosts]; + if (!_.isEqual(this.filter_hostnames, nextHostnames)) { + this.filter_hostnames = nextHostnames; + } + } else { + const nextHostnames = this.filter_hostnames.filter(host => this.hosts.includes(host)); + if (!_.isEqual(this.filter_hostnames, nextHostnames)) { + this.filter_hostnames = nextHostnames; + } + } + this.host_filter_initialized = true; + } else { + if (this.filter_hostnames.length > 0) { + this.filter_hostnames = []; + } + } + + if (this.clients.length > 0) { + if (!this.client_filter_initialized || previousAllClientsSelected) { + const nextClients = [...this.clients]; + if (!_.isEqual(this.filter_clients, nextClients)) { + this.filter_clients = nextClients; + } + } else { + const nextClients = this.filter_clients.filter(client => this.clients.includes(client)); + if (!_.isEqual(this.filter_clients, nextClients)) { + this.filter_clients = nextClients; + } + } + this.client_filter_initialized = true; + } else { + if (this.filter_clients.length > 0) { + this.filter_clients = []; + } + } + let buckets = this.all_buckets; - if (this.filter_hostname) { - buckets = _.filter(buckets, b => b.hostname == this.filter_hostname); + if (!this.all_hosts_selected) { + buckets = _.filter(buckets, b => this.filter_hostnames.includes(b.hostname)); + } + if (!this.all_clients_selected) { + buckets = _.filter(buckets, b => this.filter_clients.includes(b.client)); } - if (this.filter_client) { - buckets = _.filter(buckets, b => b.client == this.filter_client); + + // An explicitly empty category selection means no events. Return early + // so later AFK/merge processing cannot repopulate the timeline. + if ( + this.category_filter_initialized && + !this.all_categories_selected && + this.filter_categories.length === 0 + ) { + this.buckets = []; + return; } - if (this.filter_duration > 0) { + const durationMin = this.filter_duration_min; + const durationMax = this.filter_duration_max; + if (durationMin !== null || durationMax !== null) { for (const bucket of buckets) { - bucket.events = _.filter(bucket.events, e => e.duration >= this.filter_duration); + bucket.events = _.filter(bucket.events, e => { + if (durationMin !== null && e.duration < durationMin) return false; + if (durationMax !== null && e.duration > durationMax) return false; + return true; + }); } } - if (this.filter_categories.length > 0) { + if (this.category_filter_initialized && !this.all_categories_selected) { const categoryStore = useCategoryStore(); const allCats = categoryStore.classes; for (const bucket of buckets) { + if (this.filter_categories.length === 0) { + bucket.events = []; + continue; + } // Skip AFK buckets — they don't have meaningful categorization if (bucket.type === 'afkstatus') continue; bucket.events = _.filter(bucket.events, e => { @@ -449,12 +805,150 @@ export default { background: #fff; border: 1px solid #dee2e6; border-radius: 0.375rem; - padding: 0.75rem 1rem; + padding: 0.75rem 1rem 1rem; z-index: 100; - min-width: 320px; + width: min(36rem, calc(100vw - 2rem)); + max-width: calc(100vw - 2rem); + height: 44rem; + box-sizing: border-box; + overflow: hidden; } .timeline-filters[open] .timeline-filters-panel { display: block; } + +.timeline-filter-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #e9ecef; +} + +.timeline-filter-reset { + margin-right: 1rem; +} + +.timeline-duration-inputs { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.timeline-duration-control { + min-width: 0; +} + +.timeline-duration-field { + display: flex; + min-width: 0; + flex: 1 1 0; +} + +.timeline-duration-field input { + width: 0; + min-width: 0; + flex: 1 1 0; +} + +.timeline-duration-field select { + width: 6.5rem; + flex: 0 0 6.5rem; + margin-left: 0.25rem; +} + +.timeline-duration-separator { + flex: 0 0 auto; + color: #6c757d; +} + +.timeline-duration-error { + display: block; + margin-top: 0.25rem; +} + +.timeline-filter-toggles { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} + +.timeline-filter-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0; + cursor: pointer; +} + +.timeline-filter-toggle input { + margin: 0; +} + +.timeline-filters-panel table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; +} + +.timeline-filters-panel th { + width: 7rem; + vertical-align: top; +} + +.timeline-filters-panel th, +.timeline-filters-panel td { + padding-top: 0; + padding-bottom: 0.75rem; + min-width: 0; + vertical-align: top; +} + +.timeline-filters-panel tr + tr > th, +.timeline-filters-panel tr + tr > td { + padding-top: 0.75rem; + border-top: 1px solid #e9ecef; +} + +.timeline-filters-panel tr:last-child > th, +.timeline-filters-panel tr:last-child > td { + padding-bottom: 0; +} + +.timeline-filter-options { + max-height: 9rem; + width: 100%; + min-width: 0; + overflow-y: auto; + overflow-x: hidden; + box-sizing: border-box; +} + +.timeline-filters-panel tr:last-child .timeline-filter-options { + max-height: 12rem; +} + +.timeline-filter-option { + display: flex; + align-items: flex-start; + width: 100%; + min-width: 0; + margin-bottom: 0.25rem; + white-space: normal; + cursor: pointer; +} + +.timeline-filter-option input { + flex: 0 0 auto; + margin-right: 0.35rem; + margin-top: 0.2rem; +} + +.timeline-filter-option-label { + min-width: 0; + overflow-wrap: anywhere; +} diff --git a/test/unit/Timeline.test.js b/test/unit/Timeline.test.js new file mode 100644 index 000000000..699e4f3f7 --- /dev/null +++ b/test/unit/Timeline.test.js @@ -0,0 +1,280 @@ +import Timeline from '~/views/Timeline.vue'; + +const mockBucketsStore = { + getBucketsWithEvents: jest.fn(), +}; +const mockCategoryStore = { + category_sets: [], + classes: [], + load: jest.fn(), +}; + +jest.mock('~/stores/buckets', () => ({ + useBucketsStore: () => mockBucketsStore, +})); + +jest.mock('~/stores/categories', () => ({ + useCategoryStore: () => mockCategoryStore, +})); + +function makeVm(overrides = {}) { + return { + daterange: [{ format: () => 'start' }, { format: () => 'end' }], + all_buckets: null, + hosts: [], + clients: [], + buckets: null, + filter_hostnames: [], + filter_clients: [], + host_filter_initialized: true, + client_filter_initialized: true, + filter_duration_min: null, + filter_duration_max: null, + filter_afk: false, + filter_merge_similar: false, + filter_categories: [], + category_filter_initialized: false, + all_categories_selected: false, + ...overrides, + }; +} + +describe('Timeline filters', () => { + beforeEach(() => { + mockBucketsStore.getBucketsWithEvents.mockReset(); + mockCategoryStore.load.mockReset(); + mockCategoryStore.category_sets = []; + mockCategoryStore.classes = []; + }); + + test('loads categories when the store has not been initialized', () => { + Timeline.mounted.call({}); + + expect(mockCategoryStore.load).toHaveBeenCalledTimes(1); + }); + + test('does not reload an already initialized category store', () => { + mockCategoryStore.category_sets = [{ id: 'default', categories: [] }]; + + Timeline.mounted.call({}); + + expect(mockCategoryStore.load).not.toHaveBeenCalled(); + }); + + test('filters buckets by any selected host and client', async () => { + const buckets = [ + { hostname: 'host-a', client: 'client-1', events: [] }, + { hostname: 'host-b', client: 'client-2', events: [] }, + { hostname: 'host-c', client: 'client-3', events: [] }, + ]; + mockBucketsStore.getBucketsWithEvents.mockResolvedValue(buckets); + + const vm = makeVm({ + filter_hostnames: ['host-a', 'host-b'], + filter_clients: ['client-2', 'client-3'], + }); + + await Timeline.methods.getBuckets.call(vm); + + expect(vm.buckets).toEqual([buckets[1]]); + }); + + test('filters events by both minimum and maximum duration', async () => { + const buckets = [ + { + hostname: 'host-a', + client: 'client-1', + events: [{ duration: 1 }, { duration: 5 }, { duration: 10 }, { duration: 20 }], + }, + ]; + mockBucketsStore.getBucketsWithEvents.mockResolvedValue(buckets); + + const vm = makeVm({ + filter_hostnames: ['host-a'], + filter_clients: ['client-1'], + filter_duration_min: 5, + filter_duration_max: 10, + }); + + await Timeline.methods.getBuckets.call(vm); + + expect(vm.buckets[0].events.map(event => event.duration)).toEqual([5, 10]); + }); + + test('filters events with only one duration boundary', async () => { + const buckets = [ + { + hostname: 'host-a', + client: 'client-1', + events: [{ duration: 1 }, { duration: 5 }, { duration: 10 }], + }, + ]; + mockBucketsStore.getBucketsWithEvents.mockResolvedValue(buckets); + + const vm = makeVm({ + filter_hostnames: ['host-a'], + filter_clients: ['client-1'], + filter_duration_min: null, + filter_duration_max: 5, + }); + + await Timeline.methods.getBuckets.call(vm); + + expect(vm.buckets[0].events.map(event => event.duration)).toEqual([1, 5]); + }); + + test('detects an invalid duration range after unit conversion', () => { + const vm = { + pending_filter_duration_min: 2, + pending_filter_duration_min_unit: 'minutes', + pending_filter_duration_max: 30, + pending_filter_duration_max_unit: 'seconds', + normalizeDuration: Timeline.methods.normalizeDuration, + durationUnitFactor: Timeline.methods.durationUnitFactor, + }; + + expect(Timeline.computed.duration_range_invalid.call(vm)).toBe(true); + }); + + test('checks the duration range only when confirming', () => { + const vm = { + duration_range_error_visible: false, + duration_range_invalid: true, + }; + + Timeline.methods.applyFilterChanges.call(vm); + + expect(vm.duration_range_error_visible).toBe(true); + }); + + test('resets pending filters to defaults without changing applied filters', () => { + const vm = { + hosts: ['host-a', 'host-b'], + clients: ['client-a'], + category_options: [{ value: ['Work'] }, { value: ['Personal'] }], + pending_filter_hostnames: ['host-a'], + pending_filter_clients: [], + pending_filter_duration_min: 5, + pending_filter_duration_max: 10, + pending_filter_duration_min_unit: 'minutes', + pending_filter_duration_max_unit: 'hours', + pending_filter_afk: true, + pending_filter_merge_similar: true, + pending_filter_categories: [['Work']], + duration_range_error_visible: true, + }; + + Timeline.methods.resetFilterChanges.call(vm); + + expect(vm.pending_filter_hostnames).toEqual(['host-a', 'host-b']); + expect(vm.pending_filter_clients).toEqual(['client-a']); + expect(vm.pending_filter_duration_min).toBeNull(); + expect(vm.pending_filter_duration_max).toBeNull(); + expect(vm.pending_filter_duration_min_unit).toBe('seconds'); + expect(vm.pending_filter_duration_max_unit).toBe('seconds'); + expect(vm.pending_filter_afk).toBe(false); + expect(vm.pending_filter_merge_similar).toBe(false); + expect(vm.pending_filter_categories).toEqual([['Work'], ['Personal']]); + expect(vm.duration_range_error_visible).toBe(false); + }); + + test('shows no buckets when host and client selections are empty', async () => { + const buckets = [ + { hostname: 'host-a', client: 'client-1', events: [] }, + { hostname: 'host-b', client: 'client-2', events: [] }, + ]; + mockBucketsStore.getBucketsWithEvents.mockResolvedValue(buckets); + + const vm = makeVm(); + + await Timeline.methods.getBuckets.call(vm); + + expect(vm.buckets).toEqual([]); + }); + + test('shows no buckets when all categories are unchecked', async () => { + const buckets = [{ hostname: 'host-a', client: 'client-1', events: [{ duration: 10 }] }]; + mockBucketsStore.getBucketsWithEvents.mockResolvedValue(buckets); + + const vm = makeVm({ + filter_hostnames: ['host-a'], + filter_clients: ['client-1'], + filter_categories: [], + category_filter_initialized: true, + all_categories_selected: false, + }); + + await Timeline.methods.getBuckets.call(vm); + + expect(vm.buckets).toEqual([]); + }); + + test('selects or clears all hosts with the ALL checkbox', () => { + const vm = { + hosts: ['host-a', 'host-b'], + pending_filter_hostnames: ['host-a'], + all_pending_hosts_selected: false, + }; + + Timeline.methods.toggleAllPendingHosts.call(vm); + expect(vm.pending_filter_hostnames).toEqual(['host-a', 'host-b']); + + vm.all_pending_hosts_selected = true; + Timeline.methods.toggleAllPendingHosts.call(vm); + expect(vm.pending_filter_hostnames).toEqual([]); + }); + + test('does not replace unchanged all host and client selections', async () => { + const buckets = [{ hostname: 'host-a', client: 'client-1', events: [] }]; + mockBucketsStore.getBucketsWithEvents.mockResolvedValue(buckets); + + const vm = makeVm({ + hosts: ['host-a'], + clients: ['client-1'], + filter_hostnames: ['host-a'], + filter_clients: ['client-1'], + host_filter_initialized: true, + client_filter_initialized: true, + all_hosts_selected: true, + all_clients_selected: true, + }); + + await Timeline.methods.getBuckets.call(vm); + const hostSelection = vm.filter_hostnames; + const clientSelection = vm.filter_clients; + await Timeline.methods.getBuckets.call(vm); + + expect(vm.filter_hostnames).toBe(hostSelection); + expect(vm.filter_clients).toBe(clientSelection); + }); + + test('summarizes multi-selection counts in the filter title', () => { + const summary = Timeline.computed.filter_summary.call({ + filter_hostnames: ['host-a', 'host-b'], + filter_clients: ['client-1', 'client-2', 'client-3'], + filter_duration_min: null, + filter_duration_max: null, + filter_afk: false, + filter_merge_similar: false, + filter_categories: [['Work'], ['Personal']], + all_hosts_selected: false, + all_clients_selected: false, + all_categories_selected: false, + }); + + expect(summary).toBe('2 Hosts, 3 Clients, 2 Categories'); + }); + + test('toggles category selections without losing other selections', () => { + const vm = { + pending_filter_categories: [['Work']], + isPendingCategorySelected: Timeline.methods.isPendingCategorySelected, + }; + + Timeline.methods.togglePendingCategory.call(vm, ['Personal']); + expect(vm.pending_filter_categories).toEqual([['Work'], ['Personal']]); + + Timeline.methods.togglePendingCategory.call(vm, ['Work']); + expect(vm.pending_filter_categories).toEqual([['Personal']]); + }); +}); From 1bbbb324e4fc3d75f7e27722fd654f9ec4e7179c Mon Sep 17 00:00:00 2001 From: Dave <62414029+yimjr@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:10:54 +0800 Subject: [PATCH 2/4] Preserve initial timeline window during filter initialization --- src/views/Timeline.vue | 41 +++++++++++++++++++++++++------------- test/unit/Timeline.test.js | 20 +++++++++++++++++++ 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index b72b97622..252711ef1 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -200,6 +200,8 @@ export default { { value: 'bucketType', text: 'Group by bucket type' }, ], updateTimelineWindow: true, + // Keep the first chart render on the queried interval while filters initialize. + is_initial_timeline_load: true, }; }, computed: { @@ -328,32 +330,25 @@ export default { this.getBuckets(); }, filter_hostnames() { - this.updateTimelineWindow = false; - this.scheduleBucketsRefresh(); + this.handleAppliedFilterChange(); }, filter_clients() { - this.updateTimelineWindow = false; - this.scheduleBucketsRefresh(); + this.handleAppliedFilterChange(); }, filter_duration_min() { - this.updateTimelineWindow = false; - this.scheduleBucketsRefresh(); + this.handleAppliedFilterChange(); }, filter_duration_max() { - this.updateTimelineWindow = false; - this.scheduleBucketsRefresh(); + this.handleAppliedFilterChange(); }, filter_afk() { - this.updateTimelineWindow = false; - this.scheduleBucketsRefresh(); + this.handleAppliedFilterChange(); }, filter_merge_similar() { - this.updateTimelineWindow = false; - this.scheduleBucketsRefresh(); + this.handleAppliedFilterChange(); }, filter_categories() { - this.updateTimelineWindow = false; - this.scheduleBucketsRefresh(); + this.handleAppliedFilterChange(); }, category_options: { immediate: true, @@ -475,6 +470,13 @@ export default { clearDurationRangeError() { this.duration_range_error_visible = false; }, + handleAppliedFilterChange() { + // Initial filter population must not replace the queried time window. + if (this.is_initial_timeline_load) return; + + this.updateTimelineWindow = false; + this.scheduleBucketsRefresh(); + }, scheduleBucketsRefresh() { if (this.buckets_refresh_scheduled) return; @@ -527,6 +529,15 @@ export default { getBuckets: async function () { if (this.daterange == null) return; + const completeInitialTimelineLoad = () => { + if (this.is_initial_timeline_load) { + // Re-enable filter-driven refreshes after the first chart update. + this.$nextTick(() => { + this.is_initial_timeline_load = false; + }); + } + }; + this.all_buckets = Object.freeze( await useBucketsStore().getBucketsWithEvents({ start: this.daterange[0].format(), @@ -606,6 +617,7 @@ export default { this.filter_categories.length === 0 ) { this.buckets = []; + completeInitialTimelineLoad(); return; } @@ -659,6 +671,7 @@ export default { } this.buckets = buckets; + completeInitialTimelineLoad(); }, // Merges adjacent events with the same app name within window buckets. diff --git a/test/unit/Timeline.test.js b/test/unit/Timeline.test.js index 699e4f3f7..93b0d6571 100644 --- a/test/unit/Timeline.test.js +++ b/test/unit/Timeline.test.js @@ -178,6 +178,26 @@ describe('Timeline filters', () => { expect(vm.duration_range_error_visible).toBe(false); }); + test('keeps the initial timeline window while filters are initialized', () => { + const scheduleBucketsRefresh = jest.fn(); + const vm = { + is_initial_timeline_load: true, + updateTimelineWindow: true, + scheduleBucketsRefresh, + }; + + Timeline.methods.handleAppliedFilterChange.call(vm); + + expect(vm.updateTimelineWindow).toBe(true); + expect(scheduleBucketsRefresh).not.toHaveBeenCalled(); + + vm.is_initial_timeline_load = false; + Timeline.methods.handleAppliedFilterChange.call(vm); + + expect(vm.updateTimelineWindow).toBe(false); + expect(scheduleBucketsRefresh).toHaveBeenCalledTimes(1); + }); + test('shows no buckets when host and client selections are empty', async () => { const buckets = [ { hostname: 'host-a', client: 'client-1', events: [] }, From c04a4784208f347293e0d1314c94d1f6a03fcf39 Mon Sep 17 00:00:00 2001 From: Dave <62414029+yimjr@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:26:57 +0800 Subject: [PATCH 3/4] Localize timeline filters across supported languages --- src/i18n/index.ts | 15 +++++++ src/i18n/locales/de.ts | 28 ++++++++++++ src/i18n/locales/en.ts | 28 ++++++++++++ src/i18n/locales/ru.ts | 28 ++++++++++++ src/i18n/locales/sv.ts | 28 ++++++++++++ src/i18n/locales/uk.ts | 28 ++++++++++++ src/i18n/locales/zh-CN.ts | 28 ++++++++++++ src/views/Timeline.vue | 87 ++++++++++++++++++++++---------------- test/unit/Timeline.test.js | 26 ++++++++++++ 9 files changed, 259 insertions(+), 37 deletions(-) diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 56be9758d..57f6b9255 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -59,6 +59,17 @@ export function getInitialLocale(): AppLocale { return detectBrowserLocale() ?? 'en'; } +// Russian and Ukrainian use three cardinal forms for the filter count summary. +function russianUkrainianPlural(choice: number, choicesLength: number): number { + const number = Math.abs(choice); + if (choicesLength === 2) return number === 1 ? 0 : 1; + if (number % 10 === 1 && number % 100 !== 11) return 0; + if (number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20)) { + return 1; + } + return 2; +} + const MOMENT_LOCALE: Record = { en: 'en', uk: 'uk', @@ -74,6 +85,10 @@ export const i18n = new VueI18n({ locale: initialLocale, fallbackLocale: 'en', messages: { en, uk, de, ru, 'zh-CN': zhCN, sv }, + pluralizationRules: { + ru: russianUkrainianPlural, + uk: russianUkrainianPlural, + }, silentTranslationWarn: process.env.NODE_ENV === 'production', }); diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index ee805b181..287095b2e 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -369,6 +369,34 @@ export default { noEvents: 'Keine Ereignisse entsprechen den Kriterien. Zeitleiste nicht aktualisiert.', scrollHint: 'Scrollen zum Zoomen, Wischen zum Schwenken, Pfeiltasten zur Navigation', loading: 'Laden…', + filters: { + label: 'Filter:', + reset: 'Zurücksetzen', + duration: 'Dauer:', + hosts: 'Hosts:', + clients: 'Clients:', + categories: 'Kategorien:', + all: 'Alle', + min: 'Min.', + max: 'Max.', + minimumDuration: 'Mindestdauer', + maximumDuration: 'Höchstdauer', + minimumDurationUnit: 'Einheit der Mindestdauer', + maximumDurationUnit: 'Einheit der Höchstdauer', + seconds: 'Sekunden', + minutes: 'Minuten', + hours: 'Stunden', + durationError: 'Die Mindestdauer darf die Höchstdauer nicht überschreiten.', + hostCount: '{count} Host | {count} Hosts', + clientCount: '{count} Client | {count} Clients', + categoryCount: '{count} Kategorie | {count} Kategorien', + afkSummary: 'AFK gefiltert', + mergeSummary: 'nach App zusammengeführt', + none: 'keine', + durationRange: '{min} bis {max}', + durationAtLeast: 'Mindestens {value}', + durationAtMost: 'Höchstens {value}', + }, }, stopwatch: { title: 'Stoppuhr', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 13939804e..5f2b604db 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -370,6 +370,34 @@ export default { noEvents: 'No events match selected criteria. Timeline is not updated.', scrollHint: 'Scroll to zoom, swipe/horizontal-scroll to pan, arrow keys to navigate', loading: 'Loading...', + filters: { + label: 'Filters:', + reset: 'Reset', + duration: 'Duration:', + hosts: 'Hosts:', + clients: 'Clients:', + categories: 'Categories:', + all: 'All', + min: 'Min', + max: 'Max', + minimumDuration: 'Minimum duration', + maximumDuration: 'Maximum duration', + minimumDurationUnit: 'Minimum duration unit', + maximumDurationUnit: 'Maximum duration unit', + seconds: 'seconds', + minutes: 'minutes', + hours: 'hours', + durationError: 'Minimum duration cannot exceed maximum duration.', + hostCount: '{count} host | {count} hosts', + clientCount: '{count} client | {count} clients', + categoryCount: '{count} category | {count} categories', + afkSummary: 'AFK filtered', + mergeSummary: 'merged by app', + none: 'none', + durationRange: '{min} – {max}', + durationAtLeast: 'At least {value}', + durationAtMost: 'At most {value}', + }, }, stopwatch: { title: 'Stopwatch', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index d41be916e..e3d1df9f5 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -367,6 +367,34 @@ export default { scrollHint: 'Колёсико — масштаб, свайп/горизонтальная прокрутка — панорама, стрелки — навигация', loading: 'Загрузка…', + filters: { + label: 'Фильтры:', + reset: 'Сбросить', + duration: 'Длительность:', + hosts: 'Хосты:', + clients: 'Клиенты:', + categories: 'Категории:', + all: 'Все', + min: 'Мин.', + max: 'Макс.', + minimumDuration: 'Минимальная длительность', + maximumDuration: 'Максимальная длительность', + minimumDurationUnit: 'Единица минимальной длительности', + maximumDurationUnit: 'Единица максимальной длительности', + seconds: 'секунды', + minutes: 'минуты', + hours: 'часы', + durationError: 'Минимальная длительность не может превышать максимальную.', + hostCount: '{count} хост | {count} хоста | {count} хостов', + clientCount: '{count} клиент | {count} клиента | {count} клиентов', + categoryCount: '{count} категория | {count} категории | {count} категорий', + afkSummary: 'AFK отфильтровано', + mergeSummary: 'объединено по приложению', + none: 'нет', + durationRange: 'от {min} до {max}', + durationAtLeast: 'Не менее {value}', + durationAtMost: 'Не более {value}', + }, }, stopwatch: { title: 'Секундомер', diff --git a/src/i18n/locales/sv.ts b/src/i18n/locales/sv.ts index 2a097063b..09a6efffc 100644 --- a/src/i18n/locales/sv.ts +++ b/src/i18n/locales/sv.ts @@ -375,6 +375,34 @@ export default { scrollHint: 'Rulla för att zooma, svep/rulla horisontellt för att panorera, använd piltangenterna för att navigera', loading: 'Läser in...', + filters: { + label: 'Filter:', + reset: 'Återställ', + duration: 'Varaktighet:', + hosts: 'Värdar:', + clients: 'Klienter:', + categories: 'Kategorier:', + all: 'Alla', + min: 'Min', + max: 'Max', + minimumDuration: 'Minsta varaktighet', + maximumDuration: 'Högsta varaktighet', + minimumDurationUnit: 'Enhet för minsta varaktighet', + maximumDurationUnit: 'Enhet för högsta varaktighet', + seconds: 'sekunder', + minutes: 'minuter', + hours: 'timmar', + durationError: 'Minsta varaktighet kan inte vara längre än den högsta.', + hostCount: '{count} värd | {count} värdar', + clientCount: '{count} klient | {count} klienter', + categoryCount: '{count} kategori | {count} kategorier', + afkSummary: 'AFK filtrerat', + mergeSummary: 'sammanslagna efter program', + none: 'inga', + durationRange: '{min} till {max}', + durationAtLeast: 'Minst {value}', + durationAtMost: 'Högst {value}', + }, }, stopwatch: { title: 'Stoppur', diff --git a/src/i18n/locales/uk.ts b/src/i18n/locales/uk.ts index 54ab30059..a9077687e 100644 --- a/src/i18n/locales/uk.ts +++ b/src/i18n/locales/uk.ts @@ -367,6 +367,34 @@ export default { scrollHint: 'Коліщатко — масштаб, свайп/горизонтальна прокрутка — панорама, стрілки — навігація', loading: 'Завантаження…', + filters: { + label: 'Фільтри:', + reset: 'Скинути', + duration: 'Тривалість:', + hosts: 'Хости:', + clients: 'Клієнти:', + categories: 'Категорії:', + all: 'Усі', + min: 'Мін.', + max: 'Макс.', + minimumDuration: 'Мінімальна тривалість', + maximumDuration: 'Максимальна тривалість', + minimumDurationUnit: 'Одиниця мінімальної тривалості', + maximumDurationUnit: 'Одиниця максимальної тривалості', + seconds: 'секунди', + minutes: 'хвилини', + hours: 'години', + durationError: 'Мінімальна тривалість не може перевищувати максимальну.', + hostCount: '{count} хост | {count} хости | {count} хостів', + clientCount: '{count} клієнт | {count} клієнти | {count} клієнтів', + categoryCount: '{count} категорія | {count} категорії | {count} категорій', + afkSummary: 'AFK відфільтровано', + mergeSummary: 'об’єднано за застосунком', + none: 'немає', + durationRange: 'від {min} до {max}', + durationAtLeast: 'Не менше {value}', + durationAtMost: 'Не більше {value}', + }, }, stopwatch: { title: 'Секундомір', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 5758bb472..2b4d61e29 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -358,6 +358,34 @@ export default { noEvents: '没有符合筛选条件的事件。未更新时间线。', scrollHint: '滚动缩放,滑动/水平滚动平移,方向键导航', loading: '加载中...', + filters: { + label: '筛选器:', + reset: '重置', + duration: '时长:', + hosts: '主机:', + clients: '客户端:', + categories: '分类:', + all: '全部', + min: '最小值', + max: '最大值', + minimumDuration: '最小时长', + maximumDuration: '最大时长', + minimumDurationUnit: '最小时长单位', + maximumDurationUnit: '最大时长单位', + seconds: '秒', + minutes: '分钟', + hours: '小时', + durationError: '最小时长不能大于最大时长。', + hostCount: '{count} 个主机', + clientCount: '{count} 个客户端', + categoryCount: '{count} 个分类', + afkSummary: '已过滤 AFK', + mergeSummary: '已按应用合并', + none: '无', + durationRange: '{min} 至 {max}', + durationAtLeast: '至少 {value}', + durationAtMost: '至多 {value}', + }, }, stopwatch: { title: '秒表', diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index 252711ef1..b0f6c7e53 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -10,16 +10,16 @@ div details.timeline-filters.mr-2(ref="filtersDetails", @toggle="onFiltersToggle") summary.timeline-chip.timeline-chip--clickable icon.mr-1(name="filter") - b Filters: {{ filter_summary }} + b {{ $t('timeline.filters.label') }} {{ filter_summary }} div.timeline-filters-panel.shadow-sm div.timeline-filter-actions - button.btn.btn-outline-secondary.btn-sm.timeline-filter-reset(type="button", @click.stop.prevent="resetFilterChanges") Reset - button.btn.btn-primary.btn-sm(type="button", @click.stop.prevent="applyFilterChanges") Confirm - button.btn.btn-outline-secondary.btn-sm(type="button", @click.stop.prevent="cancelFilterChanges") Cancel + button.btn.btn-outline-secondary.btn-sm.timeline-filter-reset(type="button", @click.stop.prevent="resetFilterChanges") {{ $t('timeline.filters.reset') }} + button.btn.btn-primary.btn-sm(type="button", @click.stop.prevent="applyFilterChanges") {{ $t('common.confirm') }} + button.btn.btn-outline-secondary.btn-sm(type="button", @click.stop.prevent="cancelFilterChanges") {{ $t('common.cancel') }} table tr th.pr-3 - label(for="timeline-filter-duration") Duration: + label(for="timeline-filter-duration") {{ $t('timeline.filters.duration') }} td div.timeline-duration-control div.timeline-duration-inputs @@ -29,13 +29,13 @@ div min="0" step="any" v-model.number="pending_filter_duration_min" - placeholder="Min" - aria-label="Minimum duration" + :placeholder="$t('timeline.filters.min')" + :aria-label="$t('timeline.filters.minimumDuration')" @input="clearDurationRangeError" ) select.form-control.form-control-sm( v-model="pending_filter_duration_min_unit" - aria-label="Minimum duration unit" + :aria-label="$t('timeline.filters.minimumDurationUnit')" @change="clearDurationRangeError" ) option(v-for="unit in durationUnitOptions", :key="unit.value", :value="unit.value") {{ unit.text }} @@ -46,18 +46,18 @@ div min="0" step="any" v-model.number="pending_filter_duration_max" - placeholder="Max" - aria-label="Maximum duration" + :placeholder="$t('timeline.filters.max')" + :aria-label="$t('timeline.filters.maximumDuration')" @input="clearDurationRangeError" ) select.form-control.form-control-sm( v-model="pending_filter_duration_max_unit" - aria-label="Maximum duration unit" + :aria-label="$t('timeline.filters.maximumDurationUnit')" @change="clearDurationRangeError" ) option(v-for="unit in durationUnitOptions", :key="unit.value", :value="unit.value") {{ unit.text }} small.timeline-duration-error.text-danger(v-if="duration_range_error_visible && duration_range_invalid") - | Minimum duration cannot exceed maximum duration. + | {{ $t('timeline.filters.durationError') }} tr th td @@ -70,34 +70,34 @@ div span {{ $t('timeline.mergeByApp') }} tr th.pr-3 - label Host: + label {{ $t('timeline.filters.hosts') }} td div.timeline-filter-options(v-if="hosts.length > 0") - label.timeline-filter-option(:title="'ALL'") + label.timeline-filter-option(:title="$t('timeline.filters.all')") input(type="checkbox", :checked="all_pending_hosts_selected", @change="toggleAllPendingHosts") - span.timeline-filter-option-label ALL + span.timeline-filter-option-label {{ $t('timeline.filters.all') }} label.timeline-filter-option(v-for="host in hosts", :key="host", :title="host") input(type="checkbox", v-model="pending_filter_hostnames", :value="host") span.timeline-filter-option-label {{ host }} tr th.pr-3 - label Client: + label {{ $t('timeline.filters.clients') }} td div.timeline-filter-options(v-if="clients.length > 0") - label.timeline-filter-option(:title="'ALL'") + label.timeline-filter-option(:title="$t('timeline.filters.all')") input(type="checkbox", :checked="all_pending_clients_selected", @change="toggleAllPendingClients") - span.timeline-filter-option-label ALL + span.timeline-filter-option-label {{ $t('timeline.filters.all') }} label.timeline-filter-option(v-for="client in clients", :key="client", :title="client") input(type="checkbox", v-model="pending_filter_clients", :value="client") span.timeline-filter-option-label {{ client }} tr th.pr-3 - label Categories: + label {{ $t('timeline.filters.categories') }} td div.timeline-filter-options(v-if="category_options.length > 0") - label.timeline-filter-option(:title="'ALL'") + label.timeline-filter-option(:title="$t('timeline.filters.all')") input(type="checkbox", :checked="all_pending_categories_selected", @change="toggleAllPendingCategories") - span.timeline-filter-option-label ALL + span.timeline-filter-option-label {{ $t('timeline.filters.all') }} label.timeline-filter-option(v-for="cat in category_options", :key="cat.text", :title="cat.text") input(type="checkbox", :checked="isPendingCategorySelected(cat.value)", @change="togglePendingCategory(cat.value)") span.timeline-filter-option-label {{ cat.text }} @@ -180,11 +180,6 @@ export default { pending_filter_duration_min_unit: 'seconds', pending_filter_duration_max_unit: 'seconds', duration_range_error_visible: false, - durationUnitOptions: [ - { value: 'seconds', text: 'seconds' }, - { value: 'minutes', text: 'minutes' }, - { value: 'hours', text: 'hours' }, - ], filter_afk: false, pending_filter_afk: false, filter_merge_similar: false, @@ -218,6 +213,13 @@ export default { const categoryStore = useCategoryStore(); return categoryStore.allCategoriesSelect; }, + durationUnitOptions() { + return [ + { value: 'seconds', text: this.$t('timeline.filters.seconds') }, + { value: 'minutes', text: this.$t('timeline.filters.minutes') }, + { value: 'hours', text: this.$t('timeline.filters.hours') }, + ]; + }, all_hosts_selected() { return ( this.hosts.length > 0 && @@ -276,14 +278,18 @@ export default { if (this.filter_hostnames.length > 0 && !this.all_hosts_selected) { desc.push( this.filter_hostnames.length > 1 - ? `${this.filter_hostnames.length} Hosts` + ? this.$tc('timeline.filters.hostCount', this.filter_hostnames.length, { + count: this.filter_hostnames.length, + }) : this.filter_hostnames[0] ); } if (this.filter_clients.length > 0 && !this.all_clients_selected) { desc.push( this.filter_clients.length > 1 - ? `${this.filter_clients.length} Clients` + ? this.$tc('timeline.filters.clientCount', this.filter_clients.length, { + count: this.filter_clients.length, + }) : this.filter_clients[0] ); } @@ -291,35 +297,42 @@ export default { desc.push(this.duration_filter_summary); } if (this.filter_afk) { - desc.push('AFK filtered'); + desc.push(this.$t('timeline.filters.afkSummary')); } if (this.filter_merge_similar) { - desc.push('merged by app'); + desc.push(this.$t('timeline.filters.mergeSummary')); } if (this.filter_categories.length > 0 && !this.all_categories_selected) { desc.push( - this.filter_categories.length > 1 - ? `${this.filter_categories.length} Categories` - : '1 category' + this.$tc('timeline.filters.categoryCount', this.filter_categories.length, { + count: this.filter_categories.length, + }) ); } if (desc.length > 0) { return desc.join(', '); } - return 'none'; + return this.$t('timeline.filters.none'); }, duration_filter_summary() { const min = this.filter_duration_min; const max = this.filter_duration_max; if (min !== null && max !== null) { - return `${seconds_to_duration(min)} - ${seconds_to_duration(max)}`; + return this.$t('timeline.filters.durationRange', { + min: seconds_to_duration(min), + max: seconds_to_duration(max), + }); } if (min !== null) { - return `>= ${seconds_to_duration(min)}`; + return this.$t('timeline.filters.durationAtLeast', { + value: seconds_to_duration(min), + }); } if (max !== null) { - return `<= ${seconds_to_duration(max)}`; + return this.$t('timeline.filters.durationAtMost', { + value: seconds_to_duration(max), + }); } return null; }, diff --git a/test/unit/Timeline.test.js b/test/unit/Timeline.test.js index 93b0d6571..bce5fc4bd 100644 --- a/test/unit/Timeline.test.js +++ b/test/unit/Timeline.test.js @@ -1,4 +1,5 @@ import Timeline from '~/views/Timeline.vue'; +import { i18n } from '~/i18n'; const mockBucketsStore = { getBucketsWithEvents: jest.fn(), @@ -280,11 +281,36 @@ describe('Timeline filters', () => { all_hosts_selected: false, all_clients_selected: false, all_categories_selected: false, + $tc: (key, count, params) => { + const labels = { + 'timeline.filters.hostCount': `${params.count} Hosts`, + 'timeline.filters.clientCount': `${params.count} Clients`, + 'timeline.filters.categoryCount': `${params.count} Categories`, + }; + return labels[key] || `${count}`; + }, }); expect(summary).toBe('2 Hosts, 3 Clients, 2 Categories'); }); + test('uses the correct plural forms for Russian and Ukrainian filter counts', () => { + const previousLocale = i18n.locale; + const expected = { + ru: ['1 хост', '2 хоста', '5 хостов'], + uk: ['1 хост', '2 хости', '5 хостів'], + }; + + Object.entries(expected).forEach(([locale, forms]) => { + i18n.locale = locale; + expect(i18n.tc('timeline.filters.hostCount', 1, { count: 1 })).toBe(forms[0]); + expect(i18n.tc('timeline.filters.hostCount', 2, { count: 2 })).toBe(forms[1]); + expect(i18n.tc('timeline.filters.hostCount', 5, { count: 5 })).toBe(forms[2]); + }); + + i18n.locale = previousLocale; + }); + test('toggles category selections without losing other selections', () => { const vm = { pending_filter_categories: [['Work']], From b1e6f92d3fea61427c2ccbf1a342e9cf025adac9 Mon Sep 17 00:00:00 2001 From: Dave <62414029+yimjr@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:28:11 +0800 Subject: [PATCH 4/4] Validate non-negative duration filters --- src/i18n/locales/de.ts | 1 + src/i18n/locales/en.ts | 1 + src/i18n/locales/ru.ts | 1 + src/i18n/locales/sv.ts | 1 + src/i18n/locales/uk.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + src/views/Timeline.vue | 11 +++++++++-- test/unit/Timeline.test.js | 29 +++++++++++++++++++++++++++++ 8 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 287095b2e..d403629f6 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -386,6 +386,7 @@ export default { seconds: 'Sekunden', minutes: 'Minuten', hours: 'Stunden', + durationNonNegativeError: 'Dauerwerte dürfen nicht negativ sein.', durationError: 'Die Mindestdauer darf die Höchstdauer nicht überschreiten.', hostCount: '{count} Host | {count} Hosts', clientCount: '{count} Client | {count} Clients', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 5f2b604db..06ea3e887 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -387,6 +387,7 @@ export default { seconds: 'seconds', minutes: 'minutes', hours: 'hours', + durationNonNegativeError: 'Duration values cannot be negative.', durationError: 'Minimum duration cannot exceed maximum duration.', hostCount: '{count} host | {count} hosts', clientCount: '{count} client | {count} clients', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index e3d1df9f5..79a4e5fa1 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -384,6 +384,7 @@ export default { seconds: 'секунды', minutes: 'минуты', hours: 'часы', + durationNonNegativeError: 'Значения длительности не могут быть отрицательными.', durationError: 'Минимальная длительность не может превышать максимальную.', hostCount: '{count} хост | {count} хоста | {count} хостов', clientCount: '{count} клиент | {count} клиента | {count} клиентов', diff --git a/src/i18n/locales/sv.ts b/src/i18n/locales/sv.ts index 09a6efffc..551d23682 100644 --- a/src/i18n/locales/sv.ts +++ b/src/i18n/locales/sv.ts @@ -392,6 +392,7 @@ export default { seconds: 'sekunder', minutes: 'minuter', hours: 'timmar', + durationNonNegativeError: 'Varaktighetsvärden får inte vara negativa.', durationError: 'Minsta varaktighet kan inte vara längre än den högsta.', hostCount: '{count} värd | {count} värdar', clientCount: '{count} klient | {count} klienter', diff --git a/src/i18n/locales/uk.ts b/src/i18n/locales/uk.ts index a9077687e..662cb549d 100644 --- a/src/i18n/locales/uk.ts +++ b/src/i18n/locales/uk.ts @@ -384,6 +384,7 @@ export default { seconds: 'секунди', minutes: 'хвилини', hours: 'години', + durationNonNegativeError: 'Значення тривалості не можуть бути від’ємними.', durationError: 'Мінімальна тривалість не може перевищувати максимальну.', hostCount: '{count} хост | {count} хости | {count} хостів', clientCount: '{count} клієнт | {count} клієнти | {count} клієнтів', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 2b4d61e29..f4e05b0a2 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -375,6 +375,7 @@ export default { seconds: '秒', minutes: '分钟', hours: '小时', + durationNonNegativeError: '时长不能为负数。', durationError: '最小时长不能大于最大时长。', hostCount: '{count} 个主机', clientCount: '{count} 个客户端', diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index b0f6c7e53..dbf856860 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -56,7 +56,9 @@ div @change="clearDurationRangeError" ) option(v-for="unit in durationUnitOptions", :key="unit.value", :value="unit.value") {{ unit.text }} - small.timeline-duration-error.text-danger(v-if="duration_range_error_visible && duration_range_invalid") + small.timeline-duration-error.text-danger(v-if="duration_range_error_visible && duration_range_has_negative") + | {{ $t('timeline.filters.durationNonNegativeError') }} + small.timeline-duration-error.text-danger(v-else-if="duration_range_error_visible && duration_range_invalid") | {{ $t('timeline.filters.durationError') }} tr th @@ -273,6 +275,11 @@ export default { ); return min !== null && max !== null && min > max; }, + duration_range_has_negative() { + return [this.pending_filter_duration_min, this.pending_filter_duration_max].some( + value => value !== null && value !== undefined && value !== '' && Number(value) < 0 + ); + }, filter_summary() { const desc = []; if (this.filter_hostnames.length > 0 && !this.all_hosts_selected) { @@ -426,7 +433,7 @@ export default { }, applyFilterChanges() { this.duration_range_error_visible = false; - if (this.duration_range_invalid) { + if (this.duration_range_has_negative || this.duration_range_invalid) { this.duration_range_error_visible = true; return; } diff --git a/test/unit/Timeline.test.js b/test/unit/Timeline.test.js index bce5fc4bd..7878d81ba 100644 --- a/test/unit/Timeline.test.js +++ b/test/unit/Timeline.test.js @@ -137,6 +137,23 @@ describe('Timeline filters', () => { expect(Timeline.computed.duration_range_invalid.call(vm)).toBe(true); }); + test('detects negative duration boundaries before normalization', () => { + const values = [ + [-1, null], + [null, -1], + [-1, -2], + ]; + + values.forEach(([min, max]) => { + const vm = { + pending_filter_duration_min: min, + pending_filter_duration_max: max, + }; + + expect(Timeline.computed.duration_range_has_negative.call(vm)).toBe(true); + }); + }); + test('checks the duration range only when confirming', () => { const vm = { duration_range_error_visible: false, @@ -148,6 +165,18 @@ describe('Timeline filters', () => { expect(vm.duration_range_error_visible).toBe(true); }); + test('checks negative duration boundaries only when confirming', () => { + const vm = { + duration_range_error_visible: false, + duration_range_has_negative: true, + duration_range_invalid: false, + }; + + Timeline.methods.applyFilterChanges.call(vm); + + expect(vm.duration_range_error_visible).toBe(true); + }); + test('resets pending filters to defaults without changing applied filters', () => { const vm = { hosts: ['host-a', 'host-b'],