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
1 change: 1 addition & 0 deletions .github/workflows/frontend-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jobs:
VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
VITE_FASTAPI_URL: ${{ steps.env.outputs.fastapi_url }}
ARCJET_KEY: ${{ secrets.ARCJET_KEY }}

- name: Deploy to Cloudflare Workers
run: cd apps/web && npx wrangler deploy --name ${{ steps.env.outputs.worker_name }}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ VITE_CONVEX_URL=https://your-project.convex.cloud

# FastAPI backend URL
VITE_FASTAPI_URL=http://localhost:8000

# Arcjet (server-only rate limiting / shield — copy from Arcjet dashboard)
ARCJET_KEY=
6 changes: 6 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"check-types": "tsc --noEmit"
},
"dependencies": {
"@arcjet/headers": "^1.3.1",
"@arcjet/ip": "^1.3.1",
"@arcjet/protocol": "^1.3.1",
"@arcjet/transport": "^1.3.1",
"@clerk/tanstack-react-start": "^0.29.1",
"@convex-dev/react-query": "^0.1.0",
"@harness/convex-backend": "workspace:*",
Expand All @@ -33,6 +37,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arcjet": "^1.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.31.7",
Expand All @@ -51,6 +56,7 @@
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.3.6",
"use-sync-external-store": "^1.6.0",
"vinxi": "^0.5.11",
"vite-tsconfig-paths": "^6.0.2",
"zod": "^4.1.11"
},
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/components/usage-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { UsageDisplay } from "./usage-display";

export function UsageDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Usage</DialogTitle>
</DialogHeader>
<UsageDisplay />
</DialogContent>
</Dialog>
);
}
227 changes: 227 additions & 0 deletions apps/web/src/components/usage-display.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { convexQuery } from "@convex-dev/react-query";
import { api } from "@harness/convex-backend/convex/_generated/api";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@/lib/utils";

function ProgressBar({
pct,
label,
sublabel,
}: {
pct: number;
label: string;
sublabel?: string;
}) {
const color =
pct >= 90 ? "bg-red-500" : pct >= 70 ? "bg-yellow-500" : "bg-emerald-500";

return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-foreground/80">{label}</span>
<span
className={cn(
"font-medium",
pct >= 90
? "text-red-400"
: pct >= 70
? "text-yellow-400"
: "text-foreground/60",
)}
>
{Math.round(pct)}%
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-white/10">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
color,
)}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
{sublabel && <p className="text-[10px] text-foreground/40">{sublabel}</p>}
</div>
);
}

function ModelBreakdown({
items,
}: {
items: Array<{ model: string; pct: number; tokensUsed: number }>;
}) {
if (items.length === 0) return null;

const sorted = [...items].sort((a, b) => b.pct - a.pct);

return (
<div className="space-y-2">
<h4 className="text-xs font-medium text-foreground/60 uppercase tracking-wider">
By Model
</h4>
<div className="space-y-1.5">
{sorted.map((item) => (
<div key={item.model} className="flex items-center gap-2">
<span className="text-[11px] text-foreground/70 w-28 truncate">
{item.model}
</span>
<div className="flex-1 h-1 rounded-full bg-white/10">
<div
className="h-full rounded-full bg-blue-400/60 transition-all duration-500"
style={{ width: `${Math.min(item.pct, 100)}%` }}
/>
</div>
<span className="text-[10px] text-foreground/40 w-10 text-right">
{Math.round(item.pct)}%
</span>
</div>
))}
</div>
</div>
);
}

function HarnessBreakdown({
items,
}: {
items: Array<{
harnessId: string;
harnessName: string;
pct: number;
tokensUsed: number;
}>;
}) {
if (items.length === 0) return null;

const sorted = [...items].sort((a, b) => b.pct - a.pct);

return (
<div className="space-y-2">
<h4 className="text-xs font-medium text-foreground/60 uppercase tracking-wider">
By Harness
</h4>
<div className="space-y-1.5">
{sorted.map((item) => (
<div key={item.harnessId} className="flex items-center gap-2">
<span className="text-[11px] text-foreground/70 w-28 truncate">
{item.harnessName}
</span>
<div className="flex-1 h-1 rounded-full bg-white/10">
<div
className="h-full rounded-full bg-violet-400/60 transition-all duration-500"
style={{ width: `${Math.min(item.pct, 100)}%` }}
/>
</div>
<span className="text-[10px] text-foreground/40 w-10 text-right">
{Math.round(item.pct)}%
</span>
</div>
))}
</div>
</div>
);
}

export function formatResetTime(isoString: string): string {
const reset = new Date(isoString);
const now = new Date();
const diffMs = reset.getTime() - now.getTime();
if (diffMs <= 0) return "now";

const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));

if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}

export function UsageDisplay() {
const { data: usage, error } = useQuery(
convexQuery(api.usage.getUserUsage, {}),
);

if (error) {
return (
<p className="px-4 py-2 text-xs text-destructive">
Could not load usage.
</p>
);
}

if (!usage) {
return (
<div className="space-y-4 p-4">
<div className="h-4 w-32 animate-pulse rounded bg-white/10" />
<div className="h-2 w-full animate-pulse rounded bg-white/10" />
<div className="h-2 w-full animate-pulse rounded bg-white/10" />
</div>
);
}

return (
<div className="space-y-5">
<ProgressBar
pct={usage.dailyPctUsed}
label="Daily usage"
sublabel={`Resets in ${formatResetTime(usage.dailyResetAt)}`}
/>

<ProgressBar
pct={usage.weeklyPctUsed}
label="Weekly usage"
sublabel={`Resets in ${formatResetTime(usage.weeklyResetAt)}`}
/>

{(usage.dailyLimitReached || usage.weeklyLimitReached) && (
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{usage.dailyLimitReached
? "Daily usage limit reached."
: "Weekly usage limit reached."}{" "}
Your limit will reset soon.
</div>
)}

<ModelBreakdown items={usage.perModelPct} />
<HarnessBreakdown items={usage.perHarnessPct} />
</div>
);
}

/**
* Compact usage badge for the sidebar/header.
* Shows the higher of daily/weekly percentage with a color indicator.
*/
export function UsageBadge({ onClick }: { onClick?: () => void }) {
const { data: usage } = useQuery(convexQuery(api.usage.getUserUsage, {}));

if (!usage) return null;

const pct = Math.max(usage.dailyPctUsed, usage.weeklyPctUsed);
const color =
pct >= 90
? "text-red-400"
: pct >= 70
? "text-yellow-400"
: "text-foreground/50";
const dotColor =
pct >= 90 ? "bg-red-400" : pct >= 70 ? "bg-yellow-400" : "bg-emerald-400";

return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors hover:bg-white/5",
color,
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", dotColor)} />
<span>{Math.round(pct)}% used</span>
</button>
);
}
7 changes: 6 additions & 1 deletion apps/web/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from "zod";
export const env = createEnv({
server: {
SERVER_URL: z.string().url().optional(),
ARCJET_KEY: z.string().min(1),
},

/**
Expand All @@ -23,7 +24,11 @@ export const env = createEnv({
* What object holds the environment variables at runtime. This is usually
* `process.env` or `import.meta.env`.
*/
runtimeEnv: import.meta.env,
runtimeEnv: {
...import.meta.env,
SERVER_URL: process.env.SERVER_URL,
ARCJET_KEY: process.env.ARCJET_KEY,
},

/**
* By default, this library will feed the environment variables directly to
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/lib/arcjet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createClient } from "@arcjet/protocol/client.js";
import { createTransport } from "@arcjet/transport";
import arcjetCore, { shield, tokenBucket } from "arcjet";

import { env } from "@/env";

const BASE_URL = "https://decide.arcjet.com";

const client = createClient({
baseUrl: BASE_URL,
// biome-ignore lint/suspicious/noExplicitAny: Arcjet SDK doesn't export the sdkStack enum type
sdkStack: "NODEJS" as any,
// Keep in sync with arcjet version in package.json
sdkVersion: "1.3.1",
timeout: 500,
transport: createTransport(BASE_URL),
});

export const aj = arcjetCore({
client,
key: env.ARCJET_KEY,
log: {
debug: () => {},
info: () => {},
warn: () => {},
error: console.error,
},
rules: [
tokenBucket({
mode: "LIVE",
characteristics: ["userId"],
refillRate: 20,
interval: "1m",
capacity: 30,
}),
shield({ mode: "LIVE" }),
],
});
Loading
Loading