Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import {
LoadingAppCard,
} from '@/app/(app)/_components/apps/card/horizontal';

import { api } from '@/trpc/client';
import { Button } from '@/components/ui/button';
import { Info, Loader2 } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { api } from '@/trpc/client';
import { Info, Loader2 } from 'lucide-react';

export const TopApps = () => {
return (
Expand All @@ -30,7 +30,7 @@ const Apps = () => {
const [apps, { hasNextPage, fetchNextPage, isFetchingNextPage }] =
api.apps.list.public.useSuspenseInfiniteQuery(
{
page_size: 10,
page_size: 20,
},
{
getNextPageParam: lastPage =>
Expand All @@ -53,9 +53,20 @@ const Apps = () => {

return (
<AppsContainer>
{items.map(app => (
<AppCard key={app.id} {...app} />
))}
<AppsHeader />
{items.map(app => {
if (!app) return null;
return (
<AppCard
key={app.id}
id={app.id}
name={app.name}
description={app.description}
profilePictureUrl={app.profilePictureUrl}
homepageUrl={app.homepageUrl}
/>
);
})}
{hasNextPage && (
<Button
onClick={() => fetchNextPage()}
Expand All @@ -76,13 +87,29 @@ const Apps = () => {
export const LoadingApps = () => {
return (
<AppsContainer>
<AppsHeader />
{Array.from({ length: 3 }).map((_, i) => (
<LoadingAppCard key={i} />
))}
</AppsContainer>
);
};

const AppsHeader = () => {
return (
<div className="grid grid-cols-4 sm:grid-cols-6 lg:grid-cols-8 gap-2 sm:gap-4 px-4 pb-2 text-sm font-semibold text-muted-foreground">
<div className="col-span-2 sm:col-span-3">App</div>
<div className="hidden lg:flex justify-center col-span-1">Users</div>
<div className="hidden sm:flex justify-center col-span-1">
Transactions
</div>
<div className="hidden lg:flex justify-center col-span-1">Tokens</div>
<div className="flex justify-center col-span-1">Usage</div>
<div className="flex justify-end col-span-1">Earnings</div>
</div>
);
};

const AppsContainer = ({ children }: { children: React.ReactNode }) => {
return <div className="flex flex-col gap-4">{children}</div>;
};
7 changes: 5 additions & 2 deletions packages/app/control/src/app/(app)/(home)/top-apps/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ export default async function TopAppsPage(props: PageProps<'/top-apps'>) {
await userOrRedirect('/top-apps', props);

void api.apps.list.public.prefetchInfinite({
page_size: 10,
page_size: 20,
});

return (
<HydrateClient>
<Heading title="Top Apps" description="Echo apps with the most users" />
<Heading
title="Top Apps"
description="Echo apps with the highest LLM usage"
/>
<Body>
<TopApps />
</Body>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import React from 'react';

import { DollarSign } from 'lucide-react';

import { LoadingMetric, Metric } from './metric';

import { api } from '@/trpc/client';

interface Props {
appId: string;
}

export const TotalCost: React.FC<Props> = ({ appId }) => {
const { data: stats, isLoading } = api.apps.app.stats.overall.useQuery({
appId,
});

return (
<Metric
isLoading={isLoading}
value={(stats?.totalCost ?? 0).toFixed(2)}
Icon={DollarSign}
className="text-muted-foreground"
/>
);
};

export const LoadingTotalCost = () => {
return <LoadingMetric Icon={DollarSign} className="text-muted-foreground" />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import { Skeleton } from '@/components/ui/skeleton';

import { UserAvatar } from '@/components/utils/user-avatar';

import { Users, LoadingUsers } from './users';
import { LoadingTotalCost, TotalCost } from './cost';
import { Earnings, LoadingEarningsAmount } from './earnings';
import { LoadingTotalTokens, TotalTokens } from './tokens';
import { LoadingTransactions, Transactions } from './transactions';
import { LoadingUsers, Users } from './users';

import { cn } from '@/lib/utils';

Expand All @@ -30,8 +32,8 @@ export const AppCard = ({
}: Props) => {
return (
<Link href={`/app/${id}`}>
<Card className="hover:border-primary/50 transition-colors p-4 grid grid-cols-6 gap-4">
<div className="flex flex-col gap-2 col-span-3">
<Card className="hover:border-primary/50 transition-colors p-4 grid grid-cols-4 sm:grid-cols-6 lg:grid-cols-8 gap-2 sm:gap-4">
<div className="flex flex-col gap-2 col-span-2 sm:col-span-3">
<div className="flex flex-row items-center gap-2 flex-1 overflow-hidden">
<UserAvatar
className="size-10 shrink-0"
Expand All @@ -44,7 +46,7 @@ export const AppCard = ({
</h2>
<p
className={cn(
'text-xs text-muted-foreground/60',
'text-xs text-muted-foreground/60 hidden sm:block',
homepageUrl ? 'text-foreground/80' : 'text-foreground/40'
)}
>
Expand All @@ -54,19 +56,25 @@ export const AppCard = ({
</div>
<p
className={cn(
'text-sm text-muted-foreground/60',
'text-sm text-muted-foreground/60 hidden sm:block',
!description && 'text-muted-foreground/40'
)}
>
{description ?? 'No description'}
</p>
</div>
<div className="flex items-center gap-2 justify-center col-span-1">
<div className="hidden lg:flex items-center gap-2 justify-center col-span-1">
<Users appId={id} />
</div>
<div className="shrink-0 flex items-center justify-center col-span-1">
<div className="hidden sm:flex shrink-0 items-center justify-center col-span-1">
<Transactions appId={id} />
</div>
<div className="hidden lg:flex shrink-0 items-center justify-center col-span-1">
<TotalTokens appId={id} />
</div>
<div className="shrink-0 flex items-center justify-center col-span-1">
<TotalCost appId={id} />
</div>
<div className="shrink-0 flex items-center justify-end col-span-1">
<Earnings appId={id} />
</div>
Expand All @@ -77,8 +85,8 @@ export const AppCard = ({

export const LoadingAppCard = () => {
return (
<Card className="p-4 grid grid-cols-6 gap-4">
<div className="flex flex-col gap-2 col-span-3">
<Card className="p-4 grid grid-cols-4 sm:grid-cols-6 lg:grid-cols-8 gap-2 sm:gap-4">
<div className="flex flex-col gap-2 col-span-2 sm:col-span-3">
<div className="flex items-center gap-2">
<UserAvatar
className="size-10 shrink-0"
Expand All @@ -87,17 +95,23 @@ export const LoadingAppCard = () => {
/>
<div className="flex flex-col">
<Skeleton className="w-24 h-5 my-[2.5px]" />
<Skeleton className="w-24 h-3 my-0.5" />
<Skeleton className="w-24 h-3 my-0.5 hidden sm:block" />
</div>
</div>
<Skeleton className="w-3/4 h-4 my-[2px]" />
<Skeleton className="w-3/4 h-4 my-[2px] hidden sm:block" />
</div>
<div className="flex items-center gap-2 justify-center col-span-1">
<div className="hidden lg:flex items-center gap-2 justify-center col-span-1">
<LoadingUsers />
</div>
<div className="shrink-0 flex items-center justify-center col-span-1">
<div className="hidden sm:flex shrink-0 items-center justify-center col-span-1">
<LoadingTransactions />
</div>
<div className="hidden lg:flex shrink-0 items-center justify-center col-span-1">
<LoadingTotalTokens />
</div>
<div className="shrink-0 flex items-center justify-center col-span-1">
<LoadingTotalCost />
</div>
<div className="shrink-0 flex items-center justify-end col-span-1">
<LoadingEarningsAmount />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';

import React from 'react';

import { Braces } from 'lucide-react';

import { LoadingMetric, Metric } from './metric';

import { api } from '@/trpc/client';

interface Props {
appId: string;
}

export const TotalTokens: React.FC<Props> = ({ appId }) => {
const { data: stats, isLoading } = api.apps.app.stats.overall.useQuery({
appId,
});

const totalTokens = stats?.totalTokens ?? 0;
const formattedTokens =
totalTokens >= 1_000_000
? `${(totalTokens / 1_000_000).toFixed(1)}M`
: totalTokens >= 1_000
? `${(totalTokens / 1_000).toFixed(1)}K`
: totalTokens.toString();

return <Metric isLoading={isLoading} value={formattedTokens} Icon={Braces} />;
};

export const LoadingTotalTokens = () => {
return <LoadingMetric Icon={Braces} />;
};
70 changes: 58 additions & 12 deletions packages/app/control/src/services/db/apps/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,69 @@ export const listPublicApps = async (
where.name = { contains: search, mode: 'insensitive' };
}

// Get app IDs sorted by total cost using raw SQL
let sortedAppIds: Array<{ id: string }>;

if (search) {
sortedAppIds = await db.$queryRaw<Array<{ id: string }>>`
SELECT e.id
FROM echo_apps e
LEFT JOIN (
SELECT
"echoAppId",
COALESCE(SUM("totalCost"), 0) as total_cost
FROM transactions
WHERE "isArchived" = false
GROUP BY "echoAppId"
) t ON e.id = t."echoAppId"
WHERE e."isPublic" = true
AND e."isArchived" = false
AND e.name ILIKE ${`%${search}%`}
ORDER BY COALESCE(t.total_cost, 0) DESC
LIMIT ${page_size}
OFFSET ${page * page_size}
`;
} else {
sortedAppIds = await db.$queryRaw<Array<{ id: string }>>`
SELECT e.id
FROM echo_apps e
LEFT JOIN (
SELECT
"echoAppId",
COALESCE(SUM("totalCost"), 0) as total_cost
FROM transactions
WHERE "isArchived" = false
GROUP BY "echoAppId"
) t ON e.id = t."echoAppId"
WHERE e."isPublic" = true
AND e."isArchived" = false
ORDER BY COALESCE(t.total_cost, 0) DESC
LIMIT ${page_size}
OFFSET ${page * page_size}
`;
}

const appIds = sortedAppIds.map(row => row.id);

// Fetch full app data maintaining the sort order
const [apps, totalCount] = await Promise.all([
db.echoApp.findMany({
where,
skip: page * page_size,
take: page_size,
select: appSelect,
orderBy: {
appMemberships: {
_count: 'desc',
},
},
}),
appIds.length > 0
? db.echoApp.findMany({
where: { id: { in: appIds } },
select: appSelect,
})
: [],
countApps(where),
]);

// Sort apps to match the original order from the query
const appsMap = new Map(apps.map(app => [app.id, app]));
const sortedApps = appIds
.map(id => appsMap.get(id))
.filter((app): app is NonNullable<typeof app> => app !== undefined);

return toPaginatedReponse({
items: apps,
items: sortedApps,
page,
page_size,
total_count: totalCount,
Expand Down
Loading