rapid interations of server side app and firefox extension

This commit is contained in:
Bryan Van Deusen
2026-01-24 22:52:51 -05:00
commit b9b8048a2d
81 changed files with 9675 additions and 0 deletions
+329
View File
@@ -0,0 +1,329 @@
<template>
<div>
<!-- Filters -->
<v-row class="mb-4">
<v-col cols="12" md="3">
<v-select
v-model="filterStatus"
:items="statusOptions"
label="Status"
density="compact"
variant="outlined"
clearable
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="filterSourceId"
:items="sourceOptions"
label="Source"
density="compact"
variant="outlined"
clearable
/>
</v-col>
<v-col cols="12" md="3">
<v-text-field
v-model="filterFromDate"
label="From Date"
type="date"
density="compact"
variant="outlined"
clearable
/>
</v-col>
<v-col cols="12" md="3">
<v-text-field
v-model="filterToDate"
label="To Date"
type="date"
density="compact"
variant="outlined"
clearable
/>
</v-col>
</v-row>
<!-- Downloads Table -->
<v-card>
<v-data-table
:headers="headers"
:items="downloadsStore.downloads"
:loading="downloadsStore.loading"
:items-per-page="20"
class="elevation-1"
>
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="tonal"
>
<v-icon start size="small">{{ getStatusIcon(item.status) }}</v-icon>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.url="{ item }">
<span :title="item.url">{{ truncateUrl(item.url) }}</span>
</template>
<template v-slot:item.error_type="{ item }">
<v-chip
v-if="item.error_type"
color="error"
size="small"
variant="tonal"
>
{{ formatErrorType(item.error_type) }}
</v-chip>
<span v-else class="text-grey">-</span>
</template>
<template v-slot:item.file_count="{ item }">
<v-chip v-if="item.file_count > 0" color="success" size="small">
{{ item.file_count }} files
</v-chip>
<span v-else class="text-grey">0</span>
</template>
<template v-slot:item.created_at="{ item }">
{{ formatDate(item.created_at) }}
</template>
<template v-slot:item.duration="{ item }">
{{ formatDuration(item) }}
</template>
<template v-slot:item.actions="{ item }">
<v-btn
v-if="item.status === 'failed'"
icon
size="small"
variant="text"
color="primary"
@click="retryDownload(item)"
:loading="retryingIds.includes(item.id)"
>
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent">Retry</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
variant="text"
@click="showDetails(item)"
>
<v-icon>mdi-information</v-icon>
<v-tooltip activator="parent">Details</v-tooltip>
</v-btn>
</template>
</v-data-table>
</v-card>
<!-- Details Dialog -->
<v-dialog v-model="detailsDialog" max-width="700">
<v-card v-if="selectedDownload">
<v-card-title>
Download Details
<v-chip
:color="getStatusColor(selectedDownload.status)"
size="small"
class="ml-2"
>
{{ selectedDownload.status }}
</v-chip>
</v-card-title>
<v-card-text>
<v-table density="compact">
<tbody>
<tr>
<td class="font-weight-bold" width="150">URL</td>
<td style="word-break: break-all">{{ selectedDownload.url }}</td>
</tr>
<tr>
<td class="font-weight-bold">Source ID</td>
<td>{{ selectedDownload.source_id || 'N/A' }}</td>
</tr>
<tr>
<td class="font-weight-bold">Files Downloaded</td>
<td>{{ selectedDownload.file_count }}</td>
</tr>
<tr>
<td class="font-weight-bold">Started</td>
<td>{{ formatDate(selectedDownload.started_at) }}</td>
</tr>
<tr>
<td class="font-weight-bold">Completed</td>
<td>{{ formatDate(selectedDownload.completed_at) }}</td>
</tr>
<tr v-if="selectedDownload.error_type">
<td class="font-weight-bold">Error Type</td>
<td class="text-error">{{ formatErrorType(selectedDownload.error_type) }}</td>
</tr>
<tr v-if="selectedDownload.error_message">
<td class="font-weight-bold">Error Message</td>
<td class="text-error">{{ selectedDownload.error_message }}</td>
</tr>
</tbody>
</v-table>
<v-expansion-panels class="mt-4" v-if="selectedDownload.metadata">
<v-expansion-panel v-if="selectedDownload.metadata.stdout">
<v-expansion-panel-title>Output Log</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stdout }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel v-if="selectedDownload.metadata.stderr">
<v-expansion-panel-title>Error Log</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption text-error" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stderr }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
v-if="selectedDownload.status === 'failed'"
color="primary"
@click="retryDownload(selectedDownload); detailsDialog = false"
>
Retry Download
</v-btn>
<v-btn variant="text" @click="detailsDialog = false">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useDownloadsStore } from '../stores/downloads'
import { useSourcesStore } from '../stores/sources'
import { useNotificationStore } from '../stores/notifications'
const downloadsStore = useDownloadsStore()
const sourcesStore = useSourcesStore()
const notifications = useNotificationStore()
const filterStatus = ref(null)
const filterSourceId = ref(null)
const filterFromDate = ref(null)
const filterToDate = ref(null)
const detailsDialog = ref(false)
const selectedDownload = ref(null)
const retryingIds = ref([])
const statusOptions = [
{ title: 'Pending', value: 'pending' },
{ title: 'Running', value: 'running' },
{ title: 'Completed', value: 'completed' },
{ title: 'Failed', value: 'failed' },
{ title: 'Skipped', value: 'skipped' },
]
const sourceOptions = computed(() =>
sourcesStore.sources.map(s => ({ title: s.name, value: s.id }))
)
const headers = [
{ title: 'Status', key: 'status', width: 120 },
{ title: 'URL', key: 'url' },
{ title: 'Error', key: 'error_type', width: 150 },
{ title: 'Files', key: 'file_count', width: 100 },
{ title: 'Date', key: 'created_at', width: 180 },
{ title: 'Duration', key: 'duration', width: 100 },
{ title: 'Actions', key: 'actions', width: 100, sortable: false },
]
onMounted(async () => {
await Promise.all([
loadDownloads(),
sourcesStore.fetchSources(),
])
})
watch([filterStatus, filterSourceId, filterFromDate, filterToDate], () => {
loadDownloads()
})
async function loadDownloads() {
const params = {}
if (filterStatus.value) params.status = filterStatus.value
if (filterSourceId.value) params.source_id = filterSourceId.value
if (filterFromDate.value) params.from_date = filterFromDate.value
if (filterToDate.value) params.to_date = filterToDate.value
await downloadsStore.fetchDownloads(params)
}
function getStatusIcon(status) {
const icons = {
completed: 'mdi-check-circle',
failed: 'mdi-alert-circle',
running: 'mdi-loading',
pending: 'mdi-clock-outline',
skipped: 'mdi-skip-next',
}
return icons[status] || 'mdi-help-circle'
}
function getStatusColor(status) {
const colors = {
completed: 'success',
failed: 'error',
running: 'info',
pending: 'warning',
skipped: 'grey',
}
return colors[status] || 'grey'
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
function formatDuration(download) {
if (!download.started_at || !download.completed_at) return '-'
const start = new Date(download.started_at)
const end = new Date(download.completed_at)
const seconds = Math.round((end - start) / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
return `${minutes}m ${seconds % 60}s`
}
function formatErrorType(errorType) {
if (!errorType) return ''
return errorType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
function truncateUrl(url) {
if (!url) return ''
if (url.length <= 50) return url
return url.substring(0, 47) + '...'
}
function showDetails(download) {
selectedDownload.value = download
detailsDialog.value = true
}
async function retryDownload(download) {
retryingIds.value.push(download.id)
try {
await downloadsStore.retryDownload(download.id)
notifications.success('Download queued for retry')
await loadDownloads()
} catch (error) {
notifications.error(`Failed to retry: ${error.message}`)
} finally {
retryingIds.value = retryingIds.value.filter(id => id !== download.id)
}
}
</script>