Adds in Mantis features. Enabling automated downloading of Mantises into the internal database, browsing of them, and viewing of attachments (including .msg files).

Resolves #14
This commit is contained in:
Cameron Redmore 2025-04-25 23:31:50 +01:00
parent 0e77e310bd
commit 5268d6aecd
15 changed files with 1583 additions and 44 deletions

View file

@ -0,0 +1,611 @@
<template>
<q-dialog
:model-value="modelValue"
@update:model-value="emitUpdate"
@hide="resetDialog"
persistent
maximized
>
<q-card>
<q-card-section class="row items-center q-ma-none">
<div class="text-h6">
Mantis Ticket Details - {{ ticket ? ('M' + ticket.id) : '' }}
</div>
<q-space />
<q-btn
icon="close"
flat
round
dense
v-close-popup
/>
</q-card-section>
<q-separator />
<q-card-section
v-if="loading"
class="text-center"
>
<q-spinner
size="xl"
color="primary"
/>
<p>Loading ticket details...</p>
</q-card-section>
<q-card-section
v-else-if="error"
class="text-negative"
>
<q-icon
name="error"
size="md"
class="q-mr-sm"
/>
{{ error }}
</q-card-section>
<q-card-section
v-else-if="ticket"
class="q-pa-none"
>
<q-tabs
v-model="tab"
dense
class="text-grey"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
>
<q-tab
name="details"
label="Details"
/>
<q-tab
name="files"
label="Files"
/>
<q-tab
name="notes"
label="Internal Notes"
/>
</q-tabs>
<q-separator />
<q-tab-panels
v-model="tab"
animated
class="scroll"
style="max-height: calc(100vh - 103px)"
>
<q-tab-panel
name="details"
class="q-pa-md"
>
<!-- Basic Info List (Moved Here) -->
<q-list
bordered
separator
class="q-mb-md"
>
<q-item>
<q-item-section>
<q-item-label overline>
ID
</q-item-label>
<q-item-label>
<a
:href="`//styletech.mantishub.io/view.php?id=${ticket.id}`"
target="_blank"
>{{ ticket.id }}</a>
</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label overline>
Status
</q-item-label>
<q-item-label>
<q-badge
:color="getStatusColor(ticket.status)"
:label="ticket.status"
/>
</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label overline>
Priority
</q-item-label>
<q-item-label>
<q-badge
:color="getPriorityColor(ticket.priority)"
:label="ticket.priority"
/>
</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label overline>
Severity
</q-item-label>
<q-item-label>
<q-badge
:color="getSeverityColor(ticket.severity)"
:label="ticket.severity"
/>
</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label overline>
Reporter
</q-item-label>
<q-item-label>{{ ticket.reporterUsername }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label overline>
Created At
</q-item-label>
<q-item-label>{{ new Date(ticket.createdAt).toLocaleString() }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label overline>
Last Updated
</q-item-label>
<q-item-label>{{ new Date(ticket.updatedAt).toLocaleString() }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Title, Description, Comments -->
<div>
<div class="text-h6 q-mb-sm">
{{ ticket.title }}
</div>
<q-separator class="q-mb-md" />
<div class="text-subtitle1 q-mb-xs">
Description
</div>
<div
v-html="ticket.description"
class="q-mb-md mantis-description"
/>
<!-- Note: v-html can be risky if content isn't sanitized server-side -->
<q-separator class="q-mb-md" />
<div class="text-subtitle1 q-mb-xs">
Comments ({{ ticket.comments?.length || 0 }})
</div>
<q-list
bordered
separator
v-if="ticket.comments && ticket.comments.length > 0"
>
<template
v-for="comment in ticket.comments"
:key="comment.id"
>
<q-item
class="q-mb-sm"
>
<q-item-section>
<q-item-label
overline
>
<strong class="text-subtitle1">{{ comment.senderUsername }}</strong> - {{ new Date(comment.createdAt).toLocaleString() }}
</q-item-label>
<q-item-label
caption
>
<div
class="text-body1 comment-text"
v-html="sanitiseComment(comment.comment)"
/>
</q-item-label>
<q-item-section
v-if="comment.attachments && comment.attachments.length > 0"
>
<q-separator class="q-mt-sm q-mb-lg" />
<q-item-label
overline
class="q-mb-sm"
>
Attachments:
</q-item-label>
<q-list
bordered
separator
style="background-color: rgba(0,0,0,0.25);"
>
<q-item
v-for="attachment in comment.attachments"
:key="attachment.id"
>
<q-item-section>
<q-item-label>
<a
:href="`/api/${attachment.url}`"
target="_blank"
>
{{ attachment.filename }}
</a>
<!-- Add MSG preview button for .msg files -->
<q-btn
v-if="isMsgFile(attachment.filename)"
flat
round
color="primary"
icon="email"
size="sm"
class="q-ml-sm"
@click="previewMsgFile(ticket.id, attachment.url.split('/')[attachment.url.split('/').length - 1], attachment.filename)"
:loading="loadingMsgId === attachment.id"
>
<q-tooltip>Preview Email</q-tooltip>
</q-btn>
</q-item-label>
<!-- Add image preview if it's an image file -->
<q-item-label
v-if="isImageFile(attachment.filename)"
class="q-mt-sm"
>
<img
:src="`/api/${attachment.url}`"
style="max-width: 100%; max-height: 200px; border-radius: 4px;"
@click="openImageFullscreen(`/api/${attachment.url}`, attachment.filename)"
>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-item-section>
</q-item-section>
<!-- Add card for each attachment -->
</q-item>
<q-item style="background-color: rgba(0,0,0,0.25);" />
</template>
</q-list>
<div
v-else
class="text-grey q-mb-md"
>
No comments found.
</div>
</div>
</q-tab-panel>
<q-tab-panel
name="files"
class="q-pa-md"
>
<!-- Content for Files tab goes here -->
<div class="text-grey">
Files content will be added here.
</div>
</q-tab-panel>
<q-tab-panel
name="notes"
class="q-pa-md"
>
<!-- Content for Internal Notes tab goes here -->
<div class="text-grey">
Internal Notes content will be added here.
</div>
</q-tab-panel>
</q-tab-panels>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, watch, defineProps, defineEmits } from 'vue';
import axios from 'axios';
import { useQuasar } from 'quasar';
import DOMPurify from 'dompurify';
import {usePreferencesStore} from 'stores/preferences.js';
const preferencesStore = usePreferencesStore();
const props = defineProps({
modelValue: Boolean, // Controls dialog visibility (v-model)
ticketId: {
type: [Number, null],
'default': null
}
});
const emit = defineEmits(['update:modelValue', 'close']);
const $q = useQuasar();
const ticket = ref(null);
const loading = ref(false);
const error = ref(null);
const tab = ref('details'); // Add state for the active tab
const loadingMsgId = ref(null); // Track which MSG attachment is currently loading
const sanitiseComment = (comment) =>
{
//Replace all URLs with anchor tags
comment = comment.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank">$1</a>');
comment = comment.replaceAll('\r\n', '<br />');
comment = comment.replaceAll('\n', '<br />');
comment = comment.replaceAll('\r', '<br />');
comment = comment.replaceAll('\t', '&nbsp;&nbsp;&nbsp;&nbsp;');
return DOMPurify.sanitize(comment, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href']
});
};
// Function to preview MSG file content
const previewMsgFile = async(ticketId, attachmentId, filename) =>
{
loadingMsgId.value = attachmentId;
try
{
const response = await axios.get(`/api/mantis/msg-extract/${ticketId}/${attachmentId}`);
// Display email content in a dialog
$q.dialog({
title: `Email: ${filename}`,
message: createEmailPreview(response.data, ticketId, attachmentId),
html: true,
style: 'min-width: 70vw; min-height: 60vh;',
maximized: $q.screen.lt.md,
persistent: true
});
}
catch (err)
{
console.error('Error fetching MSG file content:', err);
$q.notify({
type: 'negative',
message: `Failed to load email preview: ${err.response?.data?.error || err.message}`
});
}
finally
{
loadingMsgId.value = null;
}
};
// Format the email content for display
const createEmailPreview = (emailData, ticketId, attachmentId) =>
{
if (!emailData) return '<p>No email data available</p>';
let { subject, senderName, recipients, body, headers, attachments } = emailData;
body = body.trim();
// Collapse all whitespace in the body, preserving newlines
body = body.replace(/\r\n/g, '\n'); // Normalize line endings
body = body.replace(/\n+/g, '\n'); // Collapse multiple newlines
// Extract date from headers if available
const dateMatch = headers && headers.match(/Date:\s*([^\r\n]+)/i);
const date = dateMatch ? dateMatch[1] : null;
// Create recipient string from recipients array
const recipientStr = recipients && recipients.length > 0
? recipients.map(recipient => recipient.name).join(', ')
: 'Unknown';
let html = `
<div style="font-family: Arial, sans-serif; padding: 15px;">
<div style="margin-bottom: 20px; border-bottom: 1px solid #ddd; padding-bottom: 10px;">
<p><strong>Subject:</strong> ${subject || 'No subject'}</p>
<p><strong>From:</strong> ${senderName || 'Unknown'}</p>
<p><strong>To:</strong> ${recipientStr}</p>
${date ? `<p><strong>Date:</strong> ${date}</p>` : ''}
</div>
<div style="line-height: 1.5; margin-bottom: 20px;">
${body ? DOMPurify.sanitize(body.replace(/\n/g, '<br>')) : 'No content'}
</div>`;
// Add attachments section if there are any
if (attachments && attachments.length > 0)
{
html += `
<div style="border-top: 1px solid #ddd; padding-top: 10px;">
<p><strong>Attachments (${attachments.length}):</strong></p>
<ul>
${attachments.map(att =>
{
const index = attachments.indexOf(att);
return `<li><a href="/api/mantis/msg-extract/${ticketId}/${attachmentId}/${index}" target="_blank">${att.fileName}</a> (${formatFileSize(att.contentLength)})</li>`;
}).join('')}
</ul>
</div>`;
}
html += '</div>';
return html;
};
// Format file size in bytes to human-readable format
const formatFileSize = (bytes) =>
{
if (!bytes || bytes === 0) return '0 Bytes';
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
};
const fetchTicketDetails = async(id) =>
{
if (!id) return;
loading.value = true;
error.value = null;
ticket.value = null;
try
{
const response = await axios.get(`/api/mantis/${id}`);
ticket.value = response.data;
//Check user preference for comment order
if(preferencesStore.values.mantisCommentsOrder === 'newest')
{
ticket.value.comments.reverse();
}
}
catch (err)
{
console.error(`Error fetching Mantis ticket ${id}:`, err);
error.value = `Failed to load ticket details. ${err.response?.data?.error || err.message}`;
$q.notify({
type: 'negative',
message: error.value
});
}
finally
{
loading.value = false;
}
};
const resetDialog = () =>
{
ticket.value = null;
loading.value = false;
error.value = null;
// Emit close event if needed, though v-close-popup handles visibility
emit('close');
};
const emitUpdate = (value) =>
{
emit('update:modelValue', value);
};
// Watch for changes in ticketId and fetch data when the dialog is shown
watch(() => props.ticketId, (newId) =>
{
if (props.modelValue && newId)
{
fetchTicketDetails(newId);
}
});
// Also fetch when the dialog becomes visible if ticketId is already set
watch(() => props.modelValue, (isVisible) =>
{
if (isVisible && props.ticketId && !ticket.value && !loading.value)
{
fetchTicketDetails(props.ticketId);
}
});
// Helper functions for badge colors (copied from MantisPage for consistency)
const getStatusColor = (status) =>
{
const lowerStatus = status?.toLowerCase();
if (lowerStatus === 'new') return 'blue';
if (lowerStatus === 'feedback') return 'orange';
if (lowerStatus === 'acknowledged') return 'purple';
if (lowerStatus === 'confirmed') return 'cyan';
if (lowerStatus === 'assigned') return 'teal';
if (lowerStatus === 'resolved') return 'green';
if (lowerStatus === 'closed') return 'grey';
return 'primary';
};
const getPriorityColor = (priority) =>
{
const lowerPriority = priority?.toLowerCase();
if (lowerPriority === 'none') return 'grey';
if (lowerPriority === 'low') return 'blue';
if (lowerPriority === 'normal') return 'green';
if (lowerPriority === 'high') return 'orange';
if (lowerPriority === 'urgent') return 'red';
if (lowerPriority === 'immediate') return 'purple';
return 'primary';
};
const getSeverityColor = (severity) =>
{
const lowerSeverity = severity?.toLowerCase();
if (lowerSeverity === 'feature') return 'info';
if (lowerSeverity === 'trivial') return 'grey';
if (lowerSeverity === 'text') return 'blue-grey';
if (lowerSeverity === 'tweak') return 'light-blue';
if (lowerSeverity === 'minor') return 'lime';
if (lowerSeverity === 'major') return 'amber';
if (lowerSeverity === 'crash') return 'deep-orange';
if (lowerSeverity === 'block') return 'red';
return 'primary';
};
const isImageFile = (filename) =>
{
return (/\.(jpg|jpeg|png|gif|bmp|webp)$/i).test(filename);
};
const isMsgFile = (filename) =>
{
return (/\.(msg)$/i).test(filename);
};
const openImageFullscreen = (src, filename) =>
{
$q.dialog({
title: filename,
style: 'min-width: 300px; min-height: 300px;',
message: `<img src="${src}" style="width: 100%; height: auto;" />`,
html: true,
position: {
x: 'center',
y: 'middle'
},
persistent: true
});
};
</script>
<style lang="scss" scoped>
/* Scoped styles might not apply to v-html content easily */
/* Use a more global style or target specifically if needed */
.mantis-description {
white-space: pre-wrap; /* Preserve whitespace and newlines */
word-wrap: break-word;
padding: 10px;
border: 1px solid #eee;
border-radius: 4px;
max-height: 300px; /* Limit height if descriptions are long */
overflow-y: auto;
}
a, .comment-text:deep(a) {
color: $primary;
text-decoration: none;
}
a:hover, .comment-text:deep(a:hover) {
text-decoration: underline;
}
a:visited, .comment-text:deep(a:visited) {
color: $blue-5;
}
</style>

View file

@ -5,6 +5,7 @@
bordered
persistent
:model-value="true"
:side="preferencesStore.values.drawerSide || 'left'"
>
<q-item
clickable
@ -160,7 +161,6 @@
clickable
v-ripple
:to="{ name: item.name }"
exact
dense
>
<q-tooltip
@ -261,6 +261,7 @@ import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import { useAuthStore } from 'stores/auth'; // Import the auth store
import { useChatStore } from 'stores/chat'; // Adjust path as needed
import { usePreferencesStore } from 'stores/preferences'; // Import the preferences store
import ChatInterface from 'components/ChatInterface.vue'; // Adjust path as needed
import routes from '../router/routes'; // Import routes
@ -269,6 +270,7 @@ const leftDrawerOpen = ref(false);
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
const chatStore = useChatStore();
const preferencesStore = usePreferencesStore(); // Import the preferences store
const fabOpen = ref(false); // Local state for FAB animation, not chat visibility

249
src/pages/MantisPage.vue Normal file
View file

@ -0,0 +1,249 @@
<template>
<q-page padding>
<q-card
flat
bordered
class="q-mb-xl"
>
<q-card-section class="row items-center justify-between">
<div class="text-h4">
Mantis Tickets
</div>
<q-input
dense
debounce="300"
v-model="searchTerm"
placeholder="Search tickets..."
@update:model-value="fetchTickets(1)"
clearable
style="width: 300px"
>
<template #append>
<q-icon name="search" />
</template>
</q-input>
</q-card-section>
<q-table
:rows="tickets"
:columns="columns"
row-key="id"
v-model:pagination="pagination"
:loading="loading"
@request="handleTableRequest"
binary-state-sort
flat
bordered
@row-click="onRowClick"
class="cursor-pointer"
>
<template #body-cell-status="statusProps">
<q-td :props="statusProps">
<q-badge
:color="getStatusColor(statusProps.row.status)"
:label="statusProps.row.status"
/>
</q-td>
</template>
<template #body-cell-priority="priorityProps">
<q-td :props="priorityProps">
<q-badge
:color="getPriorityColor(priorityProps.row.priority)"
:label="priorityProps.row.priority"
/>
</q-td>
</template>
<template #body-cell-severity="severityProps">
<q-td :props="severityProps">
<q-badge
:color="getSeverityColor(severityProps.row.severity)"
:label="severityProps.row.severity"
/>
</q-td>
</template>
</q-table>
</q-card>
<mantis-ticket-dialog
v-model="showDialog"
:ticket-id="selectedTicketId"
@close="closeDialog"
/>
</q-page>
</template>
<script setup>
import { ref, onMounted, watch, defineProps } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { useQuasar } from 'quasar';
import MantisTicketDialog from 'src/components/MantisTicketDialog.vue';
const props = defineProps({
ticketId: {
type: [String, Number],
'default': null
}
});
const $q = useQuasar();
const tickets = ref([]);
const loading = ref(false);
const searchTerm = ref('');
const showDialog = ref(false);
const selectedTicketId = ref(null);
const router = useRouter();
const pagination = ref({
sortBy: 'updatedAt',
descending: true,
page: 1,
rowsPerPage: 25,
rowsNumber: 0 // Total number of rows/tickets
});
const columns = [
{ name: 'id', label: 'ID', field: 'id', align: 'left', sortable: true },
{ name: 'title', label: 'Title', field: 'title', align: 'left', sortable: true },
{ name: 'status', label: 'Status', field: 'status', align: 'center', sortable: true },
{ name: 'priority', label: 'Priority', field: 'priority', align: 'center', sortable: true },
{ name: 'severity', label: 'Severity', field: 'severity', align: 'center', sortable: true },
{ name: 'reporterUsername', label: 'Reporter', field: 'reporterUsername', align: 'left', sortable: true },
{ name: 'updatedAt', label: 'Last Updated', field: 'updatedAt', align: 'left', sortable: true, format: (val) => new Date(val).toLocaleString() },
];
const fetchTickets = async(page = pagination.value.page) =>
{
loading.value = true;
try
{
const params = {
page: page,
limit: pagination.value.rowsPerPage,
search: searchTerm.value || undefined,
// Add sorting params if needed based on pagination.sortBy and pagination.descending
};
const response = await axios.get('/api/mantis', { params });
tickets.value = response.data.data;
pagination.value.rowsNumber = response.data.pagination.total;
pagination.value.page = response.data.pagination.page; // Update current page
}
catch (error)
{
console.error('Error fetching Mantis tickets:', error);
$q.notify({
type: 'negative',
message: 'Failed to fetch Mantis tickets.'
});
}
finally
{
loading.value = false;
}
};
const handleTableRequest = (props) =>
{
const { page, rowsPerPage, sortBy, descending } = props.pagination;
pagination.value.page = page;
pagination.value.rowsPerPage = rowsPerPage;
pagination.value.sortBy = sortBy;
pagination.value.descending = descending;
fetchTickets(page);
};
const onRowClick = (evt, row) =>
{
//Change the route
router.push({ name: 'mantis', params: { ticketId: row.id } });
};
const openDialogForTicket = (id) =>
{
const ticketNum = Number(id);
if (!isNaN(ticketNum) && ticketNum > 0)
{
selectedTicketId.value = ticketNum;
showDialog.value = true;
}
};
const closeDialog = () =>
{
showDialog.value = false;
selectedTicketId.value = null;
//Reset the route to remove the ticketId from the URL
router.push({ name: 'mantis' });
};
// Watch for changes in the ticketId prop
watch(() => props.ticketId, (newTicketId) =>
{
if (newTicketId)
{
openDialogForTicket(newTicketId);
}
else
{
closeDialog();
}
});
onMounted(() =>
{
fetchTickets();
// Check initial prop value on mount
if (props.ticketId)
{
openDialogForTicket(props.ticketId);
}
});
// Helper functions for badge colors (customize as needed)
const getStatusColor = (status) =>
{
const lowerStatus = status?.toLowerCase();
if (lowerStatus === 'new') return 'blue';
if (lowerStatus === 'feedback') return 'orange';
if (lowerStatus === 'acknowledged') return 'purple';
if (lowerStatus === 'confirmed') return 'cyan';
if (lowerStatus === 'assigned') return 'teal';
if (lowerStatus === 'resolved') return 'green';
if (lowerStatus === 'closed') return 'grey';
return 'primary';
};
const getPriorityColor = (priority) =>
{
const lowerPriority = priority?.toLowerCase();
if (lowerPriority === 'none') return 'grey';
if (lowerPriority === 'low') return 'blue';
if (lowerPriority === 'normal') return 'green';
if (lowerPriority === 'high') return 'orange';
if (lowerPriority === 'urgent') return 'red';
if (lowerPriority === 'immediate') return 'purple';
return 'primary';
};
const getSeverityColor = (severity) =>
{
const lowerSeverity = severity?.toLowerCase();
if (lowerSeverity === 'feature') return 'info';
if (lowerSeverity === 'trivial') return 'grey';
if (lowerSeverity === 'text') return 'blue-grey';
if (lowerSeverity === 'tweak') return 'light-blue';
if (lowerSeverity === 'minor') return 'lime';
if (lowerSeverity === 'major') return 'amber';
if (lowerSeverity === 'crash') return 'deep-orange';
if (lowerSeverity === 'block') return 'red';
return 'primary';
};
</script>
<style scoped>
/* Add any specific styles here */
</style>

View file

@ -61,6 +61,13 @@ const routes = [
{ path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
{ path: 'forms/:id/fill', name: 'formFill', component: () => import('pages/FormFillPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
{ path: 'forms/:id/responses', name: 'formResponses', component: () => import('pages/FormResponsesPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
{
path: 'mantis/:ticketId?', // Make ticketId optional
name: 'mantis',
component: () => import('pages/MantisPage.vue'),
props: true, // Pass route params as props
meta: { navGroup: 'auth', title: 'Mantis Tickets', icon: 'bug_report' } // Added meta
},
{
path: 'mantis-summaries',
name: 'mantisSummaries',

View file

@ -17,6 +17,24 @@ export const usePreferencesStore = defineStore('preferences', () =>
{ label: 'Dark', value: 'dark' },
],
},
{
name: 'Drawer Side',
key: 'drawerSide',
type: 'text',
options: [
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
],
},
{
name: 'Mantis Comments Order',
key: 'mantisCommentsOrder',
type: 'text',
options: [
{ label: 'Oldest First', value: 'oldest' },
{ label: 'Newest First', value: 'newest' },
],
}
],
API_Tokens: [
{