mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-09 23:59:40 +00:00
Make things 'betterer' across the board (#23)
* Make things 'betterer' across the board * fix: reorganize struct fields and config parameters for consistency - [x] Reorder Config struct fields alphabetically and by related functionality - [x] Reorganize Observation model fields with archival fields grouped together - [x] Reorder ObservationStore fields to group related members - [x] Reorder Store struct fields with health check caching grouped - [x] Reorganize HealthInfo and PoolMetrics struct field order - [x] Reorder maintenance Service struct fields logically - [x] Reorganize MCP server handler parameter structs alphabetically - [x] Reorder pattern detector candidate tracking fields - [x] Reorganize search Manager struct fields by functionality - [x] Reorder vector Client struct fields with mutex protections grouped - [x] Reorganize handler request/response struct fields - [x] Update handlers_test.go to expect wrapped response format - [x] Reorder middleware TokenAuth and rate limiter fields - [x] Reorganize Service struct fields with grouped functionality - [x] Fix RateLimiter field ordering for clarity - [x] Reorder CircuitBreaker metrics fields * fix(security): improve JSON output safety and path traversal protection - [x] Replace unsafe JSON string formatting with proper json.Marshal in export handler - [x] Remove escapeJSONString helper function in favor of standard JSON marshaling - [x] Add safeResolvePath function to validate paths and prevent directory traversal - [x] Apply path traversal validation in captureFileMtimes operations - [x] Cap result slice capacity in getRecentSearchQueries to prevent DoS via excessive allocation * fix(sdk): improve path traversal protection and allocation safety - [x] Enhance safeResolvePath with stricter validation using filepath.Rel - [x] Reject paths containing ".." after cleaning to prevent traversal - [x] Validate absolute paths are within cwd when cwd is specified - [x] Apply safeResolvePath validation to GetFileContent for consistency - [x] Add comprehensive test coverage for path traversal protection - [x] Fix allocation safety in getRecentSearchQueries by using constant capacity
This commit is contained in:
@@ -8,6 +8,7 @@ import Card from './Card.vue'
|
||||
import IconBox from './IconBox.vue'
|
||||
import Badge from './Badge.vue'
|
||||
import RelationGraph from './RelationGraph.vue'
|
||||
import ScoreBreakdown from './ScoreBreakdown.vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -95,6 +96,9 @@ const relationsLoading = ref(false)
|
||||
const relationsExpanded = ref(false)
|
||||
const showGraph = ref(false)
|
||||
|
||||
// Score breakdown state
|
||||
const showScoreBreakdown = ref(false)
|
||||
|
||||
const hasRelations = computed(() => relations.value.length > 0)
|
||||
const relationCount = computed(() => relations.value.length)
|
||||
|
||||
@@ -350,14 +354,15 @@ const splitPath = (path: string, components = 3) => {
|
||||
<i class="fas fa-thumbs-up text-sm" />
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-400 flex items-center gap-1 transition-all duration-300"
|
||||
<button
|
||||
@click="showScoreBreakdown = true"
|
||||
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-400 flex items-center gap-1 transition-all duration-300 hover:bg-purple-500/20 hover:text-purple-300 cursor-pointer"
|
||||
:class="{ 'text-green-400': localScore !== null && localScore > (observation.importance_score || 1), 'text-red-400': localScore !== null && localScore < (observation.importance_score || 1) }"
|
||||
:title="`Importance Score: ${currentScore.toFixed(3)}\nRetrieval Count: ${observation.retrieval_count || 0}`"
|
||||
:title="`Importance Score: ${currentScore.toFixed(3)}\nRetrieval Count: ${observation.retrieval_count || 0}\nClick for details`"
|
||||
>
|
||||
<i class="fas fa-scale-balanced text-amber-500/60" />
|
||||
<i class="fas fa-chart-bar text-purple-500/60" />
|
||||
{{ currentScore.toFixed(2) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="submitFeedback(-1)"
|
||||
@@ -383,5 +388,12 @@ const splitPath = (path: string, components = 3) => {
|
||||
@close="showGraph = false"
|
||||
@navigate-to="handleNavigateTo"
|
||||
/>
|
||||
|
||||
<!-- Score Breakdown Modal -->
|
||||
<ScoreBreakdown
|
||||
:observation-id="observation.id"
|
||||
:show="showScoreBreakdown"
|
||||
@close="showScoreBreakdown = false"
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { fetchObservationScore, type ScoreBreakdown } from '@/utils/api'
|
||||
import Card from './Card.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
observationId: number
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const data = ref<ScoreBreakdown | null>(null)
|
||||
|
||||
const loadScore = async () => {
|
||||
if (!props.observationId) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
data.value = await fetchObservationScore(props.observationId)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load score breakdown'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load on mount and when ID changes
|
||||
onMounted(() => {
|
||||
if (props.show) loadScore()
|
||||
})
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) loadScore()
|
||||
})
|
||||
|
||||
watch(() => props.observationId, () => {
|
||||
if (props.show) loadScore()
|
||||
})
|
||||
|
||||
// Score bar helper
|
||||
const getScoreBarWidth = (value: number, max: number = 2) => {
|
||||
return `${Math.min(100, Math.max(0, (value / max) * 100))}%`
|
||||
}
|
||||
|
||||
// Score color helper
|
||||
const getScoreColor = (value: number) => {
|
||||
if (value >= 1.5) return 'bg-green-500'
|
||||
if (value >= 1) return 'bg-amber-500'
|
||||
if (value >= 0.5) return 'bg-orange-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<Card
|
||||
gradient="bg-gradient-to-br from-purple-500/10 to-indigo-500/5"
|
||||
border-class="border-purple-500/30"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-chart-bar text-purple-400" />
|
||||
<h3 class="text-lg font-semibold text-purple-100">Score Breakdown</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<i class="fas fa-circle-notch fa-spin text-2xl text-purple-400" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
|
||||
<p class="text-red-300">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else-if="data" class="space-y-4">
|
||||
<!-- Observation Info -->
|
||||
<div class="p-3 bg-slate-800/50 rounded-lg">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide mb-1">Observation</div>
|
||||
<div class="text-amber-100 font-medium">{{ data.observation.title || 'Untitled' }}</div>
|
||||
<div class="flex items-center gap-2 mt-1 text-xs text-slate-400">
|
||||
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded">{{ data.observation.type }}</span>
|
||||
<span>{{ data.scoring.age_days.toFixed(1) }} days old</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Final Score -->
|
||||
<div class="p-4 bg-gradient-to-r from-purple-500/20 to-indigo-500/10 rounded-lg border border-purple-500/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-slate-300">Final Score</span>
|
||||
<span class="text-2xl font-bold text-purple-300">{{ data.scoring.final_score.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-500"
|
||||
:class="getScoreColor(data.scoring.final_score)"
|
||||
:style="{ width: getScoreBarWidth(data.scoring.final_score) }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Components -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Score Components</div>
|
||||
|
||||
<!-- Type Weight -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-tag text-blue-400 w-4" />
|
||||
<span class="text-slate-300">Type Weight</span>
|
||||
</div>
|
||||
<span class="font-mono text-blue-300">{{ data.scoring.type_weight.toFixed(2) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.type_impact }}</p>
|
||||
|
||||
<!-- Recency Decay -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-clock text-cyan-400 w-4" />
|
||||
<span class="text-slate-300">Recency Decay</span>
|
||||
</div>
|
||||
<span class="font-mono text-cyan-300">{{ data.scoring.recency_decay.toFixed(2) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.recency_impact }}</p>
|
||||
|
||||
<!-- Core Score -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-star text-amber-400 w-4" />
|
||||
<span class="text-slate-300">Core Score</span>
|
||||
</div>
|
||||
<span class="font-mono text-amber-300">{{ data.scoring.core_score.toFixed(3) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Feedback Contribution -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-thumbs-up text-green-400 w-4" />
|
||||
<span class="text-slate-300">Feedback</span>
|
||||
</div>
|
||||
<span class="font-mono" :class="data.scoring.feedback_contrib >= 0 ? 'text-green-300' : 'text-red-300'">
|
||||
{{ data.scoring.feedback_contrib >= 0 ? '+' : '' }}{{ data.scoring.feedback_contrib.toFixed(3) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.feedback_impact }}</p>
|
||||
|
||||
<!-- Concept Contribution -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-tags text-purple-400 w-4" />
|
||||
<span class="text-slate-300">Concepts</span>
|
||||
</div>
|
||||
<span class="font-mono text-purple-300">+{{ data.scoring.concept_contrib.toFixed(3) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.concept_impact }}</p>
|
||||
|
||||
<!-- Retrieval Contribution -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-search text-indigo-400 w-4" />
|
||||
<span class="text-slate-300">Retrieval</span>
|
||||
</div>
|
||||
<span class="font-mono text-indigo-300">+{{ data.scoring.retrieval_contrib.toFixed(3) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 ml-6 -mt-2">{{ data.explanation.retrieval_impact }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { fetchSearchAnalytics, fetchRecentSearches, type SearchAnalytics, type RecentQuery } from '@/utils/api'
|
||||
import Card from './Card.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const analytics = ref<SearchAnalytics | null>(null)
|
||||
const recentSearches = ref<RecentQuery[]>([])
|
||||
|
||||
const loadData = async () => {
|
||||
if (!props.show) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const [analyticsData, searchesData] = await Promise.all([
|
||||
fetchSearchAnalytics(),
|
||||
fetchRecentSearches(20)
|
||||
])
|
||||
analytics.value = analyticsData
|
||||
recentSearches.value = searchesData
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load search analytics'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load on mount and when show changes
|
||||
onMounted(() => {
|
||||
if (props.show) loadData()
|
||||
})
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) loadData()
|
||||
})
|
||||
|
||||
// Computed stats
|
||||
const cacheHitRate = computed(() => {
|
||||
if (!analytics.value || analytics.value.total_searches === 0) return 0
|
||||
return (analytics.value.cache_hits / analytics.value.total_searches) * 100
|
||||
})
|
||||
|
||||
const coalescedRate = computed(() => {
|
||||
if (!analytics.value || analytics.value.total_searches === 0) return 0
|
||||
return (analytics.value.coalesced_requests / analytics.value.total_searches) * 100
|
||||
})
|
||||
|
||||
const errorRate = computed(() => {
|
||||
if (!analytics.value || analytics.value.total_searches === 0) return 0
|
||||
return (analytics.value.search_errors / analytics.value.total_searches) * 100
|
||||
})
|
||||
|
||||
// Helper for latency color
|
||||
const getLatencyColor = (ms: number) => {
|
||||
if (ms < 10) return 'text-green-400'
|
||||
if (ms < 50) return 'text-amber-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
// Helper for formatting time ago
|
||||
const formatTimeAgo = (isoDate: string) => {
|
||||
const date = new Date(isoDate)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
return `${diffDays}d ago`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<Card
|
||||
gradient="bg-gradient-to-br from-cyan-500/10 to-blue-500/5"
|
||||
border-class="border-cyan-500/30"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-chart-line text-cyan-400" />
|
||||
<h3 class="text-lg font-semibold text-cyan-100">Search Analytics</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<i class="fas fa-circle-notch fa-spin text-2xl text-cyan-400" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
|
||||
<p class="text-red-300">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else-if="analytics" class="space-y-6">
|
||||
<!-- Overview Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<!-- Total Searches -->
|
||||
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-cyan-300">{{ analytics.total_searches.toLocaleString() }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Total Searches</div>
|
||||
</div>
|
||||
|
||||
<!-- Vector Searches -->
|
||||
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-purple-300">{{ analytics.vector_searches.toLocaleString() }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Vector Searches</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Searches -->
|
||||
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-blue-300">{{ analytics.filter_searches.toLocaleString() }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Filter Searches</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Hits -->
|
||||
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-green-300">{{ analytics.cache_hits.toLocaleString() }}</div>
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Cache Hits</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Metrics -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Performance Metrics</div>
|
||||
|
||||
<!-- Cache Hit Rate -->
|
||||
<div class="flex items-center justify-between p-3 bg-slate-800/30 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-database text-green-400 w-5" />
|
||||
<span class="text-slate-300">Cache Hit Rate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-green-500 transition-all"
|
||||
:style="{ width: `${cacheHitRate}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-mono text-green-300 w-16 text-right">{{ cacheHitRate.toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coalesced Rate -->
|
||||
<div class="flex items-center justify-between p-3 bg-slate-800/30 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-compress-arrows-alt text-amber-400 w-5" />
|
||||
<span class="text-slate-300">Coalesced Requests</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-amber-500 transition-all"
|
||||
:style="{ width: `${coalescedRate}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-mono text-amber-300 w-16 text-right">{{ coalescedRate.toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Rate -->
|
||||
<div class="flex items-center justify-between p-3 bg-slate-800/30 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-exclamation-circle text-red-400 w-5" />
|
||||
<span class="text-slate-300">Error Rate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-red-500 transition-all"
|
||||
:style="{ width: `${Math.min(100, errorRate)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-mono text-red-300 w-16 text-right">{{ errorRate.toFixed(2) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latency Stats -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Latency</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<!-- Average Latency -->
|
||||
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
|
||||
<div class="text-xl font-bold font-mono" :class="getLatencyColor(analytics.avg_latency_ms)">
|
||||
{{ analytics.avg_latency_ms.toFixed(1) }}ms
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">Average</div>
|
||||
</div>
|
||||
|
||||
<!-- Vector Latency -->
|
||||
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
|
||||
<div class="text-xl font-bold font-mono" :class="getLatencyColor(analytics.avg_vector_latency_ms)">
|
||||
{{ analytics.avg_vector_latency_ms.toFixed(1) }}ms
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">Vector</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Latency -->
|
||||
<div class="p-3 bg-slate-800/50 rounded-lg text-center">
|
||||
<div class="text-xl font-bold font-mono" :class="getLatencyColor(analytics.avg_filter_latency_ms)">
|
||||
{{ analytics.avg_filter_latency_ms.toFixed(1) }}ms
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">Filter</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Searches -->
|
||||
<div v-if="recentSearches.length > 0" class="space-y-3">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Recent Searches</div>
|
||||
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||
<div
|
||||
v-for="(search, index) in recentSearches"
|
||||
:key="index"
|
||||
class="flex items-center gap-3 p-2 bg-slate-800/30 rounded-lg text-sm"
|
||||
>
|
||||
<i class="fas fa-search text-slate-500 text-xs" />
|
||||
<span class="flex-1 text-slate-300 truncate" :title="search.query">{{ search.query }}</span>
|
||||
<span v-if="search.project" class="text-xs text-amber-600/80 font-mono">{{ search.project.split('/').pop() }}</span>
|
||||
<span v-if="search.type" class="text-xs text-cyan-500 bg-cyan-500/10 px-1.5 py-0.5 rounded">{{ search.type }}</span>
|
||||
<span class="text-xs text-slate-500 font-mono">×{{ search.count }}</span>
|
||||
<span class="text-xs text-slate-600">{{ formatTimeAgo(search.last_used) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -2,6 +2,9 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Stats, SelfCheckResponse } from '@/types'
|
||||
import ProjectFilter from './ProjectFilter.vue'
|
||||
import SearchAnalytics from './SearchAnalytics.vue'
|
||||
import SystemHealthDetails from './SystemHealthDetails.vue'
|
||||
import TopObservations from './TopObservations.vue'
|
||||
import { useGraphMetrics } from '@/composables'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -24,6 +27,15 @@ const metricsExpanded = ref(localStorage.getItem('metrics-expanded') === 'true')
|
||||
// Graph metrics composable
|
||||
const { graphStats, vectorMetrics, loading: metricsLoading, refresh: refreshMetrics } = useGraphMetrics()
|
||||
|
||||
// Search Analytics modal state
|
||||
const showSearchAnalytics = ref(false)
|
||||
|
||||
// System Health Details modal state
|
||||
const showHealthDetails = ref(false)
|
||||
|
||||
// Top Observations modal state
|
||||
const showTopObservations = ref(false)
|
||||
|
||||
function toggleCollapse() {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
localStorage.setItem('sidebar-collapsed', String(isCollapsed.value))
|
||||
@@ -106,9 +118,18 @@ function getStatusColor(status: string): string {
|
||||
|
||||
<!-- Component Health -->
|
||||
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<i :class="['fas', overallHealthIcon, overallHealthColor]" />
|
||||
<h3 class="text-sm font-semibold text-white">System Health</h3>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i :class="['fas', overallHealthIcon, overallHealthColor]" />
|
||||
<h3 class="text-sm font-semibold text-white">System Health</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="showHealthDetails = true"
|
||||
class="text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
|
||||
title="View detailed health status"
|
||||
>
|
||||
<i class="fas fa-expand" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="health" class="space-y-2">
|
||||
@@ -134,9 +155,18 @@ function getStatusColor(status: string): string {
|
||||
|
||||
<!-- Memory Stats -->
|
||||
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<i class="fas fa-brain text-purple-400" />
|
||||
<h3 class="text-sm font-semibold text-white">Memory Contents</h3>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-brain text-purple-400" />
|
||||
<h3 class="text-sm font-semibold text-white">Memory Contents</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="showTopObservations = true"
|
||||
class="text-xs text-amber-400 hover:text-amber-300 transition-colors"
|
||||
title="View top observations"
|
||||
>
|
||||
<i class="fas fa-trophy" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
@@ -171,9 +201,18 @@ function getStatusColor(status: string): string {
|
||||
|
||||
<!-- Retrieval Stats -->
|
||||
<div v-if="stats?.retrieval" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<i class="fas fa-search text-cyan-400" />
|
||||
<h3 class="text-sm font-semibold text-white">Retrieval Stats</h3>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-search text-cyan-400" />
|
||||
<h3 class="text-sm font-semibold text-white">Retrieval Stats</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="showSearchAnalytics = true"
|
||||
class="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||
title="View detailed analytics"
|
||||
>
|
||||
<i class="fas fa-chart-line" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
@@ -373,6 +412,26 @@ function getStatusColor(status: string): string {
|
||||
<i class="fas fa-chart-line text-violet-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Analytics Modal -->
|
||||
<SearchAnalytics
|
||||
:show="showSearchAnalytics"
|
||||
@close="showSearchAnalytics = false"
|
||||
/>
|
||||
|
||||
<!-- System Health Details Modal -->
|
||||
<SystemHealthDetails
|
||||
:show="showHealthDetails"
|
||||
@close="showHealthDetails = false"
|
||||
/>
|
||||
|
||||
<!-- Top Observations Modal -->
|
||||
<TopObservations
|
||||
:show="showTopObservations"
|
||||
:current-project="currentProject"
|
||||
@close="showTopObservations = false"
|
||||
@navigate-to-observation="$emit('update:project', null)"
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { fetchSystemHealth, type SystemHealth } from '@/utils/api'
|
||||
import Card from './Card.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const health = ref<SystemHealth | null>(null)
|
||||
|
||||
const loadData = async () => {
|
||||
if (!props.show) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
health.value = await fetchSystemHealth()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load system health'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load on mount and when show changes
|
||||
onMounted(() => {
|
||||
if (props.show) loadData()
|
||||
})
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) loadData()
|
||||
})
|
||||
|
||||
// Status helpers
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy': return 'fa-circle-check'
|
||||
case 'degraded': return 'fa-triangle-exclamation'
|
||||
case 'unhealthy': return 'fa-circle-xmark'
|
||||
default: return 'fa-circle-question'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy': return 'text-green-400'
|
||||
case 'degraded': return 'text-amber-400'
|
||||
case 'unhealthy': return 'text-red-400'
|
||||
default: return 'text-slate-400'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBgColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy': return 'bg-green-500/20 border-green-500/30'
|
||||
case 'degraded': return 'bg-amber-500/20 border-amber-500/30'
|
||||
case 'unhealthy': return 'bg-red-500/20 border-red-500/30'
|
||||
default: return 'bg-slate-500/20 border-slate-500/30'
|
||||
}
|
||||
}
|
||||
|
||||
const getLatencyColor = (ms: number | undefined) => {
|
||||
if (!ms) return 'text-slate-400'
|
||||
if (ms < 10) return 'text-green-400'
|
||||
if (ms < 50) return 'text-amber-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
// Count healthy/degraded/unhealthy components
|
||||
const componentCounts = computed(() => {
|
||||
if (!health.value) return { healthy: 0, degraded: 0, unhealthy: 0 }
|
||||
const counts = { healthy: 0, degraded: 0, unhealthy: 0 }
|
||||
for (const c of health.value.components) {
|
||||
if (c.status === 'healthy') counts.healthy++
|
||||
else if (c.status === 'degraded') counts.degraded++
|
||||
else if (c.status === 'unhealthy') counts.unhealthy++
|
||||
}
|
||||
return counts
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative w-full max-w-xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<Card
|
||||
gradient="bg-gradient-to-br from-emerald-500/10 to-green-500/5"
|
||||
border-class="border-emerald-500/30"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-heartbeat text-emerald-400" />
|
||||
<h3 class="text-lg font-semibold text-emerald-100">System Health</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<i class="fas fa-circle-notch fa-spin text-2xl text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
|
||||
<p class="text-red-300">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else-if="health" class="space-y-5">
|
||||
<!-- Overall Status -->
|
||||
<div
|
||||
class="p-4 rounded-lg border"
|
||||
:class="getStatusBgColor(health.status)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i
|
||||
class="fas text-3xl"
|
||||
:class="[getStatusIcon(health.status), getStatusColor(health.status)]"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-lg font-semibold capitalize" :class="getStatusColor(health.status)">
|
||||
{{ health.status }}
|
||||
</div>
|
||||
<div class="text-xs text-slate-400">Overall System Status</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-slate-300 font-mono">{{ health.version }}</div>
|
||||
<div class="text-xs text-slate-500">Version</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Component Status Summary -->
|
||||
<div class="grid grid-cols-3 gap-3 text-center">
|
||||
<div class="p-3 bg-slate-800/50 rounded-lg">
|
||||
<div class="text-xl font-bold text-green-400">{{ componentCounts.healthy }}</div>
|
||||
<div class="text-xs text-slate-500">Healthy</div>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-800/50 rounded-lg">
|
||||
<div class="text-xl font-bold text-amber-400">{{ componentCounts.degraded }}</div>
|
||||
<div class="text-xs text-slate-500">Degraded</div>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-800/50 rounded-lg">
|
||||
<div class="text-xl font-bold text-red-400">{{ componentCounts.unhealthy }}</div>
|
||||
<div class="text-xs text-slate-500">Unhealthy</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Components List -->
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Components</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="component in health.components"
|
||||
:key="component.name"
|
||||
class="flex items-center gap-3 p-3 bg-slate-800/30 rounded-lg"
|
||||
>
|
||||
<!-- Status Icon -->
|
||||
<i
|
||||
class="fas w-5 text-center"
|
||||
:class="[getStatusIcon(component.status), getStatusColor(component.status)]"
|
||||
/>
|
||||
|
||||
<!-- Name & Message -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-slate-200">{{ component.name }}</div>
|
||||
<div v-if="component.message" class="text-xs text-slate-500 truncate" :title="component.message">
|
||||
{{ component.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latency -->
|
||||
<div v-if="component.latency_ms !== undefined" class="text-right">
|
||||
<span class="font-mono text-sm" :class="getLatencyColor(component.latency_ms)">
|
||||
{{ component.latency_ms.toFixed(1) }}ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<span
|
||||
class="text-xs font-medium capitalize px-2 py-0.5 rounded"
|
||||
:class="[getStatusColor(component.status), getStatusBgColor(component.status)]"
|
||||
>
|
||||
{{ component.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div v-if="health.warnings && health.warnings.length > 0" class="space-y-3">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide">Warnings</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(warning, index) in health.warnings"
|
||||
:key="index"
|
||||
class="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg text-sm"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle text-amber-400 mt-0.5" />
|
||||
<span class="text-amber-200">{{ warning }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="loadData"
|
||||
:disabled="loading"
|
||||
class="w-full py-2 text-sm text-emerald-400 hover:text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading }" />
|
||||
Refresh Health Status
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { fetchTopObservations, fetchMostRetrievedObservations } from '@/utils/api'
|
||||
import type { Observation } from '@/types'
|
||||
import Card from './Card.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
currentProject: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
navigateToObservation: [id: number]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const topObservations = ref<Observation[]>([])
|
||||
const mostRetrieved = ref<Observation[]>([])
|
||||
const activeTab = ref<'top' | 'retrieved'>('top')
|
||||
|
||||
const loadData = async () => {
|
||||
if (!props.show) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const project = props.currentProject || undefined
|
||||
const [topData, retrievedData] = await Promise.all([
|
||||
fetchTopObservations(project, 15),
|
||||
fetchMostRetrievedObservations(project, 15)
|
||||
])
|
||||
topObservations.value = topData
|
||||
mostRetrieved.value = retrievedData
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load observations'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load on mount and when show changes
|
||||
onMounted(() => {
|
||||
if (props.show) loadData()
|
||||
})
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) loadData()
|
||||
})
|
||||
|
||||
// Also reload when project changes
|
||||
watch(() => props.currentProject, () => {
|
||||
if (props.show) loadData()
|
||||
})
|
||||
|
||||
// Type config for styling
|
||||
const typeConfig: Record<string, { icon: string; colorClass: string; bgClass: string }> = {
|
||||
discovery: { icon: 'fa-lightbulb', colorClass: 'text-amber-400', bgClass: 'bg-amber-500/20' },
|
||||
bugfix: { icon: 'fa-bug', colorClass: 'text-red-400', bgClass: 'bg-red-500/20' },
|
||||
change: { icon: 'fa-code-branch', colorClass: 'text-blue-400', bgClass: 'bg-blue-500/20' },
|
||||
refactor: { icon: 'fa-wrench', colorClass: 'text-purple-400', bgClass: 'bg-purple-500/20' },
|
||||
feature: { icon: 'fa-star', colorClass: 'text-green-400', bgClass: 'bg-green-500/20' },
|
||||
pattern: { icon: 'fa-puzzle-piece', colorClass: 'text-cyan-400', bgClass: 'bg-cyan-500/20' },
|
||||
architecture: { icon: 'fa-sitemap', colorClass: 'text-indigo-400', bgClass: 'bg-indigo-500/20' },
|
||||
preference: { icon: 'fa-heart', colorClass: 'text-pink-400', bgClass: 'bg-pink-500/20' }
|
||||
}
|
||||
|
||||
const getTypeConfig = (type: string) => {
|
||||
return typeConfig[type] || { icon: 'fa-circle', colorClass: 'text-slate-400', bgClass: 'bg-slate-500/20' }
|
||||
}
|
||||
|
||||
// Format score for display
|
||||
const formatScore = (score: number) => {
|
||||
return score.toFixed(2)
|
||||
}
|
||||
|
||||
// Current observations based on active tab
|
||||
const currentObservations = () => {
|
||||
return activeTab.value === 'top' ? topObservations.value : mostRetrieved.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<Card
|
||||
gradient="bg-gradient-to-br from-amber-500/10 to-orange-500/5"
|
||||
border-class="border-amber-500/30"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-trophy text-amber-400" />
|
||||
<h3 class="text-lg font-semibold text-amber-100">Top Observations</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button
|
||||
@click="activeTab = 'top'"
|
||||
:class="[
|
||||
'flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-colors',
|
||||
activeTab === 'top'
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
|
||||
: 'text-slate-400 hover:text-slate-300 hover:bg-slate-700/50'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-star mr-2" />
|
||||
Highest Scored
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'retrieved'"
|
||||
:class="[
|
||||
'flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-colors',
|
||||
activeTab === 'retrieved'
|
||||
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
|
||||
: 'text-slate-400 hover:text-slate-300 hover:bg-slate-700/50'
|
||||
]"
|
||||
>
|
||||
<i class="fas fa-search mr-2" />
|
||||
Most Retrieved
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Project Filter Indicator -->
|
||||
<div v-if="currentProject" class="flex items-center gap-2 mb-4 text-xs text-slate-500">
|
||||
<i class="fas fa-filter" />
|
||||
<span>Filtered by:</span>
|
||||
<span class="text-amber-600/80 font-mono">{{ currentProject.split('/').pop() }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<i class="fas fa-circle-notch fa-spin text-2xl text-amber-400" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<i class="fas fa-exclamation-triangle text-2xl text-red-400 mb-2" />
|
||||
<p class="text-red-300">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="currentObservations().length === 0" class="text-center py-8">
|
||||
<i class="fas fa-inbox text-2xl text-slate-500 mb-2" />
|
||||
<p class="text-slate-400">No observations found</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(obs, index) in currentObservations()"
|
||||
:key="obs.id"
|
||||
@click="emit('navigateToObservation', obs.id); emit('close')"
|
||||
class="flex items-center gap-3 p-3 bg-slate-800/30 hover:bg-slate-800/50 rounded-lg cursor-pointer transition-colors group"
|
||||
>
|
||||
<!-- Rank -->
|
||||
<div
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
:class="[
|
||||
index < 3 ? 'bg-amber-500/30 text-amber-300' : 'bg-slate-700/50 text-slate-400'
|
||||
]"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
|
||||
<!-- Type Icon -->
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
:class="getTypeConfig(obs.type).bgClass"
|
||||
>
|
||||
<i
|
||||
class="fas text-sm"
|
||||
:class="[getTypeConfig(obs.type).icon, getTypeConfig(obs.type).colorClass]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Title & Meta -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-slate-200 truncate group-hover:text-amber-200 transition-colors">
|
||||
{{ obs.title || 'Untitled' }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span class="capitalize">{{ obs.type }}</span>
|
||||
<span v-if="obs.project" class="text-amber-600/70 font-mono">{{ obs.project.split('/').pop() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score / Retrieval Count -->
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div
|
||||
v-if="activeTab === 'top'"
|
||||
class="text-sm font-mono font-bold"
|
||||
:class="obs.importance_score && obs.importance_score >= 1.5 ? 'text-green-400' : obs.importance_score && obs.importance_score >= 1 ? 'text-amber-400' : 'text-slate-400'"
|
||||
>
|
||||
{{ formatScore(obs.importance_score || 1) }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-sm font-mono font-bold text-cyan-400"
|
||||
>
|
||||
{{ obs.retrieval_count || 0 }}×
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
{{ activeTab === 'top' ? 'score' : 'retrieved' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<i class="fas fa-chevron-right text-slate-600 group-hover:text-slate-400 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="loadData"
|
||||
:disabled="loading"
|
||||
class="w-full mt-4 py-2 text-sm text-amber-400 hover:text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading }" />
|
||||
Refresh
|
||||
</button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
Reference in New Issue
Block a user