Skip to content

Commit

Permalink
refactor: Improve realtime stats (#428)
Browse files Browse the repository at this point in the history
  • Loading branch information
HugoRCD authored Jan 18, 2025
1 parent 5ef4f9e commit dbf3180
Show file tree
Hide file tree
Showing 14 changed files with 495 additions and 326 deletions.
6 changes: 5 additions & 1 deletion apps/base/assets/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@

:root {
--ui-radius: var(--radius-sm);

--ease-smooth: cubic-bezier(0.45, 0, 0.55, 1);
--ui-container: var(--container-5xl);

.default {
--ui-container: var(--container-5xl);
}
}

html, body, #__nuxt, #__layout {
Expand Down
10 changes: 5 additions & 5 deletions apps/base/components/ShelveMeta.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ useHead({
link: link,
})
const seoMetadata = {
const seoMetadata = ref({
title: props.title || appTitle,
description: props.description || appDescription,
author: 'Hugo Richard',
Expand All @@ -27,14 +27,14 @@ const seoMetadata = {
ogSiteName: props.title || appTitle,
ogTitle: props.title || appTitle,
ogDescription: props.description || appDescription,
}
})
if (props.defaultOgImage) {
seoMetadata.twitterImage = ogImage
seoMetadata.ogImage = ogImage
seoMetadata.value.twitterImage = ogImage
seoMetadata.value.ogImage = ogImage
}
useSeoMeta(seoMetadata)
useSeoMeta(seoMetadata.value)
</script>

<template>
Expand Down
137 changes: 86 additions & 51 deletions apps/base/composables/useStats.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,118 @@
import type { UseStatsOptions, Stats } from '@shelve/types'

export function useStats(options: UseStatsOptions = {}): {
stats: Ref<Stats | undefined>
isLoading: Ref<boolean>
error: Ref<string | null>
reconnect: () => void
} {
const stats = ref<Stats>()
import type { Stats, UseStatsOptions } from '@shelve/types'

export function useStats(options: UseStatsOptions = {}) {
const stats = useState<Stats>('stats')
const isLoading = ref(true)
const error = ref<string | null>(null)
const retryCount = ref(0)
const MAX_RETRIES = 3
const RETRY_DELAY = 2000

let eventSource: EventSource | null = null
const wsRef = ref<WebSocket | null>(null)
const isConnected = ref(false)
const isMounted = ref(true)

const getEventSourceUrl = () => {
if (options.baseUrl) {
return `${options.baseUrl}/api/stats`
async function initialFetch() {
const baseUrl = options.baseUrl || location.host
try {
stats.value = await $fetch(`${ baseUrl }/api/stats`)
} catch (error: any) {
console.error('Failed to fetch Stats:', error)
error.value = 'Failed to fetch Stats'
} finally {
isLoading.value = false
}
return `${location.origin}/api/stats`
}

const initEventSource = () => {
if (eventSource) {
eventSource.close()
const cleanup = () => {
if (wsRef.value) {
wsRef.value.close()
wsRef.value = null
}
isConnected.value = false
isLoading.value = false
}

const getWebSocketUrl = () => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
let baseUrl = options.baseUrl || location.host
baseUrl = baseUrl.replace(/^(http|https):\/\//, '')
return `${protocol}//${baseUrl}/api/stats/ws`
}

const initWebSocket = () => {
if (!isMounted.value) return

const url = getEventSourceUrl()
eventSource = new EventSource(url)
cleanup()

eventSource.onmessage = (event) => {
try {
stats.value = JSON.parse(event.data)
try {
const ws = new WebSocket(getWebSocketUrl())
wsRef.value = ws

ws.onopen = () => {
if (!isMounted.value) {
ws.close()
return
}
console.log('Stats WebSocket connected')
isConnected.value = true
isLoading.value = false
retryCount.value = 0
} catch (err) {
console.error('Failed to parse SSE data:', err)
error.value = null
}
}

eventSource.onerror = () => {
eventSource?.close()

if (retryCount.value < MAX_RETRIES) {
retryCount.value++
setTimeout(() => {
console.log(`Retrying connection (${retryCount.value}/${MAX_RETRIES})...`)
initEventSource()
}, RETRY_DELAY * retryCount.value)
} else {
error.value = 'Failed to connect to stats stream after multiple attempts'
isLoading.value = false
ws.onmessage = (event) => {
if (!isMounted.value) return
try {
stats.value = JSON.parse(event.data)
} catch (err) {
console.error('Failed to parse Stats WebSocket data:', err)
}
}

ws.onclose = (event) => {
console.log('Stats WebSocket closed:', event.code, event.reason)
isConnected.value = false
wsRef.value = null

if (isMounted.value && event.code !== 1000) {
error.value = 'Connection lost'
}
}

ws.onerror = (event) => {
if (!isMounted.value) return
console.error('Stats WebSocket error:', event)
error.value = 'Connection error'
}
} catch (err) {
if (!isMounted.value) return
console.error('Failed to initialize Stats WebSocket:', err)
error.value = 'Failed to initialize connection'
isLoading.value = false
}
}

const reconnect = () => {
retryCount.value = 0
initEventSource()
if (!isMounted.value) return
error.value = null
isLoading.value = true
initWebSocket()
}

onMounted(() => {
if (import.meta.client) {
initEventSource()
isMounted.value = true
initWebSocket()
}
})

onUnmounted(() => {
if (eventSource) {
eventSource.close()
eventSource = null
}
onBeforeUnmount(() => {
isMounted.value = false
cleanup()
})

return {
stats,
isLoading,
error,
reconnect
isConnected,
reconnect,
initialFetch
}
}
29 changes: 27 additions & 2 deletions apps/lp/app/components/landing/Stats.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ onUnmounted(() => {
const baseUrl = useRuntimeConfig().public.apiUrl
const { stats, isLoading } = useStats({ baseUrl })
const { stats, isLoading, initialFetch } = useStats({ baseUrl })
initialFetch()
const finalStats = computed(() => [
{
Expand All @@ -34,7 +35,7 @@ const finalStats = computed(() => [
},
{
value: stats.value?.projects.value ?? undefined,
label: 'Projets',
label: 'Projects',
suffix: ''
},
{
Expand All @@ -55,6 +56,30 @@ const finalStats = computed(() => [

<template>
<div>
<ClientOnly>
<Teleport defer to="#visitors">
<div class="fixed bottom-5 right-5 z-[999] text-neutral-500 text-xs flex gap-2 items-center">
<span class="relative flex size-2">
<span
class="absolute bg-green-50 inline-flex size-full animate-ping rounded-full opacity-75"
/>
<span
class="relative bg-green-500 inline-flex size-2 scale-90 rounded-full"
/>
</span>
<span>
Active visitors:
</span>
<NumberFlow
class="text-sm font-bold font-mono"
:value="stats?.activeVisitors.value ?? 0"
continuous
will-change
/>
</div>
</Teleport>
</ClientOnly>

<div class="mb-10 flex flex-col gap-2">
<h3 class="main-gradient italic text-3xl leading-8">
<LandingScrambleText label="Built for speed" />
Expand Down
37 changes: 17 additions & 20 deletions apps/lp/app/components/layout/Navbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,32 +37,29 @@ items.push(githubItem)
<template>
<div class="z-[99] relative">
<Blur position="both" />
<div class="fixed top-0 flex w-full">
<div class="z-50 flex w-full items-center justify-between sm:justify-around p-4 sm:px-5 sm:py-2">
<UHeader class="fixed w-full p-4 px-5 py-2 bg-transparent backdrop-blur-none border-none" mode="drawer">
<template #left>
<Logo />
<div class="flex items-center">
<UNavigationMenu
color="neutral"
:items
class="hidden sm:flex"
>
<template #components-trailing>
<UBadge variant="subtle" size="sm" />
</template>
</UNavigationMenu>
</div>
</template>

<UNavigationMenu color="neutral" :items>
<template #components-trailing>
<UBadge variant="subtle" size="sm" />
</template>
</UNavigationMenu>

<template #right>
<div class="flex items-center gap-2">
<div class="flex sm:hidden">
<UDropdownMenu :items>
<UButton variant="ghost" icon="lucide:menu" />
</UDropdownMenu>
</div>
<div>
<UButton label="Open App" size="sm" @click="navigateTo(`https://app.shelve.cloud/login`, { external: true })" />
</div>
</div>
</div>
</div>
</template>

<template #content>
<UNavigationMenu :items orientation="vertical" class="-mx-2.5" />
</template>
</UHeader>
</div>
</template>

2 changes: 1 addition & 1 deletion apps/lp/app/layouts/default.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="relative flex h-screen flex-col">
<div class="default relative flex h-screen flex-col">
<LayoutNavbar />
<div class="flex-1">
<slot />
Expand Down
5 changes: 4 additions & 1 deletion apps/lp/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ function useClipboard(text: string) {
</script>

<template>
<div class="flex flex-col gap-4">
<div class="relative flex flex-col gap-4">
<div id="visitors">
<!-- active visitors -->
</div>
<div class="flex h-full flex-col items-center justify-center gap-3">
<LandingHero class="h-64" />
</div>
Expand Down
Loading

0 comments on commit dbf3180

Please sign in to comment.