Updated A LOT
This commit is contained in:
219
src/views/AlertsView.vue
Normal file
219
src/views/AlertsView.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<section class="content-grid">
|
||||
<v-card class="metric span-3">
|
||||
<div class="metric-label">Open</div>
|
||||
<div class="metric-value">{{ summary.open || 0 }}</div>
|
||||
<div class="metric-subtle">Needing attention</div>
|
||||
</v-card>
|
||||
<v-card class="metric span-3">
|
||||
<div class="metric-label">Critical</div>
|
||||
<div class="metric-value">{{ summary.critical || 0 }}</div>
|
||||
<div class="metric-subtle">Overdue or expired</div>
|
||||
</v-card>
|
||||
<v-card class="metric span-3">
|
||||
<div class="metric-label">Acknowledged</div>
|
||||
<div class="metric-value">{{ summary.acknowledged || 0 }}</div>
|
||||
<div class="metric-subtle">In progress</div>
|
||||
</v-card>
|
||||
<v-card class="metric span-3 d-flex flex-column justify-center">
|
||||
<v-btn color="primary" prepend-icon="mdi-refresh" :loading="loading" @click="refresh">Run scan</v-btn>
|
||||
<v-btn v-if="isAdmin" class="mt-2" variant="tonal" size="small" prepend-icon="mdi-email-fast-outline" :loading="emailing" @click="emailDigest">Email digest now</v-btn>
|
||||
</v-card>
|
||||
|
||||
<v-card class="span-12 panel-pad">
|
||||
<div class="toolbar-row mb-3">
|
||||
<h2 class="section-title">Reminders & alerts</h2>
|
||||
<v-spacer />
|
||||
<v-select v-model="statusFilter" :items="statusItems" label="Status" density="compact" hide-details style="max-width:180px" />
|
||||
<v-select v-model="typeFilter" :items="typeItems" label="Type" density="compact" hide-details clearable style="max-width:220px" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="filteredAlerts"
|
||||
row-key="id"
|
||||
has-actions
|
||||
searchable
|
||||
search-placeholder="Filter alerts"
|
||||
empty-text="No alerts — you're all caught up."
|
||||
>
|
||||
<template #cell-severity="{ row }">
|
||||
<v-chip :color="severityColor(row.severity)" size="small" variant="tonal">{{ row.severity }}</v-chip>
|
||||
</template>
|
||||
<template #cell-type="{ row }">{{ humanize(row.type) }}</template>
|
||||
<template #cell-asset_code="{ row }">{{ row.asset_code || '—' }}</template>
|
||||
<template #cell-status="{ row }">
|
||||
<span class="text-capitalize">{{ row.status }}</span>
|
||||
</template>
|
||||
<template #cell-external_ticket_number="{ row }">
|
||||
<a v-if="row.external_ticket_number" :href="row.external_ticket_url" target="_blank" rel="noopener" class="ticket-link">
|
||||
<v-icon icon="mdi-ticket-confirmation-outline" size="14" /> {{ row.external_ticket_number }}
|
||||
</a>
|
||||
<span v-else class="quiet">—</span>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<v-btn v-if="row.status === 'open'" size="small" variant="text" @click="setStatus(row, 'acknowledge')">Ack</v-btn>
|
||||
<v-btn
|
||||
v-if="!row.external_ticket_number && row.status !== 'dismissed'"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
:loading="ticketingId === row.id"
|
||||
@click="submitTicket(row)"
|
||||
>ServiceNow</v-btn>
|
||||
<v-btn v-if="row.status !== 'dismissed'" size="small" variant="text" color="error" @click="setStatus(row, 'dismiss')">Dismiss</v-btn>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
|
||||
</v-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DataTable from '../components/DataTable.vue';
|
||||
import { apiRequest } from '../services/api';
|
||||
|
||||
export default {
|
||||
components: { DataTable },
|
||||
props: {
|
||||
token: { type: String, required: true },
|
||||
isAdmin: { type: Boolean, default: false }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
alerts: [],
|
||||
summary: {},
|
||||
statusFilter: 'open',
|
||||
typeFilter: null,
|
||||
loading: false,
|
||||
emailing: false,
|
||||
error: '',
|
||||
message: '',
|
||||
statusItems: [
|
||||
{ title: 'Open', value: 'open' },
|
||||
{ title: 'Acknowledged', value: 'acknowledged' },
|
||||
{ title: 'Dismissed', value: 'dismissed' },
|
||||
{ title: 'Resolved', value: 'resolved' },
|
||||
{ title: 'All', value: 'all' }
|
||||
],
|
||||
typeItems: [
|
||||
{ title: 'Maintenance overdue', value: 'maintenance_overdue' },
|
||||
{ title: 'Maintenance due', value: 'maintenance_due' },
|
||||
{ title: 'Warranty expired', value: 'warranty_expired' },
|
||||
{ title: 'Warranty expiring', value: 'warranty_expiring' },
|
||||
{ title: 'Lease ending', value: 'lease_expiring' }
|
||||
],
|
||||
columns: [
|
||||
{ key: 'severity', label: 'Severity' },
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'title', label: 'Alert' },
|
||||
{ key: 'message', label: 'Detail' },
|
||||
{ key: 'due_date', label: 'Due', format: 'date' },
|
||||
{ key: 'asset_code', label: 'Asset' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'external_ticket_number', label: 'Ticket', sortable: false }
|
||||
],
|
||||
ticketingId: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredAlerts() {
|
||||
return this.alerts.filter((a) =>
|
||||
(this.statusFilter === 'all' || a.status === this.statusFilter) &&
|
||||
(!this.typeFilter || a.type === this.typeFilter)
|
||||
);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.refresh();
|
||||
},
|
||||
methods: {
|
||||
async api(path, options = {}) {
|
||||
return apiRequest(path, this.token, options);
|
||||
},
|
||||
async refresh() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const data = await (await this.api('/api/alerts/scan', { method: 'POST' })).json();
|
||||
this.alerts = data.alerts;
|
||||
this.summary = data.summary;
|
||||
} catch (error) {
|
||||
// Fall back to a read-only list if the user cannot scan.
|
||||
try {
|
||||
const data = await (await this.api('/api/alerts')).json();
|
||||
this.alerts = data.alerts;
|
||||
this.summary = data.summary;
|
||||
} catch (inner) {
|
||||
this.error = inner.message;
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async setStatus(row, action) {
|
||||
try {
|
||||
await this.api(`/api/alerts/${row.id}/${action}`, { method: 'PUT' });
|
||||
const data = await (await this.api('/api/alerts')).json();
|
||||
this.alerts = data.alerts;
|
||||
this.summary = data.summary;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
}
|
||||
},
|
||||
async emailDigest() {
|
||||
this.emailing = true;
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const result = await (await this.api('/api/alerts/run', { method: 'POST' })).json();
|
||||
this.message = result.emailed
|
||||
? `Digest emailed (${result.count} alert(s)).`
|
||||
: 'Scan complete. No email sent (alerts disabled, SMTP not set, or nothing new).';
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
this.emailing = false;
|
||||
}
|
||||
},
|
||||
async submitTicket(row) {
|
||||
this.ticketingId = row.id;
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const { ticket } = await (await this.api(`/api/alerts/${row.id}/ticket`, { method: 'POST', body: JSON.stringify({}) })).json();
|
||||
this.message = ticket.duplicate
|
||||
? `Alert already linked to ${ticket.number}.`
|
||||
: `Created ServiceNow incident ${ticket.number}.`;
|
||||
const data = await (await this.api('/api/alerts')).json();
|
||||
this.alerts = data.alerts;
|
||||
this.summary = data.summary;
|
||||
} catch (error) {
|
||||
this.error = `ServiceNow ticket failed: ${error.message}`;
|
||||
} finally {
|
||||
this.ticketingId = null;
|
||||
}
|
||||
},
|
||||
severityColor(severity) {
|
||||
return { critical: 'error', warning: 'warning', info: 'info' }[severity] || 'info';
|
||||
},
|
||||
humanize(value) {
|
||||
return String(value || '').replace(/_/g, ' ');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticket-link {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ticket-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user