flock-core 0.5.0b65__py3-none-any.whl → 0.5.0b70__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.
- flock/cli.py +74 -2
- flock/engines/dspy_engine.py +40 -4
- flock/examples.py +4 -1
- flock/frontend/README.md +15 -1
- flock/frontend/package-lock.json +2 -2
- flock/frontend/package.json +1 -1
- flock/frontend/src/App.tsx +74 -6
- flock/frontend/src/__tests__/e2e/critical-scenarios.test.tsx +4 -5
- flock/frontend/src/__tests__/integration/filtering-e2e.test.tsx +7 -3
- flock/frontend/src/components/filters/ArtifactTypeFilter.tsx +21 -0
- flock/frontend/src/components/filters/FilterFlyout.module.css +104 -0
- flock/frontend/src/components/filters/FilterFlyout.tsx +80 -0
- flock/frontend/src/components/filters/FilterPills.module.css +186 -45
- flock/frontend/src/components/filters/FilterPills.test.tsx +115 -99
- flock/frontend/src/components/filters/FilterPills.tsx +120 -44
- flock/frontend/src/components/filters/ProducerFilter.tsx +21 -0
- flock/frontend/src/components/filters/SavedFiltersControl.module.css +60 -0
- flock/frontend/src/components/filters/SavedFiltersControl.test.tsx +158 -0
- flock/frontend/src/components/filters/SavedFiltersControl.tsx +159 -0
- flock/frontend/src/components/filters/TagFilter.tsx +21 -0
- flock/frontend/src/components/filters/TimeRangeFilter.module.css +24 -0
- flock/frontend/src/components/filters/TimeRangeFilter.tsx +6 -1
- flock/frontend/src/components/filters/VisibilityFilter.tsx +21 -0
- flock/frontend/src/components/graph/GraphCanvas.tsx +24 -0
- flock/frontend/src/components/layout/DashboardLayout.css +13 -0
- flock/frontend/src/components/layout/DashboardLayout.tsx +8 -24
- flock/frontend/src/components/modules/HistoricalArtifactsModule.module.css +288 -0
- flock/frontend/src/components/modules/HistoricalArtifactsModule.tsx +460 -0
- flock/frontend/src/components/modules/HistoricalArtifactsModuleWrapper.tsx +13 -0
- flock/frontend/src/components/modules/ModuleRegistry.ts +7 -1
- flock/frontend/src/components/modules/registerModules.ts +9 -10
- flock/frontend/src/hooks/useModules.ts +11 -1
- flock/frontend/src/services/api.ts +140 -0
- flock/frontend/src/services/indexeddb.ts +56 -2
- flock/frontend/src/services/websocket.ts +129 -0
- flock/frontend/src/store/filterStore.test.ts +105 -185
- flock/frontend/src/store/filterStore.ts +173 -26
- flock/frontend/src/store/graphStore.test.ts +19 -0
- flock/frontend/src/store/graphStore.ts +166 -27
- flock/frontend/src/types/filters.ts +34 -1
- flock/frontend/src/types/graph.ts +7 -0
- flock/frontend/src/utils/artifacts.ts +24 -0
- flock/orchestrator.py +23 -1
- flock/service.py +146 -9
- flock/store.py +971 -24
- {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b70.dist-info}/METADATA +26 -1
- {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b70.dist-info}/RECORD +50 -43
- flock/frontend/src/components/filters/FilterBar.module.css +0 -29
- flock/frontend/src/components/filters/FilterBar.test.tsx +0 -133
- flock/frontend/src/components/filters/FilterBar.tsx +0 -33
- flock/frontend/src/components/modules/EventLogModule.test.tsx +0 -401
- flock/frontend/src/components/modules/EventLogModule.tsx +0 -396
- flock/frontend/src/components/modules/EventLogModuleWrapper.tsx +0 -17
- {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b70.dist-info}/WHEEL +0 -0
- {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b70.dist-info}/entry_points.txt +0 -0
- {flock_core-0.5.0b65.dist-info → flock_core-0.5.0b70.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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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={
|
|
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;
|