feat: saved searches (bookmarks)
All checks were successful
CI / lint-and-test (push) Successful in 23s
All checks were successful
CI / lint-and-test (push) Successful in 23s
- Add saved_searches_collection to database.py with index on created_by+created_at - New routes/saved_searches.py: GET /api/saved-searches, POST, DELETE - Saved searches are scoped per user (created_by = token sub) - Mount router in main.py - Frontend: Save filters button, saved search pills with load/delete - loadSavedSearches called on initApp - applySavedSearch restores filters and validates services against current options - Add CSS for saved-searches row - Add tests for CRUD, delete 404, and name validation
This commit is contained in:
@@ -112,11 +112,23 @@
|
||||
<div class="actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
||||
<button type="button" class="ghost" @click="saveCurrentFilters()">Save filters</button>
|
||||
<button type="button" class="ghost" @click="bulkTagMatching()">Bulk tag matching</button>
|
||||
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
||||
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-row" x-show="savedSearches.length">
|
||||
<div class="saved-searches">
|
||||
<span>Saved:</span>
|
||||
<template x-for="ss in savedSearches" :key="ss.id">
|
||||
<span class="pill pill--tag" style="cursor:pointer;" @click="applySavedSearch(ss)">
|
||||
<span x-text="ss.name"></span>
|
||||
<button type="button" class="link" style="margin-left:4px;" @click.stop="deleteSavedSearch(ss.id)">×</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -255,6 +267,7 @@
|
||||
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '',
|
||||
},
|
||||
options: { actors: [], services: [], operations: [], results: [] },
|
||||
savedSearches: [],
|
||||
appVersion: '',
|
||||
aiFeaturesEnabled: true,
|
||||
askQuestionText: '',
|
||||
@@ -271,6 +284,7 @@
|
||||
this.loadSavedFilters();
|
||||
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
||||
await this.loadFilterOptions();
|
||||
await this.loadSavedSearches();
|
||||
await this.loadSourceHealth();
|
||||
await this.loadEvents();
|
||||
}
|
||||
@@ -529,6 +543,59 @@
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async loadSavedSearches() {
|
||||
try {
|
||||
const res = await fetch('/api/saved-searches', { headers: this.authHeader() });
|
||||
if (!res.ok) return;
|
||||
this.savedSearches = await res.json();
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async saveCurrentFilters() {
|
||||
const name = prompt('Name this saved filter:');
|
||||
if (!name || !name.trim()) return;
|
||||
try {
|
||||
const res = await fetch('/api/saved-searches', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
||||
body: JSON.stringify({ name: name.trim(), filters: { ...this.filters } }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const created = await res.json();
|
||||
this.savedSearches.unshift(created);
|
||||
this.statusText = 'Filters saved.';
|
||||
setTimeout(() => { if (this.statusText === 'Filters saved.') this.statusText = ''; }, 2000);
|
||||
} catch (err) {
|
||||
this.statusText = err.message || 'Failed to save filters.';
|
||||
}
|
||||
},
|
||||
|
||||
applySavedSearch(ss) {
|
||||
if (!ss || !ss.filters) return;
|
||||
const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags'];
|
||||
fields.forEach((f) => {
|
||||
if (ss.filters[f] !== undefined) this.filters[f] = ss.filters[f];
|
||||
});
|
||||
// Validate selectedServices against current options
|
||||
this.filters.selectedServices = this.filters.selectedServices.filter((s) => this.options.services.includes(s));
|
||||
this.resetPagination();
|
||||
this.loadEvents();
|
||||
},
|
||||
|
||||
async deleteSavedSearch(id) {
|
||||
if (!confirm('Delete this saved search?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/saved-searches/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.authHeader(),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this.savedSearches = this.savedSearches.filter((s) => s.id !== id);
|
||||
} catch (err) {
|
||||
this.statusText = err.message || 'Failed to delete saved search.';
|
||||
}
|
||||
},
|
||||
|
||||
resetPagination() {
|
||||
this.cursorStack = [];
|
||||
this.nextCursor = null;
|
||||
|
||||
@@ -370,6 +370,14 @@ input {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.saved-searches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal__explanation {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
Reference in New Issue
Block a user