Skip to content

Commit

Permalink
Usage Indicator (#278)
Browse files Browse the repository at this point in the history
  • Loading branch information
csansoon authored Sep 26, 2024
1 parent 63ff167 commit 9853c7d
Show file tree
Hide file tree
Showing 17 changed files with 741 additions and 4 deletions.
11 changes: 11 additions & 0 deletions apps/web/src/actions/workspaces/usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use server'

import { computeWorkspaceUsage } from '@latitude-data/core/services/workspaces/usage'

import { authProcedure } from '../procedures'

export const fetchWorkspaceUsageAction = authProcedure
.createServerAction()
.handler(async ({ ctx }) => {
return await computeWorkspaceUsage(ctx.workspace).then((r) => r.unwrap())
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
'use client'

import { ReactNode, useMemo } from 'react'

import { WorkspaceUsage } from '@latitude-data/core/browser'
import {
Badge,
Button,
CircularProgress,
CircularProgressProps,
Skeleton,
Text,
} from '@latitude-data/web-ui'
import useWorkspaceUsage from '$/stores/workspaceUsage'
import Link from 'next/link'
import Popover from 'node_modules/@latitude-data/web-ui/src/ds/atoms/Popover'

function UsageIndicatorCircle({
data,
isLoading,
...props
}: Omit<CircularProgressProps, 'value' | 'color'> & {
data: WorkspaceUsage | undefined
isLoading: boolean
}) {
const ratio = useMemo(() => {
if (!data) return 0
if (data.max === 0) return 0
const actualRatio = (data.max - data.usage) / data.max

if (actualRatio <= 0) return 0
if (actualRatio >= 1) return 1

if (actualRatio < 0.01) return 0.01 // Too low of a ratio makes the circle so small it disappears
return actualRatio
}, [data])

if (isLoading) {
return (
<CircularProgress
value={1}
color='foregroundMuted'
className='opacity-25 animate-pulse'
animateOnMount={false}
{...props}
/>
)
}
if (ratio <= 0) {
return <CircularProgress value={1} color='destructive' {...props} />
}

return (
<CircularProgress
value={ratio || 1}
color={
ratio > 0.25
? 'primary'
: ratio === 0
? 'destructive'
: 'warningMutedForeground'
}
{...props}
/>
)
}

function LoadingText({
isLoading,
children,
}: {
isLoading: boolean
children: ReactNode
}) {
if (isLoading) {
return <Skeleton className='w-20 h-4 animate-pulse' />
}

return children
}

function descriptionText({ usage, max }: WorkspaceUsage) {
const ratio = (max - usage) / max

if (ratio <= 0) {
return "You've reached the maximum number of runs. Your app will continue working, the Latitude limit reached means you'll no longer be able to run tests, view new logs or evaluation results."
}
if (ratio < 0.25) {
return "You're running low on runs in your current plan. Your app will continue working, the Latitude limit reached means you'll no longer be able to run tests, view new logs or evaluation results."
}

return `Your plan has included ${max} runs. You can upgrade your plan to get more runs.`
}

export function UsageIndicator() {
const { data, isLoading } = useWorkspaceUsage()

return (
<Popover.Root>
<Popover.Trigger asChild>
<Button variant='ghost' className='hover:bg-muted'>
<div className='flex flex-row items-center gap-x-2'>
<UsageIndicatorCircle data={data} isLoading={isLoading} />
<LoadingText isLoading={isLoading}>
<Text.H6>
{data?.usage} / {data?.max}
</Text.H6>
</LoadingText>
</div>
</Button>
</Popover.Trigger>
<Popover.Content
side='bottom'
sideOffset={8}
align='center'
className='bg-background rounded-md w-80 p-4 shadow-lg border border-border'
>
<div className='flex flex-col gap-4'>
<div className='flex flex-row items-center gap-2'>
<UsageIndicatorCircle
data={data}
size={20}
isLoading={isLoading}
backgroundColor='foregroundMuted'
className='overflow-clip'
/>
<LoadingText isLoading={isLoading}>
<div className='flex flex-row w-full items-center gap-2'>
<Text.H4 color='foreground'>{data?.usage}</Text.H4>
<Text.H4 color='foregroundMuted' noWrap>
{' '}
/ {data?.max} runs
</Text.H4>
<div className='w-full flex items-center justify-end'>
<Badge variant='muted'>
<Text.H6 color='foregroundMuted'>Team Plan</Text.H6>
</Badge>
</div>
</div>
</LoadingText>
</div>
{data ? (
<Text.H6>{descriptionText(data)}</Text.H6>
) : (
<div className='w-full flex flex-col gap-1'>
<Skeleton className='w-full h-3 animate-pulse' />
<Skeleton className='w-full h-3 animate-pulse' />
<Skeleton className='w-[40%] h-3 animate-pulse' />
</div>
)}
<div className='flex flex-row'>
<Link href='mailto:[email protected]'>
<Button fancy>Contact us to upgrade</Button>
</Link>
</div>
</div>
</Popover.Content>
</Popover.Root>
)
}
4 changes: 3 additions & 1 deletion apps/web/src/components/layouts/AppLayout/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Link from 'next/link'
import { Fragment } from 'react/jsx-runtime'

import AvatarDropdown from './AvatarDropdown'
import { UsageIndicator } from './UsageIndicator'

function BreadcrumbSeparator() {
return (
Expand Down Expand Up @@ -110,7 +111,8 @@ export default function AppHeader({
<div className='py-3 flex flex-row items-center justify-between'>
<Breadcrumb showLogo breadcrumbs={breadcrumbs} />
<div className='flex flex-row items-center gap-x-6'>
<nav className='flex flex-row gap-x-4'>
<nav className='flex flex-row gap-x-4 items-center'>
<UsageIndicator />
{navigationLinks.map((link, idx) => (
<NavLink key={idx} {...link} />
))}
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/stores/workspaceUsage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useCallback } from 'react'

import { WorkspaceUsage } from '@latitude-data/core/browser'
import { useToast } from '@latitude-data/web-ui'
import { fetchWorkspaceUsageAction } from '$/actions/workspaces/usage'
import { useSockets } from '$/components/Providers/WebsocketsProvider/useSockets'
import useSWR, { SWRConfiguration } from 'swr'

export default function useWorkspaceUsage(opts?: SWRConfiguration) {
const { toast } = useToast()
const {
mutate,
data = undefined,
isLoading,
error: swrError,
} = useSWR<WorkspaceUsage | undefined>(
['workspaceUsage'],
useCallback(async () => {
const [data, error] = await fetchWorkspaceUsageAction()
if (error) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
})
}
if (!data) return undefined

return data
}, []),
opts,
)

const onMessage = useCallback(() => {
mutate(
(prevData) => {
if (!prevData) return prevData
return {
...prevData,
usage: prevData.usage + 1,
}
},
{
revalidate: false,
},
)
}, [])

useSockets({ event: 'evaluationResultCreated', onMessage })
useSockets({ event: 'documentLogCreated', onMessage })

return { data, mutate, isLoading, error: swrError }
}
5 changes: 5 additions & 0 deletions apps/websockets/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ workers.on('connection', (socket) => {
const workspace = buildWorkspaceRoom({ workspaceId })
web.to(workspace).emit('evaluationResultCreated', data)
})
socket.on('documentLogCreated', (args) => {
const { workspaceId, data } = args
const workspace = buildWorkspaceRoom({ workspaceId })
web.to(workspace).emit('documentLogCreated', data)
})
})

const PORT = process.env.WEBSOCKETS_SERVER_PORT || 4002
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const LATITUDE_SLACK_URL =
'https://trylatitude.slack.com/join/shared_invite/zt-17dyj4elt-rwM~h2OorAA3NtgmibhnLA#/shared-invite/email'
export const LATITUDE_HELP_URL = LATITUDE_SLACK_URL
export const HEAD_COMMIT = 'live'
export const MAX_FREE_RUNS = 50_000

export enum CommitStatus {
All = 'all',
Merged = 'merged',
Expand Down Expand Up @@ -155,6 +157,11 @@ export type EvaluationMeanValue = {
meanValue: number
}

export type WorkspaceUsage = {
usage: number
max: number
}

export type ChainCallResponseDto = Omit<
ChainCallResponse,
'documentLogUuid' | 'providerLog'
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/events/handlers/documentLogs/createJob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createDocumentLog,
CreateDocumentLogProps,
} from '../../../services/documentLogs'
import { WebsocketClient } from '../../../websockets/workers'

export type CreateDocumentLogJobData = CreateDocumentLogProps

Expand All @@ -12,15 +13,17 @@ export const createDocumentLogJob = async ({
}: {
data: DocumentRunEvent
}) => {
const scope = new CommitsRepository(event.data.workspaceId)
const websockets = await WebsocketClient.getSocket()
const workspaceId = event.data.workspaceId
const scope = new CommitsRepository(workspaceId)
const commit = await scope
.getCommitByUuid({
projectId: event.data.projectId,
uuid: event.data.commitUuid,
})
.then((r) => r.unwrap())

await createDocumentLog({
const documentLog = await createDocumentLog({
commit,
data: {
customIdentifier: event.data.customIdentifier,
Expand All @@ -32,4 +35,15 @@ export const createDocumentLogJob = async ({
source: event.data.source,
},
}).then((r) => r.unwrap())

// TODO: Move to its own event handler.
websockets.emit('documentLogCreated', {
workspaceId,
data: {
workspaceId,
documentUuid: event.data.documentUuid,
commitUuid: event.data.commitUuid,
documentLogId: documentLog.id,
},
})
}
3 changes: 3 additions & 0 deletions packages/core/src/services/documentLogs/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type CreateDocumentLogProps = {
customIdentifier?: string
duration: number
source: LogSources
createdAt?: Date
}
}

Expand All @@ -27,6 +28,7 @@ export async function createDocumentLog(
customIdentifier,
duration,
source,
createdAt,
},
commit,
}: CreateDocumentLogProps,
Expand All @@ -44,6 +46,7 @@ export async function createDocumentLog(
customIdentifier,
duration,
source,
createdAt,
})
.returning()

Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/services/workspaces/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ export async function createWorkspace(
{
name,
user,
createdAt,
}: {
name: string
user: User
createdAt?: Date
},
db = database,
) {
return Transaction.call<Workspace>(async (tx) => {
const insertedWorkspaces = await tx
.insert(workspaces)
.values({ name, creatorId: user.id })
.values({ name, creatorId: user.id, createdAt })
.returning()
const workspace = insertedWorkspaces[0]!

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/services/workspaces/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './create'
export * from './update'
export * from './usage'
Loading

0 comments on commit 9853c7d

Please sign in to comment.