flock-core 0.5.0b65__py3-none-any.whl → 0.5.0b71__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of flock-core might be problematic. Click here for more details.

Files changed (56) hide show
  1. flock/cli.py +74 -2
  2. flock/engines/dspy_engine.py +41 -5
  3. flock/examples.py +4 -1
  4. flock/frontend/README.md +15 -1
  5. flock/frontend/package-lock.json +2 -2
  6. flock/frontend/package.json +1 -1
  7. flock/frontend/src/App.tsx +74 -6
  8. flock/frontend/src/__tests__/e2e/critical-scenarios.test.tsx +4 -5
  9. flock/frontend/src/__tests__/integration/filtering-e2e.test.tsx +7 -3
  10. flock/frontend/src/components/filters/ArtifactTypeFilter.tsx +21 -0
  11. flock/frontend/src/components/filters/FilterFlyout.module.css +104 -0
  12. flock/frontend/src/components/filters/FilterFlyout.tsx +80 -0
  13. flock/frontend/src/components/filters/FilterPills.module.css +186 -45
  14. flock/frontend/src/components/filters/FilterPills.test.tsx +115 -99
  15. flock/frontend/src/components/filters/FilterPills.tsx +120 -44
  16. flock/frontend/src/components/filters/ProducerFilter.tsx +21 -0
  17. flock/frontend/src/components/filters/SavedFiltersControl.module.css +60 -0
  18. flock/frontend/src/components/filters/SavedFiltersControl.test.tsx +158 -0
  19. flock/frontend/src/components/filters/SavedFiltersControl.tsx +159 -0
  20. flock/frontend/src/components/filters/TagFilter.tsx +21 -0
  21. flock/frontend/src/components/filters/TimeRangeFilter.module.css +24 -0
  22. flock/frontend/src/components/filters/TimeRangeFilter.tsx +6 -1
  23. flock/frontend/src/components/filters/VisibilityFilter.tsx +21 -0
  24. flock/frontend/src/components/graph/GraphCanvas.tsx +24 -0
  25. flock/frontend/src/components/layout/DashboardLayout.css +13 -0
  26. flock/frontend/src/components/layout/DashboardLayout.tsx +8 -24
  27. flock/frontend/src/components/modules/HistoricalArtifactsModule.module.css +288 -0
  28. flock/frontend/src/components/modules/HistoricalArtifactsModule.tsx +460 -0
  29. flock/frontend/src/components/modules/HistoricalArtifactsModuleWrapper.tsx +13 -0
  30. flock/frontend/src/components/modules/ModuleRegistry.ts +7 -1
  31. flock/frontend/src/components/modules/registerModules.ts +9 -10
  32. flock/frontend/src/hooks/useModules.ts +11 -1
  33. flock/frontend/src/services/api.ts +140 -0
  34. flock/frontend/src/services/indexeddb.ts +56 -2
  35. flock/frontend/src/services/websocket.ts +129 -0
  36. flock/frontend/src/store/filterStore.test.ts +105 -185
  37. flock/frontend/src/store/filterStore.ts +173 -26
  38. flock/frontend/src/store/graphStore.test.ts +19 -0
  39. flock/frontend/src/store/graphStore.ts +166 -27
  40. flock/frontend/src/types/filters.ts +34 -1
  41. flock/frontend/src/types/graph.ts +7 -0
  42. flock/frontend/src/utils/artifacts.ts +24 -0
  43. flock/orchestrator.py +23 -1
  44. flock/service.py +146 -9
  45. flock/store.py +971 -24
  46. {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b71.dist-info}/METADATA +26 -1
  47. {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b71.dist-info}/RECORD +50 -43
  48. flock/frontend/src/components/filters/FilterBar.module.css +0 -29
  49. flock/frontend/src/components/filters/FilterBar.test.tsx +0 -133
  50. flock/frontend/src/components/filters/FilterBar.tsx +0 -33
  51. flock/frontend/src/components/modules/EventLogModule.test.tsx +0 -401
  52. flock/frontend/src/components/modules/EventLogModule.tsx +0 -396
  53. flock/frontend/src/components/modules/EventLogModuleWrapper.tsx +0 -17
  54. {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b71.dist-info}/WHEEL +0 -0
  55. {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b71.dist-info}/entry_points.txt +0 -0
  56. {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b71.dist-info}/licenses/LICENSE +0 -0
@@ -1,65 +1,141 @@
1
- import React from 'react';
2
- import { useFilterStore } from '../../store/filterStore';
1
+ import React, { useMemo } from 'react';
2
+ import { useFilterStore, formatTimeRange } from '../../store/filterStore';
3
+ import { useSettingsStore } from '../../store/settingsStore';
3
4
  import styles from './FilterPills.module.css';
4
5
 
6
+ const extractLabelParts = (label: string) => {
7
+ const match = label.match(/^([^:]+):(.*)$/);
8
+ const title = match?.[1]?.trim() ?? label.trim();
9
+ const value = match?.[2]?.trim() ?? '';
10
+
11
+ return { title, value };
12
+ };
13
+
5
14
  const FilterPills: React.FC = () => {
6
- // Select the actual state values for reactivity, then derive filters
15
+ const showFilters = useSettingsStore((state) => state.ui.showFilters);
16
+ const setShowFilters = useSettingsStore((state) => state.setShowFilters);
7
17
  const correlationId = useFilterStore((state) => state.correlationId);
8
18
  const timeRange = useFilterStore((state) => state.timeRange);
19
+ const selectedArtifactTypes = useFilterStore((state) => state.selectedArtifactTypes);
20
+ const selectedProducers = useFilterStore((state) => state.selectedProducers);
21
+ const selectedTags = useFilterStore((state) => state.selectedTags);
22
+ const selectedVisibility = useFilterStore((state) => state.selectedVisibility);
9
23
  const removeFilter = useFilterStore((state) => state.removeFilter);
10
24
 
11
- // Derive active filters from state
12
- const activeFilters: Array<{ type: 'correlationId' | 'timeRange'; value: any; label: string }> = [];
25
+ const activeFilters = useMemo(() => {
26
+ const filters = [];
27
+ if (correlationId) {
28
+ filters.push({
29
+ type: 'correlationId' as const,
30
+ value: correlationId,
31
+ label: `Correlation ID: ${correlationId}`,
32
+ });
33
+ }
13
34
 
14
- if (correlationId) {
15
- activeFilters.push({
16
- type: 'correlationId',
17
- value: correlationId,
18
- label: `Correlation ID: ${correlationId}`,
35
+ filters.push({
36
+ type: 'timeRange' as const,
37
+ value: timeRange,
38
+ label: `Time: ${formatTimeRange(timeRange)}`,
19
39
  });
20
- }
21
40
 
22
- if (timeRange.preset !== 'last10min') {
23
- const formatTimeRange = (range: typeof timeRange): string => {
24
- if (range.preset === 'last5min') return 'Last 5 min';
25
- if (range.preset === 'last10min') return 'Last 10 min';
26
- if (range.preset === 'last1hour') return 'Last hour';
27
- if (range.preset === 'custom' && range.start && range.end) {
28
- const startDate = new Date(range.start).toLocaleString();
29
- const endDate = new Date(range.end).toLocaleString();
30
- return `${startDate} - ${endDate}`;
31
- }
32
- return 'Unknown';
33
- };
41
+ selectedArtifactTypes.forEach((type) => {
42
+ filters.push({
43
+ type: 'artifactTypes' as const,
44
+ value: type,
45
+ label: `Type: ${type}`,
46
+ });
47
+ });
34
48
 
35
- activeFilters.push({
36
- type: 'timeRange',
37
- value: timeRange,
38
- label: `Time: ${formatTimeRange(timeRange)}`,
49
+ selectedProducers.forEach((producer) => {
50
+ filters.push({
51
+ type: 'producers' as const,
52
+ value: producer,
53
+ label: `Producer: ${producer}`,
54
+ });
39
55
  });
40
- }
56
+
57
+ selectedTags.forEach((tag) => {
58
+ filters.push({
59
+ type: 'tags' as const,
60
+ value: tag,
61
+ label: `Tag: ${tag}`,
62
+ });
63
+ });
64
+
65
+ selectedVisibility.forEach((visibilityKind) => {
66
+ filters.push({
67
+ type: 'visibility' as const,
68
+ value: visibilityKind,
69
+ label: `Visibility: ${visibilityKind}`,
70
+ });
71
+ });
72
+
73
+ return filters;
74
+ }, [correlationId, timeRange, selectedArtifactTypes, selectedProducers, selectedTags, selectedVisibility]);
41
75
 
42
76
  if (activeFilters.length === 0) {
43
77
  return null;
44
78
  }
45
79
 
46
80
  return (
47
- <div className={styles.container}>
48
- {activeFilters.map((filter, index) => (
49
- <div
50
- key={filter.type}
51
- className={`${styles.pill} ${index === 1 ? styles.pillSecondary : ''}`}
52
- >
53
- <span className={styles.pillLabel}>{filter.label}</span>
54
- <button
55
- onClick={() => removeFilter(filter.type)}
56
- aria-label={`Remove ${filter.type} filter`}
57
- className={styles.removeButton}
58
- >
59
- ×
60
- </button>
61
- </div>
62
- ))}
81
+ <div className={styles.wrapper}>
82
+ <button
83
+ type="button"
84
+ onClick={() => setShowFilters(!showFilters)}
85
+ className={`${styles.toggleButton} ${showFilters ? styles.toggleButtonActive : ''}`}
86
+ title={`${showFilters ? 'Hide' : 'Show'} filter panel (Ctrl+Shift+F)`}
87
+ aria-pressed={showFilters}
88
+ aria-label={showFilters ? 'Hide filter panel' : 'Show filter panel'}
89
+ >
90
+ <span className={styles.toggleIcon} aria-hidden="true">
91
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
92
+ <path
93
+ d="M2.667 2.667h10.666L9.333 7v4.667l-2.666 1.333V7L2.667 2.667z"
94
+ stroke="currentColor"
95
+ strokeWidth="1.5"
96
+ strokeLinecap="round"
97
+ strokeLinejoin="round"
98
+ />
99
+ </svg>
100
+ </span>
101
+ <span className={styles.toggleLabel}>Filter</span>
102
+ </button>
103
+ <div className={styles.container} role="list">
104
+ {activeFilters.map((filter, index) => {
105
+ const labelValue =
106
+ filter.type === 'timeRange' && typeof filter.value !== 'string'
107
+ ? `Time: ${formatTimeRange(filter.value)}`
108
+ : filter.label;
109
+
110
+ const { title, value } = extractLabelParts(labelValue);
111
+ const accessibleDescriptor = value ? `${title} ${value}` : title;
112
+
113
+ return (
114
+ <div
115
+ key={`${filter.type}-${String(filter.value)}-${index}`}
116
+ className={styles.pill}
117
+ data-filter-type={filter.type}
118
+ role="listitem"
119
+ >
120
+ <span className={styles.pillAccent} aria-hidden="true" />
121
+ <div className={styles.textGroup}>
122
+ <span className={styles.pillTitle}>{title}</span>
123
+ {value && <span className={styles.pillValue}>{value}</span>}
124
+ </div>
125
+ <button
126
+ onClick={() => removeFilter(filter)}
127
+ aria-label={`Remove ${accessibleDescriptor} filter`}
128
+ className={styles.removeButton}
129
+ type="button"
130
+ >
131
+ <span aria-hidden="true" className={styles.removeIcon}>
132
+ ×
133
+ </span>
134
+ </button>
135
+ </div>
136
+ );
137
+ })}
138
+ </div>
63
139
  </div>
64
140
  );
65
141
  };
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+ import MultiSelect from '../settings/MultiSelect';
3
+ import { useFilterStore } from '../../store/filterStore';
4
+
5
+ const ProducerFilter: React.FC = () => {
6
+ const options = useFilterStore((state) => state.availableProducers);
7
+ const selected = useFilterStore((state) => state.selectedProducers);
8
+ const setProducers = useFilterStore((state) => state.setProducers);
9
+
10
+ return (
11
+ <MultiSelect
12
+ options={options}
13
+ selected={selected}
14
+ onChange={setProducers}
15
+ placeholder={options.length ? 'Select producers…' : 'No producers'}
16
+ disabled={options.length === 0}
17
+ />
18
+ );
19
+ };
20
+
21
+ export default ProducerFilter;
@@ -0,0 +1,60 @@
1
+ .container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--gap-xs);
5
+ }
6
+
7
+ .controlsRow {
8
+ display: flex;
9
+ align-items: center;
10
+ gap: var(--gap-sm);
11
+ flex-wrap: wrap;
12
+ }
13
+
14
+ .select {
15
+ min-width: 180px;
16
+ padding: var(--spacing-1) var(--spacing-2);
17
+ border-radius: var(--radius-sm);
18
+ border: 1px solid var(--color-border-subtle);
19
+ background: rgba(0, 0, 0, 0.35);
20
+ color: var(--color-text-primary);
21
+ }
22
+
23
+ .saveButton,
24
+ .applyButton,
25
+ .deleteButton {
26
+ padding: var(--spacing-1) var(--spacing-3);
27
+ border-radius: var(--radius-sm);
28
+ border: none;
29
+ font-size: var(--font-size-body-xs);
30
+ cursor: pointer;
31
+ transition: background-color var(--duration-fast) ease;
32
+ }
33
+
34
+ .saveButton {
35
+ background: var(--color-info);
36
+ color: var(--color-text-on-primary);
37
+ }
38
+
39
+ .applyButton {
40
+ background: var(--color-success);
41
+ color: var(--color-text-on-primary);
42
+ }
43
+
44
+ .deleteButton {
45
+ background: var(--color-error);
46
+ color: var(--color-text-on-primary);
47
+ }
48
+
49
+ .saveButton:disabled,
50
+ .applyButton:disabled,
51
+ .deleteButton:disabled,
52
+ .select:disabled {
53
+ opacity: 0.5;
54
+ cursor: not-allowed;
55
+ }
56
+
57
+ .error {
58
+ font-size: var(--font-size-body-xs);
59
+ color: var(--color-error);
60
+ }
@@ -0,0 +1,158 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3
+ import SavedFiltersControl from './SavedFiltersControl';
4
+ import { useFilterStore } from '../../store/filterStore';
5
+ import { indexedDBService } from '../../services/indexeddb';
6
+ import type { SavedFilterMeta, FilterSnapshot } from '../../types/filters';
7
+
8
+ vi.mock('../../store/filterStore');
9
+ vi.mock('../../services/indexeddb', () => ({
10
+ indexedDBService: {
11
+ initialize: vi.fn(),
12
+ getAllFilterPresets: vi.fn(),
13
+ saveFilterPreset: vi.fn(),
14
+ deleteFilterPreset: vi.fn(),
15
+ },
16
+ }));
17
+
18
+ const baseSnapshot: FilterSnapshot = {
19
+ correlationId: null,
20
+ timeRange: { preset: 'last10min' },
21
+ artifactTypes: [],
22
+ producers: [],
23
+ tags: [],
24
+ visibility: [],
25
+ };
26
+
27
+ const createMockState = (overrides: Record<string, unknown> = {}) => ({
28
+ savedFilters: [] as SavedFilterMeta[],
29
+ setSavedFilters: vi.fn(),
30
+ addSavedFilter: vi.fn(),
31
+ removeSavedFilter: vi.fn(),
32
+ getFilterSnapshot: vi.fn(() => baseSnapshot),
33
+ applyFilterSnapshot: vi.fn(),
34
+ getActiveFilters: () => [],
35
+ ...overrides,
36
+ });
37
+
38
+ type MockFilterState = ReturnType<typeof createMockState>;
39
+
40
+ let state: MockFilterState;
41
+ type MockedFn = ReturnType<typeof vi.fn>;
42
+ const mockedUseFilterStore = useFilterStore as unknown as MockedFn;
43
+
44
+ const getIndexedDBMocks = () => indexedDBService as unknown as {
45
+ initialize: ReturnType<typeof vi.fn>;
46
+ getAllFilterPresets: ReturnType<typeof vi.fn>;
47
+ saveFilterPreset: ReturnType<typeof vi.fn>;
48
+ deleteFilterPreset: ReturnType<typeof vi.fn>;
49
+ };
50
+
51
+ describe('SavedFiltersControl', () => {
52
+ beforeEach(() => {
53
+ vi.clearAllMocks();
54
+ state = createMockState();
55
+ mockedUseFilterStore.mockReset();
56
+ mockedUseFilterStore.mockImplementation((selector: any) => selector(state as any));
57
+
58
+ const indexedDb = getIndexedDBMocks();
59
+ indexedDb.initialize.mockResolvedValue(undefined);
60
+ indexedDb.getAllFilterPresets.mockResolvedValue([]);
61
+ indexedDb.saveFilterPreset.mockResolvedValue(undefined);
62
+ indexedDb.deleteFilterPreset.mockResolvedValue(undefined);
63
+ });
64
+
65
+ it('loads saved presets on mount', async () => {
66
+ const presets: SavedFilterMeta[] = [
67
+ {
68
+ filter_id: 'preset-1',
69
+ name: 'Recent Ops',
70
+ created_at: Date.now(),
71
+ filters: baseSnapshot,
72
+ },
73
+ ];
74
+
75
+ const indexedDb = getIndexedDBMocks();
76
+ indexedDb.getAllFilterPresets.mockResolvedValueOnce(presets);
77
+
78
+ render(<SavedFiltersControl />);
79
+
80
+ await waitFor(() => {
81
+ expect(state.setSavedFilters).toHaveBeenCalledWith(presets);
82
+ });
83
+ });
84
+
85
+ it('saves current snapshot when Save Current is clicked', async () => {
86
+ const indexedDb = getIndexedDBMocks();
87
+ indexedDb.getAllFilterPresets.mockResolvedValueOnce([]);
88
+
89
+ const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('My Filters');
90
+ const uuidSpy = vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('123e4567-e89b-12d3-a456-426614174000');
91
+
92
+ render(<SavedFiltersControl />);
93
+
94
+ const saveButton = await screen.findByRole('button', { name: /save current/i });
95
+ fireEvent.click(saveButton);
96
+
97
+ await waitFor(() => {
98
+ expect(indexedDb.saveFilterPreset).toHaveBeenCalledTimes(1);
99
+ expect(state.addSavedFilter).toHaveBeenCalledTimes(1);
100
+ });
101
+
102
+ const savedRecord = indexedDb.saveFilterPreset.mock.calls[0]?.[0];
103
+ expect(savedRecord).toBeDefined();
104
+ if (savedRecord) {
105
+ expect(savedRecord.filter_id).toBe('123e4567-e89b-12d3-a456-426614174000');
106
+ expect(savedRecord.name).toBe('My Filters');
107
+ expect(savedRecord.filters).toEqual(baseSnapshot);
108
+ }
109
+
110
+ promptSpy.mockRestore();
111
+ uuidSpy.mockRestore();
112
+ });
113
+
114
+ it('applies selected preset when Apply is clicked', async () => {
115
+ const preset: SavedFilterMeta = {
116
+ filter_id: 'preset-apply',
117
+ name: 'Important',
118
+ created_at: Date.now(),
119
+ filters: { ...baseSnapshot, artifactTypes: ['Plan'] },
120
+ };
121
+ state = createMockState({ savedFilters: [preset] });
122
+ mockedUseFilterStore.mockImplementation((selector: any) => selector(state as any));
123
+
124
+ const indexedDb = getIndexedDBMocks();
125
+ indexedDb.getAllFilterPresets.mockResolvedValueOnce([preset]);
126
+
127
+ render(<SavedFiltersControl />);
128
+
129
+ const applyButton = await screen.findByRole('button', { name: /apply/i });
130
+ fireEvent.click(applyButton);
131
+
132
+ expect(state.applyFilterSnapshot).toHaveBeenCalledWith(preset.filters);
133
+ });
134
+
135
+ it('deletes preset and updates store when Delete is clicked', async () => {
136
+ const preset: SavedFilterMeta = {
137
+ filter_id: 'preset-delete',
138
+ name: 'Old preset',
139
+ created_at: Date.now(),
140
+ filters: baseSnapshot,
141
+ };
142
+ state = createMockState({ savedFilters: [preset] });
143
+ mockedUseFilterStore.mockImplementation((selector: any) => selector(state as any));
144
+
145
+ const indexedDb = getIndexedDBMocks();
146
+ indexedDb.getAllFilterPresets.mockResolvedValueOnce([preset]);
147
+
148
+ render(<SavedFiltersControl />);
149
+
150
+ const deleteButton = await screen.findByRole('button', { name: /delete/i });
151
+ fireEvent.click(deleteButton);
152
+
153
+ await waitFor(() => {
154
+ expect(indexedDb.deleteFilterPreset).toHaveBeenCalledWith('preset-delete');
155
+ expect(state.removeSavedFilter).toHaveBeenCalledWith('preset-delete');
156
+ });
157
+ });
158
+ });
@@ -0,0 +1,159 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { indexedDBService } from '../../services/indexeddb';
3
+ import { useFilterStore } from '../../store/filterStore';
4
+ import styles from './SavedFiltersControl.module.css';
5
+
6
+ const SavedFiltersControl: React.FC = () => {
7
+ const savedFilters = useFilterStore((state) => state.savedFilters);
8
+ const setSavedFilters = useFilterStore((state) => state.setSavedFilters);
9
+ const addSavedFilter = useFilterStore((state) => state.addSavedFilter);
10
+ const removeSavedFilter = useFilterStore((state) => state.removeSavedFilter);
11
+ const getFilterSnapshot = useFilterStore((state) => state.getFilterSnapshot);
12
+ const applyFilterSnapshot = useFilterStore((state) => state.applyFilterSnapshot);
13
+
14
+ const [selectedId, setSelectedId] = useState<string>('');
15
+ const [loading, setLoading] = useState<boolean>(true);
16
+ const [error, setError] = useState<string | null>(null);
17
+
18
+ const renderCountRef = useRef(0);
19
+ renderCountRef.current += 1;
20
+ if (import.meta.env.DEV) {
21
+ console.debug('[SavedFilters] render', {
22
+ renderCount: renderCountRef.current,
23
+ savedCount: savedFilters.length,
24
+ loading,
25
+ selectedId
26
+ });
27
+ }
28
+
29
+ useEffect(() => {
30
+ let mounted = true;
31
+ (async () => {
32
+ try {
33
+ if (import.meta.env.DEV) {
34
+ console.debug('[SavedFilters] initializing IndexedDB');
35
+ }
36
+ await indexedDBService.initialize();
37
+ const presets = await indexedDBService.getAllFilterPresets();
38
+ if (import.meta.env.DEV) {
39
+ console.debug('[SavedFilters] loaded presets', { count: presets.length });
40
+ }
41
+ if (!mounted) return;
42
+ setSavedFilters(presets);
43
+ if (presets.length > 0) {
44
+ setSelectedId(presets[0]?.filter_id ?? '');
45
+ } else {
46
+ setSelectedId('');
47
+ }
48
+ setError(null);
49
+ } catch (err) {
50
+ console.error('[SavedFilters] Failed to load presets', err);
51
+ if (mounted) {
52
+ setError('Unable to load saved presets');
53
+ }
54
+ } finally {
55
+ if (mounted) {
56
+ setLoading(false);
57
+ }
58
+ }
59
+ })();
60
+ return () => {
61
+ mounted = false;
62
+ };
63
+ }, [setSavedFilters]);
64
+
65
+ const handleSavePreset = async () => {
66
+ const name = window.prompt('Save current filters as preset. Enter a name:');
67
+ if (!name || !name.trim()) {
68
+ return;
69
+ }
70
+
71
+ try {
72
+ const record = {
73
+ filter_id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `filter-${Date.now()}`,
74
+ name: name.trim(),
75
+ created_at: Date.now(),
76
+ filters: getFilterSnapshot(),
77
+ };
78
+ await indexedDBService.saveFilterPreset(record);
79
+ addSavedFilter(record);
80
+ setSelectedId(record.filter_id);
81
+ setError(null);
82
+ } catch (err) {
83
+ console.error('[SavedFilters] Failed to save preset', err);
84
+ setError('Failed to save preset');
85
+ }
86
+ };
87
+
88
+ const handleApplyPreset = () => {
89
+ if (!selectedId) return;
90
+ const preset = savedFilters.find((filter) => filter.filter_id === selectedId);
91
+ if (!preset) return;
92
+ applyFilterSnapshot(preset.filters);
93
+ };
94
+
95
+ const handleDeletePreset = async () => {
96
+ if (!selectedId) return;
97
+ try {
98
+ await indexedDBService.deleteFilterPreset(selectedId);
99
+ removeSavedFilter(selectedId);
100
+ const remaining = savedFilters.filter((filter) => filter.filter_id !== selectedId);
101
+ setSelectedId(remaining[0]?.filter_id ?? '');
102
+ setError(null);
103
+ } catch (err) {
104
+ console.error('[SavedFilters] Failed to delete preset', err);
105
+ setError('Failed to delete preset');
106
+ }
107
+ };
108
+
109
+ return (
110
+ <div className={styles.container}>
111
+ <div className={styles.controlsRow}>
112
+ <button
113
+ type="button"
114
+ className={styles.saveButton}
115
+ onClick={handleSavePreset}
116
+ disabled={loading}
117
+ >
118
+ Save Current
119
+ </button>
120
+
121
+ <select
122
+ className={styles.select}
123
+ value={selectedId}
124
+ onChange={(event) => setSelectedId(event.target.value)}
125
+ disabled={loading || savedFilters.length === 0}
126
+ aria-label="Saved filter presets"
127
+ >
128
+ {savedFilters.length === 0 && <option value="">No presets</option>}
129
+ {savedFilters.map((preset) => (
130
+ <option key={preset.filter_id} value={preset.filter_id}>
131
+ {preset.name}
132
+ </option>
133
+ ))}
134
+ </select>
135
+
136
+ <button
137
+ type="button"
138
+ className={styles.applyButton}
139
+ onClick={handleApplyPreset}
140
+ disabled={!selectedId}
141
+ >
142
+ Apply
143
+ </button>
144
+
145
+ <button
146
+ type="button"
147
+ className={styles.deleteButton}
148
+ onClick={handleDeletePreset}
149
+ disabled={!selectedId}
150
+ >
151
+ Delete
152
+ </button>
153
+ </div>
154
+ {error && <div className={styles.error}>{error}</div>}
155
+ </div>
156
+ );
157
+ };
158
+
159
+ export default SavedFiltersControl;
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+ import MultiSelect from '../settings/MultiSelect';
3
+ import { useFilterStore } from '../../store/filterStore';
4
+
5
+ const TagFilter: React.FC = () => {
6
+ const options = useFilterStore((state) => state.availableTags);
7
+ const selected = useFilterStore((state) => state.selectedTags);
8
+ const setTags = useFilterStore((state) => state.setTags);
9
+
10
+ return (
11
+ <MultiSelect
12
+ options={options}
13
+ selected={selected}
14
+ onChange={setTags}
15
+ placeholder={options.length ? 'Select tags…' : 'No tags available'}
16
+ disabled={options.length === 0}
17
+ />
18
+ );
19
+ };
20
+
21
+ export default TagFilter;
@@ -6,6 +6,7 @@
6
6
 
7
7
  .presetButtons {
8
8
  display: flex;
9
+ flex-wrap: wrap;
9
10
  gap: var(--spacing-2);
10
11
  align-items: center;
11
12
  }
@@ -40,6 +41,29 @@
40
41
  border-color: var(--color-primary-700);
41
42
  }
42
43
 
44
+ .presetButtonAll {
45
+ background: rgba(194, 65, 244, 0.15); /* secondary tint */
46
+ color: var(--color-secondary-200);
47
+ border-color: var(--color-secondary-500);
48
+ }
49
+
50
+ .presetButtonAll:hover {
51
+ background: rgba(194, 65, 244, 0.25);
52
+ border-color: var(--color-secondary-400);
53
+ color: var(--color-secondary-100);
54
+ }
55
+
56
+ .presetButtonAll.active {
57
+ background: var(--color-secondary-500);
58
+ border-color: var(--color-secondary-300);
59
+ color: var(--color-text-on-primary);
60
+ }
61
+
62
+ .presetButtonAll.active:hover {
63
+ background: var(--color-secondary-400);
64
+ border-color: var(--color-secondary-200);
65
+ }
66
+
43
67
  .customRange {
44
68
  display: flex;
45
69
  gap: var(--spacing-3);
@@ -41,6 +41,7 @@ const TimeRangeFilter: React.FC = () => {
41
41
  };
42
42
 
43
43
  const presets: { preset: TimeRangePreset; label: string }[] = [
44
+ { preset: 'all', label: 'All' },
44
45
  { preset: 'last5min', label: 'Last 5 min' },
45
46
  { preset: 'last10min', label: 'Last 10 min' },
46
47
  { preset: 'last1hour', label: 'Last hour' },
@@ -54,7 +55,11 @@ const TimeRangeFilter: React.FC = () => {
54
55
  <button
55
56
  key={preset}
56
57
  onClick={() => handlePresetClick(preset)}
57
- className={`${styles.presetButton} ${timeRange.preset === preset ? styles.active : ''}`}
58
+ className={[
59
+ styles.presetButton,
60
+ preset === 'all' ? styles.presetButtonAll : '',
61
+ timeRange.preset === preset ? styles.active : '',
62
+ ].filter(Boolean).join(' ')}
58
63
  >
59
64
  {label}
60
65
  </button>
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+ import MultiSelect from '../settings/MultiSelect';
3
+ import { useFilterStore } from '../../store/filterStore';
4
+
5
+ const VisibilityFilter: React.FC = () => {
6
+ const options = useFilterStore((state) => state.availableVisibility);
7
+ const selected = useFilterStore((state) => state.selectedVisibility);
8
+ const setVisibility = useFilterStore((state) => state.setVisibility);
9
+
10
+ return (
11
+ <MultiSelect
12
+ options={options}
13
+ selected={selected}
14
+ onChange={setVisibility}
15
+ placeholder={options.length ? 'Select visibility…' : 'No visibility options'}
16
+ disabled={options.length === 0}
17
+ />
18
+ );
19
+ };
20
+
21
+ export default VisibilityFilter;