|
| 1 | +<script setup lang="ts"> |
| 2 | +useSeoMeta({ title: 'Monitoring' }) |
| 3 | +
|
| 4 | +const { resumeSession } = useAiChat() |
| 5 | +
|
| 6 | +const filter_days = ref(7) |
| 7 | +const is_loading_session = ref(false) |
| 8 | +
|
| 9 | +const url_params = computed(() => ({ |
| 10 | + days: filter_days.value, |
| 11 | + limit: 100 |
| 12 | +})) |
| 13 | +
|
| 14 | +const { data, status } = useFetch('/api/monitoring', { |
| 15 | + query: url_params, |
| 16 | + watch: [url_params], |
| 17 | + default: () => ({ |
| 18 | + sessions: [] as Array<Record<string, unknown>>, |
| 19 | + totals: { tokens_input: 0, tokens_output: 0, cost_usd: 0, count_sessions: 0, count_tool_calls: 0 } |
| 20 | + }) |
| 21 | +}) |
| 22 | +
|
| 23 | +const list_sessions = computed(() => data.value?.sessions ?? []) |
| 24 | +const totals = computed(() => data.value?.totals ?? { tokens_input: 0, tokens_output: 0, cost_usd: 0, count_sessions: 0, count_tool_calls: 0 }) |
| 25 | +
|
| 26 | +const list_stats = computed(() => [ |
| 27 | + { key: 'sessions', label: 'Sessions', value: String(totals.value.count_sessions), icon: 'i-lucide-layers' }, |
| 28 | + { key: 'input', label: 'Input Tokens', value: formatCompact(totals.value.tokens_input), icon: 'i-lucide-arrow-down-to-line' }, |
| 29 | + { key: 'output', label: 'Output Tokens', value: formatCompact(totals.value.tokens_output), icon: 'i-lucide-arrow-up-from-line' }, |
| 30 | + { key: 'cost', label: 'Est. Cost', value: `$${totals.value.cost_usd.toFixed(2)}`, icon: 'i-lucide-coins' }, |
| 31 | + { key: 'tools', label: 'Tool Calls', value: formatCompact(totals.value.count_tool_calls), icon: 'i-lucide-wrench' } |
| 32 | +]) |
| 33 | +
|
| 34 | +// Aggregate by project |
| 35 | +const list_projects = computed(() => { |
| 36 | + const map = new Map<string, { project: string, sessions: number, tokens: number, cost: number, tools: number }>() |
| 37 | + for (const s of list_sessions.value) { |
| 38 | + const entry = map.get(s.project) ?? { project: s.project, sessions: 0, tokens: 0, cost: 0, tools: 0 } |
| 39 | + entry.sessions++ |
| 40 | + entry.tokens += s.tokens_input + s.tokens_output |
| 41 | + entry.cost += s.cost_usd |
| 42 | + entry.tools += s.count_tool_calls |
| 43 | + map.set(s.project, entry) |
| 44 | + } |
| 45 | + return Array.from(map.values()).sort((a, b) => b.cost - a.cost) |
| 46 | +}) |
| 47 | +
|
| 48 | +function formatDuration(seconds: number): string { |
| 49 | + if (seconds < 60) return `${seconds}s` |
| 50 | + const m = Math.floor(seconds / 60) |
| 51 | + const s = seconds % 60 |
| 52 | + if (m < 60) return `${m}m ${s}s` |
| 53 | + const h = Math.floor(m / 60) |
| 54 | + return `${h}h ${m % 60}m` |
| 55 | +} |
| 56 | +
|
| 57 | +function tokenBar(input: number, output: number): { input_pct: number, output_pct: number } { |
| 58 | + const total = input + output |
| 59 | + if (total === 0) return { input_pct: 0, output_pct: 0 } |
| 60 | + return { |
| 61 | + input_pct: Math.round((input / total) * 100), |
| 62 | + output_pct: Math.round((output / total) * 100) |
| 63 | + } |
| 64 | +} |
| 65 | +
|
| 66 | +// Find max cost to scale bars |
| 67 | +const max_cost = computed(() => { |
| 68 | + const costs = list_sessions.value.map(s => s.cost_usd) |
| 69 | + return Math.max(...costs, 0.01) |
| 70 | +}) |
| 71 | +
|
| 72 | +async function handleClickSession(session: { id: string, cwd: string }): Promise<void> { |
| 73 | + if (is_loading_session.value) return |
| 74 | + is_loading_session.value = true |
| 75 | + try { |
| 76 | + const data = await $fetch<{ id: string, cwd: string, messages: Array<{ role: 'user' | 'assistant', content: string, timestamp: string }> }>(`/api/ai/sessions/${session.id}`) |
| 77 | + resumeSession(data.id, data.cwd, data.messages) |
| 78 | + navigateTo('/ai') |
| 79 | + } catch (error) { |
| 80 | + console.error('Failed to load session:', error) |
| 81 | + } finally { |
| 82 | + is_loading_session.value = false |
| 83 | + } |
| 84 | +} |
| 85 | +</script> |
| 86 | + |
| 87 | +<template> |
| 88 | + <UDashboardPanel id="monitoring"> |
| 89 | + <template #header> |
| 90 | + <UDashboardNavbar title="Monitoring"> |
| 91 | + <template #leading> |
| 92 | + <UDashboardSidebarCollapse /> |
| 93 | + </template> |
| 94 | + <template #right> |
| 95 | + <div class="flex items-center gap-2"> |
| 96 | + <select |
| 97 | + v-model.number="filter_days" |
| 98 | + class="h-8 rounded-md border border-default bg-default text-sm px-2 pr-7 outline-none text-dimmed appearance-none cursor-pointer" |
| 99 | + > |
| 100 | + <option :value="1"> |
| 101 | + Last 24h |
| 102 | + </option> |
| 103 | + <option :value="7"> |
| 104 | + Last 7 days |
| 105 | + </option> |
| 106 | + <option :value="30"> |
| 107 | + Last 30 days |
| 108 | + </option> |
| 109 | + <option :value="90"> |
| 110 | + Last 90 days |
| 111 | + </option> |
| 112 | + </select> |
| 113 | + </div> |
| 114 | + </template> |
| 115 | + </UDashboardNavbar> |
| 116 | + </template> |
| 117 | + |
| 118 | + <template #body> |
| 119 | + <div class="p-6 space-y-6"> |
| 120 | + <!-- Summary stats --> |
| 121 | + <div v-if="status === 'pending'" class="grid grid-cols-2 lg:grid-cols-5 gap-4"> |
| 122 | + <USkeleton v-for="i in 5" :key="i" class="h-24" /> |
| 123 | + </div> |
| 124 | + <div v-else class="grid grid-cols-2 lg:grid-cols-5 gap-4"> |
| 125 | + <UCard v-for="item in list_stats" :key="item.key"> |
| 126 | + <div class="flex items-center gap-3"> |
| 127 | + <div class="flex items-center justify-center size-10 rounded-lg bg-primary/10"> |
| 128 | + <UIcon :name="item.icon" class="size-5 text-primary" /> |
| 129 | + </div> |
| 130 | + <div> |
| 131 | + <p class="text-sm text-dimmed"> |
| 132 | + {{ item.label }} |
| 133 | + </p> |
| 134 | + <p class="text-2xl font-bold tabular-nums"> |
| 135 | + {{ item.value }} |
| 136 | + </p> |
| 137 | + </div> |
| 138 | + </div> |
| 139 | + </UCard> |
| 140 | + </div> |
| 141 | + |
| 142 | + <!-- Project breakdown --> |
| 143 | + <UCard v-if="list_projects.length > 0"> |
| 144 | + <template #header> |
| 145 | + <h3 class="text-sm font-medium text-dimmed"> |
| 146 | + Cost by Project |
| 147 | + </h3> |
| 148 | + </template> |
| 149 | + <div class="space-y-3"> |
| 150 | + <div v-for="proj in list_projects" :key="proj.project" class="flex items-center gap-4"> |
| 151 | + <span class="text-sm font-mono w-48 truncate" :title="proj.project">{{ proj.project }}</span> |
| 152 | + <div class="flex-1 h-5 bg-elevated/50 rounded-full overflow-hidden"> |
| 153 | + <div |
| 154 | + class="h-full bg-primary/60 rounded-full transition-all" |
| 155 | + :style="{ width: `${Math.max(2, (proj.cost / (totals.cost_usd || 1)) * 100)}%` }" |
| 156 | + /> |
| 157 | + </div> |
| 158 | + <span class="text-sm tabular-nums w-20 text-right font-medium">${{ proj.cost.toFixed(2) }}</span> |
| 159 | + <span class="text-xs text-dimmed w-16 text-right">{{ proj.sessions }} sess.</span> |
| 160 | + </div> |
| 161 | + </div> |
| 162 | + </UCard> |
| 163 | + |
| 164 | + <!-- Session list --> |
| 165 | + <UCard> |
| 166 | + <template #header> |
| 167 | + <div class="flex items-center justify-between"> |
| 168 | + <h3 class="text-sm font-medium text-dimmed"> |
| 169 | + Sessions |
| 170 | + </h3> |
| 171 | + <span class="text-xs text-dimmed"> |
| 172 | + {{ list_sessions.length }} sessions |
| 173 | + </span> |
| 174 | + </div> |
| 175 | + </template> |
| 176 | + |
| 177 | + <div v-if="status === 'pending'" class="space-y-2"> |
| 178 | + <USkeleton v-for="i in 8" :key="i" class="h-14" /> |
| 179 | + </div> |
| 180 | + |
| 181 | + <div v-else-if="list_sessions.length > 0" class="divide-y divide-default"> |
| 182 | + <div |
| 183 | + v-for="session in list_sessions" |
| 184 | + :key="session.id" |
| 185 | + class="flex items-center gap-4 py-3 px-1 hover:bg-elevated/30 rounded-lg transition-colors cursor-pointer group" |
| 186 | + @click="handleClickSession(session)" |
| 187 | + > |
| 188 | + <!-- Title + meta --> |
| 189 | + <div class="flex-1 min-w-0"> |
| 190 | + <div class="flex items-center gap-2 mb-1"> |
| 191 | + <span class="text-sm truncate group-hover:text-primary transition-colors">{{ session.title || 'Untitled' }}</span> |
| 192 | + <UBadge color="neutral" variant="subtle" size="xs"> |
| 193 | + {{ session.project }} |
| 194 | + </UBadge> |
| 195 | + </div> |
| 196 | + <div class="flex items-center gap-3 text-[10px] text-dimmed/60"> |
| 197 | + <span>{{ formatTimeAgo(session.time_end) }}</span> |
| 198 | + <span>{{ formatDuration(session.duration_seconds) }}</span> |
| 199 | + <span>{{ session.count_turns }} turns</span> |
| 200 | + <span>{{ session.count_tool_calls }} tools</span> |
| 201 | + </div> |
| 202 | + </div> |
| 203 | + |
| 204 | + <!-- Token ratio bar --> |
| 205 | + <div class="w-24 flex flex-col gap-0.5"> |
| 206 | + <div class="flex h-1.5 rounded-full overflow-hidden bg-elevated/50"> |
| 207 | + <div class="bg-blue-400/70" :style="{ width: `${tokenBar(session.tokens_input, session.tokens_output).input_pct}%` }" /> |
| 208 | + <div class="bg-emerald-400/70" :style="{ width: `${tokenBar(session.tokens_input, session.tokens_output).output_pct}%` }" /> |
| 209 | + </div> |
| 210 | + <div class="flex justify-between text-[9px] text-dimmed/50"> |
| 211 | + <span>{{ formatCompact(session.tokens_input) }} in</span> |
| 212 | + <span>{{ formatCompact(session.tokens_output) }} out</span> |
| 213 | + </div> |
| 214 | + </div> |
| 215 | + |
| 216 | + <!-- Cost bar --> |
| 217 | + <div class="w-28 flex items-center gap-2"> |
| 218 | + <div class="flex-1 h-1.5 rounded-full overflow-hidden bg-elevated/50"> |
| 219 | + <div |
| 220 | + class="h-full bg-amber-400/60 rounded-full" |
| 221 | + :style="{ width: `${Math.max(2, (session.cost_usd / max_cost) * 100)}%` }" |
| 222 | + /> |
| 223 | + </div> |
| 224 | + <span class="text-xs tabular-nums text-right w-14 font-medium">${{ session.cost_usd.toFixed(3) }}</span> |
| 225 | + </div> |
| 226 | + </div> |
| 227 | + </div> |
| 228 | + |
| 229 | + <div v-else class="flex flex-col items-center justify-center py-16 text-center"> |
| 230 | + <UIcon name="i-lucide-activity" class="size-10 text-dimmed/30 mb-3" /> |
| 231 | + <p class="text-sm text-dimmed"> |
| 232 | + No session data found |
| 233 | + </p> |
| 234 | + <p class="text-xs text-dimmed/50 mt-1"> |
| 235 | + Start a conversation in Claude Code to see metrics here |
| 236 | + </p> |
| 237 | + </div> |
| 238 | + </UCard> |
| 239 | + </div> |
| 240 | + </template> |
| 241 | + </UDashboardPanel> |
| 242 | +</template> |
0 commit comments