(importDialogOpen = true)} />
+
+
+
+
+
+
+
(importDialogOpen = true)} onOpenFilters={openMobileFilters} />
+
+ {#if $filteredGridInstances.length === 0}
+
+
No instances found
+
Import torrents or adjust filters to see instances here.
+
+ {:else}
+
+ {/if}
+
+
+
+{#if mobileFiltersOpen}
+
+{/if}
+
+{#if mobileFiltersOpen}
+
+
+
+
+
Filters
+
+ {activeFiltersCount > 0
+ ? `${activeFiltersCount} active filter${activeFiltersCount > 1 ? 's' : ''}`
+ : 'Browse instances by facet'}
+
+
+
+ {#if activeFiltersCount > 0}
+
+ {/if}
+
+
+
+
+
+
+
+
+{/if}
+
+
+
+
+
- {#if $filteredGridInstances.length === 0}
-
-
No instances found
-
Import torrents or switch filters to see instances here.
+
- {:else}
-
- {/if}
+
({ ...f, stateFilter: state }));
- }
-
- function setTrackerFilter(tracker) {
- gridFilters.update(f => ({
- ...f,
- trackerFilter: f.trackerFilter.includes(tracker)
- ? f.trackerFilter.filter(value => value !== tracker)
- : [...f.trackerFilter, tracker],
- }));
- }
-
- function clearTrackerFilters() {
- gridFilters.update(f => ({ ...f, trackerFilter: [] }));
- }
-
- function handleTrackerSearch(event) {
- gridFilters.update(f => ({ ...f, trackerSearch: event.target.value }));
- }
-
function formatRate(rate) {
if (!rate || rate === 0) return '0 KB/s';
if (rate >= 1000) return (rate / 1024).toFixed(1) + ' MB/s';
@@ -912,15 +871,8 @@
{@const count = gridStats().stateCounts[sc.key] || 0}
{#if count > 0}
{@const StateIcon = sc.icon}
-
{/if}
-
-
-
Filter
-
- {#each quickFilters as qf (qf.key)}
-
- {/each}
-
-
-
- {#if $trackerFilterEntries.length > 0}
-
-
- Trackers
- {#if activeTrackerFilter.length > 0}
-
- {/if}
-
-
-
-
-
-
-
-
- {#each $trackerFilterEntries as tracker (tracker.value)}
-
- {/each}
-
-
- {/if}
-
{#if selectionStats()}
diff --git a/ui/src/lib/gridFilterOptions.js b/ui/src/lib/gridFilterOptions.js
index 25ee9e2..3d9072b 100644
--- a/ui/src/lib/gridFilterOptions.js
+++ b/ui/src/lib/gridFilterOptions.js
@@ -19,3 +19,7 @@ export function getGridTagFilterOptions(tags = []) {
return [{ value: '', label: 'All Tags' }, ...values.map(tag => ({ value: tag, label: tag }))];
}
+
+export function getGridStateMeta(value) {
+ return getGridStateFilterOption(value);
+}
diff --git a/ui/src/lib/gridFilters.js b/ui/src/lib/gridFilters.js
index a2eb4f2..cb5c8a2 100644
--- a/ui/src/lib/gridFilters.js
+++ b/ui/src/lib/gridFilters.js
@@ -1,23 +1,134 @@
+import { buildTrackerFilterEntries, getPrimaryTrackerHost } from './trackerUtils.js';
+
+export const UNTAGGED_FILTER_VALUE = '__untagged__';
+
+function hasAny(items) {
+ return Array.isArray(items) && items.length > 0;
+}
+
+export function applySearchFilter(instances, filters) {
+ if (!filters.search) {
+ return instances;
+ }
+
+ const search = filters.search.toLowerCase();
+ return instances.filter(
+ inst =>
+ inst.name.toLowerCase().includes(search) ||
+ inst.infoHash?.toLowerCase().includes(search) ||
+ inst.tags?.some(t => t.toLowerCase().includes(search))
+ );
+}
+
+export function applyStateFilter(instances, filters) {
+ if (filters.stateFilter === 'all') {
+ return instances;
+ }
+
+ return instances.filter(inst => inst.state.toLowerCase() === filters.stateFilter);
+}
+
+export function applyTagFilter(instances, filters) {
+ if (!hasAny(filters.tagFilter)) {
+ return instances;
+ }
+
+ const selected = new Set(filters.tagFilter);
+ return instances.filter(inst => {
+ const tags = inst.tags || [];
+ if (tags.length === 0) {
+ return selected.has(UNTAGGED_FILTER_VALUE);
+ }
+
+ return tags.some(tag => selected.has(tag));
+ });
+}
+
+export function applyTrackerFilter(instances, filters) {
+ if (!hasAny(filters.trackerFilter)) {
+ return instances;
+ }
+
+ const selected = new Set(filters.trackerFilter);
+ return instances.filter(inst => selected.has(getPrimaryTrackerHost(inst)));
+}
+
export function applyBaseGridFilters(instances, filters) {
- let result = instances;
-
- if (filters.search) {
- const search = filters.search.toLowerCase();
- result = result.filter(
- inst =>
- inst.name.toLowerCase().includes(search) ||
- inst.infoHash?.toLowerCase().includes(search) ||
- inst.tags?.some(t => t.toLowerCase().includes(search))
- );
+ return applyTagFilter(applyStateFilter(applySearchFilter(instances, filters), filters), filters);
+}
+
+export function applyAllGridFilters(instances, filters) {
+ return applyTrackerFilter(applyBaseGridFilters(instances, filters), filters);
+}
+
+export function buildStateFilterEntries(instances) {
+ const counts = new Map();
+
+ for (const instance of instances || []) {
+ const state = String(instance.state || 'stopped').toLowerCase();
+ counts.set(state, (counts.get(state) || 0) + 1);
}
- if (filters.stateFilter !== 'all') {
- result = result.filter(inst => inst.state.toLowerCase() === filters.stateFilter);
+ const order = ['running', 'paused', 'idle', 'starting', 'stopping', 'stopped'];
+
+ return order
+ .filter(state => counts.has(state))
+ .map(state => ({ value: state, count: counts.get(state) || 0 }));
+}
+
+export function buildTagFilterEntries(instances, filters = {}) {
+ const counts = new Map();
+ const query = String(filters.tagSearch || '')
+ .toLowerCase()
+ .trim();
+ let untaggedCount = 0;
+
+ for (const instance of instances || []) {
+ const tags = instance.tags || [];
+
+ if (tags.length === 0) {
+ untaggedCount += 1;
+ continue;
+ }
+
+ for (const tag of tags) {
+ if (query && !tag.toLowerCase().includes(query)) {
+ continue;
+ }
+ counts.set(tag, (counts.get(tag) || 0) + 1);
+ }
}
- if (filters.tagFilter) {
- result = result.filter(inst => inst.tags?.includes(filters.tagFilter));
+ const entries = [...counts.entries()]
+ .map(([value, count]) => ({ value, label: value, count }))
+ .sort((left, right) => {
+ if (right.count !== left.count) {
+ return right.count - left.count;
+ }
+ return left.label.localeCompare(right.label);
+ });
+
+ if (untaggedCount > 0 && (!query || 'untagged'.includes(query))) {
+ entries.unshift({
+ value: UNTAGGED_FILTER_VALUE,
+ label: 'Untagged',
+ count: untaggedCount,
+ muted: true,
+ });
}
- return result;
+ return entries;
+}
+
+export function clearAllGridFilters(filters) {
+ return {
+ ...filters,
+ stateFilter: 'all',
+ tagFilter: [],
+ trackerFilter: [],
+ tagSearch: '',
+ trackerSearch: '',
+ };
}
+
+export { buildTrackerFilterEntries };
diff --git a/ui/src/lib/gridStore.js b/ui/src/lib/gridStore.js
index 437a589..58fcd52 100644
--- a/ui/src/lib/gridStore.js
+++ b/ui/src/lib/gridStore.js
@@ -2,8 +2,15 @@ import { writable, derived, get } from 'svelte/store';
import { api, getRunMode } from '$lib/api';
import { normalizeViewMode } from '$lib/viewMode.js';
import { instanceActions } from '$lib/instanceStore.js';
-import { applyBaseGridFilters } from '$lib/gridFilters.js';
-import { buildTrackerFilterEntries, getPrimaryTrackerHost } from '$lib/trackerUtils.js';
+import {
+ applyAllGridFilters,
+ applyBaseGridFilters,
+ applySearchFilter,
+ applyStateFilter,
+ buildStateFilterEntries,
+ buildTagFilterEntries,
+ buildTrackerFilterEntries,
+} from '$lib/gridFilters.js';
const VIEW_MODE_KEY = 'rustatio-view-mode';
@@ -35,16 +42,33 @@ export const selectedIds = writable(new Set());
export const gridFilters = writable({
search: '',
stateFilter: 'all',
- tagFilter: '',
+ tagFilter: [],
trackerFilter: [],
+ tagSearch: '',
trackerSearch: '',
});
+export const stateFilterEntries = derived(
+ [gridInstances, gridFilters],
+ ([$instances, $filters]) => {
+ const scoped = applyBaseGridFilters(applySearchFilter($instances, $filters), {
+ ...$filters,
+ stateFilter: 'all',
+ });
+ return buildStateFilterEntries(scoped);
+ }
+);
+
+export const tagFilterEntries = derived([gridInstances, gridFilters], ([$instances, $filters]) => {
+ const scoped = applyStateFilter(applySearchFilter($instances, $filters), $filters);
+ return buildTagFilterEntries(scoped, $filters);
+});
+
export const trackerFilterEntries = derived(
[gridInstances, gridFilters],
([$instances, $filters]) => {
- const base = applyBaseGridFilters($instances, $filters);
- return buildTrackerFilterEntries(base, $filters);
+ const scoped = applyBaseGridFilters($instances, { ...$filters, trackerFilter: [] });
+ return buildTrackerFilterEntries(scoped, $filters);
}
);
@@ -58,12 +82,7 @@ export const gridSort = writable({
export const filteredGridInstances = derived(
[gridInstances, gridFilters, gridSort],
([$instances, $filters, $sort]) => {
- let result = applyBaseGridFilters($instances, $filters);
-
- if ($filters.trackerFilter.length > 0) {
- const selectedTrackers = new Set($filters.trackerFilter);
- result = result.filter(inst => selectedTrackers.has(getPrimaryTrackerHost(inst)));
- }
+ let result = applyAllGridFilters($instances, $filters);
// Sort
result = [...result].sort((a, b) => {
@@ -85,19 +104,6 @@ export const filteredGridInstances = derived(
}
);
-// All unique tags across all instances
-export const allTags = derived(gridInstances, $instances => {
- const tagSet = new Set();
- for (const inst of $instances) {
- if (inst.tags) {
- for (const tag of inst.tags) {
- tagSet.add(tag);
- }
- }
- }
- return [...tagSet].sort();
-});
-
// Polling interval reference
let pollInterval = null;
let isFetching = false;
diff --git a/ui/src/lib/gridStore.test.js b/ui/src/lib/gridStore.test.js
index 15f6281..fc3429b 100644
--- a/ui/src/lib/gridStore.test.js
+++ b/ui/src/lib/gridStore.test.js
@@ -1,7 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { applyBaseGridFilters } from './gridFilters.js';
+import {
+ UNTAGGED_FILTER_VALUE,
+ applyAllGridFilters,
+ applyBaseGridFilters,
+ buildStateFilterEntries,
+ buildTagFilterEntries,
+} from './gridFilters.js';
test('applyBaseGridFilters keeps search, state, and tag behavior', () => {
const instances = [
@@ -23,7 +29,7 @@ test('applyBaseGridFilters keeps search, state, and tag behavior', () => {
applyBaseGridFilters(instances, {
search: 'alp',
stateFilter: 'all',
- tagFilter: '',
+ tagFilter: [],
}).length,
1
);
@@ -32,7 +38,7 @@ test('applyBaseGridFilters keeps search, state, and tag behavior', () => {
applyBaseGridFilters(instances, {
search: '',
stateFilter: 'stopped',
- tagFilter: '',
+ tagFilter: [],
})[0].name,
'Beta'
);
@@ -41,8 +47,107 @@ test('applyBaseGridFilters keeps search, state, and tag behavior', () => {
applyBaseGridFilters(instances, {
search: '',
stateFilter: 'all',
- tagFilter: 'movie',
+ tagFilter: ['movie'],
})[0].name,
'Alpha'
);
});
+
+test('applyAllGridFilters supports multi-select tags and trackers', () => {
+ const instances = [
+ {
+ name: 'Alpha',
+ infoHash: 'abc123',
+ tags: ['movie'],
+ state: 'running',
+ primaryTrackerHost: 'c411.org',
+ },
+ {
+ name: 'Beta',
+ infoHash: 'def456',
+ tags: ['tv'],
+ state: 'stopped',
+ primaryTrackerHost: 'nyaa.tracker.wf',
+ },
+ {
+ name: 'Gamma',
+ infoHash: 'ghi789',
+ tags: [],
+ state: 'running',
+ primaryTrackerHost: 'open.stealth.si',
+ },
+ ];
+
+ const result = applyAllGridFilters(instances, {
+ search: '',
+ stateFilter: 'all',
+ tagFilter: ['movie', 'tv'],
+ trackerFilter: ['c411.org', 'nyaa.tracker.wf'],
+ });
+
+ assert.deepEqual(
+ result.map(instance => instance.name),
+ ['Alpha', 'Beta']
+ );
+});
+
+test('applyBaseGridFilters matches untagged torrents when selected', () => {
+ const result = applyBaseGridFilters(
+ [
+ { name: 'Alpha', infoHash: 'a', tags: ['movie'], state: 'running' },
+ { name: 'Beta', infoHash: 'b', tags: [], state: 'stopped' },
+ ],
+ {
+ search: '',
+ stateFilter: 'all',
+ tagFilter: [UNTAGGED_FILTER_VALUE],
+ }
+ );
+
+ assert.deepEqual(
+ result.map(instance => instance.name),
+ ['Beta']
+ );
+});
+
+test('buildStateFilterEntries returns ordered state counts', () => {
+ assert.deepEqual(
+ buildStateFilterEntries([
+ { state: 'stopped' },
+ { state: 'running' },
+ { state: 'running' },
+ { state: 'idle' },
+ ]),
+ [
+ { value: 'running', count: 2 },
+ { value: 'idle', count: 1 },
+ { value: 'stopped', count: 1 },
+ ]
+ );
+});
+
+test('buildTagFilterEntries aggregates and searches tags', () => {
+ assert.deepEqual(
+ buildTagFilterEntries(
+ [{ tags: ['movie', 'anime'] }, { tags: [] }, { tags: ['movie'] }, { tags: ['tv'] }],
+ { tagSearch: 'mo' }
+ ),
+ [{ value: 'movie', label: 'movie', count: 2 }]
+ );
+});
+
+test('buildTagFilterEntries includes untagged row', () => {
+ assert.deepEqual(buildTagFilterEntries([{ tags: [] }, { tags: ['movie'] }, { tags: [] }]), [
+ {
+ value: UNTAGGED_FILTER_VALUE,
+ label: 'Untagged',
+ count: 2,
+ muted: true,
+ },
+ {
+ value: 'movie',
+ label: 'movie',
+ count: 1,
+ },
+ ]);
+});