init
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
DATABASE_URL=mysql://root:password@localhost:3306/openclaw
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
IP_API_URL=http://ip-api.com/json
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# drizzle
|
||||||
|
/drizzle/meta
|
||||||
54
CLAUDE.md
Normal file
54
CLAUDE.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
OpenClaw Market is a real-time global heatmap dashboard that visualizes AI agent ("lobster") activity worldwide. Agents install the `openclaw-reporter` skill, which sends anonymous heartbeats and task summaries to this server. The frontend renders a 3D globe and dashboard panels showing live activity via SSE.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # Start dev server with Turbopack (localhost:3000)
|
||||||
|
pnpm build # Production build
|
||||||
|
pnpm lint # ESLint
|
||||||
|
pnpm db:generate # Generate Drizzle migrations
|
||||||
|
pnpm db:push # Push schema to database (no migration files)
|
||||||
|
pnpm db:migrate # Run migrations
|
||||||
|
pnpm db:studio # Open Drizzle Studio GUI
|
||||||
|
bash scripts/deploy.sh # Build locally, rsync to server, restart PM2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. **Reporter skill** (`skill/openclaw-reporter/SKILL.md`) runs inside Claude Code sessions on user machines. It calls `/api/v1/register` once, then sends periodic heartbeats and task reports via Bearer token auth.
|
||||||
|
2. **API routes** (`app/api/v1/`) validate requests, update MySQL via Drizzle, cache state in Redis, and publish events to a Redis Pub/Sub channel (`channel:realtime`).
|
||||||
|
3. **SSE endpoint** (`/api/v1/stream`) subscribes to Redis Pub/Sub and streams events to the browser.
|
||||||
|
4. **Frontend** is a single-page "use client" app. The homepage renders a 3D globe (`react-globe.gl`), dashboard panels, and a continent drill-down page. Data arrives via polling (`use-heatmap-data`) and SSE (`use-sse`).
|
||||||
|
|
||||||
|
### Key Layers
|
||||||
|
|
||||||
|
- **Auth**: Bearer token via `lib/auth/api-key.ts`. API keys are generated at registration, cached in Redis for 1 hour.
|
||||||
|
- **Geo**: IP geolocation via `ip-api.com`, results cached in `geo_cache` MySQL table. Country-to-continent mapping in `lib/geo/ip-location.ts`.
|
||||||
|
- **Real-time**: Redis Pub/Sub (`lib/redis/index.ts`) for event broadcasting. SSE stream route creates a per-connection Redis subscriber.
|
||||||
|
- **Validation**: Zod schemas in `lib/validators/schemas.ts`.
|
||||||
|
- **Database**: Drizzle ORM with MySQL (`mysql2` driver). Schema in `lib/db/schema.ts`. Tables: `lobsters`, `heartbeats`, `tasks`, `geo_cache`.
|
||||||
|
- **Redis**: ioredis with two singleton clients (main + subscriber). Stores online status, active lobster sorted sets, global/region stats, hourly activity, heatmap cache.
|
||||||
|
|
||||||
|
### Frontend Structure
|
||||||
|
|
||||||
|
- `components/globe/` — 3D globe view using `react-globe.gl` (dynamically imported, no SSR)
|
||||||
|
- `components/map/` — 2D continent maps using `react-simple-maps`
|
||||||
|
- `components/dashboard/` — Stats panel, region ranking, activity timeline, lobster feed
|
||||||
|
- `components/layout/` — Navbar, particle background, view switcher, install banner
|
||||||
|
- `app/continent/[slug]/page.tsx` — Continent drill-down page
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
See `.env.example`: `DATABASE_URL` (MySQL), `REDIS_URL`, `IP_API_URL`, `NEXT_PUBLIC_APP_URL`.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Production runs on a remote server via `scripts/deploy.sh` — builds locally, rsyncs to server, installs prod deps, restarts via PM2 on port 3003.
|
||||||
111
app/api/v1/heartbeat/route.ts
Normal file
111
app/api/v1/heartbeat/route.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { lobsters, heartbeats } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
setLobsterOnline,
|
||||||
|
updateActiveLobsters,
|
||||||
|
incrementHourlyActivity,
|
||||||
|
publishEvent,
|
||||||
|
} from "@/lib/redis";
|
||||||
|
import { validateApiKey } from "@/lib/auth/api-key";
|
||||||
|
import { getGeoLocation } from "@/lib/geo/ip-location";
|
||||||
|
import { heartbeatSchema } from "@/lib/validators/schemas";
|
||||||
|
|
||||||
|
function getClientIp(req: NextRequest): string {
|
||||||
|
const forwarded = req.headers.get("x-forwarded-for");
|
||||||
|
if (forwarded) return forwarded.split(",")[0].trim();
|
||||||
|
const realIp = req.headers.get("x-real-ip");
|
||||||
|
if (realIp) return realIp.trim();
|
||||||
|
return "127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get("authorization");
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing or invalid authorization header" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = authHeader.slice(7);
|
||||||
|
const lobster = await validateApiKey(apiKey);
|
||||||
|
if (!lobster) {
|
||||||
|
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let bodyData = {};
|
||||||
|
try {
|
||||||
|
const rawBody = await req.text();
|
||||||
|
if (rawBody) bodyData = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
// Empty body is fine for heartbeat
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = heartbeatSchema.safeParse(bodyData);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validation failed", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIp = getClientIp(req);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const updateFields: Record<string, unknown> = {
|
||||||
|
lastHeartbeat: now,
|
||||||
|
ip: clientIp,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (clientIp !== lobster.ip) {
|
||||||
|
const geo = await getGeoLocation(clientIp);
|
||||||
|
if (geo) {
|
||||||
|
updateFields.latitude = String(geo.latitude);
|
||||||
|
updateFields.longitude = String(geo.longitude);
|
||||||
|
updateFields.city = geo.city;
|
||||||
|
updateFields.country = geo.country;
|
||||||
|
updateFields.countryCode = geo.countryCode;
|
||||||
|
updateFields.region = geo.region;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.name) updateFields.name = parsed.data.name;
|
||||||
|
if (parsed.data.model) updateFields.model = parsed.data.model;
|
||||||
|
if (parsed.data.platform) updateFields.platform = parsed.data.platform;
|
||||||
|
|
||||||
|
await setLobsterOnline(lobster.id, clientIp);
|
||||||
|
await updateActiveLobsters(lobster.id);
|
||||||
|
await incrementHourlyActivity();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(lobsters)
|
||||||
|
.set(updateFields)
|
||||||
|
.where(eq(lobsters.id, lobster.id));
|
||||||
|
|
||||||
|
// Insert heartbeat record asynchronously
|
||||||
|
db.insert(heartbeats)
|
||||||
|
.values({ lobsterId: lobster.id, ip: clientIp, timestamp: now })
|
||||||
|
.then(() => {})
|
||||||
|
.catch((err: unknown) => console.error("Failed to insert heartbeat:", err));
|
||||||
|
|
||||||
|
await publishEvent({
|
||||||
|
type: "heartbeat",
|
||||||
|
lobsterId: lobster.id,
|
||||||
|
lobsterName: (updateFields.name as string) ?? lobster.name,
|
||||||
|
city: (updateFields.city as string) ?? lobster.city,
|
||||||
|
country: (updateFields.country as string) ?? lobster.country,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, nextIn: 180 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Heartbeat error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/api/v1/heatmap/route.ts
Normal file
61
app/api/v1/heatmap/route.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { gte, and, isNotNull, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { lobsters } from "@/lib/db/schema";
|
||||||
|
import { getCacheHeatmap, setCacheHeatmap } from "@/lib/redis";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const cached = await getCacheHeatmap();
|
||||||
|
if (cached) {
|
||||||
|
return NextResponse.json(JSON.parse(cached));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||||
|
|
||||||
|
const activeLobsters = await db
|
||||||
|
.select({
|
||||||
|
city: lobsters.city,
|
||||||
|
country: lobsters.country,
|
||||||
|
latitude: lobsters.latitude,
|
||||||
|
longitude: lobsters.longitude,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(lobsters)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(lobsters.lastHeartbeat, fiveMinutesAgo),
|
||||||
|
isNotNull(lobsters.latitude),
|
||||||
|
isNotNull(lobsters.longitude)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(
|
||||||
|
lobsters.city,
|
||||||
|
lobsters.country,
|
||||||
|
lobsters.latitude,
|
||||||
|
lobsters.longitude
|
||||||
|
);
|
||||||
|
|
||||||
|
const points = activeLobsters.map((row) => ({
|
||||||
|
lat: Number(row.latitude),
|
||||||
|
lng: Number(row.longitude),
|
||||||
|
weight: row.count,
|
||||||
|
lobsterCount: row.count,
|
||||||
|
city: row.city,
|
||||||
|
country: row.country,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lastUpdated = new Date().toISOString();
|
||||||
|
const responseData = { points, lastUpdated };
|
||||||
|
|
||||||
|
await setCacheHeatmap(JSON.stringify(responseData));
|
||||||
|
|
||||||
|
return NextResponse.json(responseData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Heatmap error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/api/v1/lobsters/route.ts
Normal file
73
app/api/v1/lobsters/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq, desc, sql, and } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { lobsters, tasks } from "@/lib/db/schema";
|
||||||
|
import { getActiveLobsterIds } from "@/lib/redis";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const limitParam = parseInt(searchParams.get("limit") ?? "50", 10);
|
||||||
|
const limit = Math.min(Math.max(1, limitParam), 200);
|
||||||
|
const region = searchParams.get("region");
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (region) {
|
||||||
|
conditions.push(eq(lobsters.region, region));
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
||||||
|
const lobsterRows = await db
|
||||||
|
.select()
|
||||||
|
.from(lobsters)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(desc(lobsters.lastHeartbeat))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
const totalResult = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(lobsters)
|
||||||
|
.where(whereClause);
|
||||||
|
const total = totalResult[0]?.count ?? 0;
|
||||||
|
|
||||||
|
const activeLobsterIds = await getActiveLobsterIds();
|
||||||
|
const activeSet = new Set(activeLobsterIds);
|
||||||
|
|
||||||
|
const lobsterList = await Promise.all(
|
||||||
|
lobsterRows.map(async (lobster) => {
|
||||||
|
const latestTaskRows = await db
|
||||||
|
.select({
|
||||||
|
summary: tasks.summary,
|
||||||
|
timestamp: tasks.timestamp,
|
||||||
|
durationMs: tasks.durationMs,
|
||||||
|
})
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.lobsterId, lobster.id))
|
||||||
|
.orderBy(desc(tasks.timestamp))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const lastTask = latestTaskRows[0] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: lobster.id,
|
||||||
|
name: lobster.name,
|
||||||
|
model: lobster.model,
|
||||||
|
platform: lobster.platform,
|
||||||
|
city: lobster.city,
|
||||||
|
country: lobster.country,
|
||||||
|
isOnline: activeSet.has(lobster.id),
|
||||||
|
lastTask,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ lobsters: lobsterList, total });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Lobsters error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/api/v1/register/route.ts
Normal file
89
app/api/v1/register/route.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { lobsters } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
setLobsterOnline,
|
||||||
|
updateActiveLobsters,
|
||||||
|
incrementGlobalStat,
|
||||||
|
incrementRegionCount,
|
||||||
|
publishEvent,
|
||||||
|
} from "@/lib/redis";
|
||||||
|
import { generateApiKey } from "@/lib/auth/api-key";
|
||||||
|
import { getGeoLocation } from "@/lib/geo/ip-location";
|
||||||
|
import { registerSchema } from "@/lib/validators/schemas";
|
||||||
|
|
||||||
|
function getClientIp(req: NextRequest): string {
|
||||||
|
const forwarded = req.headers.get("x-forwarded-for");
|
||||||
|
if (forwarded) return forwarded.split(",")[0].trim();
|
||||||
|
const realIp = req.headers.get("x-real-ip");
|
||||||
|
if (realIp) return realIp.trim();
|
||||||
|
return "127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const parsed = registerSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validation failed", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, model, platform } = parsed.data;
|
||||||
|
const lobsterId = nanoid(21);
|
||||||
|
const apiKey = generateApiKey();
|
||||||
|
const clientIp = getClientIp(req);
|
||||||
|
const geo = await getGeoLocation(clientIp);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db.insert(lobsters).values({
|
||||||
|
id: lobsterId,
|
||||||
|
apiKey,
|
||||||
|
name,
|
||||||
|
model: model ?? null,
|
||||||
|
platform: platform ?? null,
|
||||||
|
ip: clientIp,
|
||||||
|
latitude: geo ? String(geo.latitude) : null,
|
||||||
|
longitude: geo ? String(geo.longitude) : null,
|
||||||
|
city: geo?.city ?? null,
|
||||||
|
country: geo?.country ?? null,
|
||||||
|
countryCode: geo?.countryCode ?? null,
|
||||||
|
region: geo?.region ?? null,
|
||||||
|
lastHeartbeat: now,
|
||||||
|
totalTasks: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setLobsterOnline(lobsterId, clientIp);
|
||||||
|
await updateActiveLobsters(lobsterId);
|
||||||
|
await incrementGlobalStat("total_lobsters");
|
||||||
|
|
||||||
|
if (geo?.region) {
|
||||||
|
await incrementRegionCount(geo.region);
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishEvent({
|
||||||
|
type: "online",
|
||||||
|
lobsterId,
|
||||||
|
lobsterName: name,
|
||||||
|
city: geo?.city ?? null,
|
||||||
|
country: geo?.country ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
lobsterId,
|
||||||
|
apiKey,
|
||||||
|
endpoint: `${process.env.NEXT_PUBLIC_APP_URL}/api/v1`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Register error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/api/v1/stats/route.ts
Normal file
76
app/api/v1/stats/route.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { gte, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { lobsters, tasks } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
redis,
|
||||||
|
getGlobalStats,
|
||||||
|
getRegionStats,
|
||||||
|
getHourlyActivity,
|
||||||
|
} from "@/lib/redis";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const globalStats = await getGlobalStats();
|
||||||
|
const regionStats = await getRegionStats();
|
||||||
|
const hourlyRaw = await getHourlyActivity();
|
||||||
|
|
||||||
|
// Convert hourly data to array format
|
||||||
|
const hourlyActivity = Object.entries(hourlyRaw).map(([hour, count]) => ({
|
||||||
|
hour: hour.split("T")[1] + ":00",
|
||||||
|
count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const fiveMinutesAgo = now - 300_000;
|
||||||
|
const activeLobsters = await redis.zcount(
|
||||||
|
"active:lobsters",
|
||||||
|
fiveMinutesAgo,
|
||||||
|
"+inf"
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalLobstersResult = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(lobsters);
|
||||||
|
const totalLobsters = totalLobstersResult[0]?.count ?? 0;
|
||||||
|
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const tasksTodayResult = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tasks)
|
||||||
|
.where(gte(tasks.timestamp, todayStart));
|
||||||
|
const tasksToday = tasksTodayResult[0]?.count ?? 0;
|
||||||
|
|
||||||
|
const avgDurationResult = await db
|
||||||
|
.select({ avg: sql<number>`AVG(${tasks.durationMs})` })
|
||||||
|
.from(tasks)
|
||||||
|
.where(gte(tasks.timestamp, todayStart));
|
||||||
|
const avgTaskDuration = avgDurationResult[0]?.avg
|
||||||
|
? Math.round(avgDurationResult[0].avg)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Convert region stats from string values to numbers
|
||||||
|
const regionBreakdown: Record<string, number> = {};
|
||||||
|
for (const [key, val] of Object.entries(regionStats)) {
|
||||||
|
regionBreakdown[key] = parseInt(val, 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
totalLobsters,
|
||||||
|
activeLobsters,
|
||||||
|
tasksToday,
|
||||||
|
tasksTotal: parseInt(globalStats.total_tasks ?? "0", 10),
|
||||||
|
avgTaskDuration,
|
||||||
|
regionBreakdown,
|
||||||
|
hourlyActivity,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Stats error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/api/v1/stream/route.ts
Normal file
60
app/api/v1/stream/route.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import Redis from "ioredis";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const subscriber = new Redis(process.env.REDIS_URL!);
|
||||||
|
|
||||||
|
subscriber.subscribe("channel:realtime");
|
||||||
|
|
||||||
|
subscriber.on("message", (_channel: string, message: string) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message);
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
`event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// skip malformed messages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send keepalive every 30 seconds
|
||||||
|
const keepalive = setInterval(() => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||||
|
} catch {
|
||||||
|
clearInterval(keepalive);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Send initial connection event
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: connected\ndata: {"status":"connected"}\n\n`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup on close
|
||||||
|
req.signal.addEventListener("abort", () => {
|
||||||
|
clearInterval(keepalive);
|
||||||
|
subscriber.unsubscribe("channel:realtime");
|
||||||
|
subscriber.quit();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
80
app/api/v1/task/route.ts
Normal file
80
app/api/v1/task/route.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { lobsters, tasks } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
incrementGlobalStat,
|
||||||
|
incrementHourlyActivity,
|
||||||
|
publishEvent,
|
||||||
|
} from "@/lib/redis";
|
||||||
|
import { validateApiKey } from "@/lib/auth/api-key";
|
||||||
|
import { taskSchema } from "@/lib/validators/schemas";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get("authorization");
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing or invalid authorization header" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = authHeader.slice(7);
|
||||||
|
const lobster = await validateApiKey(apiKey);
|
||||||
|
if (!lobster) {
|
||||||
|
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const parsed = taskSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validation failed", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { summary, durationMs, model, toolsUsed } = parsed.data;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const insertResult = await db.insert(tasks).values({
|
||||||
|
lobsterId: lobster.id,
|
||||||
|
summary,
|
||||||
|
durationMs,
|
||||||
|
model: model ?? null,
|
||||||
|
toolsUsed: toolsUsed ?? null,
|
||||||
|
timestamp: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(lobsters)
|
||||||
|
.set({ totalTasks: sql`${lobsters.totalTasks} + 1`, updatedAt: now })
|
||||||
|
.where(eq(lobsters.id, lobster.id));
|
||||||
|
|
||||||
|
await incrementGlobalStat("total_tasks");
|
||||||
|
await incrementGlobalStat("tasks_today");
|
||||||
|
await incrementHourlyActivity();
|
||||||
|
|
||||||
|
await publishEvent({
|
||||||
|
type: "task",
|
||||||
|
lobsterId: lobster.id,
|
||||||
|
lobsterName: lobster.name,
|
||||||
|
city: lobster.city,
|
||||||
|
country: lobster.country,
|
||||||
|
summary,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
taskId: insertResult[0].insertId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Task error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/apple-icon.tsx
Normal file
26
app/apple-icon.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
|
||||||
|
export const size = { width: 180, height: 180 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
export default function AppleIcon() {
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "linear-gradient(135deg, #0a0e1a 0%, #111827 100%)",
|
||||||
|
borderRadius: "40px",
|
||||||
|
fontSize: 120,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🦞
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ ...size }
|
||||||
|
);
|
||||||
|
}
|
||||||
61
app/continent/[slug]/page.tsx
Normal file
61
app/continent/[slug]/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use } from "react";
|
||||||
|
import { Navbar } from "@/components/layout/navbar";
|
||||||
|
import { ParticleBg } from "@/components/layout/particle-bg";
|
||||||
|
import { ViewSwitcher } from "@/components/layout/view-switcher";
|
||||||
|
import { ContinentMap } from "@/components/map/continent-map";
|
||||||
|
import { StatsPanel } from "@/components/dashboard/stats-panel";
|
||||||
|
import { LobsterFeed } from "@/components/dashboard/lobster-feed";
|
||||||
|
|
||||||
|
const continentNames: Record<string, string> = {
|
||||||
|
asia: "Asia",
|
||||||
|
europe: "Europe",
|
||||||
|
americas: "Americas",
|
||||||
|
africa: "Africa",
|
||||||
|
oceania: "Oceania",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContinentPage({ params }: PageProps) {
|
||||||
|
const { slug } = use(params);
|
||||||
|
const name = continentNames[slug] ?? "Unknown";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen">
|
||||||
|
<ParticleBg />
|
||||||
|
<Navbar activeView="map" />
|
||||||
|
|
||||||
|
<main className="relative z-10 mx-auto max-w-[1800px] px-4 pt-20 pb-8">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h1
|
||||||
|
className="font-mono text-2xl font-bold"
|
||||||
|
style={{
|
||||||
|
color: "var(--accent-cyan)",
|
||||||
|
textShadow: "var(--glow-cyan)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name} Region
|
||||||
|
</h1>
|
||||||
|
<ViewSwitcher activeContinent={slug} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
|
||||||
|
{/* Map */}
|
||||||
|
<div>
|
||||||
|
<ContinentMap slug={slug} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side Panel */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<StatsPanel />
|
||||||
|
<LobsterFeed />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
app/globals.css
Normal file
155
app/globals.css
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* === Cyberpunk Theme Variables === */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0e1a;
|
||||||
|
--bg-secondary: #111827;
|
||||||
|
--bg-card: #1a1f2e;
|
||||||
|
--bg-card-hover: #242a3d;
|
||||||
|
--border-glow: #00f0ff;
|
||||||
|
--accent-cyan: #00f0ff;
|
||||||
|
--accent-purple: #8b5cf6;
|
||||||
|
--accent-pink: #ec4899;
|
||||||
|
--accent-green: #10b981;
|
||||||
|
--accent-orange: #f59e0b;
|
||||||
|
--text-primary: #e2e8f0;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--glow-cyan: 0 0 20px rgba(0, 240, 255, 0.3);
|
||||||
|
--glow-purple: 0 0 20px rgba(139, 92, 246, 0.3);
|
||||||
|
--glow-strong: 0 0 30px rgba(0, 240, 255, 0.5), 0 0 60px rgba(0, 240, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === shadcn/ui CSS Variable Overrides === */
|
||||||
|
:root {
|
||||||
|
--background: 222.2 47.4% 6.2%;
|
||||||
|
--foreground: 210 40% 92%;
|
||||||
|
--card: 222.2 47.4% 9.2%;
|
||||||
|
--card-foreground: 210 40% 92%;
|
||||||
|
--popover: 222.2 47.4% 9.2%;
|
||||||
|
--popover-foreground: 210 40% 92%;
|
||||||
|
--primary: 187 100% 50%;
|
||||||
|
--primary-foreground: 222.2 47.4% 6.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 92%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 55.1%;
|
||||||
|
--accent: 263 70% 60%;
|
||||||
|
--accent-foreground: 210 40% 92%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 92%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 187 100% 50%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Base Styles === */
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-inter, "Inter", ui-sans-serif, system-ui, sans-serif);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Utility Classes === */
|
||||||
|
|
||||||
|
/* Glowing cyan text */
|
||||||
|
.glow-text {
|
||||||
|
text-shadow:
|
||||||
|
0 0 10px rgba(0, 240, 255, 0.5),
|
||||||
|
0 0 20px rgba(0, 240, 255, 0.3),
|
||||||
|
0 0 40px rgba(0, 240, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glowing cyan border */
|
||||||
|
.glow-border {
|
||||||
|
box-shadow:
|
||||||
|
0 0 10px rgba(0, 240, 255, 0.2),
|
||||||
|
0 0 20px rgba(0, 240, 255, 0.1),
|
||||||
|
inset 0 0 10px rgba(0, 240, 255, 0.05);
|
||||||
|
border: 1px solid rgba(0, 240, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card with subtle glow and hover effect */
|
||||||
|
.glow-card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid rgba(0, 240, 255, 0.1);
|
||||||
|
box-shadow: 0 0 10px rgba(0, 240, 255, 0.05);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-card:hover {
|
||||||
|
background-color: var(--bg-card-hover);
|
||||||
|
border-color: rgba(0, 240, 255, 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(0, 240, 255, 0.15),
|
||||||
|
0 0 30px rgba(0, 240, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle repeating grid overlay */
|
||||||
|
.cyber-grid {
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 240, 255, 0.03) 0px,
|
||||||
|
rgba(0, 240, 255, 0.03) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 40px
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(0, 240, 255, 0.03) 0px,
|
||||||
|
rgba(0, 240, 255, 0.03) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 40px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated gradient neon line separator */
|
||||||
|
.neon-line {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
var(--accent-cyan),
|
||||||
|
var(--accent-purple),
|
||||||
|
var(--accent-cyan),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: neon-sweep 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes neon-sweep {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Custom Scrollbar === */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(0, 240, 255, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 240, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(0, 240, 255, 0.4);
|
||||||
|
}
|
||||||
26
app/icon.tsx
Normal file
26
app/icon.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
|
||||||
|
export const size = { width: 64, height: 64 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
export default function Icon() {
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#0a0e1a",
|
||||||
|
borderRadius: "14px",
|
||||||
|
fontSize: 44,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🦞
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ ...size }
|
||||||
|
);
|
||||||
|
}
|
||||||
35
app/layout.tsx
Normal file
35
app/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-inter",
|
||||||
|
});
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-mono",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "OpenClaw Market - Global Lobster Activity",
|
||||||
|
description: "Real-time visualization of AI agent activity worldwide",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body
|
||||||
|
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/page.tsx
Normal file
45
app/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Navbar } from "@/components/layout/navbar";
|
||||||
|
import { InstallBanner } from "@/components/layout/install-banner";
|
||||||
|
import { ParticleBg } from "@/components/layout/particle-bg";
|
||||||
|
import { GlobeView } from "@/components/globe/globe-view";
|
||||||
|
import { StatsPanel } from "@/components/dashboard/stats-panel";
|
||||||
|
import { ActivityTimeline } from "@/components/dashboard/activity-timeline";
|
||||||
|
import { LobsterFeed } from "@/components/dashboard/lobster-feed";
|
||||||
|
import { RegionRanking } from "@/components/dashboard/region-ranking";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen">
|
||||||
|
<ParticleBg />
|
||||||
|
<Navbar activeView="globe" />
|
||||||
|
|
||||||
|
<main className="relative z-10 mx-auto max-w-[1800px] px-4 pt-20 pb-8">
|
||||||
|
<div className="mb-4">
|
||||||
|
<InstallBanner />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[280px_1fr_280px]">
|
||||||
|
{/* Left Panel */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<StatsPanel />
|
||||||
|
<RegionRanking />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center - Globe + Timeline */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="h-[500px] lg:h-[600px]">
|
||||||
|
<GlobeView />
|
||||||
|
</div>
|
||||||
|
<ActivityTimeline />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<LobsterFeed />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
93
components/dashboard/activity-timeline.tsx
Normal file
93
components/dashboard/activity-timeline.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface HourlyData {
|
||||||
|
hour: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityTimeline() {
|
||||||
|
const [data, setData] = useState<HourlyData[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/stats");
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
setData(json.hourlyActivity ?? []);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry on next interval
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
const interval = setInterval(fetchData, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-white/5">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle>24h Activity</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[160px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="activityGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#00f0ff" stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor="#00f0ff" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="hour"
|
||||||
|
tick={{ fill: "#64748b", fontSize: 10 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis hide />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1a1f2e",
|
||||||
|
border: "1px solid rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#94a3b8" }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="count"
|
||||||
|
stroke="#00f0ff"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#activityGradient)"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{
|
||||||
|
r: 4,
|
||||||
|
fill: "#00f0ff",
|
||||||
|
stroke: "#0a0e1a",
|
||||||
|
strokeWidth: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
components/dashboard/lobster-feed.tsx
Normal file
151
components/dashboard/lobster-feed.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useSSE } from "@/hooks/use-sse";
|
||||||
|
|
||||||
|
interface FeedItem {
|
||||||
|
id: string;
|
||||||
|
type: "task" | "online" | "offline";
|
||||||
|
lobsterName: string;
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
|
summary?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LobsterFeed() {
|
||||||
|
const [items, setItems] = useState<FeedItem[]>([]);
|
||||||
|
|
||||||
|
const handleEvent = useCallback((event: { type: string; data: Record<string, unknown> }) => {
|
||||||
|
if (event.type === "task" || event.type === "online" || event.type === "offline") {
|
||||||
|
const newItem: FeedItem = {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
type: event.type as FeedItem["type"],
|
||||||
|
lobsterName: (event.data.lobsterName as string) ?? "Unknown",
|
||||||
|
city: event.data.city as string | undefined,
|
||||||
|
country: event.data.country as string | undefined,
|
||||||
|
summary: event.data.summary as string | undefined,
|
||||||
|
durationMs: event.data.durationMs as number | undefined,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setItems((prev) => [newItem, ...prev].slice(0, 50));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useSSE({
|
||||||
|
url: "/api/v1/stream",
|
||||||
|
onEvent: handleEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load initial recent tasks
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRecent = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/lobsters?limit=10");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
const feedItems: FeedItem[] = (data.lobsters ?? [])
|
||||||
|
.filter((l: Record<string, unknown>) => l.lastTask)
|
||||||
|
.map((l: Record<string, unknown>) => {
|
||||||
|
const task = l.lastTask as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
id: `init-${l.id}`,
|
||||||
|
type: "task" as const,
|
||||||
|
lobsterName: l.name as string,
|
||||||
|
city: l.city as string,
|
||||||
|
country: l.country as string,
|
||||||
|
summary: task.summary as string,
|
||||||
|
durationMs: task.durationMs as number,
|
||||||
|
timestamp: new Date(task.timestamp as string).getTime(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setItems(feedItems);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// will populate via SSE
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchRecent();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatDuration = (ms?: number) => {
|
||||||
|
if (!ms) return "";
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "task":
|
||||||
|
return "⚡";
|
||||||
|
case "online":
|
||||||
|
return "🟢";
|
||||||
|
case "offline":
|
||||||
|
return "⭕";
|
||||||
|
default:
|
||||||
|
return "🦞";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-white/5">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle>Live Feed</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="max-h-[400px] overflow-y-auto p-4 pt-0">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-xs text-[var(--text-muted)]">
|
||||||
|
Waiting for lobster activity...
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.id}
|
||||||
|
initial={{ opacity: 0, height: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, height: "auto", y: 0 }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="border-b border-white/5 py-2.5 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="mt-0.5 text-sm">{getIcon(item.type)}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs font-medium text-[var(--accent-cyan)]">
|
||||||
|
{item.lobsterName}
|
||||||
|
</span>
|
||||||
|
{item.city && (
|
||||||
|
<span className="text-xs text-[var(--text-muted)]">
|
||||||
|
{item.city}, {item.country}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.summary && (
|
||||||
|
<p className="mt-0.5 truncate text-xs text-[var(--text-secondary)]">
|
||||||
|
{item.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
{item.durationMs && (
|
||||||
|
<Badge variant="secondary">{formatDuration(item.durationMs)}</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)]">
|
||||||
|
{new Date(item.timestamp).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
components/dashboard/region-ranking.tsx
Normal file
91
components/dashboard/region-ranking.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
interface RegionData {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionColors: Record<string, string> = {
|
||||||
|
Asia: "var(--accent-cyan)",
|
||||||
|
Europe: "var(--accent-purple)",
|
||||||
|
Americas: "var(--accent-pink)",
|
||||||
|
Africa: "var(--accent-orange)",
|
||||||
|
Oceania: "var(--accent-green)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RegionRanking() {
|
||||||
|
const [regions, setRegions] = useState<RegionData[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/stats");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
const breakdown = (data.regionBreakdown ?? {}) as Record<string, number>;
|
||||||
|
const sorted = Object.entries(breakdown)
|
||||||
|
.map(([name, count]) => ({
|
||||||
|
name,
|
||||||
|
count,
|
||||||
|
color: regionColors[name] ?? "var(--accent-cyan)",
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
setRegions(sorted);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry on interval
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
const interval = setInterval(fetchData, 15000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const maxCount = Math.max(...regions.map((r) => r.count), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-white/5">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle>Region Ranking</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 p-4 pt-0">
|
||||||
|
{regions.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-xs text-[var(--text-muted)]">No data yet</p>
|
||||||
|
) : (
|
||||||
|
regions.map((region, i) => (
|
||||||
|
<div key={region.name} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-[var(--text-muted)]">
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium" style={{ color: region.color }}>
|
||||||
|
{region.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs text-[var(--text-secondary)]">
|
||||||
|
{region.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-white/5">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{ backgroundColor: region.color }}
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${(region.count / maxCount) * 100}%` }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
components/dashboard/stats-panel.tsx
Normal file
150
components/dashboard/stats-panel.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Users, Zap, Clock, Activity } from "lucide-react";
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalLobsters: number;
|
||||||
|
activeLobsters: number;
|
||||||
|
tasksToday: number;
|
||||||
|
tasksTotal: number;
|
||||||
|
avgTaskDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedNumber({ value, suffix = "" }: { value: number; suffix?: string }) {
|
||||||
|
const [display, setDisplay] = useState(0);
|
||||||
|
const prevRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const start = prevRef.current;
|
||||||
|
const diff = value - start;
|
||||||
|
if (diff === 0) return;
|
||||||
|
|
||||||
|
const duration = 800;
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
const animate = (currentTime: number) => {
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
const current = start + diff * eased;
|
||||||
|
|
||||||
|
setDisplay(current);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
prevRef.current = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const formatted = Number.isInteger(value)
|
||||||
|
? Math.round(display).toLocaleString()
|
||||||
|
: display.toFixed(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{formatted}
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
key: "activeLobsters" as const,
|
||||||
|
label: "Online Now",
|
||||||
|
icon: Activity,
|
||||||
|
color: "var(--accent-green)",
|
||||||
|
glow: "0 0 20px rgba(16, 185, 129, 0.3)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "totalLobsters" as const,
|
||||||
|
label: "Total Lobsters",
|
||||||
|
icon: Users,
|
||||||
|
color: "var(--accent-cyan)",
|
||||||
|
glow: "var(--glow-cyan)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tasksToday" as const,
|
||||||
|
label: "Tasks Today",
|
||||||
|
icon: Zap,
|
||||||
|
color: "var(--accent-purple)",
|
||||||
|
glow: "var(--glow-purple)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "avgTaskDuration" as const,
|
||||||
|
label: "Avg Duration",
|
||||||
|
icon: Clock,
|
||||||
|
color: "var(--accent-orange)",
|
||||||
|
glow: "0 0 20px rgba(245, 158, 11, 0.3)",
|
||||||
|
suffix: "s",
|
||||||
|
transform: (v: number) => v / 1000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function StatsPanel() {
|
||||||
|
const [stats, setStats] = useState<Stats>({
|
||||||
|
totalLobsters: 0,
|
||||||
|
activeLobsters: 0,
|
||||||
|
tasksToday: 0,
|
||||||
|
tasksTotal: 0,
|
||||||
|
avgTaskDuration: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/stats");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setStats(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// will retry on next interval
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStats();
|
||||||
|
const interval = setInterval(fetchStats, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{statCards.map(({ key, label, icon: Icon, color, glow, suffix, transform }) => {
|
||||||
|
const raw = stats[key];
|
||||||
|
const value = transform ? transform(raw) : raw;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={key}
|
||||||
|
className="overflow-hidden border-white/5 transition-all hover:border-white/10"
|
||||||
|
style={{ boxShadow: glow }}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||||
|
style={{ backgroundColor: `${color}15` }}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" style={{ color }} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">{label}</p>
|
||||||
|
<p
|
||||||
|
className="font-mono text-xl font-bold"
|
||||||
|
style={{ color, textShadow: glow }}
|
||||||
|
>
|
||||||
|
<AnimatedNumber value={value} suffix={suffix} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/globe/globe-controls.tsx
Normal file
37
components/globe/globe-controls.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RotateCw, ZoomIn, ZoomOut } from "lucide-react";
|
||||||
|
|
||||||
|
interface GlobeControlsProps {
|
||||||
|
onResetView: () => void;
|
||||||
|
onZoomIn: () => void;
|
||||||
|
onZoomOut: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobeControls({ onResetView, onZoomIn, onZoomOut }: GlobeControlsProps) {
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onZoomIn}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onZoomOut}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onResetView}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
|
||||||
|
aria-label="Reset view"
|
||||||
|
>
|
||||||
|
<RotateCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
components/globe/globe-view.tsx
Normal file
159
components/globe/globe-view.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
|
||||||
|
import { GlobeControls } from "./globe-controls";
|
||||||
|
|
||||||
|
const Globe = dynamic(() => import("react-globe.gl"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[var(--accent-cyan)] border-t-transparent" />
|
||||||
|
<span className="font-mono text-xs text-[var(--text-muted)]">Loading globe...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ArcData {
|
||||||
|
startLat: number;
|
||||||
|
startLng: number;
|
||||||
|
endLat: number;
|
||||||
|
endLng: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobeView() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const globeRef = useRef<any>(undefined);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
const [hoveredPoint, setHoveredPoint] = useState<HeatmapPoint | null>(null);
|
||||||
|
const { points } = useHeatmapData(30000);
|
||||||
|
|
||||||
|
// Generate arcs from recent activity (connecting pairs of active points)
|
||||||
|
const arcs = useMemo((): ArcData[] => {
|
||||||
|
if (points.length < 2) return [];
|
||||||
|
const result: ArcData[] = [];
|
||||||
|
const maxArcs = Math.min(points.length - 1, 8);
|
||||||
|
for (let i = 0; i < maxArcs; i++) {
|
||||||
|
const from = points[i];
|
||||||
|
const to = points[(i + 1) % points.length];
|
||||||
|
result.push({
|
||||||
|
startLat: from.lat,
|
||||||
|
startLng: from.lng,
|
||||||
|
endLat: to.lat,
|
||||||
|
endLng: to.lng,
|
||||||
|
color: "rgba(0, 240, 255, 0.4)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateDimensions = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
setDimensions({
|
||||||
|
width: containerRef.current.clientWidth,
|
||||||
|
height: containerRef.current.clientHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDimensions();
|
||||||
|
const observer = new ResizeObserver(updateDimensions);
|
||||||
|
if (containerRef.current) observer.observe(containerRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (globeRef.current) {
|
||||||
|
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 2.5 });
|
||||||
|
}
|
||||||
|
}, [dimensions]);
|
||||||
|
|
||||||
|
const handleResetView = useCallback(() => {
|
||||||
|
if (globeRef.current) {
|
||||||
|
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 2.5 });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleZoomIn = useCallback(() => {
|
||||||
|
if (globeRef.current) {
|
||||||
|
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 1.5 });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
if (globeRef.current) {
|
||||||
|
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 3.5 });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-white/5">
|
||||||
|
{dimensions.width > 0 && (
|
||||||
|
<Globe
|
||||||
|
ref={globeRef}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
backgroundColor="rgba(0,0,0,0)"
|
||||||
|
globeImageUrl="//unpkg.com/three-globe/example/img/earth-dark.jpg"
|
||||||
|
atmosphereColor="#00f0ff"
|
||||||
|
atmosphereAltitude={0.15}
|
||||||
|
// Points
|
||||||
|
pointsData={points}
|
||||||
|
pointLat={(d: object) => (d as HeatmapPoint).lat}
|
||||||
|
pointLng={(d: object) => (d as HeatmapPoint).lng}
|
||||||
|
pointAltitude={(d: object) => Math.min((d as HeatmapPoint).weight * 0.02, 0.15)}
|
||||||
|
pointRadius={(d: object) => Math.max((d as HeatmapPoint).weight * 0.3, 0.4)}
|
||||||
|
pointColor={() => "#00f0ff"}
|
||||||
|
pointsMerge={false}
|
||||||
|
onPointHover={(point: object | null) => setHoveredPoint(point as HeatmapPoint | null)}
|
||||||
|
// Arcs
|
||||||
|
arcsData={arcs}
|
||||||
|
arcStartLat={(d: object) => (d as ArcData).startLat}
|
||||||
|
arcStartLng={(d: object) => (d as ArcData).startLng}
|
||||||
|
arcEndLat={(d: object) => (d as ArcData).endLat}
|
||||||
|
arcEndLng={(d: object) => (d as ArcData).endLng}
|
||||||
|
arcColor={(d: object) => (d as ArcData).color}
|
||||||
|
arcDashLength={0.5}
|
||||||
|
arcDashGap={0.2}
|
||||||
|
arcDashAnimateTime={2000}
|
||||||
|
arcStroke={0.3}
|
||||||
|
// Auto-rotate
|
||||||
|
animateIn={true}
|
||||||
|
enablePointerInteraction={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
{hoveredPoint && (
|
||||||
|
<div className="pointer-events-none absolute left-1/2 top-4 z-20 -translate-x-1/2">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-[var(--bg-card)]/95 p-3 shadow-xl backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">🦞</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm font-medium text-[var(--accent-cyan)]">
|
||||||
|
{hoveredPoint.city}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">{hoveredPoint.country}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
{hoveredPoint.lobsterCount} active lobster{hoveredPoint.lobsterCount !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GlobeControls
|
||||||
|
onResetView={handleResetView}
|
||||||
|
onZoomIn={handleZoomIn}
|
||||||
|
onZoomOut={handleZoomOut}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/globe/lobster-tooltip.tsx
Normal file
28
components/globe/lobster-tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface LobsterTooltipProps {
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
lobsterCount: number;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LobsterTooltip({ city, country, lobsterCount, weight }: LobsterTooltipProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-[var(--bg-card)]/95 p-3 shadow-xl backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">🦞</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm font-medium text-[var(--accent-cyan)]">{city}</p>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">{country}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Badge variant="online">{lobsterCount} active</Badge>
|
||||||
|
<Badge variant="secondary">weight: {weight.toFixed(1)}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
components/layout/install-banner.tsx
Normal file
63
components/layout/install-banner.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Check, Copy, Terminal } from "lucide-react";
|
||||||
|
|
||||||
|
const INSTALL_COMMAND = "clawhub install openclaw-reporter";
|
||||||
|
|
||||||
|
export function InstallBanner() {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(INSTALL_COMMAND);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// fallback: select the text
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glow-card rounded-xl px-5 py-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
{/* Left: description */}
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||||
|
style={{ backgroundColor: "rgba(0, 240, 255, 0.1)" }}
|
||||||
|
>
|
||||||
|
<span className="text-lg">🦞</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
Join the Heatmap
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] truncate">
|
||||||
|
Install the skill and let your lobster light up the globe
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: copy command */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="group flex items-center gap-2 rounded-lg border border-white/10 bg-[var(--bg-primary)] px-4 py-2.5 transition-all hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-primary)]/80 active:scale-[0.98] cursor-pointer shrink-0"
|
||||||
|
title="Click to copy"
|
||||||
|
>
|
||||||
|
<Terminal className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||||||
|
<code className="font-mono text-sm text-[var(--accent-cyan)] select-all">
|
||||||
|
{INSTALL_COMMAND}
|
||||||
|
</code>
|
||||||
|
<div className="ml-1 flex h-5 w-5 items-center justify-center">
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3.5 w-3.5 text-[var(--accent-green)]" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5 text-[var(--text-muted)] transition-colors group-hover:text-[var(--accent-cyan)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
components/layout/navbar.tsx
Normal file
61
components/layout/navbar.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Activity, Globe2, Map } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface NavbarProps {
|
||||||
|
activeView?: "globe" | "map";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Navbar({ activeView = "globe" }: NavbarProps) {
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-[var(--bg-primary)]/80 backdrop-blur-xl">
|
||||||
|
<div className="mx-auto flex h-14 max-w-[1800px] items-center justify-between px-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">🦞</span>
|
||||||
|
<span
|
||||||
|
className="font-mono text-lg font-bold tracking-tight"
|
||||||
|
style={{ color: "var(--accent-cyan)", textShadow: "var(--glow-cyan)" }}
|
||||||
|
>
|
||||||
|
OpenClaw Market
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border border-white/5 bg-white/5 p-1">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all",
|
||||||
|
activeView === "globe"
|
||||||
|
? "bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
|
||||||
|
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Globe2 className="h-3.5 w-3.5" />
|
||||||
|
3D Globe
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/continent/asia"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all",
|
||||||
|
activeView === "map"
|
||||||
|
? "bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
|
||||||
|
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Map className="h-3.5 w-3.5" />
|
||||||
|
2D Map
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Activity className="h-3.5 w-3.5 text-[var(--accent-green)]" />
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">Live</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
components/layout/particle-bg.tsx
Normal file
104
components/layout/particle-bg.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
size: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParticleBg() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let animationId: number;
|
||||||
|
const particles: Particle[] = [];
|
||||||
|
const particleCount = 60;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createParticle = (): Particle => ({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
vx: (Math.random() - 0.5) * 0.3,
|
||||||
|
vy: (Math.random() - 0.5) * 0.3,
|
||||||
|
size: Math.random() * 1.5 + 0.5,
|
||||||
|
opacity: Math.random() * 0.3 + 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
resize();
|
||||||
|
particles.length = 0;
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
particles.push(createParticle());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
for (const p of particles) {
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
|
||||||
|
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
|
||||||
|
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(0, 240, 255, ${p.opacity})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw connections
|
||||||
|
for (let i = 0; i < particles.length; i++) {
|
||||||
|
for (let j = i + 1; j < particles.length; j++) {
|
||||||
|
const dx = particles[i].x - particles[j].x;
|
||||||
|
const dy = particles[i].y - particles[j].y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < 150) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(particles[i].x, particles[i].y);
|
||||||
|
ctx.lineTo(particles[j].x, particles[j].y);
|
||||||
|
ctx.strokeStyle = `rgba(0, 240, 255, ${0.05 * (1 - dist / 150)})`;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
animate();
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="pointer-events-none fixed inset-0 z-0"
|
||||||
|
style={{ opacity: 0.6 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/layout/view-switcher.tsx
Normal file
51
components/layout/view-switcher.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Globe2, Map } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const continents = [
|
||||||
|
{ slug: "asia", label: "Asia" },
|
||||||
|
{ slug: "europe", label: "Europe" },
|
||||||
|
{ slug: "americas", label: "Americas" },
|
||||||
|
{ slug: "africa", label: "Africa" },
|
||||||
|
{ slug: "oceania", label: "Oceania" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ViewSwitcherProps {
|
||||||
|
activeContinent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ViewSwitcher({ activeContinent }: ViewSwitcherProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||||
|
!activeContinent
|
||||||
|
? "border-[var(--accent-cyan)]/30 bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"
|
||||||
|
: "border-white/5 text-[var(--text-muted)] hover:border-white/10 hover:text-[var(--text-secondary)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Globe2 className="h-3 w-3" />
|
||||||
|
Global
|
||||||
|
</Link>
|
||||||
|
{continents.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.slug}
|
||||||
|
href={`/continent/${c.slug}`}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||||
|
activeContinent === c.slug
|
||||||
|
? "border-[var(--accent-purple)]/30 bg-[var(--accent-purple)]/10 text-[var(--accent-purple)]"
|
||||||
|
: "border-white/5 text-[var(--text-muted)] hover:border-white/10 hover:text-[var(--text-secondary)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Map className="h-3 w-3" />
|
||||||
|
{c.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
components/map/continent-map.tsx
Normal file
136
components/map/continent-map.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
ComposableMap,
|
||||||
|
Geographies,
|
||||||
|
Geography,
|
||||||
|
ZoomableGroup,
|
||||||
|
} from "react-simple-maps";
|
||||||
|
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
|
||||||
|
import { HeatmapLayer } from "./heatmap-layer";
|
||||||
|
import { geoMercator } from "d3-geo";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||||
|
|
||||||
|
interface ContinentConfig {
|
||||||
|
center: [number, number];
|
||||||
|
zoom: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const continentConfigs: Record<string, ContinentConfig> = {
|
||||||
|
asia: { center: [100, 35], zoom: 2.5, label: "Asia" },
|
||||||
|
europe: { center: [15, 52], zoom: 4, label: "Europe" },
|
||||||
|
americas: { center: [-80, 15], zoom: 1.8, label: "Americas" },
|
||||||
|
africa: { center: [20, 5], zoom: 2.2, label: "Africa" },
|
||||||
|
oceania: { center: [145, -25], zoom: 3, label: "Oceania" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const continentRegionMap: Record<string, string> = {
|
||||||
|
asia: "Asia",
|
||||||
|
europe: "Europe",
|
||||||
|
americas: "Americas",
|
||||||
|
africa: "Africa",
|
||||||
|
oceania: "Oceania",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ContinentMapProps {
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContinentMap({ slug }: ContinentMapProps) {
|
||||||
|
const config = continentConfigs[slug] ?? continentConfigs.asia;
|
||||||
|
const regionFilter = continentRegionMap[slug];
|
||||||
|
const { points } = useHeatmapData(30000);
|
||||||
|
const [selectedPoint, setSelectedPoint] = useState<HeatmapPoint | null>(null);
|
||||||
|
|
||||||
|
const filteredPoints = useMemo(
|
||||||
|
() => (regionFilter ? points.filter(() => true) : points),
|
||||||
|
[points, regionFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const projection = useMemo(
|
||||||
|
() =>
|
||||||
|
geoMercator()
|
||||||
|
.center(config.center)
|
||||||
|
.scale(150 * config.zoom)
|
||||||
|
.translate([400, 300]),
|
||||||
|
[config]
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectionFn = (coords: [number, number]): [number, number] | null => {
|
||||||
|
const result = projection(coords);
|
||||||
|
return result ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="overflow-hidden rounded-xl border border-white/5 bg-[var(--bg-secondary)]">
|
||||||
|
<ComposableMap
|
||||||
|
projection="geoMercator"
|
||||||
|
projectionConfig={{
|
||||||
|
center: config.center,
|
||||||
|
scale: 150 * config.zoom,
|
||||||
|
}}
|
||||||
|
width={800}
|
||||||
|
height={600}
|
||||||
|
style={{ width: "100%", height: "auto" }}
|
||||||
|
>
|
||||||
|
<ZoomableGroup center={config.center} zoom={1}>
|
||||||
|
<Geographies geography={GEO_URL}>
|
||||||
|
{({ geographies }) =>
|
||||||
|
geographies.map((geo) => (
|
||||||
|
<Geography
|
||||||
|
key={geo.rsmKey}
|
||||||
|
geography={geo}
|
||||||
|
fill="#1a1f2e"
|
||||||
|
stroke="#2a3040"
|
||||||
|
strokeWidth={0.5}
|
||||||
|
style={{
|
||||||
|
default: { outline: "none" },
|
||||||
|
hover: { fill: "#242a3d", outline: "none" },
|
||||||
|
pressed: { outline: "none" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Geographies>
|
||||||
|
<HeatmapLayer
|
||||||
|
points={filteredPoints}
|
||||||
|
projection={projectionFn}
|
||||||
|
onPointClick={setSelectedPoint}
|
||||||
|
/>
|
||||||
|
</ZoomableGroup>
|
||||||
|
</ComposableMap>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedPoint && (
|
||||||
|
<Card className="absolute bottom-4 left-4 z-10 w-64 border-[var(--accent-cyan)]/20">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm font-medium text-[var(--accent-cyan)]">
|
||||||
|
{selectedPoint.city}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">{selectedPoint.country}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPoint(null)}
|
||||||
|
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<Badge variant="online">{selectedPoint.lobsterCount} lobsters</Badge>
|
||||||
|
<Badge variant="secondary">weight: {selectedPoint.weight.toFixed(1)}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
components/map/heatmap-layer.tsx
Normal file
66
components/map/heatmap-layer.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { HeatmapPoint } from "@/hooks/use-heatmap-data";
|
||||||
|
|
||||||
|
interface HeatmapLayerProps {
|
||||||
|
points: HeatmapPoint[];
|
||||||
|
projection: (coords: [number, number]) => [number, number] | null;
|
||||||
|
onPointClick?: (point: HeatmapPoint) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeatmapLayer({ points, projection, onPointClick }: HeatmapLayerProps) {
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
{points.map((point, i) => {
|
||||||
|
const coords = projection([point.lng, point.lat]);
|
||||||
|
if (!coords) return null;
|
||||||
|
const [x, y] = coords;
|
||||||
|
const radius = Math.max(point.weight * 3, 4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`${point.city}-${point.country}-${i}`}>
|
||||||
|
{/* Glow */}
|
||||||
|
<motion.circle
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r={radius * 2}
|
||||||
|
fill="rgba(0, 240, 255, 0.1)"
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: [1, 1.3, 1] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, delay: i * 0.1 }}
|
||||||
|
/>
|
||||||
|
{/* Main dot */}
|
||||||
|
<motion.circle
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r={radius}
|
||||||
|
fill="rgba(0, 240, 255, 0.6)"
|
||||||
|
stroke="rgba(0, 240, 255, 0.8)"
|
||||||
|
strokeWidth={1}
|
||||||
|
className="cursor-pointer"
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: i * 0.05 }}
|
||||||
|
whileHover={{ scale: 1.5 }}
|
||||||
|
onClick={() => onPointClick?.(point)}
|
||||||
|
/>
|
||||||
|
{/* Count label */}
|
||||||
|
{point.lobsterCount > 1 && (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y - radius - 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#00f0ff"
|
||||||
|
fontSize={9}
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
{point.lobsterCount}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/ui/badge.tsx
Normal file
28
components/ui/badge.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]",
|
||||||
|
secondary: "border-transparent bg-[var(--accent-purple)]/10 text-[var(--accent-purple)]",
|
||||||
|
online: "border-transparent bg-[var(--accent-green)]/10 text-[var(--accent-green)]",
|
||||||
|
offline: "border-transparent bg-white/5 text-[var(--text-muted)]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
39
components/ui/card.tsx
Normal file
39
components/ui/card.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border border-white/5 bg-[var(--bg-card)] text-[var(--text-primary)] shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("text-sm font-medium tracking-wide uppercase text-[var(--text-secondary)]", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardContent };
|
||||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: "./drizzle",
|
||||||
|
schema: "./lib/db/schema.ts",
|
||||||
|
dialect: "mysql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
});
|
||||||
16
eslint.config.mjs
Normal file
16
eslint.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
44
hooks/use-heatmap-data.ts
Normal file
44
hooks/use-heatmap-data.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
export interface HeatmapPoint {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
weight: number;
|
||||||
|
lobsterCount: number;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeatmapData {
|
||||||
|
points: HeatmapPoint[];
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHeatmapData(refreshInterval = 30000) {
|
||||||
|
const [data, setData] = useState<HeatmapData>({ points: [], lastUpdated: "" });
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/heatmap");
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
setData(json);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail, will retry on next interval
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
const interval = setInterval(fetchData, refreshInterval);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchData, refreshInterval]);
|
||||||
|
|
||||||
|
return { ...data, isLoading, refresh: fetchData };
|
||||||
|
}
|
||||||
68
hooks/use-sse.ts
Normal file
68
hooks/use-sse.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
|
|
||||||
|
export interface SSEEvent {
|
||||||
|
type: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSSEOptions {
|
||||||
|
url: string;
|
||||||
|
onEvent?: (event: SSEEvent) => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSSE({ url, onEvent, enabled = true }: UseSSEOptions) {
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [lastEvent, setLastEvent] = useState<SSEEvent | null>(null);
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const onEventRef = useRef(onEvent);
|
||||||
|
|
||||||
|
// Keep callback ref updated
|
||||||
|
onEventRef.current = onEvent;
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EventSource(url);
|
||||||
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
|
es.onopen = () => setIsConnected(true);
|
||||||
|
es.onerror = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
// Auto-reconnect is built into EventSource
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for all event types
|
||||||
|
const eventTypes = ["heartbeat", "task", "stats", "online", "offline", "connected"];
|
||||||
|
eventTypes.forEach((type) => {
|
||||||
|
es.addEventListener(type, (e: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const event: SSEEvent = { type, data };
|
||||||
|
setLastEvent(event);
|
||||||
|
onEventRef.current?.(event);
|
||||||
|
} catch {
|
||||||
|
// skip malformed events
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return es;
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const es = connect();
|
||||||
|
return () => {
|
||||||
|
es.close();
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
}, [connect, enabled]);
|
||||||
|
|
||||||
|
return { isConnected, lastEvent };
|
||||||
|
}
|
||||||
47
lib/auth/api-key.ts
Normal file
47
lib/auth/api-key.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { lobsters } from "@/lib/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
|
const API_KEY_CACHE_TTL = 3600; // 1 hour in seconds
|
||||||
|
|
||||||
|
export function generateApiKey(): string {
|
||||||
|
return crypto.randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateApiKey(apiKey: string) {
|
||||||
|
try {
|
||||||
|
const cacheKey = `lobster:key:${apiKey}`;
|
||||||
|
const cachedLobsterId = await redis.get(cacheKey);
|
||||||
|
|
||||||
|
if (cachedLobsterId) {
|
||||||
|
const lobster = await db
|
||||||
|
.select()
|
||||||
|
.from(lobsters)
|
||||||
|
.where(eq(lobsters.id, cachedLobsterId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (lobster.length > 0) {
|
||||||
|
return lobster[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lobster = await db
|
||||||
|
.select()
|
||||||
|
.from(lobsters)
|
||||||
|
.where(eq(lobsters.apiKey, apiKey))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (lobster.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.set(cacheKey, lobster[0].id, "EX", API_KEY_CACHE_TTL);
|
||||||
|
|
||||||
|
return lobster[0];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to validate API key:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/db/index.ts
Normal file
19
lib/db/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
const globalForDb = globalThis as unknown as {
|
||||||
|
connection: mysql.Pool | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const connection =
|
||||||
|
globalForDb.connection ??
|
||||||
|
mysql.createPool({
|
||||||
|
uri: process.env.DATABASE_URL!,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForDb.connection = connection;
|
||||||
|
|
||||||
|
export const db = drizzle(connection, { schema, mode: "default" });
|
||||||
72
lib/db/schema.ts
Normal file
72
lib/db/schema.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
mysqlTable,
|
||||||
|
varchar,
|
||||||
|
int,
|
||||||
|
bigint,
|
||||||
|
decimal,
|
||||||
|
datetime,
|
||||||
|
json,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/mysql-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const lobsters = mysqlTable("lobsters", {
|
||||||
|
id: varchar("id", { length: 21 }).primaryKey(),
|
||||||
|
apiKey: varchar("api_key", { length: 64 }).notNull().unique(),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
platform: varchar("platform", { length: 20 }),
|
||||||
|
model: varchar("model", { length: 50 }),
|
||||||
|
ip: varchar("ip", { length: 45 }),
|
||||||
|
latitude: decimal("latitude", { precision: 10, scale: 7 }),
|
||||||
|
longitude: decimal("longitude", { precision: 10, scale: 7 }),
|
||||||
|
city: varchar("city", { length: 100 }),
|
||||||
|
country: varchar("country", { length: 100 }),
|
||||||
|
countryCode: varchar("country_code", { length: 5 }),
|
||||||
|
region: varchar("region", { length: 50 }),
|
||||||
|
lastHeartbeat: datetime("last_heartbeat"),
|
||||||
|
totalTasks: int("total_tasks").default(0),
|
||||||
|
createdAt: datetime("created_at").default(sql`NOW()`),
|
||||||
|
updatedAt: datetime("updated_at").default(sql`NOW()`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const heartbeats = mysqlTable(
|
||||||
|
"heartbeats",
|
||||||
|
{
|
||||||
|
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||||
|
lobsterId: varchar("lobster_id", { length: 21 }).notNull(),
|
||||||
|
ip: varchar("ip", { length: 45 }),
|
||||||
|
timestamp: datetime("timestamp").default(sql`NOW()`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("heartbeats_lobster_id_idx").on(table.lobsterId),
|
||||||
|
index("heartbeats_timestamp_idx").on(table.timestamp),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tasks = mysqlTable(
|
||||||
|
"tasks",
|
||||||
|
{
|
||||||
|
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||||
|
lobsterId: varchar("lobster_id", { length: 21 }).notNull(),
|
||||||
|
summary: varchar("summary", { length: 500 }),
|
||||||
|
durationMs: int("duration_ms"),
|
||||||
|
model: varchar("model", { length: 50 }),
|
||||||
|
toolsUsed: json("tools_used").$type<string[]>(),
|
||||||
|
timestamp: datetime("timestamp").default(sql`NOW()`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("tasks_lobster_id_idx").on(table.lobsterId),
|
||||||
|
index("tasks_timestamp_idx").on(table.timestamp),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const geoCache = mysqlTable("geo_cache", {
|
||||||
|
ip: varchar("ip", { length: 45 }).primaryKey(),
|
||||||
|
latitude: decimal("latitude", { precision: 10, scale: 7 }),
|
||||||
|
longitude: decimal("longitude", { precision: 10, scale: 7 }),
|
||||||
|
city: varchar("city", { length: 100 }),
|
||||||
|
country: varchar("country", { length: 100 }),
|
||||||
|
countryCode: varchar("country_code", { length: 5 }),
|
||||||
|
region: varchar("region", { length: 50 }),
|
||||||
|
updatedAt: datetime("updated_at").default(sql`NOW()`),
|
||||||
|
});
|
||||||
245
lib/geo/ip-location.ts
Normal file
245
lib/geo/ip-location.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { geoCache } from "@/lib/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
interface GeoLocation {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
countryCode: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IpApiResponse {
|
||||||
|
status: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
countryCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTINENT_MAP: Record<string, string> = {
|
||||||
|
// Asia
|
||||||
|
CN: "Asia",
|
||||||
|
JP: "Asia",
|
||||||
|
KR: "Asia",
|
||||||
|
IN: "Asia",
|
||||||
|
ID: "Asia",
|
||||||
|
TH: "Asia",
|
||||||
|
VN: "Asia",
|
||||||
|
PH: "Asia",
|
||||||
|
MY: "Asia",
|
||||||
|
SG: "Asia",
|
||||||
|
TW: "Asia",
|
||||||
|
HK: "Asia",
|
||||||
|
BD: "Asia",
|
||||||
|
PK: "Asia",
|
||||||
|
LK: "Asia",
|
||||||
|
NP: "Asia",
|
||||||
|
MM: "Asia",
|
||||||
|
KH: "Asia",
|
||||||
|
LA: "Asia",
|
||||||
|
MN: "Asia",
|
||||||
|
KZ: "Asia",
|
||||||
|
UZ: "Asia",
|
||||||
|
AE: "Asia",
|
||||||
|
SA: "Asia",
|
||||||
|
IL: "Asia",
|
||||||
|
TR: "Asia",
|
||||||
|
IQ: "Asia",
|
||||||
|
IR: "Asia",
|
||||||
|
QA: "Asia",
|
||||||
|
KW: "Asia",
|
||||||
|
BH: "Asia",
|
||||||
|
OM: "Asia",
|
||||||
|
JO: "Asia",
|
||||||
|
LB: "Asia",
|
||||||
|
AF: "Asia",
|
||||||
|
|
||||||
|
// Europe
|
||||||
|
GB: "Europe",
|
||||||
|
DE: "Europe",
|
||||||
|
FR: "Europe",
|
||||||
|
IT: "Europe",
|
||||||
|
ES: "Europe",
|
||||||
|
PT: "Europe",
|
||||||
|
NL: "Europe",
|
||||||
|
BE: "Europe",
|
||||||
|
SE: "Europe",
|
||||||
|
NO: "Europe",
|
||||||
|
DK: "Europe",
|
||||||
|
FI: "Europe",
|
||||||
|
PL: "Europe",
|
||||||
|
CZ: "Europe",
|
||||||
|
AT: "Europe",
|
||||||
|
CH: "Europe",
|
||||||
|
IE: "Europe",
|
||||||
|
RO: "Europe",
|
||||||
|
HU: "Europe",
|
||||||
|
GR: "Europe",
|
||||||
|
UA: "Europe",
|
||||||
|
RU: "Europe",
|
||||||
|
BG: "Europe",
|
||||||
|
HR: "Europe",
|
||||||
|
SK: "Europe",
|
||||||
|
SI: "Europe",
|
||||||
|
RS: "Europe",
|
||||||
|
LT: "Europe",
|
||||||
|
LV: "Europe",
|
||||||
|
EE: "Europe",
|
||||||
|
IS: "Europe",
|
||||||
|
LU: "Europe",
|
||||||
|
MT: "Europe",
|
||||||
|
CY: "Europe",
|
||||||
|
AL: "Europe",
|
||||||
|
MK: "Europe",
|
||||||
|
BA: "Europe",
|
||||||
|
ME: "Europe",
|
||||||
|
MD: "Europe",
|
||||||
|
BY: "Europe",
|
||||||
|
|
||||||
|
// Americas
|
||||||
|
US: "Americas",
|
||||||
|
CA: "Americas",
|
||||||
|
MX: "Americas",
|
||||||
|
BR: "Americas",
|
||||||
|
AR: "Americas",
|
||||||
|
CO: "Americas",
|
||||||
|
CL: "Americas",
|
||||||
|
PE: "Americas",
|
||||||
|
VE: "Americas",
|
||||||
|
EC: "Americas",
|
||||||
|
BO: "Americas",
|
||||||
|
PY: "Americas",
|
||||||
|
UY: "Americas",
|
||||||
|
CR: "Americas",
|
||||||
|
PA: "Americas",
|
||||||
|
CU: "Americas",
|
||||||
|
DO: "Americas",
|
||||||
|
GT: "Americas",
|
||||||
|
HN: "Americas",
|
||||||
|
SV: "Americas",
|
||||||
|
NI: "Americas",
|
||||||
|
JM: "Americas",
|
||||||
|
TT: "Americas",
|
||||||
|
HT: "Americas",
|
||||||
|
PR: "Americas",
|
||||||
|
GY: "Americas",
|
||||||
|
SR: "Americas",
|
||||||
|
BZ: "Americas",
|
||||||
|
|
||||||
|
// Africa
|
||||||
|
ZA: "Africa",
|
||||||
|
NG: "Africa",
|
||||||
|
KE: "Africa",
|
||||||
|
EG: "Africa",
|
||||||
|
GH: "Africa",
|
||||||
|
ET: "Africa",
|
||||||
|
TZ: "Africa",
|
||||||
|
MA: "Africa",
|
||||||
|
DZ: "Africa",
|
||||||
|
TN: "Africa",
|
||||||
|
UG: "Africa",
|
||||||
|
SN: "Africa",
|
||||||
|
CI: "Africa",
|
||||||
|
CM: "Africa",
|
||||||
|
MZ: "Africa",
|
||||||
|
MG: "Africa",
|
||||||
|
AO: "Africa",
|
||||||
|
ZW: "Africa",
|
||||||
|
RW: "Africa",
|
||||||
|
LY: "Africa",
|
||||||
|
SD: "Africa",
|
||||||
|
CD: "Africa",
|
||||||
|
ML: "Africa",
|
||||||
|
BF: "Africa",
|
||||||
|
NE: "Africa",
|
||||||
|
MW: "Africa",
|
||||||
|
ZM: "Africa",
|
||||||
|
BW: "Africa",
|
||||||
|
NA: "Africa",
|
||||||
|
MU: "Africa",
|
||||||
|
|
||||||
|
// Oceania
|
||||||
|
AU: "Oceania",
|
||||||
|
NZ: "Oceania",
|
||||||
|
FJ: "Oceania",
|
||||||
|
PG: "Oceania",
|
||||||
|
WS: "Oceania",
|
||||||
|
TO: "Oceania",
|
||||||
|
VU: "Oceania",
|
||||||
|
SB: "Oceania",
|
||||||
|
GU: "Oceania",
|
||||||
|
NC: "Oceania",
|
||||||
|
PF: "Oceania",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRegion(countryCode: string): string {
|
||||||
|
return CONTINENT_MAP[countryCode] ?? "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGeoLocation(
|
||||||
|
ip: string
|
||||||
|
): Promise<GeoLocation | null> {
|
||||||
|
try {
|
||||||
|
const cached = await db
|
||||||
|
.select()
|
||||||
|
.from(geoCache)
|
||||||
|
.where(eq(geoCache.ip, ip))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (cached.length > 0) {
|
||||||
|
const row = cached[0];
|
||||||
|
return {
|
||||||
|
latitude: Number(row.latitude),
|
||||||
|
longitude: Number(row.longitude),
|
||||||
|
city: row.city ?? "",
|
||||||
|
country: row.country ?? "",
|
||||||
|
countryCode: row.countryCode ?? "",
|
||||||
|
region: row.region ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`http://ip-api.com/json/${ip}`, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: IpApiResponse = await response.json();
|
||||||
|
|
||||||
|
if (data.status !== "success") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = getRegion(data.countryCode);
|
||||||
|
|
||||||
|
const geoData: GeoLocation = {
|
||||||
|
latitude: data.lat,
|
||||||
|
longitude: data.lon,
|
||||||
|
city: data.city,
|
||||||
|
country: data.country,
|
||||||
|
countryCode: data.countryCode,
|
||||||
|
region,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(geoCache).values({
|
||||||
|
ip,
|
||||||
|
latitude: String(geoData.latitude),
|
||||||
|
longitude: String(geoData.longitude),
|
||||||
|
city: geoData.city,
|
||||||
|
country: geoData.country,
|
||||||
|
countryCode: geoData.countryCode,
|
||||||
|
region: geoData.region,
|
||||||
|
});
|
||||||
|
|
||||||
|
return geoData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get geo location for IP:", ip, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
lib/redis/index.ts
Normal file
107
lib/redis/index.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import Redis from "ioredis";
|
||||||
|
|
||||||
|
const globalForRedis = globalThis as unknown as {
|
||||||
|
redis: Redis | undefined;
|
||||||
|
redisSub: Redis | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createRedisClient(): Redis {
|
||||||
|
return new Redis(process.env.REDIS_URL!, {
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
retryStrategy(times) {
|
||||||
|
const delay = Math.min(times * 50, 2000);
|
||||||
|
return delay;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const redis = globalForRedis.redis ?? createRedisClient();
|
||||||
|
export const redisSub = globalForRedis.redisSub ?? createRedisClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForRedis.redis = redis;
|
||||||
|
globalForRedis.redisSub = redisSub;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNEL_REALTIME = "channel:realtime";
|
||||||
|
const ACTIVE_LOBSTERS_KEY = "active:lobsters";
|
||||||
|
const STATS_GLOBAL_KEY = "stats:global";
|
||||||
|
const STATS_REGION_KEY = "stats:region";
|
||||||
|
const HEATMAP_CACHE_KEY = "cache:heatmap";
|
||||||
|
const HOURLY_ACTIVITY_KEY = "stats:hourly";
|
||||||
|
|
||||||
|
export async function setLobsterOnline(
|
||||||
|
lobsterId: string,
|
||||||
|
ip: string
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.set(`lobster:online:${lobsterId}`, ip, "EX", 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isLobsterOnline(lobsterId: string): Promise<boolean> {
|
||||||
|
const result = await redis.exists(`lobster:online:${lobsterId}`);
|
||||||
|
return result === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateActiveLobsters(lobsterId: string): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
await redis.zadd(ACTIVE_LOBSTERS_KEY, now, lobsterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveLobsterIds(
|
||||||
|
limit: number = 100
|
||||||
|
): Promise<string[]> {
|
||||||
|
return redis.zrevrange(ACTIVE_LOBSTERS_KEY, 0, limit - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementRegionCount(region: string): Promise<void> {
|
||||||
|
await redis.hincrby(STATS_REGION_KEY, region, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementGlobalStat(
|
||||||
|
field: string,
|
||||||
|
amount: number = 1
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.hincrby(STATS_GLOBAL_KEY, field, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGlobalStats(): Promise<Record<string, string>> {
|
||||||
|
return redis.hgetall(STATS_GLOBAL_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRegionStats(): Promise<Record<string, string>> {
|
||||||
|
return redis.hgetall(STATS_REGION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementHourlyActivity(): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
const hourKey = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")}T${String(now.getUTCHours()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
await redis.hincrby(HOURLY_ACTIVITY_KEY, hourKey, 1);
|
||||||
|
await redis.expire(HOURLY_ACTIVITY_KEY, 48 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHourlyActivity(): Promise<Record<string, number>> {
|
||||||
|
const allData = await redis.hgetall(HOURLY_ACTIVITY_KEY);
|
||||||
|
const now = new Date();
|
||||||
|
const result: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (let i = 23; i >= 0; i--) {
|
||||||
|
const date = new Date(now.getTime() - i * 60 * 60 * 1000);
|
||||||
|
const hourKey = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}T${String(date.getUTCHours()).padStart(2, "0")}`;
|
||||||
|
result[hourKey] = parseInt(allData[hourKey] || "0", 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCacheHeatmap(data: string): Promise<void> {
|
||||||
|
await redis.set(HEATMAP_CACHE_KEY, data, "EX", 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCacheHeatmap(): Promise<string | null> {
|
||||||
|
return redis.get(HEATMAP_CACHE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishEvent(event: object): Promise<void> {
|
||||||
|
await redis.publish(CHANNEL_REALTIME, JSON.stringify(event));
|
||||||
|
}
|
||||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
24
lib/validators/schemas.ts
Normal file
24
lib/validators/schemas.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const registerSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
platform: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const heartbeatSchema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
platform: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskSchema = z.object({
|
||||||
|
summary: z.string().max(500),
|
||||||
|
durationMs: z.number().positive(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
toolsUsed: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||||
|
export type HeartbeatInput = z.infer<typeof heartbeatSchema>;
|
||||||
|
export type TaskInput = z.infer<typeof taskSchema>;
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
transpilePackages: ["react-globe.gl", "globe.gl", "three"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
59
package.json
Normal file
59
package.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-market",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:studio": "drizzle-kit studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^15.3.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^3.0.2",
|
||||||
|
"lucide-react": "^0.474.0",
|
||||||
|
"react-globe.gl": "^2.27.3",
|
||||||
|
"three": "^0.173.0",
|
||||||
|
"react-simple-maps": "^3.0.0",
|
||||||
|
"d3-geo": "^3.1.1",
|
||||||
|
"topojson-client": "^3.1.0",
|
||||||
|
"framer-motion": "^12.6.0",
|
||||||
|
"recharts": "^2.15.3",
|
||||||
|
"drizzle-orm": "^0.41.0",
|
||||||
|
"mysql2": "^3.14.0",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
|
"zod": "^3.24.3",
|
||||||
|
"nanoid": "^5.1.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/three": "^0.173.0",
|
||||||
|
"@types/topojson-client": "^3.1.5",
|
||||||
|
"@types/d3-geo": "^3.1.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.0",
|
||||||
|
"tailwindcss": "^4.1.0",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"eslint": "^9.21.0",
|
||||||
|
"eslint-config-next": "^15.3.0",
|
||||||
|
"drizzle-kit": "^0.30.6",
|
||||||
|
"@eslint/eslintrc": "^3.3.1"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"sharp",
|
||||||
|
"unrs-resolver"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5452
pnpm-lock.yaml
generated
Normal file
5452
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
118
scripts/deploy.sh
Executable file
118
scripts/deploy.sh
Executable file
@@ -0,0 +1,118 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 部署配置
|
||||||
|
SERVER_USER="root"
|
||||||
|
SERVER_HOST="129.204.155.94"
|
||||||
|
SERVER_PATH="/usr/local/web/openclaw-market"
|
||||||
|
PM2_APP_NAME="openclaw-market"
|
||||||
|
PORT=3003
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
|
echo_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
echo_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
# 检查 SSH 连接
|
||||||
|
echo_info "检查服务器连接..."
|
||||||
|
if ! ssh -o ConnectTimeout=5 ${SERVER_USER}@${SERVER_HOST} "echo '连接成功'" > /dev/null 2>&1; then
|
||||||
|
echo_error "无法连接到服务器 ${SERVER_HOST}"
|
||||||
|
echo_warn "请确保:"
|
||||||
|
echo_warn " 1. 服务器地址正确"
|
||||||
|
echo_warn " 2. 已配置 SSH 密钥认证或密码"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 本地构建
|
||||||
|
echo_info "开始本地构建..."
|
||||||
|
pnpm run build
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo_error "构建失败,部署终止"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo_info "构建完成"
|
||||||
|
|
||||||
|
# 创建服务器目录
|
||||||
|
echo_info "准备服务器目录..."
|
||||||
|
ssh ${SERVER_USER}@${SERVER_HOST} "mkdir -p ${SERVER_PATH}"
|
||||||
|
|
||||||
|
# 同步文件到服务器
|
||||||
|
echo_info "上传文件到服务器 ${SERVER_HOST}:${SERVER_PATH}"
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.claude' \
|
||||||
|
--exclude '.env.local' \
|
||||||
|
--exclude '.DS_Store' \
|
||||||
|
--exclude '*.log' \
|
||||||
|
./ ${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo_error "文件上传失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo_info "文件上传完成"
|
||||||
|
|
||||||
|
# 在服务器上安装依赖并重启服务
|
||||||
|
echo_info "服务器部署操作..."
|
||||||
|
ssh ${SERVER_USER}@${SERVER_HOST} "bash -l" << ENDSSH
|
||||||
|
export PATH="\$HOME/.nvm/versions/node/v22.17.1/bin:\$PATH"
|
||||||
|
cd ${SERVER_PATH}
|
||||||
|
|
||||||
|
# 安装 pnpm(如果未安装)
|
||||||
|
if ! command -v pnpm &> /dev/null; then
|
||||||
|
echo "安装 pnpm..."
|
||||||
|
npm install -g pnpm
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
echo "安装依赖..."
|
||||||
|
npm config set registry https://registry.npmmirror.com
|
||||||
|
pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
|
# 检查 PM2 是否安装
|
||||||
|
if ! command -v pm2 &> /dev/null; then
|
||||||
|
echo "安装 PM2..."
|
||||||
|
npm install -g pm2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 停止旧服务(如果存在)
|
||||||
|
if pm2 describe ${PM2_APP_NAME} &> /dev/null; then
|
||||||
|
echo "停止旧服务..."
|
||||||
|
pm2 stop ${PM2_APP_NAME}
|
||||||
|
pm2 delete ${PM2_APP_NAME}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动新服务
|
||||||
|
echo "启动服务(端口 ${PORT})..."
|
||||||
|
PORT=${PORT} pm2 start ./node_modules/next/dist/bin/next --name ${PM2_APP_NAME} -- start -p ${PORT}
|
||||||
|
|
||||||
|
# 保存 PM2 配置
|
||||||
|
pm2 save --force
|
||||||
|
|
||||||
|
# 等待服务启动并检查状态
|
||||||
|
sleep 3
|
||||||
|
echo "服务状态:"
|
||||||
|
pm2 status ${PM2_APP_NAME}
|
||||||
|
|
||||||
|
# 检查服务是否在线
|
||||||
|
if pm2 describe ${PM2_APP_NAME} | grep -q "online"; then
|
||||||
|
echo "✓ 服务启动成功!"
|
||||||
|
else
|
||||||
|
echo "✗ 服务启动失败,查看日志:"
|
||||||
|
pm2 logs ${PM2_APP_NAME} --lines 20 --nostream
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ENDSSH
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo_info "部署成功!"
|
||||||
|
echo_info "应用地址: http://${SERVER_HOST}:${PORT}"
|
||||||
|
else
|
||||||
|
echo_error "部署失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
180
skill/openclaw-reporter/SKILL.md
Normal file
180
skill/openclaw-reporter/SKILL.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
---
|
||||||
|
name: openclaw-reporter
|
||||||
|
description: |
|
||||||
|
Opt-in reporter for the OpenClaw global lobster heatmap.
|
||||||
|
On first use, ASKS the user for consent before registering.
|
||||||
|
Sends heartbeats (platform + model only) and generic task summaries to https://kymr.top/.
|
||||||
|
All network calls run synchronously in the foreground — output is visible to the user.
|
||||||
|
Data collected: user-chosen lobster name, OS platform, model name, generic task category.
|
||||||
|
Credentials: the server returns an apiKey on registration, which is stored locally
|
||||||
|
in ~/.openclaw/config.json and sent as a Bearer token in subsequent requests.
|
||||||
|
No file paths, code, or project-specific data is ever sent.
|
||||||
|
requirements:
|
||||||
|
network: true
|
||||||
|
writeFiles:
|
||||||
|
- "~/.openclaw/config.json"
|
||||||
|
binaries:
|
||||||
|
- curl
|
||||||
|
- python3
|
||||||
|
- uname
|
||||||
|
env:
|
||||||
|
- name: CLAUDE_MODEL
|
||||||
|
required: false
|
||||||
|
description: "Current Claude model identifier. Used to report which model is active. Falls back to 'unknown' if not set."
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenClaw Reporter
|
||||||
|
|
||||||
|
This skill lets you participate in the OpenClaw global lobster activity heatmap. It reports **anonymous, non-identifying** activity data to visualize worldwide AI agent usage.
|
||||||
|
|
||||||
|
## Data Disclosure
|
||||||
|
|
||||||
|
This skill sends the following data to `https://kymr.top/`:
|
||||||
|
|
||||||
|
| Data Field | Example | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Lobster Name | `CoolLobster42` (user-chosen) | Your display name on the heatmap |
|
||||||
|
| Platform | `darwin`, `linux` | OS type for heatmap stats |
|
||||||
|
| Model | `claude-sonnet-4-6` | Model usage stats |
|
||||||
|
| Task summary | `"Fixed a bug"` | Generic activity category |
|
||||||
|
|
||||||
|
**Stored locally:** Registration returns an `apiKey` from the server, saved in `~/.openclaw/config.json`. This token is sent as `Authorization: Bearer` header in heartbeat and task report requests. The `CLAUDE_MODEL` environment variable is read (if available) to identify the active model — it falls back to `"unknown"` if not set.
|
||||||
|
|
||||||
|
**Never sent:** system usernames, file paths, code snippets, project names, secrets, IP-derived geolocation beyond what the server naturally receives.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Config is stored at `~/.openclaw/config.json`. The skill checks for this file at the start of each conversation.
|
||||||
|
|
||||||
|
## First-Time Setup (Requires User Consent)
|
||||||
|
|
||||||
|
If `~/.openclaw/config.json` does not exist, follow these two steps:
|
||||||
|
|
||||||
|
### Step 1: Ask for consent
|
||||||
|
|
||||||
|
Use the AskUserQuestion tool:
|
||||||
|
|
||||||
|
- Question: "OpenClaw Reporter wants to register a lobster name and send activity heartbeats to https://kymr.top/ for the global heatmap. The server will issue an API key stored locally at ~/.openclaw/config.json. No file paths, code, or project data is sent. Allow?"
|
||||||
|
- Options: "Yes, register" / "No, skip"
|
||||||
|
|
||||||
|
**If the user selects "No, skip":** do NOT register and do NOT ask again in this session. Stop here.
|
||||||
|
|
||||||
|
### Step 2: Ask for a lobster name
|
||||||
|
|
||||||
|
If the user consented, use AskUserQuestion again:
|
||||||
|
|
||||||
|
- Question: "Pick a name for your lobster on the heatmap! (e.g. CoolLobster, NightCrawler, CodeClaw)"
|
||||||
|
- Let the user type a custom name via the "Other" option (no preset options needed, but provide a few fun suggestions as options)
|
||||||
|
- Options: "CoolLobster" / "CodeClaw" / "NightCrawler"
|
||||||
|
|
||||||
|
Use whatever the user provides as the lobster name.
|
||||||
|
|
||||||
|
### Step 3: Register
|
||||||
|
|
||||||
|
After receiving the name (stored as `LOBSTER_NAME`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create config directory
|
||||||
|
mkdir -p ~/.openclaw
|
||||||
|
|
||||||
|
OPENCLAW_ENDPOINT="https://kymr.top"
|
||||||
|
LOBSTER_NAME="USER_CHOSEN_NAME_HERE"
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -X POST "$OPENCLAW_ENDPOINT/api/v1/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"name\": \"$LOBSTER_NAME\",
|
||||||
|
\"platform\": \"$(uname -s | tr '[:upper:]' '[:lower:]')\",
|
||||||
|
\"model\": \"$(echo $CLAUDE_MODEL 2>/dev/null || echo 'unknown')\"
|
||||||
|
}")
|
||||||
|
|
||||||
|
# Save config
|
||||||
|
echo "$RESPONSE" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
config = {
|
||||||
|
'lobsterId': data.get('lobsterId', ''),
|
||||||
|
'apiKey': data.get('apiKey', ''),
|
||||||
|
'name': '$LOBSTER_NAME',
|
||||||
|
'endpoint': '$OPENCLAW_ENDPOINT'
|
||||||
|
}
|
||||||
|
with open('$HOME/.openclaw/config.json', 'w') as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
print('Registered as:', '$LOBSTER_NAME')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Heartbeat (On Every Conversation Start)
|
||||||
|
|
||||||
|
If the config file exists, send a heartbeat. The request runs **synchronously in the foreground** with a 5-second timeout, so the user sees it happen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if [ -f ~/.openclaw/config.json ]; then
|
||||||
|
OPENCLAW_CONFIG=$(cat ~/.openclaw/config.json)
|
||||||
|
OPENCLAW_KEY=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['apiKey'])")
|
||||||
|
OPENCLAW_ENDPOINT=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['endpoint'])")
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w "OpenClaw heartbeat: HTTP %{http_code}\n" \
|
||||||
|
--max-time 5 \
|
||||||
|
-X POST "$OPENCLAW_ENDPOINT/api/v1/heartbeat" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $OPENCLAW_KEY" \
|
||||||
|
-d "{
|
||||||
|
\"platform\": \"$(uname -s | tr '[:upper:]' '[:lower:]')\",
|
||||||
|
\"model\": \"$(echo $CLAUDE_MODEL 2>/dev/null || echo 'unknown')\"
|
||||||
|
}"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Completion Report
|
||||||
|
|
||||||
|
When a significant task is completed, report a **generic summary only**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if [ -f ~/.openclaw/config.json ]; then
|
||||||
|
OPENCLAW_CONFIG=$(cat ~/.openclaw/config.json)
|
||||||
|
OPENCLAW_KEY=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['apiKey'])")
|
||||||
|
OPENCLAW_ENDPOINT=$(echo "$OPENCLAW_CONFIG" | python3 -c "import json,sys; print(json.load(sys.stdin)['endpoint'])")
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w "OpenClaw task report: HTTP %{http_code}\n" \
|
||||||
|
--max-time 5 \
|
||||||
|
-X POST "$OPENCLAW_ENDPOINT/api/v1/task" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $OPENCLAW_KEY" \
|
||||||
|
-d "{
|
||||||
|
\"summary\": \"TASK_SUMMARY_HERE\",
|
||||||
|
\"durationMs\": DURATION_MS_HERE,
|
||||||
|
\"model\": \"$(echo $CLAUDE_MODEL 2>/dev/null || echo 'unknown')\",
|
||||||
|
\"toolsUsed\": [\"LIST_OF_TOOLS\"]
|
||||||
|
}"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task Summary Rules (STRICT)
|
||||||
|
|
||||||
|
- NEVER include file paths, variable names, or code snippets
|
||||||
|
- NEVER include API keys, secrets, or credentials
|
||||||
|
- NEVER include project names, repository names, or organization names
|
||||||
|
- NEVER include personal information (names, emails, usernames)
|
||||||
|
- ONLY use generic categories: "Implemented a feature", "Fixed a bug", "Refactored code", "Updated configuration", "Added tests"
|
||||||
|
- Keep summaries under 100 characters
|
||||||
|
|
||||||
|
## Opt-Out / Unregister
|
||||||
|
|
||||||
|
To stop all OpenClaw reporting, delete the config file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f ~/.openclaw/config.json
|
||||||
|
rmdir ~/.openclaw 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
After deletion, no heartbeats or task reports will be sent. The skill will ask for consent again if re-enabled.
|
||||||
|
|
||||||
|
## Behavior Rules
|
||||||
|
|
||||||
|
1. **User consent is REQUIRED** before first registration — never register silently
|
||||||
|
2. If the user declines registration, respect the decision and do not ask again in the same session
|
||||||
|
3. All network calls run **synchronously in the foreground** (no `&`) with `--max-time 5` — the user sees every request
|
||||||
|
4. If any OpenClaw request fails, continue normally — never interrupt the user's workflow
|
||||||
|
5. Only report genuinely completed tasks, not intermediate steps
|
||||||
|
6. Never include identifying information in task summaries
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
57
types/react-simple-maps.d.ts
vendored
Normal file
57
types/react-simple-maps.d.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
declare module "react-simple-maps" {
|
||||||
|
import { ComponentType, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ComposableMapProps {
|
||||||
|
projection?: string;
|
||||||
|
projectionConfig?: {
|
||||||
|
center?: [number, number];
|
||||||
|
scale?: number;
|
||||||
|
rotate?: [number, number, number];
|
||||||
|
};
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeographiesProps {
|
||||||
|
geography: string | object;
|
||||||
|
children: (data: {
|
||||||
|
geographies: Geography[];
|
||||||
|
}) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Geography {
|
||||||
|
rsmKey: string;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeographyProps {
|
||||||
|
geography: Geography;
|
||||||
|
key?: string;
|
||||||
|
fill?: string;
|
||||||
|
stroke?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
style?: {
|
||||||
|
default?: React.CSSProperties & { outline?: string };
|
||||||
|
hover?: React.CSSProperties & { outline?: string };
|
||||||
|
pressed?: React.CSSProperties & { outline?: string };
|
||||||
|
};
|
||||||
|
onClick?: () => void;
|
||||||
|
onMouseEnter?: () => void;
|
||||||
|
onMouseLeave?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZoomableGroupProps {
|
||||||
|
center?: [number, number];
|
||||||
|
zoom?: number;
|
||||||
|
minZoom?: number;
|
||||||
|
maxZoom?: number;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComposableMap: ComponentType<ComposableMapProps>;
|
||||||
|
export const Geographies: ComponentType<GeographiesProps>;
|
||||||
|
export const Geography: ComponentType<GeographyProps>;
|
||||||
|
export const ZoomableGroup: ComponentType<ZoomableGroupProps>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user