Skip to content

Commit 44186c8

Browse files
committed
feat(frontend): implement log streaming and filtering functionality
1 parent c563ebc commit 44186c8

File tree

6 files changed

+490
-53
lines changed

6 files changed

+490
-53
lines changed
Lines changed: 256 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,159 @@
11
<script setup lang="ts">
2-
import { Card } from '@/components/ui/card'
2+
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
3+
import { useI18n } from 'vue-i18n'
4+
import { toast } from 'vue-sonner'
5+
import { useLogsStream } from '@/composables/mcp-server/installation'
6+
import { McpLogsService } from '@/services/mcpLogsService'
37
import { LogsNoAccess } from '@/components/mcp-server/installation'
8+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
9+
import { Alert, AlertDescription } from '@/components/ui/alert'
10+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
11+
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
12+
import { Card } from '@/components/ui/card'
13+
import { AlertCircle, Radio } from 'lucide-vue-next'
414
import type { McpInstallation } from '@/types/mcp-installations'
15+
import type { McpLog } from '@/types/mcp-logs'
516
617
interface Props {
718
installation: McpInstallation
819
teamId: string
920
userTeamRole?: 'team_admin' | 'team_user' | null
1021
}
1122
12-
defineProps<Props>()
23+
const props = defineProps<Props>()
24+
const { t } = useI18n()
25+
26+
const { logs, isConnected, isLoading, error, connect, disconnect } = useLogsStream()
27+
28+
type FilterType = 'all' | 'info' | 'warn' | 'error' | 'debug'
29+
type ViewMode = 'live' | 'api'
30+
const filter = ref<FilterType>('all')
31+
const viewMode = ref<ViewMode>('live')
32+
33+
// Filtered logs based on filter selection
34+
const filteredLogs = computed(() => {
35+
if (filter.value === 'all') return logs.value
36+
return logs.value.filter(log => log.level === filter.value)
37+
})
38+
39+
// Format timestamp in Vercel style: "Dec 17 09:27:37.30"
40+
function formatTimestamp(dateString: string): string {
41+
const date = new Date(dateString)
42+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
43+
const month = months[date.getMonth()]
44+
const day = date.getDate().toString().padStart(2, '0')
45+
const hours = date.getHours().toString().padStart(2, '0')
46+
const minutes = date.getMinutes().toString().padStart(2, '0')
47+
const seconds = date.getSeconds().toString().padStart(2, '0')
48+
const ms = Math.floor(date.getMilliseconds() / 10).toString().padStart(2, '0')
49+
return `${month} ${day} ${hours}:${minutes}:${seconds}.${ms}`
50+
}
51+
52+
// Format local timezone timestamp
53+
function formatLocalTimestamp(dateString: string): string {
54+
const date = new Date(dateString)
55+
return date.toLocaleString('de-DE', {
56+
day: '2-digit',
57+
month: 'short',
58+
year: 'numeric',
59+
hour: '2-digit',
60+
minute: '2-digit',
61+
second: '2-digit',
62+
fractionalSecondDigits: 3
63+
} as Intl.DateTimeFormatOptions)
64+
}
65+
66+
// Format UTC timestamp
67+
function formatUtcTimestamp(dateString: string): string {
68+
const date = new Date(dateString)
69+
return date.toLocaleString('de-DE', {
70+
day: '2-digit',
71+
month: 'short',
72+
year: 'numeric',
73+
hour: '2-digit',
74+
minute: '2-digit',
75+
second: '2-digit',
76+
fractionalSecondDigits: 3,
77+
timeZone: 'UTC'
78+
} as Intl.DateTimeFormatOptions)
79+
}
80+
81+
// Format relative time
82+
function formatRelativeTime(dateString: string): string {
83+
const date = new Date(dateString)
84+
const now = new Date()
85+
const diffMs = now.getTime() - date.getTime()
86+
const diffSecs = Math.floor(diffMs / 1000)
87+
const diffMins = Math.floor(diffSecs / 60)
88+
const diffHours = Math.floor(diffMins / 60)
89+
const diffDays = Math.floor(diffHours / 24)
90+
91+
if (diffSecs < 60) return `${diffSecs} seconds ago`
92+
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`
93+
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`
94+
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`
95+
}
96+
97+
// Get Unix timestamp in milliseconds
98+
function getUnixTimestamp(dateString: string): number {
99+
return new Date(dateString).getTime()
100+
}
101+
102+
// Get user's timezone name
103+
function getUserTimezone(): string {
104+
return Intl.DateTimeFormat().resolvedOptions().timeZone
105+
}
106+
107+
// Get text color class based on log level
108+
function getLevelTextClass(level: McpLog['level']): string {
109+
switch (level) {
110+
case 'error':
111+
return 'text-red-600 dark:text-red-500'
112+
case 'warn':
113+
return 'text-amber-600 dark:text-amber-500'
114+
case 'debug':
115+
return 'text-neutral-500 dark:text-neutral-400'
116+
case 'info':
117+
default:
118+
return 'text-green-600 dark:text-green-500'
119+
}
120+
}
121+
122+
// Connect to SSE stream
123+
function connectStream() {
124+
const options: { level?: FilterType; limit?: number } = {}
125+
// Note: We filter client-side for better UX, so we don't pass level filter to stream
126+
const url = McpLogsService.getStreamUrl(props.teamId, props.installation.id, { limit: 100 })
127+
connect(url)
128+
}
129+
130+
// Watch for view mode changes and show toast
131+
watch(viewMode, (newMode, oldMode) => {
132+
if (!oldMode) return // Skip initial mount
133+
134+
if (newMode === 'live') {
135+
connectStream()
136+
toast.success(t('mcpInstallations.details.logs.viewMode.switchedToLive'), {
137+
description: t('mcpInstallations.details.logs.viewMode.liveDescription')
138+
})
139+
} else if (newMode === 'api') {
140+
disconnect()
141+
toast(t('mcpInstallations.details.logs.viewMode.switchedToApi'), {
142+
description: t('mcpInstallations.details.logs.viewMode.apiDescription')
143+
})
144+
}
145+
})
146+
147+
onMounted(() => {
148+
// Only connect if user is admin
149+
if (props.userTeamRole === 'team_admin') {
150+
connectStream()
151+
}
152+
})
153+
154+
onUnmounted(() => {
155+
disconnect()
156+
})
13157
</script>
14158

15159
<template>
@@ -18,64 +162,123 @@ defineProps<Props>()
18162
<LogsNoAccess v-if="userTeamRole !== 'team_admin'" />
19163

20164
<!-- Logs Content for Admin Users -->
21-
<Card v-else class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950 p-6">
22-
<div class="space-y-6">
23-
<!-- Title -->
24-
<div>
25-
<h3 class="text-lg font-semibold">Logs</h3>
26-
<p class="text-sm text-muted-foreground mt-1">
27-
View and analyze MCP server logs
28-
</p>
29-
</div>
30-
31-
<!-- Lorem Ipsum Content -->
32-
<div class="space-y-4">
33-
<p class="text-neutral-800 dark:text-neutral-100">
34-
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
35-
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
36-
</p>
165+
<div v-else>
166+
<!-- Error State (outside card) -->
167+
<Alert v-if="error" variant="destructive" class="mb-6">
168+
<AlertCircle class="h-4 w-4" />
169+
<AlertDescription>
170+
{{ t('mcpInstallations.details.logs.error.description', { error }) }}
171+
</AlertDescription>
172+
</Alert>
37173

38-
<p class="text-neutral-800 dark:text-neutral-100">
39-
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
40-
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
41-
</p>
42-
43-
<div class="border-l-4 border-neutral-300 dark:border-neutral-700 pl-4 py-2 bg-neutral-50 dark:bg-neutral-900">
44-
<p class="text-sm text-neutral-600 dark:text-neutral-400 italic">
45-
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium,
46-
totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
47-
</p>
174+
<!-- Main Card -->
175+
<Card v-else class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950 p-0 gap-0">
176+
<!-- Header with live indicator, view mode and filter -->
177+
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
178+
<div class="flex items-center gap-2">
179+
<div
180+
v-if="viewMode === 'live'"
181+
class="flex items-center gap-1.5 text-sm"
182+
:class="isConnected ? 'text-green-600' : 'text-muted-foreground'"
183+
>
184+
<Radio class="h-3 w-3" :class="{ 'animate-pulse': isConnected }" />
185+
<span v-if="isConnected">{{ t('mcpInstallations.details.logs.connection.live') }}</span>
186+
<span v-else-if="isLoading">{{ t('mcpInstallations.details.logs.connection.reconnecting') }}</span>
187+
<span v-else>{{ t('mcpInstallations.details.logs.connection.disconnected') }}</span>
188+
</div>
48189
</div>
49190

50-
<p class="text-neutral-800 dark:text-neutral-100">
51-
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos
52-
qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur,
53-
adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.
54-
</p>
55-
56-
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
57-
<div class="p-4 border border-neutral-200 dark:border-neutral-800 rounded-lg">
58-
<h4 class="font-medium text-neutral-800 dark:text-neutral-100 mb-2">Sample Log Entry</h4>
59-
<p class="text-sm text-neutral-600 dark:text-neutral-400">
60-
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti.
61-
</p>
62-
</div>
191+
<div class="flex items-center gap-2">
192+
<Select v-model="viewMode">
193+
<SelectTrigger class="w-32">
194+
<SelectValue />
195+
</SelectTrigger>
196+
<SelectContent>
197+
<SelectItem value="live">Live</SelectItem>
198+
<SelectItem value="api">API</SelectItem>
199+
</SelectContent>
200+
</Select>
63201

64-
<div class="p-4 border border-neutral-200 dark:border-neutral-800 rounded-lg">
65-
<h4 class="font-medium text-neutral-800 dark:text-neutral-100 mb-2">Another Log Entry</h4>
66-
<p class="text-sm text-neutral-600 dark:text-neutral-400">
67-
Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio.
68-
</p>
69-
</div>
202+
<Select v-model="filter">
203+
<SelectTrigger class="w-40">
204+
<SelectValue />
205+
</SelectTrigger>
206+
<SelectContent>
207+
<SelectItem value="all">{{ t('mcpInstallations.details.logs.filter.all') }}</SelectItem>
208+
<SelectItem value="info">{{ t('mcpInstallations.details.logs.filter.info') }}</SelectItem>
209+
<SelectItem value="warn">{{ t('mcpInstallations.details.logs.filter.warn') }}</SelectItem>
210+
<SelectItem value="error">{{ t('mcpInstallations.details.logs.filter.error') }}</SelectItem>
211+
<SelectItem value="debug">{{ t('mcpInstallations.details.logs.filter.debug') }}</SelectItem>
212+
</SelectContent>
213+
</Select>
70214
</div>
215+
</div>
71216

72-
<p class="text-neutral-800 dark:text-neutral-100">
73-
Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates
74-
repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut
75-
reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.
217+
<!-- Empty State -->
218+
<div v-if="filteredLogs.length === 0" class="text-center py-12 px-6">
219+
<p class="text-sm">{{ t('mcpInstallations.details.logs.emptyState.title') }}</p>
220+
<p class="mt-2 text-sm text-muted-foreground max-w-md mx-auto">
221+
{{ t('mcpInstallations.details.logs.emptyState.description') }}
76222
</p>
77223
</div>
78-
</div>
79-
</Card>
224+
225+
<!-- Logs Table -->
226+
<div v-else>
227+
<Table>
228+
<TableHeader>
229+
<TableRow>
230+
<TableHead class="w-24">{{ t('mcpInstallations.details.logs.table.columns.level') }}</TableHead>
231+
<TableHead class="w-40">{{ t('mcpInstallations.details.logs.table.columns.time') }}</TableHead>
232+
<TableHead>{{ t('mcpInstallations.details.logs.table.columns.message') }}</TableHead>
233+
</TableRow>
234+
</TableHeader>
235+
<TableBody>
236+
<TableRow
237+
v-for="log in filteredLogs"
238+
:key="log.id"
239+
>
240+
<TableCell class="w-24">
241+
<span class="text-xs font-medium" :class="getLevelTextClass(log.level)">
242+
{{ log.level.toUpperCase() }}
243+
</span>
244+
</TableCell>
245+
<TableCell class="text-sm text-muted-foreground font-mono tabular-nums">
246+
<HoverCard>
247+
<HoverCardTrigger class="cursor-pointer">
248+
{{ formatTimestamp(log.created_at) }}
249+
</HoverCardTrigger>
250+
<HoverCardContent align="start" class="w-auto">
251+
<table class="text-sm">
252+
<tbody>
253+
<tr>
254+
<td class="text-muted-foreground pr-4 py-0.5">{{ getUserTimezone() }}</td>
255+
<td class="font-mono tabular-nums text-right py-0.5">{{ formatLocalTimestamp(log.created_at) }}</td>
256+
</tr>
257+
<tr>
258+
<td class="text-muted-foreground pr-4 py-0.5">UTC</td>
259+
<td class="font-mono tabular-nums text-right py-0.5">{{ formatUtcTimestamp(log.created_at) }}</td>
260+
</tr>
261+
<tr>
262+
<td class="text-muted-foreground pr-4 py-0.5">Relative</td>
263+
<td class="text-right py-0.5">{{ formatRelativeTime(log.created_at) }}</td>
264+
</tr>
265+
<tr>
266+
<td class="text-muted-foreground pr-4 py-0.5">Timestamp</td>
267+
<td class="font-mono tabular-nums text-right py-0.5">{{ getUnixTimestamp(log.created_at) }}</td>
268+
</tr>
269+
</tbody>
270+
</table>
271+
</HoverCardContent>
272+
</HoverCard>
273+
</TableCell>
274+
<TableCell class="text-sm">
275+
{{ log.message }}
276+
</TableCell>
277+
</TableRow>
278+
</TableBody>
279+
</Table>
280+
</div>
281+
</Card>
282+
</div>
80283
</div>
81284
</template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { useMcpInstallationCache } from './useInstallationCache'
22
export { useRequestsStream } from './useRequestsStream'
33
export { useStatusStream } from './useStatusStream'
4+
export { useLogsStream } from './useLogsStream'

0 commit comments

Comments
 (0)