Updated A LOT

This commit is contained in:
2026-06-05 04:15:24 -05:00
parent 8335f1af1b
commit 4072695432
92 changed files with 16139 additions and 570 deletions

219
src/views/AlertsView.vue Normal file
View 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 &amp; 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>