yunlonggong's picture
Initial project upload
1b44660
<script lang="ts" setup>
// import { ingestedItemStatusEnum } from '@meridian/database';
definePageMeta({ layout: 'admin' });
const route = useRoute();
const sourceId = Number(route.params.id);
// state for filters and sorting
const currentPage = ref(1);
const statusFilter = ref<string>('all');
const completenessFilter = ref<string>('all');
const qualityFilter = ref<string>('all');
const sortBy = ref<string>('createdAt');
const sortOrder = ref<'asc' | 'desc'>('desc');
const statuses = ['PENDING_PROCESSING', 'PROCESSED', 'FETCH_FAILED', 'RENDER_FAILED', 'PROCESS_FAILED'];
const completenessLevels = ['COMPLETE', 'PARTIAL_USEFUL', 'PARTIAL_USELESS'];
const qualityLevels = ['OK', 'LOW_QUALITY', 'JUNK'];
// fetch feed details and sample articles
const {
data: feedDetails,
error: feedError,
refresh,
} = await useFetch(() => `/api/admin/sources/${sourceId}/details`, {
query: computed(() => ({
page: currentPage.value,
status: statusFilter.value,
completeness: completenessFilter.value,
quality: qualityFilter.value,
sortBy: sortBy.value,
sortOrder: sortOrder.value,
})),
});
if (feedError.value) {
console.error(feedError.value);
if (feedError.value.statusCode === 401) {
await navigateTo('/admin/login');
} else {
throw createError({ statusCode: 500, statusMessage: 'Failed to fetch feed details' });
}
}
type FeedDetails = NonNullable<typeof feedDetails.value>;
type Article = NonNullable<FeedDetails['articles']>[number];
const formatDate = (dateStr: string | undefined) => {
if (dateStr === undefined) {
return '-';
}
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) {
return '-';
}
const Y = date.getFullYear();
const M = String(date.getMonth() + 1).padStart(2, '0');
const D = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const m = String(date.getMinutes()).padStart(2, '0');
const s = String(date.getSeconds()).padStart(2, '0');
return `${Y}-${M}-${D} ${h}:${m}:${s}`;
};
const getStatusColor = (status: Article['status']) => {
switch (status) {
case 'PROCESSED':
return 'text-green-600';
case 'PENDING_PROCESSING':
return 'text-yellow-600';
default:
return 'text-red-600';
}
};
// modal state for analysis view
const selectedArticle = ref<Article | null>(null);
const showAnalysisModal = ref(false);
const viewAnalysis = (article: Article) => {
selectedArticle.value = article;
showAnalysisModal.value = true;
};
// watch for filter/sort changes and refresh data
watch([currentPage, statusFilter, completenessFilter, qualityFilter, sortBy, sortOrder], () => {
refresh();
});
async function initDOs() {
await $fetch(`/api/admin/sources/${sourceId}/init-dos`, {
method: 'POST',
});
}
// Add delete functionality
async function deleteSource() {
if (!confirm('Are you sure you want to delete this source? This action cannot be undone.')) {
return;
}
try {
await $fetch(`/api/admin/sources/${sourceId}`, {
method: 'DELETE',
});
// Redirect back to sources list after successful deletion
await navigateTo('/admin');
} catch (error) {
console.error('Failed to delete source:', error);
alert('Failed to delete source. Please try again.');
}
}
</script>
<template>
<!-- Main container div removed, handled by layout -->
<div>
<!-- Back link removed, can be part of page content if needed -->
<!-- <div class="mb-4">
<NuxtLink to="/admin" class="text-blue-600 hover:underline">&larr; Back to Sources</NuxtLink>
</div> -->
<div v-if="feedDetails" class="space-y-4">
<!-- Source Info -->
<div class="bg-white rounded-lg border p-4">
<div class="flex justify-between items-start mb-3">
<h1 class="text-xl font-medium text-gray-900">{{ feedDetails.name }}</h1>
<div class="flex gap-2">
<button
v-if="feedDetails.initialized === false"
class="border bg-red-500 px-4 py-2 rounded hover:cursor-pointer hover:bg-red-600 text-white"
@click="initDOs"
>
Init DOs
</button>
<button
class="border bg-red-500 px-4 py-2 rounded hover:cursor-pointer hover:bg-red-600 text-white"
@click="deleteSource"
>
Delete Source
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<span class="text-gray-500">Source URL:</span>
<a :href="feedDetails.url" target="_blank" class="text-blue-600 hover:underline ml-2">{{
feedDetails.url
}}</a>
</div>
<div>
<span class="text-gray-500">Frequency:</span>
<span class="ml-2">{{ feedDetails.frequency }}</span>
</div>
<div>
<span class="text-gray-500">Last Fetched:</span>
<span class="ml-2">{{ formatDate(feedDetails.lastFetched) }}</span>
</div>
<div>
<span class="text-gray-500">Total Articles:</span>
<span class="ml-2">{{ feedDetails.pagination.totalItems }}</span>
</div>
</div>
</div>
<!-- Filters -->
<div class="bg-white rounded-lg border p-3">
<div class="flex flex-wrap gap-3 items-center text-sm">
<div class="flex items-center gap-2">
<label class="text-gray-600">Status:</label>
<select v-model="statusFilter" class="border rounded px-2 py-1 text-sm">
<option value="all">All</option>
<option v-for="status in statuses" :key="status" :value="status">{{ status }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-gray-600">Completeness:</label>
<select v-model="completenessFilter" class="border rounded px-2 py-1 text-sm">
<option value="all">All</option>
<option v-for="level in completenessLevels" :key="level" :value="level">{{ level }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-gray-600">Quality:</label>
<select v-model="qualityFilter" class="border rounded px-2 py-1 text-sm">
<option value="all">All</option>
<option v-for="level in qualityLevels" :key="level" :value="level">{{ level }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-gray-600">Sort by:</label>
<select v-model="sortBy" class="border rounded px-2 py-1 text-sm">
<option value="publishedAt">Published Date</option>
<option value="processedAt">Processed Date</option>
<option value="createdAt">Created Date</option>
</select>
<button class="p-1 rounded hover:bg-gray-100" @click="sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'">
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</button>
</div>
</div>
</div>
<!-- Articles Table -->
<div class="bg-white rounded-lg border overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead>
<tr class="bg-gray-50 text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-2 py-2 text-left">Title</th>
<th class="px-2 py-2 text-left w-24">Status</th>
<th class="px-2 py-2 text-left w-32">Published</th>
<th class="px-2 py-2 text-left w-32">Processed</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr
v-for="article in feedDetails.articles"
:key="article.id"
class="hover:bg-gray-50 cursor-pointer"
@click="viewAnalysis(article)"
>
<td class="px-2 py-2">
<div class="flex items-center">
<a
:href="article.url"
target="_blank"
class="text-blue-600 hover:underline truncate max-w-md"
@click.stop
>{{ article.title }}</a
>
<span v-if="!article.hasEmbedding" class="ml-2 text-xs text-yellow-600">(No Embedding)</span>
</div>
</td>
<td class="px-2 py-2">
<div>
<span :class="getStatusColor(article.status)" class="text-xs">{{ article.status }}</span>
<div v-if="article.failReason" class="text-xs text-red-600 truncate max-w-[12rem]">
{{ article.failReason }}
</div>
</div>
</td>
<td class="px-2 py-2 text-xs">{{ formatDate(article.publishedAt) }}</td>
<td class="px-2 py-2 text-xs">{{ formatDate(article.processedAt) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<div class="flex justify-between items-center text-sm">
<div class="text-gray-600">
Showing {{ (currentPage - 1) * 50 + 1 }} to
{{ Math.min(currentPage * 50, feedDetails.pagination.totalItems) }} of
{{ feedDetails.pagination.totalItems }} articles
</div>
<div class="flex gap-2">
<button
class="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
:disabled="currentPage === 1"
@click="currentPage--"
>
Previous
</button>
<button
class="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
:disabled="currentPage === feedDetails.pagination.totalPages"
@click="currentPage++"
>
Next
</button>
</div>
</div>
</div>
</div>
</template>