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
340 lines
7.4 KiB
Vue
340 lines
7.4 KiB
Vue
<!-- app/components/admin/AdminAlertsPanel.vue -->
|
|
<template>
|
|
<div v-if="hasContent" class="alerts-panel">
|
|
<div class="section-label">Needs Attention</div>
|
|
|
|
<div
|
|
v-for="alert in visibleAlerts"
|
|
:key="alert.type"
|
|
class="alert-row"
|
|
:class="`severity-${alert.severity}`"
|
|
>
|
|
<div class="alert-head">
|
|
<div>
|
|
<span class="alert-title">{{ alert.title }}</span>
|
|
<span class="alert-count">{{ alert.count }}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="dismiss-btn"
|
|
:disabled="dismissing[alert.type]"
|
|
@click="dismissAlert(alert)"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
<ul v-if="alert.items.length" class="alert-items">
|
|
<li v-for="(item, idx) in displayItems(alert)" :key="item.id || idx">
|
|
<NuxtLink v-if="item.href" :to="item.href">{{ item.label }}</NuxtLink>
|
|
<span v-else>{{ item.label }}</span>
|
|
<span v-if="item.sublabel" class="alert-item-sub">— {{ item.sublabel }}</span>
|
|
</li>
|
|
<li v-if="alert.items.length > maxItems" class="alert-more">
|
|
and {{ alert.items.length - maxItems }} more
|
|
</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: 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)
|
|
}
|
|
|
|
async function dismissAlert(alert) {
|
|
dismissing[alert.type] = true
|
|
try {
|
|
await $fetch('/api/admin/alerts/dismiss', {
|
|
method: 'POST',
|
|
body: { alertType: alert.type, signature: alert.signature }
|
|
})
|
|
// 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)
|
|
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>
|
|
.alerts-panel {
|
|
border-bottom: 1px dashed var(--border);
|
|
padding: 24px 28px;
|
|
}
|
|
|
|
.section-label {
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--text-faint);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.alert-row {
|
|
border: 1px dashed var(--border);
|
|
border-left-width: 3px;
|
|
border-left-style: solid;
|
|
padding: 12px 16px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.alert-row.severity-critical {
|
|
border-left-color: var(--ember);
|
|
}
|
|
|
|
.alert-row.severity-attention {
|
|
border-left-color: var(--candle);
|
|
}
|
|
|
|
.alert-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.alert-title {
|
|
font-size: 13px;
|
|
color: var(--text-bright);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.alert-count {
|
|
display: inline-block;
|
|
margin-left: 8px;
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
.dismiss-btn {
|
|
background: none;
|
|
border: 1px dashed var(--border);
|
|
padding: 4px 10px;
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.dismiss-btn:hover:not(:disabled) {
|
|
border-color: var(--candle);
|
|
color: var(--candle);
|
|
}
|
|
|
|
.dismiss-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: default;
|
|
}
|
|
|
|
.alert-items {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 6px 0 0;
|
|
}
|
|
|
|
.alert-items li {
|
|
font-size: 12px;
|
|
color: var(--text);
|
|
padding: 3px 0;
|
|
}
|
|
|
|
.alert-items a {
|
|
color: var(--text);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.alert-items a:hover {
|
|
color: var(--candle);
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.alert-item-sub {
|
|
color: var(--text-faint);
|
|
margin-left: 4px;
|
|
}
|
|
|
|
.alert-more {
|
|
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>
|