|
| 1 | +<script setup lang="ts"> |
| 2 | +import { h, resolveComponent } from 'vue' |
| 3 | +import type { TableColumn } from '@nuxt/ui' |
| 4 | +import type { ContextHealthResponse, ContextSuggestion, PluginHealthItem } from '~/types' |
| 5 | +
|
| 6 | +useSeoMeta({ title: 'Context Health' }) |
| 7 | +
|
| 8 | +const UBadge = resolveComponent('UBadge') |
| 9 | +const UButton = resolveComponent('UButton') |
| 10 | +
|
| 11 | +const { data, status, refresh } = useFetch<ContextHealthResponse>('/api/context-health') |
| 12 | +
|
| 13 | +const is_toggling = ref<string | null>(null) |
| 14 | +
|
| 15 | +async function togglePlugin(id_plugin: string, is_enabled: boolean): Promise<void> { |
| 16 | + is_toggling.value = id_plugin |
| 17 | + try { |
| 18 | + await $fetch('/api/plugins/toggle', { |
| 19 | + method: 'POST', |
| 20 | + body: { id_plugin, is_enabled } |
| 21 | + }) |
| 22 | + await refresh() |
| 23 | + } |
| 24 | + finally { |
| 25 | + is_toggling.value = null |
| 26 | + } |
| 27 | +} |
| 28 | +
|
| 29 | +async function applySuggestion(suggestion: ContextSuggestion): Promise<void> { |
| 30 | + if (suggestion.action_type === 'disable_plugin' && suggestion.action_target) { |
| 31 | + await togglePlugin(suggestion.action_target, false) |
| 32 | + } |
| 33 | +} |
| 34 | +
|
| 35 | +const list_stats = computed(() => { |
| 36 | + if (!data.value) return [] |
| 37 | + return [ |
| 38 | + { |
| 39 | + key: 'tokens', |
| 40 | + label: 'Est. Tokens', |
| 41 | + value: formatCompact(data.value.tokens_estimated), |
| 42 | + icon: 'i-lucide-binary', |
| 43 | + }, |
| 44 | + { |
| 45 | + key: 'plugins', |
| 46 | + label: 'Plugins Enabled', |
| 47 | + value: String(data.value.count_plugins_enabled), |
| 48 | + icon: 'i-lucide-puzzle', |
| 49 | + }, |
| 50 | + { |
| 51 | + key: 'skills', |
| 52 | + label: 'Skills in Context', |
| 53 | + value: String(data.value.count_skills_total), |
| 54 | + icon: 'i-lucide-zap', |
| 55 | + }, |
| 56 | + { |
| 57 | + key: 'rules', |
| 58 | + label: 'Rule Files', |
| 59 | + value: String(data.value.count_rules), |
| 60 | + icon: 'i-lucide-scale', |
| 61 | + }, |
| 62 | + ] |
| 63 | +}) |
| 64 | +
|
| 65 | +const set_duplicate_plugins = computed(() => { |
| 66 | + if (!data.value) return new Set<string>() |
| 67 | + return new Set(data.value.list_duplicates.flatMap(dup => dup.list_sources)) |
| 68 | +}) |
| 69 | +
|
| 70 | +const columns_plugins: TableColumn<PluginHealthItem>[] = [ |
| 71 | + { |
| 72 | + accessorKey: 'name_plugin', |
| 73 | + header: 'Plugin', |
| 74 | + cell: ({ row }) => { |
| 75 | + const plugin = row.original |
| 76 | + const is_dup = set_duplicate_plugins.value.has(plugin.id_plugin) |
| 77 | + const children = [ |
| 78 | + h('span', { class: 'font-mono text-sm' }, plugin.name_plugin), |
| 79 | + ] |
| 80 | + if (is_dup) { |
| 81 | + children.push(h(UBadge, { |
| 82 | + color: 'error', |
| 83 | + variant: 'subtle', |
| 84 | + size: 'xs', |
| 85 | + class: 'ml-2', |
| 86 | + }, () => 'Duplicate')) |
| 87 | + } |
| 88 | + if (plugin.count_skills === 0 && plugin.is_enabled) { |
| 89 | + children.push(h(UBadge, { |
| 90 | + color: 'neutral', |
| 91 | + variant: 'subtle', |
| 92 | + size: 'xs', |
| 93 | + class: 'ml-2', |
| 94 | + }, () => '0 skills')) |
| 95 | + } |
| 96 | + return h('div', { class: 'flex items-center gap-1 flex-wrap' }, children) |
| 97 | + }, |
| 98 | + }, |
| 99 | + { |
| 100 | + accessorKey: 'source', |
| 101 | + header: 'Source', |
| 102 | + cell: ({ row }) => h('span', { class: 'text-sm text-dimmed font-mono' }, row.original.source), |
| 103 | + }, |
| 104 | + { |
| 105 | + accessorKey: 'count_skills', |
| 106 | + header: 'Skills', |
| 107 | + cell: ({ row }) => h('span', { class: 'tabular-nums' }, String(row.original.count_skills)), |
| 108 | + }, |
| 109 | + { |
| 110 | + accessorKey: 'tokens_estimated', |
| 111 | + header: 'Est. Tokens', |
| 112 | + cell: ({ row }) => h('span', { |
| 113 | + class: 'tabular-nums text-sm', |
| 114 | + }, row.original.tokens_estimated > 0 ? formatCompact(row.original.tokens_estimated) : '-'), |
| 115 | + }, |
| 116 | + { |
| 117 | + accessorKey: 'is_enabled', |
| 118 | + header: 'Enabled', |
| 119 | + cell: ({ row }) => { |
| 120 | + const plugin = row.original |
| 121 | + return h(UButton, { |
| 122 | + color: plugin.is_enabled ? 'success' : 'neutral', |
| 123 | + variant: 'subtle', |
| 124 | + size: 'xs', |
| 125 | + loading: is_toggling.value === plugin.id_plugin, |
| 126 | + onClick: () => togglePlugin(plugin.id_plugin, !plugin.is_enabled), |
| 127 | + }, () => plugin.is_enabled ? 'On' : 'Off') |
| 128 | + }, |
| 129 | + }, |
| 130 | +] |
| 131 | +
|
| 132 | +const tokens_rules = computed(() => { |
| 133 | + if (!data.value?.list_rules) return 0 |
| 134 | + return data.value.list_rules.reduce((sum, r) => sum + r.tokens_estimated, 0) |
| 135 | +}) |
| 136 | +
|
| 137 | +const color_severity: Record<ContextSuggestion['severity'], string> = { |
| 138 | + high: 'error', |
| 139 | + medium: 'warning', |
| 140 | + low: 'info', |
| 141 | +} |
| 142 | +</script> |
| 143 | + |
| 144 | +<template> |
| 145 | + <UDashboardPanel id="context-health"> |
| 146 | + <template #header> |
| 147 | + <UDashboardNavbar title="Context Health"> |
| 148 | + <template #leading> |
| 149 | + <UDashboardSidebarCollapse /> |
| 150 | + </template> |
| 151 | + <template #trailing> |
| 152 | + <UButton |
| 153 | + icon="i-lucide-refresh-cw" |
| 154 | + variant="ghost" |
| 155 | + size="sm" |
| 156 | + :loading="status === 'pending'" |
| 157 | + @click="refresh()" |
| 158 | + /> |
| 159 | + </template> |
| 160 | + </UDashboardNavbar> |
| 161 | + </template> |
| 162 | + |
| 163 | + <template #body> |
| 164 | + <div class="p-6 space-y-6"> |
| 165 | + <!-- Summary Cards --> |
| 166 | + <div v-if="status === 'pending'" class="grid grid-cols-2 lg:grid-cols-4 gap-4"> |
| 167 | + <USkeleton v-for="i in 4" :key="i" class="h-24" /> |
| 168 | + </div> |
| 169 | + <div v-else-if="data" class="grid grid-cols-2 lg:grid-cols-4 gap-4"> |
| 170 | + <StatCard |
| 171 | + v-for="item in list_stats" |
| 172 | + :key="item.key" |
| 173 | + :label="item.label" |
| 174 | + :count="item.value" |
| 175 | + :icon="item.icon" |
| 176 | + /> |
| 177 | + </div> |
| 178 | + |
| 179 | + <!-- Suggestions --> |
| 180 | + <UCard v-if="data?.list_suggestions?.length"> |
| 181 | + <template #header> |
| 182 | + <h3 class="text-sm font-medium text-dimmed">Optimization Suggestions</h3> |
| 183 | + </template> |
| 184 | + <div class="space-y-3"> |
| 185 | + <div |
| 186 | + v-for="(suggestion, idx) in data.list_suggestions" |
| 187 | + :key="idx" |
| 188 | + class="flex items-start gap-3 p-3 rounded-lg bg-elevated/50" |
| 189 | + > |
| 190 | + <UBadge |
| 191 | + :color="color_severity[suggestion.severity] ?? 'neutral'" |
| 192 | + variant="subtle" |
| 193 | + size="sm" |
| 194 | + class="mt-0.5 shrink-0" |
| 195 | + > |
| 196 | + {{ suggestion.severity }} |
| 197 | + </UBadge> |
| 198 | + <div class="flex-1 min-w-0"> |
| 199 | + <p class="text-sm font-medium">{{ suggestion.title }}</p> |
| 200 | + <p class="text-sm text-dimmed mt-0.5">{{ suggestion.description }}</p> |
| 201 | + </div> |
| 202 | + <UButton |
| 203 | + v-if="suggestion.action_type === 'disable_plugin' && suggestion.action_target" |
| 204 | + color="error" |
| 205 | + variant="soft" |
| 206 | + size="xs" |
| 207 | + :loading="is_toggling === suggestion.action_target" |
| 208 | + @click="applySuggestion(suggestion)" |
| 209 | + > |
| 210 | + Disable |
| 211 | + </UButton> |
| 212 | + </div> |
| 213 | + </div> |
| 214 | + </UCard> |
| 215 | + |
| 216 | + <!-- Plugin Table --> |
| 217 | + <UCard> |
| 218 | + <template #header> |
| 219 | + <h3 class="text-sm font-medium text-dimmed">Plugins</h3> |
| 220 | + </template> |
| 221 | + |
| 222 | + <div v-if="status === 'pending'"> |
| 223 | + <USkeleton v-for="i in 6" :key="i" class="h-10 mb-2" /> |
| 224 | + </div> |
| 225 | + <UTable |
| 226 | + v-else-if="data?.list_plugins?.length" |
| 227 | + :data="data.list_plugins" |
| 228 | + :columns="columns_plugins" |
| 229 | + /> |
| 230 | + <div v-else class="text-sm text-dimmed"> |
| 231 | + No plugins found. |
| 232 | + </div> |
| 233 | + </UCard> |
| 234 | + |
| 235 | + <!-- Duplicates --> |
| 236 | + <UCard v-if="data?.list_duplicates?.length"> |
| 237 | + <template #header> |
| 238 | + <h3 class="text-sm font-medium text-dimmed">Duplicate Skills</h3> |
| 239 | + </template> |
| 240 | + <div class="space-y-2"> |
| 241 | + <div |
| 242 | + v-for="dup in data.list_duplicates" |
| 243 | + :key="dup.name_skill" |
| 244 | + class="flex items-center gap-3 p-2 rounded-lg bg-elevated/50" |
| 245 | + > |
| 246 | + <UIcon name="i-lucide-copy" class="size-4 text-error shrink-0" /> |
| 247 | + <span class="font-mono text-sm">{{ dup.name_skill }}</span> |
| 248 | + <div class="flex gap-1 flex-wrap"> |
| 249 | + <UBadge |
| 250 | + v-for="src in dup.list_sources" |
| 251 | + :key="src" |
| 252 | + color="neutral" |
| 253 | + variant="subtle" |
| 254 | + size="xs" |
| 255 | + > |
| 256 | + {{ src }} |
| 257 | + </UBadge> |
| 258 | + </div> |
| 259 | + </div> |
| 260 | + </div> |
| 261 | + </UCard> |
| 262 | + |
| 263 | + <!-- Rules --> |
| 264 | + <UCard v-if="data?.list_rules?.length"> |
| 265 | + <template #header> |
| 266 | + <h3 class="text-sm font-medium text-dimmed"> |
| 267 | + Rule Files ({{ formatCompact(tokens_rules) }} est. tokens) |
| 268 | + </h3> |
| 269 | + </template> |
| 270 | + <div class="space-y-1"> |
| 271 | + <div |
| 272 | + v-for="rule in data.list_rules" |
| 273 | + :key="rule.path_relative" |
| 274 | + class="flex items-center justify-between py-1.5 px-2 rounded hover:bg-elevated/50" |
| 275 | + > |
| 276 | + <span class="font-mono text-sm text-dimmed">{{ rule.path_relative }}</span> |
| 277 | + <span class="text-xs tabular-nums text-dimmed">~{{ rule.tokens_estimated }} tokens</span> |
| 278 | + </div> |
| 279 | + </div> |
| 280 | + </UCard> |
| 281 | + </div> |
| 282 | + </template> |
| 283 | + </UDashboardPanel> |
| 284 | +</template> |
0 commit comments