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
2 changes: 1 addition & 1 deletion app/auth/forgot-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default function ForgotPasswordPage() {
return (
<AdminShell
title="Reset password"
description="Enter your email address and Neon Auth will send password recovery instructions."
description="Enter your email address and we'll send password recovery instructions."
>
<form className="mt-2 space-y-4" onSubmit={onSubmit}>
<div className="space-y-2">
Expand Down
14 changes: 12 additions & 2 deletions app/auth/sign-in/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,15 @@ function SignInForm() {
return (
<AdminShell
title="Sign in"
description="Sign in with your Neon Auth account to manage nodes, routes, and events."
description="Sign in to manage your nodes, routes, and events."
>
<form className="mt-2 space-y-4" onSubmit={onSubmit}>
{searchParams.get('registered') === '1' ? (
<Alert>
<AlertDescription>Account created. Sign in to continue.</AlertDescription>
</Alert>
) : null}

<div className="space-y-2">
<Label htmlFor="sign-in-email">Email</Label>
<Input
Expand Down Expand Up @@ -109,7 +115,11 @@ function SignInForm() {
</form>

<p className="text-muted-foreground mt-6 text-sm">
Need an account? Create one through your Neon Auth configuration flow first.
Need an account?{' '}
<Link className="text-foreground underline underline-offset-4" href="/auth/sign-up">
Create one
</Link>
.
</p>

<div className="mt-6 flex items-center justify-between gap-2 text-sm">
Expand Down
17 changes: 12 additions & 5 deletions app/auth/sign-up/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,18 @@ export default function SignUpPage() {
posthog.identify(email, { email, name: name.trim() || email });
posthog.capture('user_signed_up', { email, name: name.trim() || email });

setSuccess('Account created. You can now sign in.');
setTimeout(() => {
router.push('/auth/sign-in');
// Sign the new account straight in: bouncing to the sign-in form made a successful
// sign-up look like a failure. If that doesn't work, say so on the sign-in page.
const signedIn = await auth.signIn.email({ email, password });
if (signedIn?.error) {
router.push('/auth/sign-in?registered=1');
router.refresh();
}, 800);
return;
}

setSuccess('Account created. Taking you to your dashboard…');
router.push('/dashboard');
router.refresh();
} catch (err) {
posthog.captureException(err);
setError(err instanceof Error ? err.message : 'Sign-up failed');
Expand All @@ -62,7 +69,7 @@ export default function SignUpPage() {
return (
<AdminShell
title="Create account"
description="Create a Neon Auth account for managing nodes, routes, and events."
description="Create an account to manage your nodes, routes, and events."
>
<form className="mt-2 space-y-4" onSubmit={onSubmit}>
<div className="space-y-2">
Expand Down
53 changes: 40 additions & 13 deletions app/dashboard/nodes/nodes-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import posthog from 'posthog-js';

import { formatRelativeTime } from '@/app/lib/dashboard/datetime';
import {
buildHermesSetup,
buildOpenClawSetupBlock,
getNodeHealth,
type NodeHealth,
Expand Down Expand Up @@ -141,15 +142,29 @@ function ConnectGuide({
const pullUrl = `${origin}/api/nodes/pull`;
const ackUrl = `${origin}/api/nodes/ack`;
const setupBlock = buildOpenClawSetupBlock(origin, token, forwardBaseUrl || undefined);
const hermesSetup = buildHermesSetup(origin, token);

return (
<>
<h3 className="text-lg font-semibold">Connect your OpenClaw node</h3>
<h3 className="text-lg font-semibold">Connect your node</h3>
<p className="text-muted-foreground mt-1 text-sm">
Install this skill on your OpenClaw node. The skill will connect to clawproxy via WebSocket
for real-time event delivery, falling back to HTTP polling when needed.
Your node connects out to clawproxy over WebSocket for real-time delivery, falling back to
HTTP polling when needed. Hermes Agent has a plugin; any other agent can speak the protocol
below.
</p>

<div className="border-border/70 bg-background/40 mt-4 rounded-xl border p-4">
<div className="mb-1.5 flex items-center justify-between">
<p className="text-xs font-medium">Hermes Agent — install the plugin</p>
<Button size="xs" variant="outline" onClick={() => onCopy(hermesSetup, 'hermes')}>
{copiedField === 'hermes' ? 'Copied' : 'Copy'}
</Button>
</div>
<pre className="text-muted-foreground overflow-x-auto text-[11px] leading-relaxed whitespace-pre-wrap">
{hermesSetup}
</pre>
</div>

<div className="mt-5 space-y-4">
<div>
<p className="mb-1.5 text-xs font-medium">
Expand Down Expand Up @@ -214,7 +229,7 @@ function ConnectGuide({

<div>
<Label htmlFor="openclaw-url" className="mb-1.5 text-xs font-medium">
OpenClaw base URL{' '}
Delivery base URL{' '}
<span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Input
Expand All @@ -225,15 +240,15 @@ function ConnectGuide({
onChange={(e) => setForwardBaseUrl(e.target.value)}
/>
<p className="text-muted-foreground mt-1 text-xs">
The base URL of your OpenClaw instance. Used to fill in the{' '}
Where your agent receives forwarded events. Used to fill in the{' '}
<code className="bg-muted rounded px-1 py-0.5">forward.webhook_url</code> in the skill
configuration below.
</p>
</div>

<div>
<div className="mb-1.5 flex items-center justify-between">
<p className="text-xs font-medium">One-paste OpenClaw setup block</p>
<p className="text-xs font-medium">Any other agent — protocol setup block</p>
<Button
size="xs"
variant="outline"
Expand All @@ -258,7 +273,7 @@ function ConnectGuide({
The skill connects via WebSocket and receives events in real-time. Alternatively, it polls
the pull endpoint with{' '}
<code className="bg-muted rounded px-1 py-0.5">Authorization: Bearer &lt;token&gt;</code>.
Each event&apos;s original headers and body are forwarded to your OpenClaw webhook system
Each event&apos;s original headers and body are forwarded to your agent&apos;s webhook
using the <code className="bg-muted rounded px-1 py-0.5">forward.webhook_url</code>{' '}
(replacing <code className="bg-muted rounded px-1 py-0.5">{'{routeSlug}'}</code> with the
event&apos;s route slug), then delivery is acknowledged via the ack endpoint.
Expand All @@ -278,6 +293,7 @@ export function NodesClient({ initialNodes }: Props) {
const [nodeList, setNodeList] = useState<NodeRow[]>(initialNodes);
const [openClawBaseUrl, setOpenClawBaseUrl] = useState(DEFAULT_OPENCLAW_BASE_URL);
const [openClawInput, setOpenClawInput] = useState(DEFAULT_OPENCLAW_BASE_URL);
const [savedBaseUrl, setSavedBaseUrl] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [nameInput, setNameInput] = useState('');
const [slugInput, setSlugInput] = useState('');
Expand Down Expand Up @@ -410,12 +426,14 @@ export function NodesClient({ initialNodes }: Props) {
}
setOpenClawBaseUrl(savedUrl);
setOpenClawInput(savedUrl);
setSavedBaseUrl(savedUrl);
}, [OPENCLAW_URL_STORAGE_KEY]);

function handleSaveOpenClawBaseUrl() {
const normalized = openClawInput.trim();
window.localStorage.setItem(OPENCLAW_URL_STORAGE_KEY, normalized);
setOpenClawBaseUrl(normalized);
setSavedBaseUrl(normalized);
}

return (
Expand All @@ -431,9 +449,10 @@ export function NodesClient({ initialNodes }: Props) {

<Card size="sm" className="border-border/70 bg-background/40 ring-0">
<CardHeader>
<CardTitle>Connect your OpenClaw instance</CardTitle>
<CardTitle>Where should events be delivered?</CardTitle>
<CardDescription>
Save your OpenClaw base URL once and reuse it in node connect instructions.
Save your agent&apos;s base URL once and reuse it in every node&apos;s connect
instructions.
</CardDescription>
</CardHeader>
<CardContent>
Expand All @@ -451,8 +470,8 @@ export function NodesClient({ initialNodes }: Props) {
Save URL
</Button>
</div>
{openClawBaseUrl && (
<p className="text-muted-foreground mt-2 text-xs">Saved: {openClawBaseUrl}</p>
{savedBaseUrl && (
<p className="text-muted-foreground mt-2 text-xs">Saved: {savedBaseUrl}</p>
)}
</CardContent>
</Card>
Expand Down Expand Up @@ -507,8 +526,16 @@ export function NodesClient({ initialNodes }: Props) {
)}

{nodeList.length === 0 ? (
<div className="border-border/70 bg-background/40 rounded-2xl border p-5">
<p className="text-muted-foreground text-sm">No nodes have been registered yet.</p>
<div className="border-border/70 bg-background/40 space-y-3 rounded-2xl border p-5">
<p className="text-muted-foreground text-sm">
No nodes yet. A node is the agent that receives your webhooks — Hermes Agent, OpenClaw,
or your own client.
</p>
{!showCreateForm && (
<Button size="sm" onClick={() => setShowCreateForm(true)}>
Create your first node
</Button>
)}
</div>
) : (
<div className="border-border/70 bg-background/40 overflow-hidden rounded-2xl border">
Expand Down
40 changes: 25 additions & 15 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'server-only';

import { count, desc, eq } from 'drizzle-orm';
import Link from 'next/link';

import { requireAdminUser } from '@/app/lib/auth/require-admin';
import {
Expand All @@ -10,6 +11,8 @@ import {
import { decrypt } from '@/app/lib/crypto/encryption';
import { db } from '@/app/lib/db/client';
import { events, nodes } from '@/db/schema';
import { overviewIntro } from '@/app/lib/dashboard/overview-intro';
import { Button } from '@/components/ui/button';
import { DashboardEventsChart } from '@/components/app/dashboard-events-chart';
import { DashboardMetricCards } from '@/components/app/dashboard-metric-cards';
import { DashboardPageHeader } from '@/components/app/dashboard-page-header';
Expand All @@ -22,7 +25,7 @@ export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const user = await requireAdminUser();

const [countsByStatus, dailySeries, recentList] = await Promise.all([
const [countsByStatus, dailySeries, recentList, nodeCountRows] = await Promise.all([
db
.select({
status: events.status,
Expand All @@ -45,8 +48,11 @@ export default async function DashboardPage() {
.where(eq(events.userId, user.id))
.orderBy(desc(events.receivedAt))
.limit(10),
db.select({ count: count() }).from(nodes).where(eq(nodes.userId, user.id)),
]);

const intro = overviewIntro(Number(nodeCountRows[0]?.count ?? 0));

const totals = countsByStatus.reduce<Record<string, number>>((acc, row) => {
acc[row.status] = Number(row.count);
return acc;
Expand All @@ -70,23 +76,27 @@ export default async function DashboardPage() {

return (
<section className="space-y-8">
<DashboardPageHeader
eyebrow="Overview"
title="Welcome back"
description="Use the dashboard navigation to manage nodes, routes, and events for your public ingress and Hermes Agent / OpenClaw delivery pipeline."
/>
<DashboardPageHeader eyebrow="Overview" title={intro.title} description={intro.description} />

{intro.cta ? (
<Button asChild size="lg" className="w-fit rounded-full px-5">
<Link href={intro.cta.href}>{intro.cta.label}</Link>
</Button>
) : null}

<DashboardRelaySummary />

<DashboardMetricCards
total={total}
successRate={successRate}
delivered={delivered}
failed={failed}
pending={pending}
leased={leased}
expired={expired}
/>
{intro.showMetrics ? (
<DashboardMetricCards
total={total}
successRate={successRate}
delivered={delivered}
failed={failed}
pending={pending}
leased={leased}
expired={expired}
/>
) : null}

<div className="flex min-w-0 flex-col gap-6">
<Card size="sm" className="min-w-0">
Expand Down
21 changes: 21 additions & 0 deletions app/lib/dashboard/node-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ description: Pulls and delivers webhook events from clawproxy
connection:
ws_url: "${wsOrigin}/api/nodes/ws"
pull_url: "${origin}/api/nodes/pull"
pull_method: "POST"
ack_url: "${origin}/api/nodes/ack"
token: "${tokenValue}"

Expand Down Expand Up @@ -46,6 +47,7 @@ connection:
http_fallback:
enabled: true
pull_url: "${origin}/api/nodes/pull"
pull_method: "POST"
ack_url: "${origin}/api/nodes/ack"
auth_header: "Authorization: Bearer ${tokenValue}"
poll_interval_seconds: 30
Expand Down Expand Up @@ -80,3 +82,22 @@ export function getNodeHealth(
if (ms < 60 * 60 * 1000) return 'stale';
return 'offline';
}

/**
* Setup for Hermes Agent, which has a first-party plugin: it holds this node's connection inside
* the gateway and hands each event to the Hermes webhook route of the same name.
*/
export function buildHermesSetup(origin: string, token?: string): string {
const tokenValue = token ?? 'YOUR_NODE_TOKEN_HERE';
return `# 1. Install the plugin on the machine running Hermes Agent
hermes plugins install ericdahl-dev/clawproxy-hermes

# 2. Add these to ~/.hermes/.env (or your profile's .env)
CLAWPROXY_NODE_TOKEN=${tokenValue}
CLAWPROXY_HERMES_WEBHOOK_SECRET=<platforms.webhook.extra.secret from ~/.hermes/config.yaml>
${origin === 'https://clawproxy.io' ? '' : `CLAWPROXY_SERVER_URL=${origin}\n`}
# 3. Restart the gateway, then check: hermes gateway status

# Route names must match: a clawproxy route named "github-prs" is delivered to the Hermes
# webhook route with the same name, so create that route in Hermes first.`;
}
33 changes: 33 additions & 0 deletions app/lib/dashboard/overview-intro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export type OverviewIntro = {
title: string;
description: string;
isFirstRun: boolean;
showMetrics: boolean;
cta: { href: string; label: string } | null;
};

/**
* What the dashboard home should say. A new account has no nodes, so greeting it with
* "Welcome back" above eight zeroed metrics tells it nothing; point it at the first step instead.
*/
export function overviewIntro(nodeCount: number): OverviewIntro {
if (nodeCount === 0) {
return {
title: 'Set up your first node',
description:
'A node is the agent that receives your webhooks. Create one, connect it, then point a provider at the route URL clawproxy gives you.',
isFirstRun: true,
showMetrics: false,
cta: { href: '/dashboard/nodes', label: 'Create your first node' },
};
}

return {
title: 'Welcome back',
description:
'Manage the nodes, routes and events behind your public ingress and private agent delivery.',
isFirstRun: false,
showMetrics: true,
cta: null,
};
}
Loading
Loading