220 lines
7.8 KiB
Vue
220 lines
7.8 KiB
Vue
<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>
|