diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index bc50206..2cbdda9 100644 --- a/src/fgai/dashboard.py +++ b/src/fgai/dashboard.py @@ -669,7 +669,7 @@ async function refresh() { {label:'Common denominators', render:r => esc((r.common_fields || []).slice(0,5).map(item => `${item.field} ${(item.coverage*100).toFixed(0)}%`).join(', ') || '-')}, {label:'Shared fields', render:r => esc((r.shared_fields || []).slice(0,4).map(sharedFieldLabel).join(', ') || '-')}, {label:'Discovery', render:r => profileDiscoveryDetails(r)}, - {label:'Action', render:r => ``} + {label:'Action', render:r => `${r.profile_exists ? ` ` : ''}`} ], 'profile-suggestions') : '

No missing stream profiles. Enable "show existing profiles" to inspect already configured profiles.

'; const llmText = llm.text ? esc(llm.text).replace(/\\n/g, '
') : esc(llm.error || 'LLM assessment disabled or waiting for first run.'); document.getElementById('llmAssessment').innerHTML = `
Status: ${esc(llm.status || (llmEnabled ? 'starting' : 'disabled'))}

${llmText}

`; @@ -843,7 +843,6 @@ async function refresh() { '

Local-in Failures

' + table(d.local_in_failures || [], [{label:'Source', key:'src_ip', render:r => entityCell(r, 'src_ip')}, {label:'Service', key:'service'}, {label:'Policy', key:'policy'}, {label:'Count', key:'count'}]); document.querySelectorAll('details[data-detail-id]').forEach(item => { if (openDetails.has(item.dataset.detailId)) item.open = true; }); document.querySelectorAll('[data-sort-table]').forEach(button => button.addEventListener('click', () => { const current=tableSort[button.dataset.sortTable]; tableSort[button.dataset.sortTable]={key:button.dataset.sortKey,direction:current && current.key===button.dataset.sortKey ? -current.direction : 1}; refresh(); })); - document.querySelectorAll('.apply-suggested-profile').forEach(button => button.addEventListener('click', () => applySuggestedProfile(button.dataset.streamId))); } catch (error) { showRefreshError(error); } @@ -945,7 +944,8 @@ function renderStreamPicker(errorText='') {
${esc(enabledCount)} enabled, ${esc(streams.length)} visible, ${esc(window.availableStreams.length)} total
${streams.map(stream => { const selected = window.streamSelection[stream.id] || {id:stream.id, title:stream.title, enabled:false}; - return `
`; + const hasProfile = (window.streamProfiles || []).some(profile => String(profile.stream_id) === String(stream.id)); + return `
${hasProfile ? `` : ''}
`; }).join('')}
`; document.getElementById('streamFilter').addEventListener('input', () => renderStreamPicker()); document.getElementById('streamEnabledOnly').addEventListener('change', () => renderStreamPicker()); @@ -1043,6 +1043,32 @@ async function applySuggestedProfile(streamId) { if (button) button.disabled = false; } } +async function deleteStreamProfile(streamId, streamName='') { + const notice = document.getElementById('profileApplyResult') || document.getElementById('settingsResult'); + const title = streamName || streamId; + if (!confirm(`Delete profile for ${title}? Baseline data is not deleted, but this stream will stop using the profile until you apply or create a new one.`)) return; + try { + if (notice) notice.textContent = `Deleting profile for ${title}...`; + const configResponse = await fetch('/api/config', {cache: 'no-store'}); + const config = await configResponse.json(); + const profiles = (config.graylog_stream_profiles || []).filter(item => String(item.stream_id) !== String(streamId)); + const response = await fetch('/api/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({graylog_stream_profiles: profiles})}); + let result = {}; + try { result = await response.json(); } catch {} + if (!response.ok) throw new Error(result.error || response.statusText || response.status); + window.streamProfiles = result.graylog_stream_profiles || profiles; + if (window.activeProfileStream === streamId) { + window.activeProfileStream = ''; + window.activeProfileTitle = ''; + document.getElementById('profileEditorStatus').textContent = 'Profile deleted. Select a stream to edit or apply a recommended profile.'; + document.getElementById('fieldPicker').textContent = 'No profile selected for editing.'; + } + if (notice) notice.textContent = `Deleted profile for ${title}. Monitor will stop using it on the next cycle.`; + loadSettings(); + } catch (error) { + if (notice) notice.textContent = `Could not delete profile: ${error}`; + } +} async function editStreamProfile(streamId, title) { document.getElementById('profileEditorStatus').innerHTML = `Editing profile for ${esc(title || streamId)}`; document.getElementById('fieldPicker').textContent = 'Loading stream fields...'; @@ -1059,10 +1085,25 @@ async function editStreamProfile(streamId, title) { window.activeProfileTitle = title || streamId; } document.getElementById('streamPicker').addEventListener('click', event => { + const deleteButton = event.target.closest('.delete-stream-profile'); + if (deleteButton) { + deleteStreamProfile(deleteButton.dataset.streamId, deleteButton.dataset.streamName); + return; + } const button = event.target.closest('.edit-profile'); if (!button) return; editStreamProfile(button.dataset.id, button.dataset.title); }); +document.getElementById('profileSuggestions').addEventListener('click', event => { + const deleteButton = event.target.closest('.delete-stream-profile'); + if (deleteButton) { + deleteStreamProfile(deleteButton.dataset.streamId, deleteButton.dataset.streamName); + return; + } + const applyButton = event.target.closest('.apply-suggested-profile'); + if (!applyButton) return; + applySuggestedProfile(applyButton.dataset.streamId); +}); document.getElementById('streamPicker').addEventListener('change', event => { const checkbox = event.target.closest('.graylog-stream'); if (!checkbox || !window.streamSelection[checkbox.dataset.id]) return; diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 181ab9c..556a9ab 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -34,6 +34,11 @@ class DashboardTests(unittest.TestCase): self.assertIn("Update profile", HTML) self.assertIn("function mergeProfile", HTML) + def test_dashboard_can_delete_stream_profiles(self): + self.assertIn("Delete profile", HTML) + self.assertIn("function deleteStreamProfile", HTML) + self.assertIn("delete-stream-profile", HTML) + def test_dashboard_has_direct_stream_selection_save(self): self.assertIn("Save streams", HTML) self.assertIn("function saveStreamSelection", HTML)