Spaces:
Running
Running
<script lang="ts" setup> | |
import { z } from 'zod'; | |
import { formatDistanceToNow } from 'date-fns'; | |
import { LockClosedIcon, LockOpenIcon } from '@heroicons/vue/20/solid'; | |
definePageMeta({ layout: 'admin' }); | |
const { data, error: sourcesError } = await useFetch('/api/admin/sources'); | |
if (sourcesError.value) { | |
console.error(sourcesError.value); | |
if (sourcesError.value.statusCode === 401) { | |
await navigateTo('/admin/login'); | |
} else { | |
throw createError({ statusCode: 500, statusMessage: 'Failed to fetch sources' }); | |
} | |
} | |
type Source = NonNullable<typeof data.value>['sources'][number]; | |
// type Overview = NonNullable<typeof data.value>['overview']; | |
const sources = computed(() => data.value?.sources ?? []); | |
const overview = computed(() => data.value?.overview); | |
// const config = useRuntimeConfig(); | |
const sortKey = ref<keyof Source | ''>(''); | |
const sortOrder = ref<'asc' | 'desc'>('asc'); | |
const selectedFrequency = ref<string>('all'); | |
const showPaywallOnly = ref(false); | |
const showErrorsOnly = ref(false); | |
const errorThreshold = ref(5); | |
const staleHours = ref(24); | |
const enableTimeFilter = ref(false); | |
const articleCountFilter = ref<'more' | 'less'>('more'); | |
const articleCountThreshold = ref(100); | |
const enableArticleFilter = ref(false); | |
const FREQUENCIES = ['Hourly', '4 Hours', '6 Hours', 'Daily'] as const; | |
const totalArticles = computed(() => sources.value?.reduce((sum, source) => sum + source.totalArticles, 0) ?? 0); | |
const filteredArticles = computed(() => filteredSources.value.reduce((sum, source) => sum + source.totalArticles, 0)); | |
const stats = computed(() => { | |
if (!filteredSources.value.length) return null; | |
return { | |
avgProcessSuccess: Math.round( | |
filteredSources.value.reduce((sum, s) => sum + (s.processSuccessRate ?? 0), 0) / filteredSources.value.length | |
), | |
avgErrorRate: Math.round( | |
filteredSources.value.reduce((sum, s) => sum + (s.errorRate ?? 0), 0) / filteredSources.value.length | |
), | |
avgArticlesPerDay: Math.round( | |
filteredSources.value.reduce((sum, s) => sum + (s.avgPerDay || 0), 0) / filteredSources.value.length | |
), | |
}; | |
}); | |
const filteredSources = computed(() => { | |
if (!sources.value) return []; | |
let filtered = sources.value; | |
// frequency filter | |
if (selectedFrequency.value !== 'all') { | |
filtered = filtered.filter(source => source.frequency === selectedFrequency.value); | |
} | |
// paywall filter | |
if (showPaywallOnly.value) { | |
filtered = filtered.filter(source => source.paywall); | |
} | |
// errors filter | |
if (showErrorsOnly.value) { | |
filtered = filtered.filter(source => (source.errorRate ?? 0) > errorThreshold.value); | |
} | |
// stale sources filter | |
if (enableTimeFilter.value) { | |
const cutoffTime = new Date(); | |
cutoffTime.setHours(cutoffTime.getHours() - staleHours.value); | |
filtered = filtered.filter(source => { | |
const lastCheck = new Date(source.lastChecked ?? ''); | |
return lastCheck > cutoffTime; | |
}); | |
} | |
// article count filter | |
if (enableArticleFilter.value) { | |
filtered = filtered.filter(source => { | |
if (articleCountFilter.value === 'more') { | |
return source.totalArticles > articleCountThreshold.value; | |
} | |
return source.totalArticles < articleCountThreshold.value; | |
}); | |
} | |
return filtered; | |
}); | |
const sortedSources = computed(() => { | |
if (!sortKey.value) return filteredSources.value; | |
const key = sortKey.value as keyof Source; | |
return [...filteredSources.value].sort((a, b) => { | |
if (key === 'lastChecked') { | |
const aTime = new Date(a[key] ?? '').getTime(); | |
const bTime = new Date(b[key] ?? '').getTime(); | |
return sortOrder.value === 'asc' ? aTime - bTime : bTime - aTime; | |
} | |
const aVal = a[key]; | |
const bVal = b[key]; | |
return sortOrder.value === 'asc' ? Number(aVal) - Number(bVal) : Number(bVal) - Number(aVal); | |
}); | |
}); | |
const toggleSort = (key: keyof Source) => { | |
if (sortKey.value === key) { | |
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'; | |
} else { | |
sortKey.value = key; | |
sortOrder.value = 'asc'; | |
} | |
}; | |
const formatDate = (dateStr: string) => { | |
const date = new Date(dateStr); | |
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 formatTimeAgo = (dateStr: string | null) => { | |
if (!dateStr) return 'Never'; | |
return formatDistanceToNow(new Date(dateStr), { addSuffix: true }); | |
}; | |
async function addSource() { | |
const url = prompt('Enter the URL of the source you want to add'); | |
if (!url) return; | |
const urlSchema = z.string().url(); | |
const result = urlSchema.safeParse(url); | |
if (!result.success) { | |
alert('Invalid URL'); | |
return; | |
} | |
try { | |
await $fetch('/api/admin/sources', { | |
method: 'POST', | |
body: { url }, | |
}); | |
alert('Source added successfully'); | |
} catch (error) { | |
console.error(sourcesError, error); | |
throw createError({ statusCode: 500, statusMessage: 'Failed to fetch sources' }); | |
} | |
} | |
// Add health status computation | |
const getSourceHealth = (source: Source) => { | |
const isStale = source.lastChecked | |
? new Date().getTime() - new Date(source.lastChecked).getTime() > 24 * 60 * 60 * 1000 | |
: true; | |
if ((source.errorRate ?? 0) > 10 || isStale) return 'red'; | |
if ((source.errorRate ?? 0) > 0) return 'yellow'; | |
return 'green'; | |
}; | |
const getHealthColor = (health: string) => { | |
switch (health) { | |
case 'red': | |
return 'bg-red-500'; | |
case 'yellow': | |
return 'bg-yellow-500'; | |
case 'green': | |
return 'bg-green-500'; | |
default: | |
return 'bg-gray-500'; | |
} | |
}; | |
const isSourceStale = (lastChecked: string | null | undefined) => { | |
if (!lastChecked) return true; | |
return new Date().getTime() - new Date(lastChecked).getTime() > 24 * 60 * 60 * 1000; | |
}; | |
</script> | |
<template> | |
<div> | |
<div class="flex justify-between items-center mb-6"> | |
<h1 class="text-xl font-medium text-gray-900">Source Analytics</h1> | |
<!-- button to add a new source --> | |
<button class="border px-4 py-2 rounded hover:cursor-pointer hover:bg-gray-100" @click="addSource"> | |
Add Source | |
</button> | |
</div> | |
<!-- Overview Section --> | |
<div v-if="overview" class="grid grid-cols-4 gap-4 mb-6"> | |
<div class="col-span-4 bg-white p-4 rounded border"> | |
<h2 class="text-lg font-medium text-gray-900 mb-4">System Overview</h2> | |
<div class="grid grid-cols-4 gap-4"> | |
<!-- Last Activity --> | |
<div class="space-y-2"> | |
<div class="text-xs text-gray-500 uppercase tracking-wide">Last Activity</div> | |
<div> | |
<div class="text-sm text-gray-600">Source Check: {{ formatTimeAgo(overview.lastSourceCheck) }}</div> | |
<div class="text-sm text-gray-600"> | |
Article Processed: {{ formatTimeAgo(overview.lastArticleProcessed) }} | |
</div> | |
<div class="text-sm text-gray-600">Article Fetched: {{ formatTimeAgo(overview.lastArticleFetched) }}</div> | |
</div> | |
</div> | |
<!-- Today's Stats --> | |
<div class="space-y-2"> | |
<div class="text-xs text-gray-500 uppercase tracking-wide">Today's Stats</div> | |
<div> | |
<div class="text-sm text-gray-600">Articles Fetched: {{ overview.articlesFetchedToday }}</div> | |
<div class="text-sm text-gray-600">Articles Processed: {{ overview.articlesProcessedToday }}</div> | |
<div class="text-sm text-gray-600">Errors: {{ overview.errorsToday }}</div> | |
</div> | |
</div> | |
<!-- Source Health --> | |
<div class="space-y-2"> | |
<div class="text-xs text-gray-500 uppercase tracking-wide">Source Health</div> | |
<div> | |
<div class="text-sm text-gray-600">Total Sources: {{ overview.totalSourcesCount }}</div> | |
<div | |
class="text-sm" | |
:class="{ | |
'text-red-600': overview.staleSourcesCount > 0, | |
'text-gray-600': overview.staleSourcesCount === 0, | |
}" | |
> | |
Stale Sources: {{ overview.staleSourcesCount }} | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="grid grid-cols-4 gap-4 mb-6"> | |
<div class="bg-white p-4 rounded border"> | |
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Avg Process Success</div> | |
<div class="text-2xl font-medium text-gray-900">{{ stats?.avgProcessSuccess ?? '-' }}%</div> | |
</div> | |
<div class="bg-white p-4 rounded border"> | |
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Avg Error Rate</div> | |
<div | |
class="text-2xl font-medium" | |
:class="{ 'text-red-600': (stats?.avgErrorRate ?? 0) > 5, 'text-gray-900': (stats?.avgErrorRate ?? 0) <= 5 }" | |
> | |
{{ stats?.avgErrorRate ?? '-' }}% | |
</div> | |
</div> | |
<div class="bg-white p-4 rounded border"> | |
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Avg Articles/Day</div> | |
<div class="text-2xl font-medium text-gray-900">{{ stats?.avgArticlesPerDay ?? '-' }}</div> | |
</div> | |
</div> | |
<div class="bg-white rounded border p-4 mb-4"> | |
<div class="flex flex-wrap gap-6 items-center text-sm"> | |
<div class="flex items-center gap-2"> | |
<label class="text-gray-600">Frequency:</label> | |
<select v-model="selectedFrequency" class="border rounded px-2 py-1.5 text-sm bg-white"> | |
<option value="all">All</option> | |
<option v-for="freq in FREQUENCIES" :key="freq" :value="freq">{{ freq }}</option> | |
</select> | |
</div> | |
<div class="flex items-center gap-2"> | |
<label class="inline-flex items-center gap-2"> | |
<input v-model="showPaywallOnly" type="checkbox" class="rounded border-gray-300" /> | |
<span class="text-gray-600">Paywall only</span> | |
</label> | |
</div> | |
<div class="flex items-center gap-2"> | |
<label class="inline-flex items-center gap-2"> | |
<input v-model="showErrorsOnly" type="checkbox" class="rounded border-gray-300" /> | |
<span class="text-gray-600">Error rate above:</span> | |
</label> | |
<input | |
v-model="errorThreshold" | |
type="number" | |
min="0" | |
max="100" | |
:disabled="!showErrorsOnly" | |
class="border rounded px-2 py-1.5 w-16 text-sm disabled:opacity-50 bg-white" | |
/> | |
<span class="text-gray-600">%</span> | |
</div> | |
<div class="flex items-center gap-2"> | |
<label class="inline-flex items-center gap-2"> | |
<input v-model="enableTimeFilter" type="checkbox" class="rounded border-gray-300" /> | |
<span class="text-gray-600">Sources checked within:</span> | |
</label> | |
<input | |
v-model="staleHours" | |
type="number" | |
min="1" | |
:disabled="!enableTimeFilter" | |
class="border rounded px-2 py-1.5 w-16 text-sm disabled:opacity-50 bg-white" | |
/> | |
<span class="text-gray-600">hours</span> | |
</div> | |
<div class="flex items-center gap-2"> | |
<label class="inline-flex items-center gap-2"> | |
<input v-model="enableArticleFilter" type="checkbox" class="rounded border-gray-300" /> | |
<span class="text-gray-600">Articles:</span> | |
</label> | |
<select | |
v-model="articleCountFilter" | |
:disabled="!enableArticleFilter" | |
class="border rounded px-2 py-1.5 text-sm disabled:opacity-50 bg-white" | |
> | |
<option value="more">More than</option> | |
<option value="less">Less than</option> | |
</select> | |
<input | |
v-model="articleCountThreshold" | |
type="number" | |
min="0" | |
:disabled="!enableArticleFilter" | |
class="border rounded px-2 py-1.5 w-20 text-sm disabled:opacity-50 bg-white" | |
/> | |
</div> | |
</div> | |
</div> | |
<div class="text-sm text-gray-600 mb-4"> | |
Showing {{ filteredArticles }} / {{ totalArticles }} articles from {{ filteredSources.length }} / | |
{{ sources?.length ?? 0 }} sources | |
</div> | |
<div class="bg-white text-gray-800 rounded border overflow-hidden"> | |
<table class="min-w-full divide-y divide-gray-200"> | |
<thead> | |
<tr class="bg-gray-50"> | |
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-8" /> | |
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th> | |
<th | |
class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" | |
@click="toggleSort('lastChecked')" | |
> | |
Last Checked | |
<span v-if="sortKey === 'lastChecked'" class="text-gray-400">{{ sortOrder === 'asc' ? 'β' : 'β' }}</span> | |
</th> | |
<th | |
class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" | |
@click="toggleSort('errorRate')" | |
> | |
Error Rate | |
<span v-if="sortKey === 'errorRate'" class="text-gray-400">{{ sortOrder === 'asc' ? 'β' : 'β' }}</span> | |
</th> | |
<th | |
class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" | |
@click="toggleSort('processSuccessRate')" | |
> | |
Success Rate | |
<span v-if="sortKey === 'processSuccessRate'" class="text-gray-400">{{ | |
sortOrder === 'asc' ? 'β' : 'β' | |
}}</span> | |
</th> | |
<th | |
class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" | |
@click="toggleSort('totalArticles')" | |
> | |
Total Articles | |
<span v-if="sortKey === 'totalArticles'" class="text-gray-400">{{ | |
sortOrder === 'asc' ? 'β' : 'β' | |
}}</span> | |
</th> | |
<th | |
class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" | |
@click="toggleSort('avgPerDay')" | |
> | |
Avg/Day | |
<span v-if="sortKey === 'avgPerDay'" class="text-gray-400">{{ sortOrder === 'asc' ? 'β' : 'β' }}</span> | |
</th> | |
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-8">Paywall</th> | |
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> | |
</tr> | |
</thead> | |
<tbody class="divide-y divide-gray-200"> | |
<tr | |
v-for="source in sortedSources" | |
:key="source.id" | |
class="hover:bg-gray-50/50 transition-colors" | |
:class="{ | |
'bg-red-50 dark:bg-red-900/10': getSourceHealth(source) === 'red', | |
'bg-yellow-50 dark:bg-yellow-900/10': getSourceHealth(source) === 'yellow', | |
'bg-green-50 dark:bg-green-900/10': getSourceHealth(source) === 'green', | |
}" | |
> | |
<td class="px-4 py-2"> | |
<span class="inline-block w-3 h-3 rounded-full" :class="getHealthColor(getSourceHealth(source))" /> | |
</td> | |
<td class="px-4 py-2"> | |
<NuxtLink :to="source.url" target="_blank" class="text-gray-500 hover:underline">{{ | |
source.name | |
}}</NuxtLink> | |
</td> | |
<td | |
class="px-4 py-2" | |
:class="{ | |
'text-gray-400': isSourceStale(source.lastChecked), | |
'text-red-600': !source.lastChecked, | |
}" | |
> | |
{{ formatDate(source.lastChecked ?? '') }} | |
</td> | |
<td class="px-4 py-2" :class="{ 'text-red-600': (source.errorRate ?? 0) > 5 }"> | |
{{ source.errorRate?.toFixed(1) ?? 'N/A' }}% | |
</td> | |
<td class="px-4 py-2">{{ source.processSuccessRate?.toFixed(1) ?? 'N/A' }}%</td> | |
<td class="px-4 py-2">{{ source.totalArticles }}</td> | |
<td class="px-4 py-2">{{ source.avgPerDay ? source.avgPerDay.toFixed(1) : 'N/A' }}</td> | |
<td class="px-4 py-2"> | |
<component | |
:is="source.paywall ? LockClosedIcon : LockOpenIcon" | |
class="w-4 h-4" | |
:class="source.paywall ? 'text-amber-600' : 'text-gray-400'" | |
/> | |
</td> | |
<td class="px-4 py-2"> | |
<NuxtLink :to="`/admin/feed/${source.id}`" class="text-blue-600 hover:underline"> View Feed </NuxtLink> | |
</td> | |
</tr> | |
</tbody> | |
</table> | |
</div> | |
</div> | |
</template> | |