SPB Git

Research console (Next 16) + control-plane API + mld deployment for www.socialcrawl.co

- apps/dashboard: SocialCrawl console — observatory, sessions console (decisions, page state,
  entities, runtime APIs, media, world-model graph, live events), entity evidence pages,
  runtime API explorer, connectors, missions launcher; passcode gate + authenticated API proxy.
- apps/api: node:http control plane (overview, sessions, entities search, schemas, platforms,
  SSE stream, job runner spawning worker processes), token-protected mutations.
- worker: bounded action execution (60 s), PLAY_VIDEO no longer awaits play(); --job linking.
- storage: jsonb sanitiser, linkJob; util: unicode thousands separators, stopwords.
- deploy/social-runtime-crawler.mld.json: PM2 api 8350 + web 8351, MacLustr Tunnel route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 12, 2026) parent 40fba6b Browse files

46 changed files +7,607 −105

46 changed files
modified .gitignore +5 −0
@@ -14,3 +14,8 @@ data/platform_model/*/
14 14
15 15 # Compiled (learned) connectors are versioned intentionally; regression fixtures may be large.
16 16 connectors/*/regression_tests/large/
17 +
18 +# Next.js dashboard
19 +apps/dashboard/.next/
20 +apps/dashboard/next-env.d.ts
21 +*.tsbuildinfo
modified apps/api/package.json +5 −1
@@ -5,6 +5,10 @@
5 5 "type": "module",
6 6 "dependencies": {
7 7 "@src/shared": "workspace:*",
8 − "@src/storage": "workspace:*"
8 + "@src/storage": "workspace:*",
9 + "@src/platform-model": "workspace:*"
10 + },
11 + "scripts": {
12 + "start": "tsx src/server.ts"
9 13 }
10 14 }
added apps/api/src/jobs.ts +102 −0
@@ -0,0 +1,102 @@
1 +import { spawn, type ChildProcess } from "node:child_process";
2 +import fs from "node:fs";
3 +import path from "node:path";
4 +import { fileURLToPath } from "node:url";
5 +import { createLogger, isPlatform, newId, type AgentMode, type AppConfig } from "@src/shared";
6 +import type { PostgresStore } from "@src/storage";
7 +
8 +const log = createLogger("jobs");
9 +const here = path.dirname(fileURLToPath(import.meta.url));
10 +const repoRoot = path.resolve(here, "..", "..", "..");
11 +
12 +export interface JobRequest {
13 + platform: string;
14 + goal?: string;
15 + query?: string;
16 + mode?: AgentMode;
17 + seed?: string;
18 + minutes?: number;
19 + actions?: number;
20 + media?: number;
21 + headless?: boolean;
22 + account?: string;
23 +}
24 +
25 +/**
26 + * Job runner (§63 control plane, single-node version): spawns the worker CLI as a child process,
27 + * streams its log to data/jobs/<id>.log and tracks status in crawl_jobs. One browser per job.
28 + */
29 +export class JobRunner {
30 + private running = new Map<string, ChildProcess>();
31 + readonly logsDir: string;
32 + constructor(private readonly store: PostgresStore, private readonly cfg: AppConfig, private readonly maxConcurrent = 2) {
33 + this.logsDir = path.join(cfg.dataDir, "jobs");
34 + fs.mkdirSync(this.logsDir, { recursive: true });
35 + }
36 +
37 + get activeCount() {
38 + return this.running.size;
39 + }
40 +
41 + async start(req: JobRequest): Promise<{ job_id: string }> {
42 + if (!isPlatform(req.platform)) throw new Error("unknown platform");
43 + if (!["youtube", "reddit"].includes(req.platform)) throw new Error(`no adapter for ${req.platform} yet (Phase 1 = youtube, reddit)`);
44 + if (this.running.size >= this.maxConcurrent) throw new Error(`max ${this.maxConcurrent} concurrent jobs`);
45 + const job_id = newId("job");
46 + const mode = req.mode ?? "research";
47 + const minutes = clamp(req.minutes ?? 10, 1, 120);
48 + const actions = clamp(req.actions ?? 60, 1, 1000);
49 + const media = clamp(req.media ?? 1, 0, 4);
50 + const goal = req.goal?.trim() || (req.query ? `Discover public content about “${req.query}”` : `Observe the ${req.platform} feed`);
51 + const args = ["node_modules/tsx/dist/cli.mjs", "apps/worker/src/cli.ts", mode === "learn" ? "learn" : "crawl", req.platform, "--job", job_id, "--goal", goal, "--mode", mode, "--minutes", String(minutes), "--actions", String(actions), "--media", String(media), "--account", req.account?.trim() || "research-01"];
52 + if (req.query) args.push("--query", req.query);
53 + if (req.seed) args.push("--seed", req.seed);
54 + if (req.headless !== false) args.push("--headless");
55 +
56 + await this.store.pool.query(`insert into crawl_jobs(job_id, platform, mode, goal, budget, status) values ($1,$2,$3,$4,$5,'queued')`, [job_id, req.platform, mode, goal, JSON.stringify({ minutes, actions, media, query: req.query ?? null, seed: req.seed ?? null, headless: req.headless !== false, account: req.account ?? "research-01" })]);
57 +
58 + const logFile = path.join(this.logsDir, `${job_id}.log`);
59 + const out = fs.openSync(logFile, "a");
60 + const child = spawn(process.execPath, args, { cwd: repoRoot, env: { ...process.env, SRC_LOG_PRETTY: "1", SRC_LOG_LEVEL: "info" }, stdio: ["ignore", out, out] });
61 + this.running.set(job_id, child);
62 + await this.store.pool.query(`update crawl_jobs set status='running' where job_id=$1`, [job_id]);
63 + log.info("job started", { job_id, platform: req.platform, mode, pid: child.pid });
64 + child.on("exit", async (code, signal) => {
65 + this.running.delete(job_id);
66 + fs.closeSync(out);
67 + const status = signal ? "stopped" : code === 0 ? "done" : "failed";
68 + let result: unknown = null;
69 + try {
70 + const sid = (await this.store.pool.query(`select session_id from crawl_jobs where job_id=$1`, [job_id])).rows[0]?.session_id as string | undefined;
71 + if (sid) {
72 + const f = path.join(this.cfg.sessionsDir, sid, "summary.json");
73 + if (fs.existsSync(f)) result = JSON.parse(fs.readFileSync(f, "utf8"));
74 + }
75 + } catch {
76 + /* ignore */
77 + }
78 + await this.store.pool.query(`update crawl_jobs set status=$2, finished_at=now(), result=$3 where job_id=$1`, [job_id, status, result ? JSON.stringify(result) : null]).catch(() => {});
79 + log.info("job finished", { job_id, status, code, signal });
80 + });
81 + return { job_id };
82 + }
83 +
84 + stop(job_id: string): boolean {
85 + const child = this.running.get(job_id);
86 + if (!child) return false;
87 + child.kill("SIGTERM");
88 + setTimeout(() => child.kill("SIGKILL"), 15_000).unref();
89 + return true;
90 + }
91 +
92 + logTail(job_id: string, lines = 200): string {
93 + const f = path.join(this.logsDir, `${job_id}.log`);
94 + if (!fs.existsSync(f)) return "";
95 + const all = fs.readFileSync(f, "utf8").split("\n");
96 + return all.slice(-lines).join("\n");
97 + }
98 +}
99 +
100 +function clamp(n: number, lo: number, hi: number) {
101 + return Math.max(lo, Math.min(hi, Number.isFinite(n) ? n : lo));
102 +}
added apps/api/src/queries.ts +188 −0
@@ -0,0 +1,188 @@
1 +import fs from "node:fs";
2 +import path from "node:path";
3 +import type { PostgresStore } from "@src/storage";
4 +import { PLATFORMS, type AppConfig, type Platform } from "@src/shared";
5 +import { PlatformModel } from "@src/platform-model";
6 +
7 +/** Read-side queries for the research console. All read PostgreSQL; files (models, manifests, session dirs) complete them. */
8 +export class Queries {
9 + constructor(private readonly store: PostgresStore, private readonly cfg: AppConfig) {}
10 + private get q() {
11 + return this.store.pool;
12 + }
13 +
14 + async overview() {
15 + const [totals, byType, running, recentSessions, series, surfaces, topActions, patterns] = await Promise.all([
16 + this.q.query(`select
17 + (select count(*) from sessions)::int as sessions,
18 + (select count(*) from entities)::int as entities,
19 + (select count(*) from media)::int as media,
20 + (select count(*) from schema_patterns)::int as schemas,
21 + (select count(*) from actions)::int as actions,
22 + (select count(*) from observations)::int as observations,
23 + (select count(*) from relationships)::int as relationships,
24 + (select count(*) from observations where event_type='CONNECTOR_PATTERN_LEARNED')::int as patterns_learned,
25 + (select count(*) from feed_items)::int as feed_items`),
26 + this.q.query(`select platform, entity_type, count(*)::int as n from entities group by 1,2 order by 3 desc`),
27 + this.q.query(`select session_id, platform, account_alias, started_at, health from sessions where ended_at is null and started_at > now() - interval '6 hours' order by started_at desc`),
28 + this.q.query(`select s.session_id, s.platform, s.goal, s.mode, s.started_at, s.ended_at, s.health,
29 + (select count(*) from actions a where a.session_id=s.session_id)::int as actions,
30 + (select count(distinct fingerprint) from entity_observations eo where eo.session_id=s.session_id)::int as entities
31 + from sessions s order by started_at desc limit 8`),
32 + this.q.query(`select date_trunc('hour', ts) + (floor(extract(minute from ts)/10)*10) * interval '1 minute' as bucket,
33 + count(*) filter (where event_type in ('VIDEO_DISCOVERED','POST_DISCOVERED','PROFILE_DISCOVERED','ENTITY_DISCOVERED'))::int as entities,
34 + count(*) filter (where event_type='NETWORK_RESPONSE_OBSERVED')::int as responses,
35 + count(*) filter (where event_type in ('ACTION_EXECUTED','ACTION_FAILED'))::int as actions,
36 + count(*) filter (where event_type='NETWORK_SCHEMA_DISCOVERED')::int as schemas
37 + from observations where ts > now() - interval '48 hours' group by 1 order by 1`),
38 + this.q.query(`select coalesce(sum((payload->>'dom_entities')::int),0)::int as dom, coalesce(sum((payload->>'network_entities')::int),0)::int as network,
39 + coalesce(sum((payload->>'both_surfaces')::int),0)::int as both, coalesce(sum((payload->>'field_agreements')::int),0)::int as agreements,
40 + coalesce(sum(jsonb_array_length(coalesce(payload->'field_conflicts','[]'::jsonb))),0)::int as conflicts
41 + from observations where event_type='PAGE_OPENED' and payload ? 'dom_entities'`),
42 + this.q.query(`select action_type, count(*)::int as n, avg(expected_gain)::float as avg_gain, avg(case when success then 1 else 0 end)::float as success_rate,
43 + avg((after_state->>'new_entities')::float) as avg_new_entities from actions group by 1 order by 2 desc`),
44 + this.q.query(`select platform, count(*)::int as n, avg((payload->>'confidence')::float) as conf from observations where event_type='CONNECTOR_PATTERN_LEARNED' group by 1`),
45 + ]);
46 + return {
47 + totals: totals.rows[0],
48 + by_type: byType.rows,
49 + running: running.rows,
50 + recent_sessions: recentSessions.rows,
51 + series: series.rows,
52 + surfaces: surfaces.rows[0],
53 + actions: topActions.rows,
54 + patterns_by_platform: patterns.rows,
55 + platforms: this.platforms(),
56 + };
57 + }
58 +
59 + platforms() {
60 + return PLATFORMS.map((p) => {
61 + const file = path.join(this.cfg.platformModelDir, p, "platform_model.json");
62 + const manifest = path.join(process.cwd(), "connectors", p, "manifest.json");
63 + if (!fs.existsSync(file)) return { platform: p, learned: false, adapter: p === "youtube" || p === "reddit", confidence: 0, page_types: 0, entity_types: 0, navigation_actions: 0, network_schemas: 0, media_patterns: 0, sessions: 0, has_manifest: fs.existsSync(manifest), profiles: this.profilesFor(p) };
64 + const m = new PlatformModel(p, this.cfg.platformModelDir);
65 + return { learned: true, adapter: p === "youtube" || p === "reddit", has_manifest: fs.existsSync(manifest), profiles: this.profilesFor(p), ...m.summary() };
66 + });
67 + }
68 +
69 + profilesFor(p: Platform) {
70 + if (!fs.existsSync(this.cfg.profilesDir)) return [];
71 + return fs
72 + .readdirSync(this.cfg.profilesDir)
73 + .filter((d) => d.startsWith(`${p}-`))
74 + .map((d) => ({ alias: d.slice(p.length + 1), has_state: fs.existsSync(path.join(this.cfg.profilesDir, d, "Default", "Cookies")) || fs.existsSync(path.join(this.cfg.profilesDir, d, "Default", "Network", "Cookies")) }));
75 + }
76 +
77 + platformDetail(p: Platform) {
78 + const file = path.join(this.cfg.platformModelDir, p, "platform_model.json");
79 + const manifest = path.join(process.cwd(), "connectors", p, "manifest.json");
80 + return {
81 + platform: p,
82 + model: fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null,
83 + manifest: fs.existsSync(manifest) ? JSON.parse(fs.readFileSync(manifest, "utf8")) : null,
84 + summary: fs.existsSync(file) ? new PlatformModel(p, this.cfg.platformModelDir).summary() : null,
85 + profiles: this.profilesFor(p),
86 + };
87 + }
88 +
89 + async sessions(limit = 100) {
90 + return (
91 + await this.q.query(
92 + `select s.*, (select count(*) from actions a where a.session_id=s.session_id)::int as actions,
93 + (select count(distinct fingerprint) from entity_observations eo where eo.session_id=s.session_id)::int as entities,
94 + (select count(*) from observations o where o.session_id=s.session_id and o.event_type='NETWORK_SCHEMA_DISCOVERED')::int as schemas,
95 + (select count(*) from observations o where o.session_id=s.session_id and o.event_type='CONNECTOR_PATTERN_LEARNED')::int as patterns,
96 + (select payload->>'reason' from observations o where o.session_id=s.session_id and o.event_type='BUDGET_EXHAUSTED' limit 1) as ended_because
97 + from sessions s order by started_at desc limit $1`,
98 + [limit],
99 + )
100 + ).rows;
101 + }
102 +
103 + async session(id: string) {
104 + const row = (await this.q.query(`select * from sessions where session_id=$1`, [id])).rows[0];
105 + if (!row) return null;
106 + const dir = path.join(this.cfg.sessionsDir, id);
107 + const read = (f: string) => {
108 + try {
109 + return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
110 + } catch {
111 + return null;
112 + }
113 + };
114 + const counts = (await this.q.query(`select event_type, count(*)::int as n from observations where session_id=$1 group by 1`, [id])).rows;
115 + return { ...row, job: read("job.json"), summary: read("summary.json"), event_counts: counts };
116 + }
117 +
118 + async pages(id: string) {
119 + return (await this.q.query(`select step, ts, payload from observations where session_id=$1 and event_type='PAGE_OPENED' and payload ? 'page_type' order by ts`, [id])).rows;
120 + }
121 +
122 + async world(id: string) {
123 + const dir = path.join(this.cfg.sessionsDir, id, "world_model.json");
124 + if (fs.existsSync(dir)) return JSON.parse(fs.readFileSync(dir, "utf8"));
125 + // Reconstruct from the database for sessions crawled on another machine.
126 + const nodes = (await this.q.query(`select e.fingerprint, e.entity_type as type, e.name, e.url, e.platform_id, min(eo.step) as first_seen_step, max(eo.step) as last_seen_step, count(*)::int as seen_count from entities e join entity_observations eo on eo.fingerprint=e.fingerprint where eo.session_id=$1 group by e.fingerprint limit 1500`, [id])).rows;
127 + const edges = (await this.q.query(`select from_fp as "from", to_fp as "to", rel_type as type, step from relationships where session_id=$1 limit 4000`, [id])).rows;
128 + return { nodes, edges };
129 + }
130 +
131 + async searchEntities(o: { q?: string; type?: string; platform?: string; limit: number; offset: number; sort?: string }) {
132 + const params: unknown[] = [];
133 + const where: string[] = [];
134 + if (o.q) {
135 + params.push(`%${o.q}%`);
136 + where.push(`(name ilike $${params.length} or text_excerpt ilike $${params.length} or author ilike $${params.length} or platform_id ilike $${params.length})`);
137 + }
138 + if (o.type) {
139 + params.push(o.type);
140 + where.push(`entity_type = $${params.length}`);
141 + }
142 + if (o.platform) {
143 + params.push(o.platform);
144 + where.push(`platform = $${params.length}`);
145 + }
146 + const w = where.length ? `where ${where.join(" and ")}` : "";
147 + const order = o.sort === "views" ? `(metrics->>'views')::numeric desc nulls last` : o.sort === "seen" ? `seen_count desc` : `last_seen desc`;
148 + params.push(o.limit, o.offset);
149 + const rows = (await this.q.query(`select *, (select count(*) from entity_observations eo where eo.fingerprint=entities.fingerprint)::int as observations from entities ${w} order by ${order} limit $${params.length - 1} offset $${params.length}`, params)).rows;
150 + const total = (await this.q.query(`select count(*)::int as n from entities ${w}`, params.slice(0, -2))).rows[0]?.n ?? 0;
151 + return { rows, total };
152 + }
153 +
154 + async entity(fp: string) {
155 + const e = (await this.q.query(`select * from entities where fingerprint=$1`, [fp])).rows[0];
156 + if (!e) return null;
157 + const observations = (await this.q.query(`select eo.session_id, eo.step, eo.ts, eo.surfaces, eo.snapshot, s.goal from entity_observations eo left join sessions s on s.session_id=eo.session_id where eo.fingerprint=$1 order by eo.ts desc limit 100`, [fp])).rows;
158 + const relations = (await this.q.query(`select r.rel_type, r.from_fp, r.to_fp, r.step, coalesce(a.name, a.platform_id) as from_name, coalesce(b.name, b.platform_id) as to_name, a.entity_type as from_type, b.entity_type as to_type from relationships r left join entities a on a.fingerprint=r.from_fp left join entities b on b.fingerprint=r.to_fp where r.from_fp=$1 or r.to_fp=$1 limit 200`, [fp])).rows;
159 + const media = (await this.q.query(`select * from media where fingerprint=$1`, [fp])).rows[0] ?? null;
160 + const feed = (await this.q.query(`select session_id, step, page_type, feed_position, visible, ts from feed_items where fingerprint=$1 order by ts desc limit 50`, [fp])).rows;
161 + return { entity: e, observations, relations, media, feed };
162 + }
163 +
164 + async schemas(platform?: string) {
165 + const params: unknown[] = [];
166 + let w = "";
167 + if (platform) {
168 + params.push(platform);
169 + w = `where platform=$1`;
170 + }
171 + return (await this.q.query(`select * from schema_patterns ${w} order by observed_count desc limit 300`, params)).rows;
172 + }
173 +
174 + async liveEvents(since: string, session?: string, limit = 200) {
175 + const params: unknown[] = [since];
176 + let w = `ts > $1`;
177 + if (session) {
178 + params.push(session);
179 + w += ` and session_id=$2`;
180 + }
181 + params.push(limit);
182 + return (await this.q.query(`select event_id, session_id, platform, event_type, step, ts, payload, provenance from observations where ${w} order by ts asc limit $${params.length}`, params)).rows;
183 + }
184 +
185 + async jobs(limit = 50) {
186 + return (await this.q.query(`select j.*, s.health, (select count(*) from actions a where a.session_id=j.session_id)::int as actions, (select count(distinct fingerprint) from entity_observations eo where eo.session_id=j.session_id)::int as entities from crawl_jobs j left join sessions s on s.session_id=j.session_id order by created_at desc limit $1`, [limit])).rows;
187 + }
188 +}
modified apps/api/src/server.ts +115 −74
@@ -2,108 +2,149 @@ import http from "node:http";
2 2 import fs from "node:fs";
3 3 import path from "node:path";
4 4 import { fileURLToPath } from "node:url";
5 −import { createLogger, loadConfig } from "@src/shared";
6 −import { JsonlEventLog } from "@src/events";
5 +import { createLogger, isPlatform, loadConfig } from "@src/shared";
7 6 import { PostgresStore } from "@src/storage";
7 +import { Queries } from "./queries.ts";
8 +import { JobRunner } from "./jobs.ts";
8 9
9 10 /**
10 − * Research/Debug dashboard API (§53, §82). Zero framework: node:http + a static page.
11 − * Reads PostgreSQL when configured, else the JSONL session logs.
11 + * Control-plane + research API (§53, §63, §82). Plain node:http, JSON, SSE.
12 + * Consumed by the Next.js console (apps/dashboard) through its authenticated proxy; mutations require SRC_API_TOKEN.
12 13 */
13 −const log = createLogger("dashboard");
14 +const log = createLogger("api");
14 15 const cfg = loadConfig();
15 16 const here = path.dirname(fileURLToPath(import.meta.url));
16 17 const publicDir = path.join(here, "..", "public");
17 −let store: PostgresStore | undefined;
18 −try {
19 − if (cfg.databaseUrl) store = await PostgresStore.connect(cfg.databaseUrl);
20 −} catch (err) {
21 − log.warn("no database — serving JSONL logs", { err: (err as Error).message });
18 +const API_TOKEN = process.env.SRC_API_TOKEN ?? "";
19 +const PORT = Number(process.env.SRC_API_PORT ?? cfg.dashboardPort);
20 +
21 +if (!cfg.databaseUrl) {
22 + console.error("SRC_DATABASE_URL is required for the API");
23 + process.exit(1);
22 24 }
25 +const store = await PostgresStore.connect(cfg.databaseUrl);
26 +await store.migrate();
27 +const queries = new Queries(store, cfg);
28 +const jobs = new JobRunner(store, cfg, Number(process.env.SRC_MAX_JOBS ?? 2));
23 29
24 30 function json(res: http.ServerResponse, data: unknown, status = 200) {
25 − res.writeHead(status, { "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*" });
31 + res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
26 32 res.end(JSON.stringify(data));
27 33 }
28 −
29 −function sessionDirs(): string[] {
30 − return fs.existsSync(cfg.sessionsDir) ? fs.readdirSync(cfg.sessionsDir).filter((d) => fs.existsSync(path.join(cfg.sessionsDir, d, "events.jsonl"))).sort().reverse() : [];
31 −}
32 −
33 −function jsonlSessions() {
34 − return sessionDirs().map((id) => {
35 − const job = readJson(path.join(cfg.sessionsDir, id, "job.json")) as Record<string, unknown> | undefined;
36 − const summary = readJson(path.join(cfg.sessionsDir, id, "summary.json")) as Record<string, unknown> | undefined;
37 − return { session_id: id, platform: job?.platform, mode: job?.mode, goal: job?.goal, account_alias: job?.account_alias, started_at: fs.statSync(path.join(cfg.sessionsDir, id)).birthtime, health: summary ? "stopped" : "running", actions: summary?.steps ?? null, entities: summary?.entities ?? null, ended_because: summary?.ended_because };
34 +function readBody(req: http.IncomingMessage): Promise<string> {
35 + return new Promise((resolve, reject) => {
36 + let s = "";
37 + req.on("data", (c) => {
38 + s += c;
39 + if (s.length > 1e6) reject(new Error("body too large"));
40 + });
41 + req.on("end", () => resolve(s));
42 + req.on("error", reject);
38 43 });
39 44 }
40 −
41 −function readJson(file: string): unknown {
42 − try {
43 − return JSON.parse(fs.readFileSync(file, "utf8"));
44 − } catch {
45 − return undefined;
46 − }
45 +function authorized(req: http.IncomingMessage): boolean {
46 + if (!API_TOKEN) return true; // dev
47 + return req.headers["x-src-token"] === API_TOKEN;
47 48 }
48 49
49 50 const server = http.createServer(async (req, res) => {
50 51 const url = new URL(req.url ?? "/", "http://localhost");
51 52 const parts = url.pathname.split("/").filter(Boolean);
53 + const method = req.method ?? "GET";
52 54 try {
53 − if (parts[0] === "api") {
54 − if (parts[1] === "sessions" && !parts[2]) return json(res, store ? await store.listSessions() : jsonlSessions());
55 − if (parts[1] === "sessions" && parts[2]) {
56 − const id = parts[2];
57 − const dir = path.join(cfg.sessionsDir, id);
58 − const sub = parts[3];
59 − if (sub === "events") {
60 − const types = url.searchParams.get("types")?.split(",").filter(Boolean);
61 − const limit = Number(url.searchParams.get("limit") ?? 300);
62 − if (store) return json(res, await store.sessionEvents(id, { types, limit }));
63 − const evs = JsonlEventLog.read(dir).filter((e) => !types?.length || types.includes(e.event_type));
64 − return json(res, evs.slice(-limit).reverse());
65 − }
66 − if (sub === "actions") return json(res, store ? await store.sessionActions(id) : JsonlEventLog.read(dir).filter((e) => e.event_type === "ACTION_PLANNED").map((e) => ({ step: e.step, ...(e.payload as Record<string, unknown>) })));
67 − if (sub === "entities") {
68 − if (store) return json(res, await store.sessionEntities(id));
69 − const seen = new Map<string, unknown>();
70 − for (const e of JsonlEventLog.read(dir)) if (/DISCOVERED|ENTITY_OBSERVED/.test(e.event_type) && (e.payload as { entity?: { fingerprint: string } }).entity) seen.set((e.payload as { entity: { fingerprint: string } }).entity.fingerprint, { ...(e.payload as { entity: object }).entity, last_step: e.step, provenance: e.provenance });
71 − return json(res, [...seen.values()].reverse());
72 − }
73 − if (sub === "media") return json(res, store ? await store.sessionMedia(id) : JsonlEventLog.read(dir).filter((e) => e.event_type === "MEDIA_DISCOVERED").map((e) => e.payload));
74 − if (sub === "world") return json(res, readJson(path.join(dir, "world_model.json")) ?? { nodes: [], edges: [] });
75 − if (sub === "summary") return json(res, { job: readJson(path.join(dir, "job.json")), summary: readJson(path.join(dir, "summary.json")) });
76 − if (sub === "schemas") {
77 − const platform = (readJson(path.join(dir, "job.json")) as { platform?: string } | undefined)?.platform;
78 − if (store) return json(res, await store.schemas(platform));
79 − return json(res, JsonlEventLog.read(dir).filter((e) => e.event_type === "NETWORK_SCHEMA_DISCOVERED").map((e) => e.payload));
55 + if (parts[0] !== "api" && parts[0] !== "media") {
56 + // legacy static console (apps/api/public) — kept for local debugging
57 + const file = path.join(publicDir, parts.length ? parts.join("/") : "index.html");
58 + if (!file.startsWith(publicDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
59 + res.writeHead(200, { "content-type": file.endsWith(".html") ? "text/html; charset=utf-8" : "application/octet-stream" });
60 + return fs.createReadStream(file).pipe(res);
61 + }
62 + if (parts[0] === "media") {
63 + const file = path.join(cfg.mediaDir, ...parts.slice(1).map(decodeURIComponent));
64 + if (!file.startsWith(cfg.mediaDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
65 + res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "public, max-age=86400" });
66 + return fs.createReadStream(file).pipe(res);
67 + }
68 + if (method !== "GET" && !authorized(req)) return json(res, { error: "unauthorized" }, 401);
69 + const [, r1, r2, r3] = parts;
70 +
71 + if (r1 === "health") return json(res, { ok: true, jobs_running: jobs.activeCount, db: true, ts: new Date().toISOString() });
72 + if (r1 === "overview") return json(res, await queries.overview());
73 + if (r1 === "platforms" && !r2) return json(res, queries.platforms());
74 + if (r1 === "platforms" && r2) return isPlatform(r2) ? json(res, queries.platformDetail(r2)) : json(res, { error: "unknown platform" }, 404);
75 + if (r1 === "sessions" && !r2) return json(res, await queries.sessions(Number(url.searchParams.get("limit") ?? 100)));
76 + if (r1 === "sessions" && r2) {
77 + const id = r2;
78 + if (!r3) {
79 + const s = await queries.session(id);
80 + return s ? json(res, s) : json(res, { error: "not found" }, 404);
81 + }
82 + if (r3 === "events") return json(res, await store.sessionEvents(id, { types: url.searchParams.get("types")?.split(",").filter(Boolean), limit: Number(url.searchParams.get("limit") ?? 300) }));
83 + if (r3 === "actions") return json(res, await store.sessionActions(id));
84 + if (r3 === "entities") return json(res, await store.sessionEntities(id, Number(url.searchParams.get("limit") ?? 500)));
85 + if (r3 === "media") return json(res, await store.sessionMedia(id));
86 + if (r3 === "pages") return json(res, await queries.pages(id));
87 + if (r3 === "world") return json(res, await queries.world(id));
88 + if (r3 === "schemas") {
89 + const s = await queries.session(id);
90 + return json(res, await queries.schemas(s?.platform));
91 + }
92 + if (r3 === "relationships") return json(res, await store.sessionRelationships(id));
93 + }
94 + if (r1 === "entities" && !r2) return json(res, await queries.searchEntities({ q: url.searchParams.get("q") ?? undefined, type: url.searchParams.get("type") ?? undefined, platform: url.searchParams.get("platform") ?? undefined, sort: url.searchParams.get("sort") ?? undefined, limit: Math.min(200, Number(url.searchParams.get("limit") ?? 50)), offset: Number(url.searchParams.get("offset") ?? 0) }));
95 + if (r1 === "entities" && r2) {
96 + const e = await queries.entity(decodeURIComponent(r2));
97 + return e ? json(res, e) : json(res, { error: "not found" }, 404);
98 + }
99 + if (r1 === "schemas") return json(res, await queries.schemas(url.searchParams.get("platform") ?? undefined));
100 + if (r1 === "stats") return json(res, await store.entityStats());
101 +
102 + if (r1 === "stream") {
103 + // Server-Sent Events: poll the observations table and push new rows (§53 live panels).
104 + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive", "x-accel-buffering": "no" });
105 + let since = url.searchParams.get("since") ?? new Date(Date.now() - 60_000).toISOString();
106 + const session = url.searchParams.get("session") ?? undefined;
107 + const types = url.searchParams.get("types")?.split(",").filter(Boolean);
108 + let alive = true;
109 + req.on("close", () => (alive = false));
110 + res.write(`event: hello\ndata: ${JSON.stringify({ since })}\n\n`);
111 + while (alive) {
112 + try {
113 + const rows = await queries.liveEvents(since, session, 200);
114 + for (const row of rows) {
115 + since = new Date(row.ts).toISOString();
116 + if (types && !types.includes(row.event_type)) continue;
117 + res.write(`event: observation\ndata: ${JSON.stringify(row)}\n\n`);
118 + }
119 + res.write(`: ping ${Date.now()}\n\n`);
120 + } catch (err) {
121 + res.write(`event: error\ndata: ${JSON.stringify({ error: (err as Error).message })}\n\n`);
80 122 }
123 + await new Promise((r) => setTimeout(r, 1500));
81 124 }
82 − if (parts[1] === "platform-model" && parts[2]) {
83 − const f = path.join(cfg.platformModelDir, parts[2], "platform_model.json");
84 − return json(res, readJson(f) ?? { error: "no model yet" });
125 + return res.end();
126 + }
127 +
128 + if (r1 === "jobs" && method === "GET" && !r2) return json(res, await queries.jobs());
129 + if (r1 === "jobs" && method === "POST" && !r2) {
130 + const body = JSON.parse((await readBody(req)) || "{}");
131 + try {
132 + return json(res, await jobs.start(body), 201);
133 + } catch (err) {
134 + return json(res, { error: (err as Error).message }, 400);
85 135 }
86 − if (parts[1] === "entity" && parts[2]) return json(res, store ? await store.entity(decodeURIComponent(parts[2])) : { error: "needs database" });
87 − if (parts[1] === "stats") return json(res, store ? await store.entityStats() : []);
88 − return json(res, { error: "not found" }, 404);
89 136 }
90 − if (parts[0] === "media") {
91 − // serve sampled frames
92 − const file = path.join(cfg.mediaDir, ...parts.slice(1));
93 − if (!file.startsWith(cfg.mediaDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
94 − res.writeHead(200, { "content-type": "image/jpeg" });
95 − fs.createReadStream(file).pipe(res);
96 − return;
137 + if (r1 === "jobs" && r2 && r3 === "log") {
138 + res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
139 + return res.end(jobs.logTail(r2, Number(url.searchParams.get("lines") ?? 200)));
97 140 }
98 − const file = path.join(publicDir, parts.length ? parts.join("/") : "index.html");
99 − if (!file.startsWith(publicDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
100 − const ext = path.extname(file);
101 − res.writeHead(200, { "content-type": ext === ".html" ? "text/html; charset=utf-8" : ext === ".js" ? "text/javascript" : ext === ".css" ? "text/css" : "application/octet-stream" });
102 − fs.createReadStream(file).pipe(res);
141 + if (r1 === "jobs" && r2 && r3 === "stop" && method === "POST") return json(res, { stopped: jobs.stop(r2) });
142 +
143 + return json(res, { error: "not found" }, 404);
103 144 } catch (err) {
104 145 log.error("request failed", { url: req.url, err: (err as Error).message });
105 146 json(res, { error: (err as Error).message }, 500);
106 147 }
107 148 });
108 149
109 −server.listen(cfg.dashboardPort, () => log.info(`dashboard → http://localhost:${cfg.dashboardPort} (${store ? "postgres" : "jsonl"})`));
150 +server.listen(PORT, "127.0.0.1", () => log.info(`api → http://127.0.0.1:${PORT} (token ${API_TOKEN ? "on" : "off"})`));
added apps/dashboard/next.config.ts +13 −0
@@ -0,0 +1,13 @@
1 +import type { NextConfig } from "next";
2 +
3 +const nextConfig: NextConfig = {
4 + reactStrictMode: true,
5 + poweredByHeader: false,
6 + images: { unoptimized: true },
7 + experimental: { serverActions: { allowedOrigins: ["www.socialcrawl.co", "socialcrawl.co", "localhost:8351"] } },
8 + async headers() {
9 + return [{ source: "/(.*)", headers: [{ key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "same-origin" }] }];
10 + },
11 +};
12 +
13 +export default nextConfig;
added apps/dashboard/package.json +31 −0
@@ -0,0 +1,31 @@
1 +{
2 + "name": "@src/dashboard",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "scripts": {
7 + "dev": "next dev -p 8351",
8 + "build": "next build",
9 + "start": "next start -p 8351 -H 0.0.0.0",
10 + "typecheck": "tsc -p tsconfig.json --noEmit"
11 + },
12 + "dependencies": {
13 + "clsx": "^2.1.1",
14 + "d3-force": "^3.0.0",
15 + "framer-motion": "^12.23.0",
16 + "lucide-react": "^1.0.0",
17 + "next": "16.3.4",
18 + "react": "19.2.8",
19 + "react-dom": "19.2.8",
20 + "recharts": "^3.0.0"
21 + },
22 + "devDependencies": {
23 + "@tailwindcss/postcss": "^4",
24 + "@types/d3-force": "^3.0.10",
25 + "@types/node": "^24.0.0",
26 + "@types/react": "^19",
27 + "@types/react-dom": "^19",
28 + "tailwindcss": "^4",
29 + "typescript": "^5.9.3"
30 + }
31 +}
added apps/dashboard/postcss.config.mjs +2 −0
@@ -0,0 +1,2 @@
1 +const config = { plugins: { "@tailwindcss/postcss": {} } };
2 +export default config;
added apps/dashboard/public/brand/icon.svg +1 −0
@@ -0,0 +1 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="10" fill="#05070b"/><defs><linearGradient id="g" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#38e1ff"/><stop offset="1" stop-color="#9d7bff"/></linearGradient></defs><ellipse cx="24" cy="24" rx="19" ry="10" fill="none" stroke="url(#g)" stroke-width="2.2" transform="rotate(-24 24 24)"/><ellipse cx="24" cy="24" rx="19" ry="10" fill="none" stroke="url(#g)" stroke-width="1.2" opacity=".5" transform="rotate(36 24 24)"/><circle cx="24" cy="24" r="5.5" fill="url(#g)"/><circle cx="40" cy="16" r="2.2" fill="#ffb347"/></svg>
added apps/dashboard/src/app/access/page.tsx +39 −0
@@ -0,0 +1,39 @@
1 +import { cookies } from "next/headers";
2 +import { redirect } from "next/navigation";
3 +import { COOKIE, checkPasscode, gateEnabled, makeToken } from "@/lib/auth";
4 +import { Brand } from "@/components/Brand";
5 +
6 +export const dynamic = "force-dynamic";
7 +
8 +async function login(formData: FormData) {
9 + "use server";
10 + const code = String(formData.get("passcode") ?? "");
11 + const next = String(formData.get("next") ?? "/");
12 + if (!checkPasscode(code)) redirect(`/access?error=1&next=${encodeURIComponent(next)}`);
13 + const jar = await cookies();
14 + jar.set(COOKIE, makeToken(), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 30 * 24 * 3600 });
15 + redirect(next.startsWith("/") ? next : "/");
16 +}
17 +
18 +export default async function AccessPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
19 + const sp = await searchParams;
20 + if (!gateEnabled()) redirect("/");
21 + return (
22 + <main className="min-h-screen grid place-items-center grid-bg px-6">
23 + <form action={login} className="panel w-full max-w-sm p-8 space-y-6">
24 + <Brand size="lg" />
25 + <div className="space-y-1">
26 + <h1 className="text-lg font-semibold">Research console</h1>
27 + <p className="text-sm text-fg-2">This console observes live social runtimes and can start browser sessions on the cluster. Enter the access code.</p>
28 + </div>
29 + <input type="hidden" name="next" value={sp.next ?? "/"} />
30 + <label className="block space-y-2">
31 + <span className="text-xs uppercase tracking-widest text-dim">Access code</span>
32 + <input name="passcode" type="password" autoFocus required className="w-full rounded-lg bg-bg-2 border border-line-2 px-3 py-2 mono text-fg outline-none focus:border-cyan/60 focus:glow-cyan" />
33 + </label>
34 + {sp.error && <p className="text-sm text-red">Wrong code.</p>}
35 + <button className="w-full rounded-lg bg-cyan/90 hover:bg-cyan text-bg font-semibold py-2 transition">Enter</button>
36 + </form>
37 + </main>
38 + );
39 +}
added apps/dashboard/src/app/api/[...path]/route.ts +28 −0
@@ -0,0 +1,28 @@
1 +import { cookies } from "next/headers";
2 +import { NextResponse, type NextRequest } from "next/server";
3 +import { COOKIE, verifyToken } from "@/lib/auth";
4 +
5 +export const dynamic = "force-dynamic";
6 +const API_URL = process.env.API_URL ?? "http://127.0.0.1:8350";
7 +const API_TOKEN = process.env.SRC_API_TOKEN ?? "";
8 +
9 +/** Authenticated pass-through to the control-plane API (adds the server token, streams SSE as-is). */
10 +async function forward(req: NextRequest, path: string[]) {
11 + const jar = await cookies();
12 + if (!verifyToken(jar.get(COOKIE)?.value)) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
13 + const target = new URL(`/api/${path.join("/")}`, API_URL);
14 + target.search = req.nextUrl.search;
15 + const headers: Record<string, string> = { "x-src-token": API_TOKEN };
16 + if (req.headers.get("content-type")) headers["content-type"] = req.headers.get("content-type")!;
17 + const upstream = await fetch(target, { method: req.method, headers, body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.text(), cache: "no-store", // @ts-expect-error — undici option for streaming request bodies
18 + duplex: "half" });
19 + const ct = upstream.headers.get("content-type") ?? "application/json";
20 + return new Response(upstream.body, { status: upstream.status, headers: { "content-type": ct, "cache-control": "no-store", ...(ct.includes("event-stream") ? { connection: "keep-alive", "x-accel-buffering": "no" } : {}) } });
21 +}
22 +
23 +export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
24 + return forward(req, (await ctx.params).path);
25 +}
26 +export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
27 + return forward(req, (await ctx.params).path);
28 +}
added apps/dashboard/src/app/entities/[fp]/page.tsx +173 −0
@@ -0,0 +1,173 @@
1 +"use client";
2 +
3 +import { use } from "react";
4 +import Link from "next/link";
5 +import { ExternalLink } from "lucide-react";
6 +import { useApi } from "@/lib/api";
7 +import { fmtDate, fmtDuration, fmtNum, truncate } from "@/lib/format";
8 +import { Empty, Metric, Panel, Skeleton, SurfaceChips, Table, Tag, TypeTag } from "@/components/ui";
9 +import type { EntityRow } from "@/components/EntityTable";
10 +
11 +interface Detail {
12 + entity: EntityRow & { first_seen: string; last_seen: string; seen_count: number; first_session: string };
13 + observations: { session_id: string; step: number; ts: string; surfaces: string[]; snapshot: { name?: string; author?: string; metrics?: Record<string, number>; context?: string; provenance?: { surface: string; confidence: number; detail?: string }[] }; goal: string | null }[];
14 + relations: { rel_type: string; from_fp: string; to_fp: string; from_name: string | null; to_name: string | null; from_type: string; to_type: string; step: number }[];
15 + media: { duration_s: number | null; width: number | null; height: number | null; delivery: { kind: string; hostnames: string[] } | null; frames: string[] | null; thumbnail_url: string | null } | null;
16 + feed: { session_id: string; step: number; page_type: string; feed_position: number; visible: boolean; ts: string }[];
17 +}
18 +
19 +export default function EntityPage({ params }: { params: Promise<{ fp: string }> }) {
20 + const { fp } = use(params);
21 + const { data, loading, error } = useApi<Detail>(`/entities/${encodeURIComponent(decodeURIComponent(fp))}`);
22 + if (loading) return <Skeleton rows={10} />;
23 + if (error || !data) return <Empty>{error ?? "not found"}</Empty>;
24 + const e = data.entity;
25 + const type = e.entity_type ?? e.type ?? "?";
26 + return (
27 + <div className="space-y-4">
28 + <div className="text-xs text-dim mono">
29 + <Link href="/entities" className="hover:text-cyan">entities</Link> / {e.fingerprint}
30 + </div>
31 + <div className="flex flex-wrap items-start gap-4">
32 + <div className="min-w-0">
33 + <div className="flex items-center gap-2">
34 + <TypeTag type={type} />
35 + <Tag>{e.platform}</Tag>
36 + {e.url && (
37 + <a href={e.url} target="_blank" rel="noreferrer" className="text-dim hover:text-cyan flex items-center gap-1 text-xs">
38 + open on platform <ExternalLink className="h-3.5 w-3.5" />
39 + </a>
40 + )}
41 + </div>
42 + <h1 className="text-xl font-semibold tracking-tight mt-2 max-w-3xl">{e.name ?? e.text_excerpt ?? e.platform_id}</h1>
43 + {e.author && <div className="text-sm text-fg-2 mt-1">by {e.author}</div>}
44 + {e.text_excerpt && e.name && <p className="text-sm text-fg-2 mt-2 max-w-3xl">{e.text_excerpt}</p>}
45 + </div>
46 + </div>
47 +
48 + <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
49 + <Panel title="Evidence per field" sub="every canonical value points back to the surface that produced it (§38)" className="xl:col-span-2" pad={false}>
50 + <div className="p-4">
51 + <Table head={["field", "value", "evidence"]} dense>
52 + {Object.entries(e.fields ?? {}).map(([k, f]) => (
53 + <tr key={k}>
54 + <td className="mono text-fg-2 whitespace-nowrap">{k}</td>
55 + <td className="max-w-[480px] break-words">{typeof f.value === "string" ? f.value : JSON.stringify(f.value)}</td>
56 + <td>
57 + <SurfaceChips provenance={f.provenance} />
58 + {f.provenance?.[0]?.detail && <div className="text-[10px] text-dim mono truncate max-w-[280px]">{f.provenance[0].detail}</div>}
59 + </td>
60 + </tr>
61 + ))}
62 + </Table>
63 + </div>
64 + </Panel>
65 + <div className="space-y-4">
66 + <Panel title="Canonical record">
67 + <Metric label="platform id" value={e.platform_id ?? "—"} />
68 + <Metric label="confidence" value={e.confidence ? Number(e.confidence).toFixed(2) : "—"} />
69 + <Metric label="observed" value={`${e.seen_count}× in ${new Set(data.observations.map((o) => o.session_id)).size} session(s)`} />
70 + <Metric label="first seen" value={fmtDate(e.first_seen)} />
71 + <Metric label="last seen" value={fmtDate(e.last_seen)} />
72 + {e.metrics &&
73 + Object.entries(e.metrics).map(([k, v]) => (
74 + <Metric key={k} label={k} value={fmtNum(v)} />
75 + ))}
76 + {data.media && (
77 + <>
78 + <Metric label="duration" value={fmtDuration(data.media.duration_s)} />
79 + {data.media.width ? <Metric label="resolution" value={`${data.media.width}×${data.media.height}`} /> : null}
80 + {data.media.delivery && <Metric label="delivery" value={`${data.media.delivery.kind} · ${data.media.delivery.hostnames.slice(0, 2).join(", ")}`} />}
81 + </>
82 + )}
83 + </Panel>
84 + {data.media?.frames?.length ? (
85 + <Panel title="Sampled frames" sub="0 / 25 / 50 / 75 / 100 % (LEVEL 2)">
86 + <div className="grid grid-cols-5 gap-1">
87 + {data.media.frames.map((f) => (
88 + // eslint-disable-next-line @next/next/no-img-element
89 + <img key={f} src={`/media/${f.split("/media/")[1] ?? ""}`} alt="" className="aspect-video object-cover rounded" />
90 + ))}
91 + </div>
92 + </Panel>
93 + ) : data.media?.thumbnail_url || e.media?.thumbnail_url ? (
94 + <Panel title="Thumbnail">
95 + {/* eslint-disable-next-line @next/next/no-img-element */}
96 + <img src={data.media?.thumbnail_url ?? e.media?.thumbnail_url ?? ""} alt="" className="rounded w-full" />
97 + </Panel>
98 + ) : null}
99 + </div>
100 + </div>
101 +
102 + <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
103 + <Panel title="Observations" sub="raw sightings, never overwritten (§37)" pad={false}>
104 + <div className="p-4">
105 + <Table head={["when", "session", "step", "surfaces", "context", "name as seen"]} dense>
106 + {data.observations.map((o, i) => (
107 + <tr key={i}>
108 + <td className="mono text-dim whitespace-nowrap">{fmtDate(o.ts)}</td>
109 + <td>
110 + <Link href={`/sessions/${o.session_id}`} className="mono text-[11px] hover:text-cyan">
111 + {truncate(o.goal ?? o.session_id, 32)}
112 + </Link>
113 + </td>
114 + <td className="mono">#{o.step}</td>
115 + <td>
116 + <SurfaceChips provenance={o.snapshot.provenance ?? o.surfaces.map((s) => ({ surface: s, confidence: 0 }))} />
117 + </td>
118 + <td className="text-dim text-[11px]">{o.snapshot.context}</td>
119 + <td className="text-fg-2 max-w-[260px] truncate">{o.snapshot.name}</td>
120 + </tr>
121 + ))}
122 + </Table>
123 + </div>
124 + </Panel>
125 + <div className="space-y-4">
126 + <Panel title="Relationships" pad={false}>
127 + <div className="p-4">
128 + {data.relations.length ? (
129 + <Table head={["from", "relation", "to"]} dense>
130 + {data.relations.map((r, i) => (
131 + <tr key={i}>
132 + <td className="max-w-[240px] truncate">
133 + <TypeTag type={r.from_type ?? "?"} /> <Link href={`/entities/${encodeURIComponent(r.from_fp)}`} className="hover:text-cyan">{truncate(r.from_name ?? r.from_fp, 40)}</Link>
134 + </td>
135 + <td>
136 + <Tag color="#9d7bff">{r.rel_type}</Tag>
137 + </td>
138 + <td className="max-w-[240px] truncate">
139 + <TypeTag type={r.to_type ?? "?"} /> <Link href={`/entities/${encodeURIComponent(r.to_fp)}`} className="hover:text-cyan">{truncate(r.to_name ?? r.to_fp, 40)}</Link>
140 + </td>
141 + </tr>
142 + ))}
143 + </Table>
144 + ) : (
145 + <Empty>no relationships recorded</Empty>
146 + )}
147 + </div>
148 + </Panel>
149 + <Panel title="Feed exposure" sub="where it ranked when the crawler saw it (§44)" pad={false}>
150 + <div className="p-4">
151 + {data.feed.length ? (
152 + <Table head={["when", "page", "position", "visible"]} dense>
153 + {data.feed.map((f, i) => (
154 + <tr key={i}>
155 + <td className="mono text-dim whitespace-nowrap">{fmtDate(f.ts)}</td>
156 + <td>
157 + <Tag color="#38e1ff">{f.page_type}</Tag>
158 + </td>
159 + <td className="mono">#{f.feed_position + 1}</td>
160 + <td className="mono">{f.visible ? "yes" : "below fold"}</td>
161 + </tr>
162 + ))}
163 + </Table>
164 + ) : (
165 + <Empty>not seen in a feed</Empty>
166 + )}
167 + </div>
168 + </Panel>
169 + </div>
170 + </div>
171 + </div>
172 + );
173 +}
added apps/dashboard/src/app/entities/page.tsx +85 −0
@@ -0,0 +1,85 @@
1 +"use client";
2 +
3 +import { useState } from "react";
4 +import { Search } from "lucide-react";
5 +import { useApi } from "@/lib/api";
6 +import { fmtNum } from "@/lib/format";
7 +import { Empty, Panel, Skeleton } from "@/components/ui";
8 +import { EntityTable, type EntityRow } from "@/components/EntityTable";
9 +
10 +const TYPES = ["", "video", "channel", "profile", "post", "comment", "community", "hashtag", "page"];
11 +const PLATFORMS = ["", "youtube", "reddit", "facebook", "instagram", "tiktok", "x"];
12 +
13 +export default function Entities() {
14 + const [q, setQ] = useState("");
15 + const [type, setType] = useState("");
16 + const [platform, setPlatform] = useState("");
17 + const [sort, setSort] = useState("last_seen");
18 + const [page, setPage] = useState(0);
19 + const limit = 50;
20 + const params = new URLSearchParams({ limit: String(limit), offset: String(page * limit), sort });
21 + if (q) params.set("q", q);
22 + if (type) params.set("type", type);
23 + if (platform) params.set("platform", platform);
24 + const { data, loading } = useApi<{ rows: EntityRow[]; total: number }>(`/entities?${params}`);
25 + const sel = "rounded-lg bg-bg-2 border border-line-2 px-2 py-1.5 text-sm text-fg outline-none focus:border-cyan/60";
26 + return (
27 + <div className="space-y-4">
28 + <div className="flex flex-wrap items-end gap-3">
29 + <div>
30 + <h1 className="text-2xl font-semibold tracking-tight">Entities</h1>
31 + <p className="text-sm text-fg-2">Canonical entities with per-field evidence. {data ? <span className="mono">{fmtNum(data.total)} match</span> : null}</p>
32 + </div>
33 + <div className="ml-auto flex flex-wrap gap-2 items-center">
34 + <label className="relative">
35 + <Search className="h-4 w-4 absolute left-2 top-2 text-dim" />
36 + <input
37 + value={q}
38 + onChange={(e) => {
39 + setQ(e.target.value);
40 + setPage(0);
41 + }}
42 + placeholder="search name, text, author, id…"
43 + className={`${sel} pl-8 w-72 mono`}
44 + />
45 + </label>
46 + <select value={type} onChange={(e) => (setType(e.target.value), setPage(0))} className={sel}>
47 + {TYPES.map((t) => (
48 + <option key={t} value={t}>
49 + {t || "all types"}
50 + </option>
51 + ))}
52 + </select>
53 + <select value={platform} onChange={(e) => (setPlatform(e.target.value), setPage(0))} className={sel}>
54 + {PLATFORMS.map((p) => (
55 + <option key={p} value={p}>
56 + {p || "all platforms"}
57 + </option>
58 + ))}
59 + </select>
60 + <select value={sort} onChange={(e) => setSort(e.target.value)} className={sel}>
61 + <option value="last_seen">recently seen</option>
62 + <option value="seen">most observed</option>
63 + <option value="views">most views</option>
64 + </select>
65 + </div>
66 + </div>
67 + <Panel pad={false}>
68 + <div className="p-4">{loading ? <Skeleton rows={10} /> : data?.rows.length ? <EntityTable rows={data.rows} showPlatform /> : <Empty>Nothing matches.</Empty>}</div>
69 + {data && data.total > limit && (
70 + <div className="flex items-center gap-3 px-4 py-3 border-t border-line text-xs">
71 + <button disabled={page === 0} onClick={() => setPage((p) => p - 1)} className="px-2 py-1 rounded border border-line disabled:opacity-40 hover:border-cyan/50">
72 + ← prev
73 + </button>
74 + <span className="mono text-dim">
75 + {page * limit + 1}–{Math.min(data.total, (page + 1) * limit)} of {data.total}
76 + </span>
77 + <button disabled={(page + 1) * limit >= data.total} onClick={() => setPage((p) => p + 1)} className="px-2 py-1 rounded border border-line disabled:opacity-40 hover:border-cyan/50">
78 + next →
79 + </button>
80 + </div>
81 + )}
82 + </Panel>
83 + </div>
84 + );
85 +}
added apps/dashboard/src/app/globals.css +87 −0
@@ -0,0 +1,87 @@
1 +@import "tailwindcss";
2 +
3 +@theme {
4 + --color-bg: #05070b;
5 + --color-bg-2: #0a0e15;
6 + --color-panel: #0d1219;
7 + --color-panel-2: #111823;
8 + --color-line: #1a2331;
9 + --color-line-2: #243042;
10 + --color-fg: #e6edf5;
11 + --color-fg-2: #a9b6c6;
12 + --color-dim: #6b7a8c;
13 + --color-cyan: #38e1ff;
14 + --color-amber: #ffb347;
15 + --color-green: #43e69a;
16 + --color-red: #ff5c7a;
17 + --color-violet: #9d7bff;
18 + --color-pink: #ff7ad9;
19 + --font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
20 + --font-mono: var(--font-mono), ui-monospace, "SF Mono", Menlo, monospace;
21 +}
22 +
23 +:root {
24 + color-scheme: dark;
25 +}
26 +
27 +html,
28 +body {
29 + background: var(--color-bg);
30 + color: var(--color-fg);
31 + font-family: var(--font-sans);
32 + -webkit-font-smoothing: antialiased;
33 +}
34 +
35 +body {
36 + background-image:
37 + radial-gradient(1200px 600px at 10% -10%, rgba(56, 225, 255, 0.07), transparent 60%),
38 + radial-gradient(900px 500px at 100% 0%, rgba(157, 123, 255, 0.07), transparent 60%);
39 + background-attachment: fixed;
40 +}
41 +
42 +.grid-bg {
43 + background-image:
44 + linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
45 + linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
46 + background-size: 28px 28px;
47 + mask-image: radial-gradient(ellipse at top, black 40%, transparent 85%);
48 +}
49 +
50 +.panel {
51 + background: linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.005)), var(--color-panel);
52 + border: 1px solid var(--color-line);
53 + border-radius: 14px;
54 + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.03) inset, 0 10px 30px -20px rgba(0, 0, 0, 0.8);
55 +}
56 +
57 +.glow-cyan { box-shadow: 0 0 0 1px rgba(56, 225, 255, 0.25), 0 0 24px -6px rgba(56, 225, 255, 0.5); }
58 +
59 +.mono { font-family: var(--font-mono); font-feature-settings: "tnum" 1, "zero" 1; }
60 +
61 +.scrollbar-thin::-webkit-scrollbar { width: 8px; height: 8px; }
62 +.scrollbar-thin::-webkit-scrollbar-thumb { background: var(--color-line-2); border-radius: 8px; }
63 +.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
64 +
65 +@keyframes pulse-dot {
66 + 0%, 100% { transform: scale(1); opacity: 1; }
67 + 50% { transform: scale(1.6); opacity: 0.35; }
68 +}
69 +.live-dot::after {
70 + content: "";
71 + position: absolute;
72 + inset: 0;
73 + border-radius: 9999px;
74 + background: currentColor;
75 + animation: pulse-dot 1.6s ease-in-out infinite;
76 +}
77 +
78 +@keyframes shimmer { from { background-position: -200% 0; } to { background-position: 200% 0; } }
79 +.skeleton {
80 + background: linear-gradient(90deg, rgba(255,255,255,0.03) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 75%);
81 + background-size: 200% 100%;
82 + animation: shimmer 1.4s linear infinite;
83 + border-radius: 6px;
84 +}
85 +
86 +a { color: inherit; }
87 +::selection { background: rgba(56, 225, 255, 0.3); }
added apps/dashboard/src/app/jobs/page.tsx +213 −0
@@ -0,0 +1,213 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import Link from "next/link";
5 +import { Play, Square } from "lucide-react";
6 +import { api, useApi } from "@/lib/api";
7 +import { ago, truncate } from "@/lib/format";
8 +import { Empty, Panel, Skeleton, StatusPill, Table, Tag } from "@/components/ui";
9 +
10 +interface Job {
11 + job_id: string;
12 + session_id: string | null;
13 + platform: string;
14 + mode: string;
15 + goal: string;
16 + budget: { minutes: number; actions: number; media: number; query?: string | null; headless: boolean; account: string };
17 + status: string;
18 + created_at: string;
19 + finished_at: string | null;
20 + result: { steps?: number; entities?: number; videos?: number; ended_because?: string } | null;
21 + actions: number;
22 + entities: number;
23 +}
24 +
25 +const PRESETS = [
26 + { label: "Topic · AI Québec on YouTube", platform: "youtube", mode: "topic", query: "intelligence artificielle Québec", goal: "Discover public Quebec creators and channels discussing artificial intelligence", minutes: 10, actions: 60, media: 2 },
27 + { label: "Learn · YouTube runtime", platform: "youtube", mode: "learn", query: "", goal: "", minutes: 15, actions: 80, media: 1 },
28 + { label: "Observe · YouTube home feed", platform: "youtube", mode: "observe", query: "", goal: "Observe the recommendation feed without navigating", minutes: 5, actions: 30, media: 0 },
29 + { label: "Research · Reddit r/Quebec AI", platform: "reddit", mode: "research", query: "intelligence artificielle Québec", goal: "Discover public Reddit posts and users discussing AI in Quebec", minutes: 10, actions: 60, media: 1 },
30 +];
31 +
32 +export default function Missions() {
33 + const jobs = useApi<Job[]>("/jobs", { refreshMs: 5000 });
34 + const [form, setForm] = useState({ platform: "youtube", mode: "research", query: "intelligence artificielle Québec", goal: "Discover public Quebec creators discussing AI", minutes: 10, actions: 60, media: 1, headless: true, account: "research-01" });
35 + const [busy, setBusy] = useState(false);
36 + const [msg, setMsg] = useState<string | null>(null);
37 + const [logJob, setLogJob] = useState<string | null>(null);
38 + const input = "w-full rounded-lg bg-bg-2 border border-line-2 px-3 py-2 text-sm outline-none focus:border-cyan/60";
39 +
40 + async function launch() {
41 + setBusy(true);
42 + setMsg(null);
43 + try {
44 + const r = await api<{ job_id: string }>("/jobs", { method: "POST", body: JSON.stringify(form) });
45 + setMsg(`mission ${r.job_id} launched`);
46 + setLogJob(r.job_id);
47 + jobs.refresh();
48 + } catch (e) {
49 + setMsg((e as Error).message);
50 + } finally {
51 + setBusy(false);
52 + }
53 + }
54 + async function stop(id: string) {
55 + await api(`/jobs/${id}/stop`, { method: "POST", body: "{}" });
56 + jobs.refresh();
57 + }
58 +
59 + return (
60 + <div className="space-y-4">
61 + <div>
62 + <h1 className="text-2xl font-semibold tracking-tight">Missions</h1>
63 + <p className="text-sm text-fg-2 max-w-2xl">A mission opens one browser on the cluster node, gives the agent a goal and a budget, and lets it explore. Read-only: the crawler never likes, follows, comments or subscribes.</p>
64 + </div>
65 + <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
66 + <Panel title="New mission" className="xl:col-span-1">
67 + <div className="space-y-3">
68 + <div className="flex flex-wrap gap-1">
69 + {PRESETS.map((p) => (
70 + <button key={p.label} onClick={() => setForm((f) => ({ ...f, ...p }))} className="text-[11px] rounded-md border border-line px-2 py-1 hover:border-cyan/50 text-fg-2 hover:text-fg">
71 + {p.label}
72 + </button>
73 + ))}
74 + </div>
75 + <div className="grid grid-cols-2 gap-2">
76 + <label className="text-xs text-dim">
77 + platform
78 + <select value={form.platform} onChange={(e) => setForm({ ...form, platform: e.target.value })} className={input}>
79 + <option value="youtube">youtube</option>
80 + <option value="reddit">reddit</option>
81 + </select>
82 + </label>
83 + <label className="text-xs text-dim">
84 + mode
85 + <select value={form.mode} onChange={(e) => setForm({ ...form, mode: e.target.value })} className={input}>
86 + {["research", "topic", "observe", "profile", "learn"].map((m) => (
87 + <option key={m}>{m}</option>
88 + ))}
89 + </select>
90 + </label>
91 + </div>
92 + <label className="text-xs text-dim block">
93 + topic / search query
94 + <input value={form.query} onChange={(e) => setForm({ ...form, query: e.target.value })} className={input} placeholder="e.g. intelligence artificielle Québec" />
95 + </label>
96 + <label className="text-xs text-dim block">
97 + goal (what the agent optimises for)
98 + <textarea value={form.goal} onChange={(e) => setForm({ ...form, goal: e.target.value })} rows={2} className={input} />
99 + </label>
100 + <div className="grid grid-cols-3 gap-2">
101 + <label className="text-xs text-dim">
102 + minutes
103 + <input type="number" min={1} max={120} value={form.minutes} onChange={(e) => setForm({ ...form, minutes: Number(e.target.value) })} className={`${input} mono`} />
104 + </label>
105 + <label className="text-xs text-dim">
106 + actions
107 + <input type="number" min={1} max={1000} value={form.actions} onChange={(e) => setForm({ ...form, actions: Number(e.target.value) })} className={`${input} mono`} />
108 + </label>
109 + <label className="text-xs text-dim">
110 + media level
111 + <select value={form.media} onChange={(e) => setForm({ ...form, media: Number(e.target.value) })} className={input}>
112 + <option value={0}>0 · metadata</option>
113 + <option value={1}>1 · + thumbnail</option>
114 + <option value={2}>2 · + frames</option>
115 + </select>
116 + </label>
117 + </div>
118 + <div className="grid grid-cols-2 gap-2">
119 + <label className="text-xs text-dim">
120 + account profile
121 + <input value={form.account} onChange={(e) => setForm({ ...form, account: e.target.value })} className={`${input} mono`} />
122 + </label>
123 + <label className="text-xs text-dim flex items-end gap-2 pb-2">
124 + <input type="checkbox" checked={form.headless} onChange={(e) => setForm({ ...form, headless: e.target.checked })} /> headless browser
125 + </label>
126 + </div>
127 + <button disabled={busy} onClick={launch} className="w-full rounded-lg bg-cyan/90 hover:bg-cyan disabled:opacity-50 text-bg font-semibold py-2 flex items-center justify-center gap-2">
128 + <Play className="h-4 w-4" /> Launch mission
129 + </button>
130 + {msg && <div className="text-xs text-fg-2 mono">{msg}</div>}
131 + </div>
132 + </Panel>
133 + <Panel title="Missions" className="xl:col-span-2" pad={false} right={jobs.data ? `${jobs.data.filter((j) => j.status === "running").length} running` : ""}>
134 + <div className="p-4">
135 + {jobs.loading ? (
136 + <Skeleton rows={6} />
137 + ) : jobs.data?.length ? (
138 + <Table head={["status", "platform", "goal", "mode", "budget", "steps", "entities", "created", ""]} dense>
139 + {jobs.data.map((j) => (
140 + <tr key={j.job_id}>
141 + <td>
142 + <StatusPill status={j.status} />
143 + </td>
144 + <td>
145 + <Tag>{j.platform}</Tag>
146 + </td>
147 + <td className="max-w-[360px]">
148 + {j.session_id ? (
149 + <Link href={`/sessions/${j.session_id}`} className="hover:text-cyan">
150 + {truncate(j.goal, 80)}
151 + </Link>
152 + ) : (
153 + truncate(j.goal, 80)
154 + )}
155 + {j.budget?.query && <div className="text-[10.5px] text-dim">“{j.budget.query}”</div>}
156 + </td>
157 + <td className="text-fg-2">{j.mode}</td>
158 + <td className="mono text-[11px] text-dim">
159 + {j.budget?.minutes}m · {j.budget?.actions}a · L{j.budget?.media}
160 + </td>
161 + <td className="mono">{j.actions || j.result?.steps || 0}</td>
162 + <td className="mono">{j.entities || j.result?.entities || 0}</td>
163 + <td className="text-dim whitespace-nowrap">{ago(j.created_at)}</td>
164 + <td className="whitespace-nowrap">
165 + <button onClick={() => setLogJob(j.job_id)} className="text-[11px] text-dim hover:text-cyan mr-2">
166 + log
167 + </button>
168 + {j.status === "running" && (
169 + <button onClick={() => stop(j.job_id)} className="text-[11px] text-red hover:text-red/80 inline-flex items-center gap-1">
170 + <Square className="h-3 w-3" /> stop
171 + </button>
172 + )}
173 + </td>
174 + </tr>
175 + ))}
176 + </Table>
177 + ) : (
178 + <Empty>No missions yet — launch one on the left.</Empty>
179 + )}
180 + </div>
181 + </Panel>
182 + </div>
183 + {logJob && <JobLog id={logJob} onClose={() => setLogJob(null)} />}
184 + </div>
185 + );
186 +}
187 +
188 +function JobLog({ id, onClose }: { id: string; onClose: () => void }) {
189 + const [text, setText] = useState("");
190 + useEffect(() => {
191 + let alive = true;
192 + let timer: ReturnType<typeof setTimeout> | undefined;
193 + const tick = async () => {
194 + try {
195 + const r = await fetch(`/api/jobs/${id}/log?lines=300`, { cache: "no-store" });
196 + if (alive) setText(await r.text());
197 + } catch {
198 + /* ignore */
199 + }
200 + if (alive) timer = setTimeout(tick, 3000);
201 + };
202 + tick();
203 + return () => {
204 + alive = false;
205 + if (timer) clearTimeout(timer);
206 + };
207 + }, [id]);
208 + return (
209 + <Panel title={`Worker log · ${id}`} right={<button onClick={onClose} className="hover:text-cyan">close</button>}>
210 + <pre className="mono text-[11px] leading-5 text-fg-2 whitespace-pre-wrap max-h-[420px] overflow-auto scrollbar-thin">{text || "…"}</pre>
211 + </Panel>
212 + );
213 +}
added apps/dashboard/src/app/layout.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import type { Metadata } from "next";
2 +import { Inter, JetBrains_Mono } from "next/font/google";
3 +import "./globals.css";
4 +import { Shell } from "@/components/Shell";
5 +
6 +const sans = Inter({ subsets: ["latin"], variable: "--font-sans", display: "swap" });
7 +const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono", display: "swap" });
8 +
9 +export const metadata: Metadata = {
10 + title: { default: "SocialCrawl — Social Runtime Mining console", template: "%s · SocialCrawl" },
11 + description: "Research console of the Social Runtime Crawler: live observation of social-media runtimes, information-gain navigation, learned connectors.",
12 + robots: { index: false, follow: false },
13 + icons: { icon: "/brand/icon.svg" },
14 +};
15 +
16 +export default function RootLayout({ children }: { children: React.ReactNode }) {
17 + return (
18 + <html lang="en" className={`${sans.variable} ${mono.variable}`}>
19 + <body className="min-h-screen">
20 + <Shell>{children}</Shell>
21 + </body>
22 + </html>
23 + );
24 +}
added apps/dashboard/src/app/media/[...path]/route.ts +15 −0
@@ -0,0 +1,15 @@
1 +import { cookies } from "next/headers";
2 +import { NextResponse, type NextRequest } from "next/server";
3 +import { COOKIE, verifyToken } from "@/lib/auth";
4 +
5 +export const dynamic = "force-dynamic";
6 +const API_URL = process.env.API_URL ?? "http://127.0.0.1:8350";
7 +
8 +/** Sampled video frames, served through the gate. */
9 +export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
10 + const jar = await cookies();
11 + if (!verifyToken(jar.get(COOKIE)?.value)) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
12 + const { path } = await ctx.params;
13 + const upstream = await fetch(new URL(`/media/${path.map(encodeURIComponent).join("/")}`, API_URL), { cache: "no-store" });
14 + return new Response(upstream.body, { status: upstream.status, headers: { "content-type": upstream.headers.get("content-type") ?? "image/jpeg", "cache-control": "private, max-age=3600" } });
15 +}
added apps/dashboard/src/app/page.tsx +126 −0
@@ -0,0 +1,126 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { ArrowRight } from "lucide-react";
5 +import { useApi } from "@/lib/api";
6 +import { ago, fmtNum, truncate } from "@/lib/format";
7 +import { Bar, Empty, Kpi, Panel, Skeleton, StatusPill, Tag } from "@/components/ui";
8 +import { ActivityChart, SurfaceBars, TypeDonut, YieldBars } from "@/components/charts";
9 +import { LiveFeed } from "@/components/LiveFeed";
10 +
11 +interface Overview {
12 + totals: { sessions: number; entities: number; media: number; schemas: number; actions: number; observations: number; relationships: number; patterns_learned: number; feed_items: number };
13 + by_type: { platform: string; entity_type: string; n: number }[];
14 + running: { session_id: string; platform: string; started_at: string }[];
15 + recent_sessions: { session_id: string; platform: string; goal: string | null; mode: string | null; started_at: string; ended_at: string | null; health: string; actions: number; entities: number }[];
16 + series: { bucket: string; entities: number; responses: number; actions: number; schemas: number }[];
17 + surfaces: { dom: number; network: number; both: number; agreements: number; conflicts: number };
18 + actions: { action_type: string; n: number; avg_gain: number | null; success_rate: number | null; avg_new_entities: number | null }[];
19 + platforms: { platform: string; learned: boolean; adapter: boolean; confidence: number; page_types: number; entity_types: number; network_schemas: number; sessions: number; has_manifest: boolean }[];
20 +}
21 +
22 +export default function Observatory() {
23 + const { data, loading } = useApi<Overview>("/overview", { refreshMs: 8000 });
24 + const t = data?.totals;
25 + const s = data?.surfaces;
26 + const agreement = s && s.agreements + s.conflicts > 0 ? s.agreements / (s.agreements + s.conflicts) : null;
27 + const spark = (k: keyof Overview["series"][number]) => (data?.series ?? []).slice(-30).map((r) => Number(r[k]));
28 + return (
29 + <div className="space-y-6">
30 + <div className="flex flex-wrap items-end gap-4">
31 + <div>
32 + <h1 className="text-2xl font-semibold tracking-tight">Observatory</h1>
33 + <p className="text-sm text-fg-2 mt-1 max-w-2xl">The crawler does not download pages. It watches social applications run inside an authenticated browser, fuses what the network and the DOM reveal, and learns the platform as it goes.</p>
34 + </div>
35 + <div className="ml-auto flex gap-2">
36 + {data?.running.map((r) => (
37 + <Link key={r.session_id} href={`/sessions/${r.session_id}`} className="panel px-3 py-1.5 text-xs flex items-center gap-2 hover:border-amber/50">
38 + <StatusPill status="running" /> {r.platform} <span className="text-dim">{ago(r.started_at)}</span>
39 + </Link>
40 + ))}
41 + <Link href="/jobs" className="rounded-lg bg-cyan/90 hover:bg-cyan text-bg text-sm font-semibold px-3 py-1.5 flex items-center gap-1">
42 + New mission <ArrowRight className="h-4 w-4" />
43 + </Link>
44 + </div>
45 + </div>
46 +
47 + <div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
48 + <Kpi label="entities" value={fmtNum(t?.entities)} hint={`${fmtNum(t?.relationships)} relationships`} spark={spark("entities")} />
49 + <Kpi label="media" value={fmtNum(t?.media)} hint="videos & images with metadata" accent="pink" />
50 + <Kpi label="runtime schemas" value={fmtNum(t?.schemas)} hint={`${fmtNum(t?.patterns_learned)} patterns learned`} accent="violet" spark={spark("schemas")} />
51 + <Kpi label="actions" value={fmtNum(t?.actions)} hint={`${fmtNum(t?.sessions)} sessions`} accent="amber" spark={spark("actions")} />
52 + <Kpi label="observations" value={fmtNum(t?.observations)} hint="raw, append-only" accent="fg" spark={spark("responses")} />
53 + <Kpi label="surface agreement" value={agreement === null ? "—" : `${Math.round(agreement * 100)}%`} hint={s ? `${fmtNum(s.both)} entities seen on both surfaces` : ""} accent="green" />
54 + </div>
55 +
56 + <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
57 + <Panel title="Activity" sub="10-minute buckets, last 48 h" className="xl:col-span-2">
58 + {loading ? <Skeleton rows={6} /> : data?.series.length ? <ActivityChart data={data.series} /> : <Empty>No activity yet — launch a mission.</Empty>}
59 + </Panel>
60 + <Panel title="Entity mix" sub="canonical entities by type">
61 + {data?.by_type.length ? <TypeDonut data={data.by_type} /> : <Empty>—</Empty>}
62 + </Panel>
63 + </div>
64 +
65 + <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
66 + <Panel title="Live observation stream" sub="every event, as the browser sees it" className="xl:col-span-2" pad={false}>
67 + <div className="p-2">
68 + <LiveFeed max={80} height={380} />
69 + </div>
70 + </Panel>
71 + <div className="space-y-4">
72 + <Panel title="Network vs DOM" sub="where entities were confirmed">
73 + {s ? <SurfaceBars dom={s.dom} network={s.network} both={s.both} /> : <Skeleton />}
74 + <div className="text-xs text-dim mt-2">
75 + field agreements <span className="mono text-fg">{fmtNum(s?.agreements)}</span> · conflicts <span className="mono text-fg">{fmtNum(s?.conflicts)}</span>
76 + </div>
77 + </Panel>
78 + <Panel title="Action yield" sub="average new entities per action type">
79 + {data?.actions.length ? <YieldBars data={data.actions} height={190} /> : <Empty>—</Empty>}
80 + </Panel>
81 + </div>
82 + </div>
83 +
84 + <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
85 + <Panel title="Recent sessions" right={<Link href="/sessions" className="hover:text-cyan">all →</Link>} pad={false}>
86 + {data?.recent_sessions.length ? (
87 + <ul className="divide-y divide-line/60">
88 + {data.recent_sessions.map((r) => (
89 + <li key={r.session_id}>
90 + <Link href={`/sessions/${r.session_id}`} className="flex items-center gap-3 px-4 py-2.5 hover:bg-white/[0.03]">
91 + <StatusPill status={r.ended_at ? "done" : r.health} />
92 + <Tag>{r.platform}</Tag>
93 + <span className="text-sm truncate">{truncate(r.goal ?? "(no goal)", 70)}</span>
94 + <span className="ml-auto mono text-xs text-dim whitespace-nowrap">
95 + {r.actions} steps · {r.entities} ent · {ago(r.started_at)}
96 + </span>
97 + </Link>
98 + </li>
99 + ))}
100 + </ul>
101 + ) : (
102 + <Empty>No sessions yet.</Empty>
103 + )}
104 + </Panel>
105 + <Panel title="Connectors" sub="learned platform knowledge" right={<Link href="/platforms" className="hover:text-cyan">details →</Link>}>
106 + <div className="space-y-3">
107 + {data?.platforms
108 + .filter((p) => p.adapter || p.learned)
109 + .map((p) => (
110 + <Link key={p.platform} href={`/platforms/${p.platform}`} className="block group">
111 + <div className="flex items-center gap-3 text-sm">
112 + <span className="w-20 capitalize group-hover:text-cyan">{p.platform}</span>
113 + <Bar value={p.confidence / 100} color={p.confidence >= 70 ? "#43e69a" : p.confidence >= 40 ? "#38e1ff" : "#ffb347"} className="flex-1" />
114 + <span className="mono text-xs w-10 text-right">{p.confidence}%</span>
115 + </div>
116 + <div className="text-[10.5px] text-dim mono ml-[92px]">
117 + {p.page_types} page types · {p.entity_types} entity types · {p.network_schemas} schemas · {p.sessions} sessions{p.has_manifest ? " · compiled" : ""}
118 + </div>
119 + </Link>
120 + ))}
121 + </div>
122 + </Panel>
123 + </div>
124 + </div>
125 + );
126 +}
added apps/dashboard/src/app/platforms/[platform]/page.tsx +198 −0
@@ -0,0 +1,198 @@
1 +"use client";
2 +
3 +import { use } from "react";
4 +import Link from "next/link";
5 +import { useApi } from "@/lib/api";
6 +import { fmtDate } from "@/lib/format";
7 +import { Bar, Empty, Metric, Panel, Skeleton, Table, Tag } from "@/components/ui";
8 +
9 +interface Detail {
10 + platform: string;
11 + summary: { confidence: number; page_types: number; entity_types: number; navigation_actions: number; network_schemas: number; media_patterns: number; sessions: number } | null;
12 + model: {
13 + updated_at: string;
14 + response_patterns: Record<string, { shape_hash: string; hostname: string; path_pattern: string; method: string; graphql_operation?: string; likely_entity_types: Record<string, number>; observed_count: number; entity_yield_total: number; confidence: number; triggered_by: Record<string, number>; first_seen: string; last_seen: string }>;
15 + action_patterns: Record<string, { action_type: string; from_page_type: string; observed_count: number; avg_new_entities: number; avg_network_responses: number; to_page_types: Record<string, number>; failures: number }>;
16 + page_types: Record<string, { page_type: string; observed_count: number; url_patterns: Record<string, number>; avg_entities: number; entity_types: Record<string, number>; avg_confidence: number }>;
17 + media_patterns: Record<string, { hostname: string; kind: string; observed_count: number }>;
18 + sessions_learned: string[];
19 + } | null;
20 + manifest: { compiled_at: string; confidence: number; network_patterns: unknown[] } | null;
21 + profiles: { alias: string; has_state: boolean }[];
22 +}
23 +
24 +export default function PlatformPage({ params }: { params: Promise<{ platform: string }> }) {
25 + const { platform } = use(params);
26 + const { data, loading } = useApi<Detail>(`/platforms/${platform}`, { refreshMs: 15_000 });
27 + if (loading) return <Skeleton rows={10} />;
28 + if (!data) return <Empty>unknown platform</Empty>;
29 + const m = data.model;
30 + const rps = m ? Object.values(m.response_patterns).filter((r) => Object.keys(r.likely_entity_types).length).sort((a, b) => b.confidence - a.confidence) : [];
31 + const aps = m ? Object.values(m.action_patterns).filter((a) => a.from_page_type !== "ANY").sort((a, b) => b.observed_count - a.observed_count) : [];
32 + const pts = m ? Object.values(m.page_types).sort((a, b) => b.observed_count - a.observed_count) : [];
33 + return (
34 + <div className="space-y-4">
35 + <div className="text-xs text-dim mono">
36 + <Link href="/platforms" className="hover:text-cyan">connectors</Link> / {platform}
37 + </div>
38 + <div className="flex flex-wrap items-end gap-4">
39 + <h1 className="text-2xl font-semibold tracking-tight capitalize">{platform}</h1>
40 + {data.summary && (
41 + <div className="ml-auto flex items-center gap-3 w-72">
42 + <span className="text-xs text-dim">confidence</span>
43 + <Bar value={data.summary.confidence / 100} className="flex-1" color={data.summary.confidence >= 70 ? "#43e69a" : "#38e1ff"} />
44 + <span className="mono">{data.summary.confidence}%</span>
45 + </div>
46 + )}
47 + </div>
48 + {!m ? (
49 + <Panel>
50 + <Empty>
51 + Nothing learned yet on {platform}. {["youtube", "reddit"].includes(platform) ? "Launch a learning mission from the Missions page." : "No adapter yet — this platform is scheduled for a later phase."}
52 + </Empty>
53 + </Panel>
54 + ) : (
55 + <>
56 + <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
57 + <Panel title="Learned model">
58 + <Metric label="page types" value={data.summary?.page_types} />
59 + <Metric label="entity types" value={data.summary?.entity_types} />
60 + <Metric label="navigation actions" value={data.summary?.navigation_actions} />
61 + <Metric label="network schemas" value={data.summary?.network_schemas} />
62 + <Metric label="media patterns" value={data.summary?.media_patterns} />
63 + <Metric label="sessions learned from" value={m.sessions_learned.length} />
64 + <Metric label="updated" value={fmtDate(m.updated_at)} />
65 + <Metric label="compiled manifest" value={data.manifest ? `${fmtDate(data.manifest.compiled_at)} · ${data.manifest.network_patterns.length} patterns` : "not yet (needs ≥ 40 %)"} />
66 + <Metric label="browser profiles" value={data.profiles.length ? data.profiles.map((p) => `${p.alias}${p.has_state ? " ✓" : ""}`).join(", ") : "none — run pnpm login"} />
67 + </Panel>
68 + <Panel title="Page types" sub="what the crawler recognises, and what each page usually yields" className="xl:col-span-2" pad={false}>
69 + <div className="p-4">
70 + <Table head={["page type", "seen", "avg entities", "avg conf", "url patterns", "entity mix"]} dense>
71 + {pts.map((p) => (
72 + <tr key={p.page_type}>
73 + <td>
74 + <Tag color="#38e1ff">{p.page_type}</Tag>
75 + </td>
76 + <td className="mono">{p.observed_count}</td>
77 + <td className="mono">{p.avg_entities.toFixed(1)}</td>
78 + <td className="mono">{p.avg_confidence.toFixed(2)}</td>
79 + <td className="mono text-[11px] text-fg-2">
80 + {Object.entries(p.url_patterns)
81 + .sort((a, b) => b[1] - a[1])
82 + .slice(0, 3)
83 + .map(([k, v]) => `${k} (${v})`)
84 + .join(" ")}
85 + </td>
86 + <td className="text-[11px]">
87 + {Object.entries(p.entity_types)
88 + .sort((a, b) => b[1] - a[1])
89 + .slice(0, 4)
90 + .map(([k, v]) => (
91 + <Tag key={k} className="mr-1">
92 + {k} {v}
93 + </Tag>
94 + ))}
95 + </td>
96 + </tr>
97 + ))}
98 + </Table>
99 + </div>
100 + </Panel>
101 + </div>
102 + <Panel title="Runtime API map" sub="action → response shape → entity types (§71) — the learned frontend API of the platform" pad={false}>
103 + <div className="p-4">
104 + {rps.length ? (
105 + <Table head={["endpoint", "entity types", "triggered by", "seen", "yield", "confidence"]} dense>
106 + {rps.map((r) => (
107 + <tr key={r.shape_hash}>
108 + <td className="mono text-[11.5px]">
109 + <Tag color="#9d7bff" className="mr-1">
110 + {r.method}
111 + </Tag>
112 + {r.hostname}
113 + <span className="text-fg-2">{r.path_pattern}</span>
114 + {r.graphql_operation && <Tag color="#ff7ad9" className="ml-1">{r.graphql_operation}</Tag>}
115 + <div className="text-[10px] text-dim">#{r.shape_hash.slice(0, 10)}</div>
116 + </td>
117 + <td>
118 + {Object.entries(r.likely_entity_types).map(([t, n]) => (
119 + <Tag key={t} color="#38e1ff" className="mr-1">
120 + {t} {n}
121 + </Tag>
122 + ))}
123 + </td>
124 + <td>
125 + {Object.entries(r.triggered_by).map(([a, n]) => (
126 + <Tag key={a} color="#ffb347" className="mr-1">
127 + {a.toLowerCase()} {n}
128 + </Tag>
129 + ))}
130 + </td>
131 + <td className="mono">{r.observed_count}</td>
132 + <td className="mono">{(r.entity_yield_total / Math.max(1, r.observed_count)).toFixed(1)} ent/resp</td>
133 + <td className="w-32">
134 + <Bar value={r.confidence} color={r.confidence >= 0.5 ? "#43e69a" : "#ffb347"} />
135 + <div className="mono text-[10px] text-dim">{r.confidence.toFixed(2)}</div>
136 + </td>
137 + </tr>
138 + ))}
139 + </Table>
140 + ) : (
141 + <Empty>no entity-bearing schema learned yet</Empty>
142 + )}
143 + </div>
144 + </Panel>
145 + <div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
146 + <Panel title="Navigation patterns" sub="action from a page type → where it leads and what it yields (§30)" pad={false}>
147 + <div className="p-4">
148 + <Table head={["action", "from", "→ to", "seen", "avg new ent", "avg resp", "fail"]} dense>
149 + {aps.map((a) => (
150 + <tr key={a.action_type + a.from_page_type}>
151 + <td>
152 + <Tag color="#ffb347">{a.action_type.toLowerCase()}</Tag>
153 + </td>
154 + <td>
155 + <Tag color="#38e1ff">{a.from_page_type}</Tag>
156 + </td>
157 + <td className="text-[11px]">
158 + {Object.entries(a.to_page_types)
159 + .sort((x, y) => y[1] - x[1])
160 + .map(([k, v]) => `${k} ${v}`)
161 + .join(", ")}
162 + </td>
163 + <td className="mono">{a.observed_count}</td>
164 + <td className="mono">{a.avg_new_entities.toFixed(1)}</td>
165 + <td className="mono">{a.avg_network_responses.toFixed(1)}</td>
166 + <td className="mono">{a.failures}</td>
167 + </tr>
168 + ))}
169 + </Table>
170 + </div>
171 + </Panel>
172 + <Panel title="Media delivery patterns" pad={false}>
173 + <div className="p-4">
174 + {Object.values(m.media_patterns).length ? (
175 + <Table head={["host", "kind", "seen"]} dense>
176 + {Object.values(m.media_patterns)
177 + .sort((a, b) => b.observed_count - a.observed_count)
178 + .map((mp) => (
179 + <tr key={mp.hostname + mp.kind}>
180 + <td className="mono text-[11.5px]">{mp.hostname}</td>
181 + <td>
182 + <Tag color="#ff7ad9">{mp.kind}</Tag>
183 + </td>
184 + <td className="mono">{mp.observed_count}</td>
185 + </tr>
186 + ))}
187 + </Table>
188 + ) : (
189 + <Empty>none yet</Empty>
190 + )}
191 + </div>
192 + </Panel>
193 + </div>
194 + </>
195 + )}
196 + </div>
197 + );
198 +}
added apps/dashboard/src/app/platforms/page.tsx +82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/app/schemas/page.tsx +97 −0
Large diff (97 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/app/sessions/[id]/page.tsx +405 −0
Large diff (405 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/app/sessions/page.tsx +69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/components/Brand.tsx +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/components/EntityTable.tsx +79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/components/LiveFeed.tsx +78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/components/Shell.tsx +78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/components/WorldGraph.tsx +237 −0
Large diff (237 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/components/charts.tsx +110 −0
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/components/ui.tsx +135 −0
Large diff (135 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/lib/api.ts +94 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/lib/auth.ts +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/lib/format.ts +84 −0
Large diff (84 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/src/proxy.ts +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added apps/dashboard/tsconfig.json +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added apps/worker/qa/screenshots.mjs +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified apps/worker/src/cli.ts +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified apps/worker/src/engine.ts +1 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified apps/worker/src/executor.ts +0 −0

Binary file not shown.

modified connectors/youtube/manifest.json +2993 −14
Large diff (3,065 lines) is not loaded by default to keep the page fast.Load diff
added deploy/social-runtime-crawler.mld.json +102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
modified package.json +5 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified packages/media/src/index.ts +2 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
modified packages/storage/src/index.ts +0 −0

Binary file not shown.

modified pnpm-lock.yaml +1358 −12
Large diff (1,692 lines) is not loaded by default to keep the page fast.Load diff
modified tsconfig.json +1 −1
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme