Skip to content
Draft
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
88 changes: 88 additions & 0 deletions apps/web/src/app/api/kiloclaw/clawmetry/[instanceId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { getUserFromAuth } from '@/lib/user.server';
import { KILOCLAW_API_URL } from '@/lib/config.server';
import { generateApiToken, TOKEN_EXPIRY } from '@/lib/tokens';

/**
* POST /api/kiloclaw/clawmetry/[instanceId]
*
* One-shot endpoint backing the "View Observability Dashboard" button. Does
* two things on the user's KiloClaw instance and returns the dashboard URL
* the browser should open in a new tab:
*
* 1. POST `/_kilo/clawmetry-start-sync` — spawns the sync daemon (idempotent)
* 2. GET `/_kilo/clawmetry-dashboard-url` — reads the self-decrypting URL
*
* Both calls go through the existing per-instance worker proxy
* (`{KILOCLAW_API_URL}/i/{instanceId}/...`) which already handles JWT auth,
* access control, and the gateway-token signing for the controller.
*
* The returned URL contains the AES-256-GCM enc_key in its `#fragment` —
* that fragment is meaningful only to the browser (servers never see it).
* The browser stashes the key in localStorage and decrypts ClawMetry
* events client-side.
*/
export async function POST(_req: Request, { params }: { params: Promise<{ instanceId: string }> }) {
const { instanceId } = await params;
if (!instanceId || !/^[A-Za-z0-9_-]+$/.test(instanceId)) {
return NextResponse.json({ error: 'Invalid instance ID' }, { status: 400 });
}

const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse) return authFailedResponse;

if (!KILOCLAW_API_URL) {
return NextResponse.json({ error: 'KiloClaw not configured' }, { status: 503 });
}

const token = generateApiToken(user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes });
const proxyBase = `${KILOCLAW_API_URL}/i/${encodeURIComponent(instanceId)}`;
const headers = { Authorization: `Bearer ${token}` };

// 1. Start the sync daemon — idempotent. Failure here is non-fatal; we
// still try to return the dashboard URL so the user can at least see
// historical data (or an empty dashboard with a clear error state).
try {
const startRes = await fetch(`${proxyBase}/_kilo/clawmetry-start-sync`, {
method: 'POST',
headers,
});
if (!startRes.ok && startRes.status !== 404) {
console.warn(`[clawmetry] start-sync returned ${startRes.status} for instance ${instanceId}`);
}
} catch (err) {
console.warn(`[clawmetry] start-sync error for instance ${instanceId}:`, err);
}

// 2. Fetch the self-decrypting dashboard URL.
let urlRes: Response;
try {
urlRes = await fetch(`${proxyBase}/_kilo/clawmetry-dashboard-url`, { headers });
} catch (err) {
console.error(`[clawmetry] dashboard-url fetch failed for instance ${instanceId}:`, err);
return NextResponse.json({ error: 'Failed to reach instance' }, { status: 502 });
}

if (urlRes.status === 404) {
return NextResponse.json(
{
error:
'ClawMetry not provisioned on this instance — try redeploying or check KILOCLAW_CLAWMETRY_DISABLED env var',
},
{ status: 404 }
);
}
if (!urlRes.ok) {
return NextResponse.json(
{ error: `Instance returned ${urlRes.status}` },
{ status: urlRes.status }
);
}

const body = (await urlRes.json()) as { url?: string };
if (!body.url) {
return NextResponse.json({ error: 'Instance returned no dashboard URL' }, { status: 502 });
}

return NextResponse.json({ url: body.url });
}
26 changes: 26 additions & 0 deletions apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,32 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) {
</Button>
)}

<Button
variant="outline"
onClick={async () => {
const t = toast.loading('Starting ClawMetry sync…');
try {
const res = await fetch(
`/api/kiloclaw/clawmetry/${encodeURIComponent(instanceId)}`,
{
method: 'POST',
}
);
const body = (await res.json()) as { url?: string; error?: string };
if (!res.ok || !body.url) {
toast.error(body.error ?? 'Could not open observability dashboard', { id: t });
return;
}
toast.success('Opening ClawMetry — syncing your data…', { id: t });
window.open(body.url, '_blank', 'noopener,noreferrer');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Network error', { id: t });
}
}}
>
View Observability
</Button>

{subscription.showConversionPrompt ? (
<Button variant="outline" onClick={() => setConfirmationAction('switchToCredits')}>
Switch to Credits
Expand Down
37 changes: 36 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 22 additions & 1 deletion services/kiloclaw/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ ENV NODE_VERSION=24.15.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl gnupg git xz-utils unzip jq ripgrep rsync zstd \
build-essential python3 ffmpeg tmux chromium \
build-essential python3 python3-venv ffmpeg tmux chromium \
&& ARCH="$(dpkg --print-architecture)" \
&& case "${ARCH}" in \
amd64) NODE_ARCH="x64" ;; \
Expand Down Expand Up @@ -74,6 +74,27 @@ RUN npm install -g openclaw@2026.4.23 \
&& echo "OK: patched actionRequiresTarget in $(basename "$CT_FILE")" \
&& openclaw --version

# Install ClawMetry — observability dashboard for the OpenClaw runtime.
# Pre-installed so every KiloClaw instance has it available; the controller's
# bootstrap step `provisionClawMetrySync` activates it (one-time account
# auto-provisioning + sync daemon) when CLAWMETRY_PARTNER_KEY is set on the
# instance env.
#
# Uses the upstream one-line installer instead of pinning a PyPI version.
# This delegates version selection + bootstrap (venv layout, symlink, future
# install steps) to ClawMetry's install.sh so it can evolve without a PR
# back to this repo. Trade-off: builds are not bit-reproducible across
# rebuilds — the image picks up whatever version PyPI has at build time.
# That's intentional; the integration is best-effort observability and the
# sync daemon is fail-soft (see provisionClawMetrySync).
#
# install.sh creates a venv at /root/.clawmetry and symlinks the binary at
# /root/.local/bin/clawmetry. We add that to PATH so subsequent build steps
# and runtime callers can invoke `clawmetry` by name.
ENV PATH="/root/.local/bin:${PATH}"
RUN curl -fsSL https://clawmetry.com/install.sh | bash \
&& clawmetry --version

# Bake bundled plugin runtime deps into the image so first boot is pure
# startup — no npm install from `openclaw doctor` on shared-cpu Fly machines.
# Each step writes to a temp file and is chained with && so failures break
Expand Down
Loading