feat(admin): add restore dismissed alerts flow
Some checks failed
Test / vitest (push) Successful in 11m48s
Test / playwright (push) Failing after 9m50s
Test / visual (push) Failing after 9m19s
Test / Notify on failure (push) Successful in 2s

Admins can now surface dismissed alert types without waiting for the
underlying data to change. Adds a collapsible "Restore dismissed"
section below the active alerts with per-type checkboxes.

- ALERT_METADATA map in adminAlerts.js as the single source of truth
  for slug → title/severity; detectors refactored to reference it
- GET /api/admin/alerts/dismissed returns this admin's dismissals
  joined with metadata (title, severity, dismissedAt)
- POST /api/admin/alerts/restore deletes dismissals by alertType[],
  returns the deleted count
- AdminAlertsPanel fetches both active + dismissed; stays visible
  when either is non-empty; checkboxes + "Restore selected" button
- adminAlertRestoreSchema validates the POST body against the enum
- Auth guards test covers both new routes
This commit is contained in:
Jennie Robinson Faber 2026-04-08 12:22:35 +01:00
parent a2af4e31ff
commit 92e7dae74c
7 changed files with 423 additions and 41 deletions

View file

@ -1,7 +1,8 @@
<!-- app/components/admin/AdminAlertsPanel.vue -->
<template>
<div v-if="visibleAlerts.length" class="alerts-panel">
<div v-if="hasContent" class="alerts-panel">
<div class="section-label">Needs Attention</div>
<div
v-for="alert in visibleAlerts"
:key="alert.type"
@ -33,16 +34,77 @@
</li>
</ul>
</div>
<div v-if="!visibleAlerts.length" class="empty-active">
No active alerts.
</div>
<div v-if="dismissedAlerts.length" class="restore-section">
<button
v-if="!restoreOpen"
type="button"
class="restore-toggle"
@click="restoreOpen = true"
>
Restore dismissed ({{ dismissedAlerts.length }})
</button>
<div v-else class="restore-panel">
<div class="section-label restore-label">Restore dismissed alerts</div>
<ul class="restore-list">
<li v-for="d in dismissedAlerts" :key="d.alertType">
<label class="restore-option">
<input
v-model="selectedRestore"
type="checkbox"
:value="d.alertType"
/>
<span>{{ d.title }}</span>
<span class="restore-when">dismissed {{ formatDismissedAt(d.dismissedAt) }}</span>
</label>
</li>
</ul>
<div class="restore-actions">
<button
type="button"
class="dismiss-btn"
:disabled="!selectedRestore.length || restoring"
@click="restoreSelected"
>
Restore selected
</button>
<button
type="button"
class="dismiss-btn"
@click="cancelRestore"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
const maxItems = 5
const dismissing = reactive({})
const restoreOpen = ref(false)
const selectedRestore = ref([])
const restoring = ref(false)
const { data, refresh } = await useFetch('/api/admin/alerts', { default: () => ({ alerts: [] }) })
const { data, refresh } = await useFetch('/api/admin/alerts', {
default: () => ({ alerts: [] })
})
const { data: dismissedData, refresh: refreshDismissed } = await useFetch(
'/api/admin/alerts/dismissed',
{ default: () => ({ dismissed: [] }) }
)
const visibleAlerts = computed(() => data.value?.alerts || [])
const dismissedAlerts = computed(() => dismissedData.value?.dismissed || [])
const hasContent = computed(
() => visibleAlerts.value.length > 0 || dismissedAlerts.value.length > 0
)
function displayItems(alert) {
return alert.items.slice(0, maxItems)
@ -55,18 +117,51 @@ async function dismissAlert(alert) {
method: 'POST',
body: { alertType: alert.type, signature: alert.signature }
})
// Optimistic local removal
data.value = {
alerts: visibleAlerts.value.filter((a) => a.type !== alert.type)
}
// Refetch both lists so the dismissed alert appears in the restore list
await Promise.all([refresh(), refreshDismissed()])
} catch (err) {
console.error('Failed to dismiss alert', err)
// On error, refetch to get authoritative state
await refresh()
} finally {
dismissing[alert.type] = false
}
}
async function restoreSelected() {
if (!selectedRestore.value.length) return
restoring.value = true
try {
await $fetch('/api/admin/alerts/restore', {
method: 'POST',
body: { alertTypes: selectedRestore.value }
})
selectedRestore.value = []
restoreOpen.value = false
await Promise.all([refresh(), refreshDismissed()])
} catch (err) {
console.error('Failed to restore alerts', err)
} finally {
restoring.value = false
}
}
function cancelRestore() {
selectedRestore.value = []
restoreOpen.value = false
}
function formatDismissedAt(value) {
if (!value) return ''
const d = new Date(value)
const now = Date.now()
const diffMin = Math.round((now - d.getTime()) / 60000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffH = Math.round(diffMin / 60)
if (diffH < 24) return `${diffH}h ago`
const diffD = Math.round(diffH / 24)
return `${diffD}d ago`
}
</script>
<style scoped>
@ -170,4 +265,76 @@ async function dismissAlert(alert) {
color: var(--text-faint);
font-style: italic;
}
.empty-active {
font-size: 12px;
color: var(--text-faint);
font-style: italic;
padding: 4px 0 8px;
}
.restore-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border);
}
.restore-toggle {
background: none;
border: none;
padding: 0;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
font-family: inherit;
text-decoration: underline dashed;
text-underline-offset: 3px;
}
.restore-toggle:hover {
color: var(--candle);
}
.restore-panel {
padding: 4px 0;
}
.restore-label {
margin-bottom: 8px;
}
.restore-list {
list-style: none;
padding: 0;
margin: 0 0 10px;
}
.restore-list li {
padding: 4px 0;
}
.restore-option {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text);
cursor: pointer;
}
.restore-option input[type='checkbox'] {
accent-color: var(--candle);
cursor: pointer;
}
.restore-when {
color: var(--text-faint);
font-size: 11px;
margin-left: auto;
}
.restore-actions {
display: flex;
gap: 8px;
}
</style>