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
Unified Split
modified .gitignore +5 −0
@@ -14,3 +14,8 @@ data/platform_model/*/
14 14
15 # Compiled (learned) connectors are versioned intentionally; regression fixtures may be large. 15 # Compiled (learned) connectors are versioned intentionally; regression fixtures may be large.
16 connectors/*/regression_tests/large/ 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 "type": "module", 5 "type": "module",
6 "dependencies": { 6 "dependencies": {
7 "@src/shared": "workspace:*", 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 import fs from "node:fs"; 2 import fs from "node:fs";
3 import path from "node:path"; 3 import path from "node:path";
4 import { fileURLToPath } from "node:url"; 4 import { fileURLToPath } from "node:url";
5−import { createLogger, loadConfig } from "@src/shared"; 5+import { createLogger, isPlatform, loadConfig } from "@src/shared";
6−import { JsonlEventLog } from "@src/events";
7 import { PostgresStore } from "@src/storage"; 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+ * Control-plane + research API (§53, §63, §82). Plain node:http, JSON, SSE.
11− * Reads PostgreSQL when configured, else the JSONL session logs. 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 const cfg = loadConfig(); 15 const cfg = loadConfig();
15 const here = path.dirname(fileURLToPath(import.meta.url)); 16 const here = path.dirname(fileURLToPath(import.meta.url));
16 const publicDir = path.join(here, "..", "public"); 17 const publicDir = path.join(here, "..", "public");
17−let store: PostgresStore | undefined; 18+const API_TOKEN = process.env.SRC_API_TOKEN ?? "";
18−try { 19+const PORT = Number(process.env.SRC_API_PORT ?? cfg.dashboardPort);
19− if (cfg.databaseUrl) store = await PostgresStore.connect(cfg.databaseUrl); 20+
20−} catch (err) { 21+if (!cfg.databaseUrl) {
21− log.warn("no database — serving JSONL logs", { err: (err as Error).message }); 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 function json(res: http.ServerResponse, data: unknown, status = 200) { 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 res.end(JSON.stringify(data)); 32 res.end(JSON.stringify(data));
27 } 33 }
28− 34+function readBody(req: http.IncomingMessage): Promise<string> {
29−function sessionDirs(): string[] { 35+ return new Promise((resolve, reject) => {
30− return fs.existsSync(cfg.sessionsDir) ? fs.readdirSync(cfg.sessionsDir).filter((d) => fs.existsSync(path.join(cfg.sessionsDir, d, "events.jsonl"))).sort().reverse() : []; 36+ let s = "";
31−} 37+ req.on("data", (c) => {
32− 38+ s += c;
33−function jsonlSessions() { 39+ if (s.length > 1e6) reject(new Error("body too large"));
34− return sessionDirs().map((id) => { 40+ });
35− const job = readJson(path.join(cfg.sessionsDir, id, "job.json")) as Record<string, unknown> | undefined; 41+ req.on("end", () => resolve(s));
36− const summary = readJson(path.join(cfg.sessionsDir, id, "summary.json")) as Record<string, unknown> | undefined; 42+ req.on("error", reject);
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 };
38 }); 43 });
39 } 44 }
40− 45+function authorized(req: http.IncomingMessage): boolean {
41−function readJson(file: string): unknown { 46+ if (!API_TOKEN) return true; // dev
42− try { 47+ return req.headers["x-src-token"] === API_TOKEN;
43− return JSON.parse(fs.readFileSync(file, "utf8"));
44− } catch {
45− return undefined;
46− }
47 } 48 }
48 49
49 const server = http.createServer(async (req, res) => { 50 const server = http.createServer(async (req, res) => {
50 const url = new URL(req.url ?? "/", "http://localhost"); 51 const url = new URL(req.url ?? "/", "http://localhost");
51 const parts = url.pathname.split("/").filter(Boolean); 52 const parts = url.pathname.split("/").filter(Boolean);
53+ const method = req.method ?? "GET";
52 try { 54 try {
53− if (parts[0] === "api") { 55+ if (parts[0] !== "api" && parts[0] !== "media") {
54− if (parts[1] === "sessions" && !parts[2]) return json(res, store ? await store.listSessions() : jsonlSessions()); 56+ // legacy static console (apps/api/public) — kept for local debugging
55− if (parts[1] === "sessions" && parts[2]) { 57+ const file = path.join(publicDir, parts.length ? parts.join("/") : "index.html");
56− const id = parts[2]; 58+ if (!file.startsWith(publicDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
57− const dir = path.join(cfg.sessionsDir, id); 59+ res.writeHead(200, { "content-type": file.endsWith(".html") ? "text/html; charset=utf-8" : "application/octet-stream" });
58− const sub = parts[3]; 60+ return fs.createReadStream(file).pipe(res);
59− if (sub === "events") { 61+ }
60− const types = url.searchParams.get("types")?.split(",").filter(Boolean); 62+ if (parts[0] === "media") {
61− const limit = Number(url.searchParams.get("limit") ?? 300); 63+ const file = path.join(cfg.mediaDir, ...parts.slice(1).map(decodeURIComponent));
62− if (store) return json(res, await store.sessionEvents(id, { types, limit })); 64+ if (!file.startsWith(cfg.mediaDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404);
63− const evs = JsonlEventLog.read(dir).filter((e) => !types?.length || types.includes(e.event_type)); 65+ res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "public, max-age=86400" });
64− return json(res, evs.slice(-limit).reverse()); 66+ return fs.createReadStream(file).pipe(res);
65− } 67+ }
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>) }))); 68+ if (method !== "GET" && !authorized(req)) return json(res, { error: "unauthorized" }, 401);
67− if (sub === "entities") { 69+ const [, r1, r2, r3] = parts;
68− if (store) return json(res, await store.sessionEntities(id)); 70+
69− const seen = new Map<string, unknown>(); 71+ if (r1 === "health") return json(res, { ok: true, jobs_running: jobs.activeCount, db: true, ts: new Date().toISOString() });
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 }); 72+ if (r1 === "overview") return json(res, await queries.overview());
71− return json(res, [...seen.values()].reverse()); 73+ if (r1 === "platforms" && !r2) return json(res, queries.platforms());
72− } 74+ if (r1 === "platforms" && r2) return isPlatform(r2) ? json(res, queries.platformDetail(r2)) : json(res, { error: "unknown platform" }, 404);
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)); 75+ if (r1 === "sessions" && !r2) return json(res, await queries.sessions(Number(url.searchParams.get("limit") ?? 100)));
74− if (sub === "world") return json(res, readJson(path.join(dir, "world_model.json")) ?? { nodes: [], edges: [] }); 76+ if (r1 === "sessions" && r2) {
75− if (sub === "summary") return json(res, { job: readJson(path.join(dir, "job.json")), summary: readJson(path.join(dir, "summary.json")) }); 77+ const id = r2;
76− if (sub === "schemas") { 78+ if (!r3) {
77− const platform = (readJson(path.join(dir, "job.json")) as { platform?: string } | undefined)?.platform; 79+ const s = await queries.session(id);
78− if (store) return json(res, await store.schemas(platform)); 80+ return s ? json(res, s) : json(res, { error: "not found" }, 404);
79− return json(res, JsonlEventLog.read(dir).filter((e) => e.event_type === "NETWORK_SCHEMA_DISCOVERED").map((e) => e.payload)); 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]) { 125+ return res.end();
83− const f = path.join(cfg.platformModelDir, parts[2], "platform_model.json"); 126+ }
84− return json(res, readJson(f) ?? { error: "no model yet" }); 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") { 137+ if (r1 === "jobs" && r2 && r3 === "log") {
91− // serve sampled frames 138+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
92− const file = path.join(cfg.mediaDir, ...parts.slice(1)); 139+ return res.end(jobs.logTail(r2, Number(url.searchParams.get("lines") ?? 200)));
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;
97 } 140 }
98− const file = path.join(publicDir, parts.length ? parts.join("/") : "index.html"); 141+ if (r1 === "jobs" && r2 && r3 === "stop" && method === "POST") return json(res, { stopped: jobs.stop(r2) });
99− if (!file.startsWith(publicDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404); 142+
100− const ext = path.extname(file); 143+ return json(res, { error: "not found" }, 404);
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);
103 } catch (err) { 144 } catch (err) {
104 log.error("request failed", { url: req.url, err: (err as Error).message }); 145 log.error("request failed", { url: req.url, err: (err as Error).message });
105 json(res, { error: (err as Error).message }, 500); 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
@@ -0,0 +1,82 @@
1+"use client";
2+
3+import Link from "next/link";
4+import { useApi } from "@/lib/api";
5+import { Bar, Panel, Skeleton, Tag } from "@/components/ui";
6+
7+interface P {
8+ platform: string;
9+ learned: boolean;
10+ adapter: boolean;
11+ confidence: number;
12+ page_types: number;
13+ entity_types: number;
14+ navigation_actions: number;
15+ network_schemas: number;
16+ media_patterns: number;
17+ sessions: number;
18+ has_manifest: boolean;
19+ profiles: { alias: string; has_state: boolean }[];
20+}
21+
22+const PHASE: Record<string, string> = { youtube: "Phase 1", reddit: "Phase 1", facebook: "Phase 2", instagram: "Phase 2", tiktok: "Phase 3", x: "Phase 3", linkedin: "Phase 4", threads: "Phase 4" };
23+
24+export default function Platforms() {
25+ const { data, loading } = useApi<P[]>("/platforms", { refreshMs: 15_000 });
26+ return (
27+ <div className="space-y-4">
28+ <div>
29+ <h1 className="text-2xl font-semibold tracking-tight">Connectors</h1>
30+ <p className="text-sm text-fg-2 max-w-2xl">A connector is learned state, not hand-written truth: page types, response schemas, action → observation patterns and media patterns accumulated across sessions, compiled into a manifest once confidence is sufficient.</p>
31+ </div>
32+ {loading ? (
33+ <Skeleton rows={6} />
34+ ) : (
35+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
36+ {data?.map((p) => (
37+ <Link key={p.platform} href={`/platforms/${p.platform}`} className="block">
38+ <Panel className="h-full hover:border-cyan/40 transition">
39+ <div className="flex items-center gap-2">
40+ <span className="text-lg font-semibold capitalize">{p.platform}</span>
41+ <span className="ml-auto text-[10px] uppercase tracking-widest text-dim">{PHASE[p.platform]}</span>
42+ </div>
43+ <div className="flex gap-1 mt-2 flex-wrap">
44+ <Tag color={p.adapter ? "#43e69a" : "#6b7a8c"}>{p.adapter ? "adapter" : "no adapter"}</Tag>
45+ <Tag color={p.learned ? "#38e1ff" : "#6b7a8c"}>{p.learned ? "learned" : "unlearned"}</Tag>
46+ {p.has_manifest && <Tag color="#9d7bff">compiled</Tag>}
47+ {p.profiles.map((pr) => (
48+ <Tag key={pr.alias} color={pr.has_state ? "#ffb347" : "#6b7a8c"}>
49+ profile {pr.alias}
50+ </Tag>
51+ ))}
52+ </div>
53+ <div className="mt-4">
54+ <div className="flex justify-between text-xs text-dim mb-1">
55+ <span>connector confidence</span>
56+ <span className="mono text-fg">{p.confidence}%</span>
57+ </div>
58+ <Bar value={p.confidence / 100} color={p.confidence >= 70 ? "#43e69a" : p.confidence >= 40 ? "#38e1ff" : "#ffb347"} />
59+ </div>
60+ <dl className="grid grid-cols-2 gap-x-3 gap-y-1 mt-4 text-xs">
61+ {[
62+ ["page types", p.page_types],
63+ ["entity types", p.entity_types],
64+ ["navigation actions", p.navigation_actions],
65+ ["network schemas", p.network_schemas],
66+ ["media patterns", p.media_patterns],
67+ ["sessions", p.sessions],
68+ ].map(([k, v]) => (
69+ <div key={k as string} className="flex justify-between border-b border-line/60 py-1">
70+ <dt className="text-dim">{k as string}</dt>
71+ <dd className="mono">{v as number}</dd>
72+ </div>
73+ ))}
74+ </dl>
75+ </Panel>
76+ </Link>
77+ ))}
78+ </div>
79+ )}
80+ </div>
81+ );
82+}
added apps/dashboard/src/app/schemas/page.tsx +97 −0
@@ -0,0 +1,97 @@
1+"use client";
2+
3+import { useState } from "react";
4+import { useApi } from "@/lib/api";
5+import { fmtDate } from "@/lib/format";
6+import { Empty, Panel, Skeleton, Table, Tag } from "@/components/ui";
7+
8+interface Schema {
9+ platform: string;
10+ shape_hash: string;
11+ fingerprint: { hostname: string; path_pattern: string; method: string; graphql_operation?: string; observed_entity_types?: string[] };
12+ schema: { candidate_entity_types: { type: string; confidence: number; path: string }[]; fields: { path: string; semantic?: { kind: string; confidence: number }; example?: string; types?: string[] }[]; object_count: number; repeated_object_paths: string[] };
13+ observed_count: number;
14+ sample_url: string;
15+ first_seen: string;
16+ last_seen: string;
17+}
18+
19+export default function Schemas() {
20+ const [platform, setPlatform] = useState("");
21+ const { data, loading } = useApi<Schema[]>(`/schemas${platform ? `?platform=${platform}` : ""}`, { refreshMs: 15_000 });
22+ const platforms = [...new Set((data ?? []).map((s) => s.platform))];
23+ return (
24+ <div className="space-y-4">
25+ <div className="flex flex-wrap items-end gap-3">
26+ <div>
27+ <h1 className="text-2xl font-semibold tracking-tight">Runtime APIs</h1>
28+ <p className="text-sm text-fg-2 max-w-2xl">Response shapes the browser received while a human-like session unfolded. Endpoints are fingerprinted by (host, path pattern, method, structure hash) and their fields are semantically inferred — nothing here was written by hand.</p>
29+ </div>
30+ <select value={platform} onChange={(e) => setPlatform(e.target.value)} className="ml-auto rounded-lg bg-bg-2 border border-line-2 px-2 py-1.5 text-sm">
31+ <option value="">all platforms</option>
32+ {["youtube", "reddit", ...platforms.filter((p) => !["youtube", "reddit"].includes(p))].map((p) => (
33+ <option key={p} value={p}>
34+ {p}
35+ </option>
36+ ))}
37+ </select>
38+ </div>
39+ {loading ? (
40+ <Skeleton rows={8} />
41+ ) : data?.length ? (
42+ <div className="space-y-3">
43+ {data.map((sc) => (
44+ <details key={sc.platform + sc.shape_hash} className="panel open:glow-cyan/30">
45+ <summary className="cursor-pointer px-4 py-3 flex flex-wrap items-center gap-2 text-sm">
46+ <Tag>{sc.platform}</Tag>
47+ <Tag color="#9d7bff">{sc.fingerprint.method}</Tag>
48+ <span className="mono">
49+ {sc.fingerprint.hostname}
50+ <span className="text-fg-2">{sc.fingerprint.path_pattern}</span>
51+ </span>
52+ {sc.fingerprint.graphql_operation && <Tag color="#ff7ad9">{sc.fingerprint.graphql_operation}</Tag>}
53+ <span className="ml-auto flex gap-1 flex-wrap">
54+ {sc.schema.candidate_entity_types.slice(0, 5).map((c) => (
55+ <Tag key={c.type + c.path} color="#38e1ff" title={c.path}>
56+ {c.type} {Number(c.confidence).toFixed(2)}
57+ </Tag>
58+ ))}
59+ </span>
60+ <span className="mono text-xs text-dim whitespace-nowrap">
61+ seen {sc.observed_count}× · {sc.schema.object_count} objects · #{sc.shape_hash.slice(0, 8)}
62+ </span>
63+ </summary>
64+ <div className="px-4 pb-4 space-y-3">
65+ <div className="text-[11px] text-dim mono break-all">
66+ sample {sc.sample_url} · first {fmtDate(sc.first_seen)} · last {fmtDate(sc.last_seen)}
67+ </div>
68+ {sc.schema.repeated_object_paths?.length ? (
69+ <div className="text-xs text-fg-2">
70+ repeated object arrays (entity lists): <span className="mono">{sc.schema.repeated_object_paths.slice(0, 5).join(" · ")}</span>
71+ </div>
72+ ) : null}
73+ <Table head={["field path", "inferred semantic", "conf", "types", "example"]} dense>
74+ {sc.schema.fields.slice(0, 80).map((f) => (
75+ <tr key={f.path}>
76+ <td className="mono text-[11px] text-fg-2 max-w-[560px] truncate" title={f.path}>
77+ {f.path}
78+ </td>
79+ <td>{f.semantic ? <Tag color={f.semantic.kind === "identifier" ? "#ffb347" : f.semantic.kind.includes("url") ? "#ff7ad9" : "#43e69a"}>{f.semantic.kind}</Tag> : "—"}</td>
80+ <td className="mono text-[11px]">{f.semantic ? Number(f.semantic.confidence).toFixed(2) : ""}</td>
81+ <td className="mono text-[11px] text-dim">{f.types?.join("|")}</td>
82+ <td className="mono text-[11px] text-dim max-w-[320px] truncate">{f.example}</td>
83+ </tr>
84+ ))}
85+ </Table>
86+ </div>
87+ </details>
88+ ))}
89+ </div>
90+ ) : (
91+ <Panel>
92+ <Empty>No schemas discovered yet.</Empty>
93+ </Panel>
94+ )}
95+ </div>
96+ );
97+}
added apps/dashboard/src/app/sessions/[id]/page.tsx +405 −0
@@ -0,0 +1,405 @@
1+"use client";
2+
3+import { use, useMemo, useState } from "react";
4+import Link from "next/link";
5+import clsx from "clsx";
6+import { ExternalLink } from "lucide-react";
7+import { useApi } from "@/lib/api";
8+import { ago, eventColor, fmtDate, fmtDuration, fmtTime, truncate } from "@/lib/format";
9+import { Bar, Empty, Metric, Panel, Skeleton, StatusPill, Tag, Table } from "@/components/ui";
10+import { GainChart } from "@/components/charts";
11+import { LiveFeed } from "@/components/LiveFeed";
12+import { WorldGraph, type GraphEdge, type GraphNode } from "@/components/WorldGraph";
13+import { EntityTable, type EntityRow } from "@/components/EntityTable";
14+
15+interface Session {
16+ session_id: string;
17+ platform: string;
18+ account_alias: string;
19+ mode: string | null;
20+ goal: string | null;
21+ started_at: string;
22+ ended_at: string | null;
23+ health: string;
24+ job: { budget?: Record<string, number>; query?: string; media_level?: number } | null;
25+ summary: { steps: number; entities: number; videos: number; network_responses: number; schemas: number; patterns_learned: number; ended_because: string; world: { nodes: number; edges: number; visited: number; multi_surface: number; by_type: Record<string, number> }; connector: { confidence: number; page_types: number; network_schemas: number } } | null;
26+ event_counts: { event_type: string; n: number }[];
27+}
28+interface Action {
29+ step: number;
30+ action_type: string;
31+ label: string;
32+ target_url: string | null;
33+ planner: string;
34+ expected_gain: number;
35+ novelty: number;
36+ relevance: number;
37+ reason: string;
38+ scores: { action_id: string; information_gain: number; penalties: string[] }[];
39+ before_state: { url: string; page_type: string; visible_entities: number } | null;
40+ after_state: { url: string; page_type: string; visible_entities: number; new_entities: number; network_responses: number; dom_nodes_added: number } | null;
41+ success: boolean;
42+ error: string | null;
43+ duration_ms: number;
44+ ts: string;
45+}
46+interface PageRow {
47+ step: number;
48+ ts: string;
49+ payload: { url: string; title: string; page_type: string; confidence: number; signals: string[]; entities: number; dom_entities: number; network_entities: number; both_surfaces: number; field_agreements: number; field_conflicts: { field: string; network: unknown; dom: unknown }[]; videos: number; summary: string };
50+}
51+interface Media {
52+ fingerprint: string;
53+ media_type: string;
54+ platform_media_id: string | null;
55+ title: string | null;
56+ author: string | null;
57+ duration_s: number | null;
58+ width: number | null;
59+ height: number | null;
60+ thumbnail_url: string | null;
61+ page_url: string | null;
62+ delivery: { kind: string; hostnames: string[]; manifest_url?: string } | null;
63+ frames: string[] | null;
64+}
65+interface Schema {
66+ shape_hash: string;
67+ fingerprint: { hostname: string; path_pattern: string; method: string; graphql_operation?: string };
68+ schema: { candidate_entity_types: { type: string; confidence: number; path: string }[]; fields: { path: string; semantic?: { kind: string; confidence: number }; example?: string }[]; object_count: number };
69+ observed_count: number;
70+ sample_url: string;
71+}
72+
73+const TABS = ["decisions", "pages", "entities", "runtime apis", "media", "world model", "events"] as const;
74+
75+export default function SessionConsole({ params }: { params: Promise<{ id: string }> }) {
76+ const { id } = use(params);
77+ const [tab, setTab] = useState<(typeof TABS)[number]>("decisions");
78+ const s = useApi<Session>(`/sessions/${id}`, { refreshMs: 10_000 });
79+ const live = !s.data?.ended_at;
80+ const actions = useApi<Action[]>(`/sessions/${id}/actions`, { refreshMs: live ? 5000 : undefined });
81+ const pages = useApi<PageRow[]>(`/sessions/${id}/pages`, { refreshMs: live ? 5000 : undefined });
82+ const entities = useApi<EntityRow[]>(tab === "entities" ? `/sessions/${id}/entities?limit=500` : null, { refreshMs: live ? 8000 : undefined });
83+ const media = useApi<Media[]>(tab === "media" ? `/sessions/${id}/media` : null);
84+ const schemas = useApi<Schema[]>(tab === "runtime apis" ? `/sessions/${id}/schemas` : null);
85+ const world = useApi<{ nodes: GraphNode[]; edges: GraphEdge[] }>(tab === "world model" ? `/sessions/${id}/world` : null, { refreshMs: live ? 10_000 : undefined });
86+ const [selStep, setSelStep] = useState<number | null>(null);
87+
88+ const gainSeries = useMemo(() => (actions.data ?? []).map((a) => ({ step: a.step, expected_gain: Number(a.expected_gain), novelty: Number(a.novelty), relevance: Number(a.relevance), action_type: a.action_type, new_entities: a.after_state?.new_entities })), [actions.data]);
89+ const sess = s.data;
90+ const sum = sess?.summary;
91+ const lastPage = pages.data?.[pages.data.length - 1];
92+ const selPage = selStep !== null ? pages.data?.find((p) => p.step === selStep) : lastPage;
93+
94+ return (
95+ <div className="space-y-4">
96+ <div className="flex flex-wrap items-start gap-3">
97+ <div className="min-w-0">
98+ <div className="flex items-center gap-2 text-xs text-dim mono">
99+ <Link href="/sessions" className="hover:text-cyan">sessions</Link> / {id}
100+ </div>
101+ <h1 className="text-xl font-semibold tracking-tight mt-1 truncate max-w-3xl">{sess?.goal ?? "…"}</h1>
102+ <div className="flex flex-wrap items-center gap-2 mt-2 text-xs">
103+ {sess && <StatusPill status={sess.ended_at ? (sess.health === "auth_required" ? "auth_required" : "done") : sess.health} />}
104+ {sess && <Tag>{sess.platform}</Tag>}
105+ {sess?.mode && <Tag color="#ffb347">{sess.mode}</Tag>}
106+ {sess?.job?.query && <Tag color="#9d7bff">“{sess.job.query}”</Tag>}
107+ {sess && <span className="text-dim">started {fmtDate(sess.started_at)} · {sess.ended_at ? `ended ${ago(sess.ended_at)}` : "running"}</span>}
108+ {sum && <span className="text-dim">· ended because <span className="text-fg-2">{sum.ended_because}</span></span>}
109+ </div>
110+ </div>
111+ <div className="ml-auto grid grid-cols-3 sm:grid-cols-6 gap-2 text-center">
112+ {[
113+ ["steps", actions.data?.length ?? sum?.steps],
114+ ["entities", sum?.entities ?? sess?.event_counts.filter((c) => /DISCOVERED/.test(c.event_type)).reduce((a, c) => a + c.n, 0)],
115+ ["videos", sum?.videos],
116+ ["responses", sum?.network_responses ?? sess?.event_counts.find((c) => c.event_type === "NETWORK_RESPONSE_OBSERVED")?.n],
117+ ["schemas", sum?.schemas ?? sess?.event_counts.find((c) => c.event_type === "NETWORK_SCHEMA_DISCOVERED")?.n],
118+ ["learned", sum?.patterns_learned ?? sess?.event_counts.find((c) => c.event_type === "CONNECTOR_PATTERN_LEARNED")?.n],
119+ ].map(([k, v]) => (
120+ <div key={k as string} className="panel px-3 py-2">
121+ <div className="mono text-lg text-cyan">{v ?? "—"}</div>
122+ <div className="text-[10px] uppercase tracking-widest text-dim">{k as string}</div>
123+ </div>
124+ ))}
125+ </div>
126+ </div>
127+
128+ <div className="flex gap-1 border-b border-line text-sm">
129+ {TABS.map((t) => (
130+ <button key={t} onClick={() => setTab(t)} className={clsx("px-3 py-2 -mb-px border-b-2 capitalize transition", tab === t ? "border-cyan text-cyan" : "border-transparent text-fg-2 hover:text-fg")}>
131+ {t}
132+ </button>
133+ ))}
134+ </div>
135+
136+ {tab === "decisions" && (
137+ <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
138+ <div className="xl:col-span-2 space-y-4">
139+ <Panel title="Information gain per step" sub="what the agent expected of each move">
140+ {gainSeries.length ? <GainChart data={gainSeries} /> : <Empty>no decisions yet</Empty>}
141+ </Panel>
142+ <Panel title="Agent decisions" sub="why did the crawler do that? — click a step to see its page state" pad={false}>
143+ {actions.loading ? (
144+ <div className="p-4">
145+ <Skeleton rows={6} />
146+ </div>
147+ ) : (
148+ <ul className="divide-y divide-line/60 max-h-[720px] overflow-auto scrollbar-thin">
149+ {(actions.data ?? [])
150+ .slice()
151+ .reverse()
152+ .map((a) => (
153+ <li key={a.step} onClick={() => setSelStep(a.step)} className={clsx("px-4 py-2.5 cursor-pointer hover:bg-white/[0.03]", selStep === a.step && "bg-cyan/5")}>
154+ <div className="flex items-center gap-2 text-sm">
155+ <span className="mono text-dim w-8">#{a.step}</span>
156+ <Tag color={a.success ? "#43e69a" : "#ff5c7a"}>{a.action_type.replace(/_/g, " ").toLowerCase()}</Tag>
157+ <span className="truncate text-fg">{truncate(a.label.replace(/^Open \w+ E\d+: /, ""), 80)}</span>
158+ <span className="ml-auto mono text-xs text-amber whitespace-nowrap">gain {Number(a.expected_gain).toFixed(3)}</span>
159+ </div>
160+ <div className="flex flex-wrap gap-x-4 text-[11px] text-dim mono mt-1 ml-10">
161+ <span>nov {Number(a.novelty).toFixed(2)}</span>
162+ <span>rel {Number(a.relevance).toFixed(2)}</span>
163+ <span>{a.planner}</span>
164+ {a.after_state && (
165+ <span>
166+ → {a.after_state.page_type} · +{a.after_state.new_entities} ent · {a.after_state.network_responses} resp · {a.duration_ms} ms
167+ </span>
168+ )}
169+ {a.error && <span className="text-red">{truncate(a.error, 60)}</span>}
170+ </div>
171+ <div className="text-xs text-fg-2 mt-1 ml-10">{a.reason}</div>
172+ </li>
173+ ))}
174+ </ul>
175+ )}
176+ </Panel>
177+ </div>
178+ <div className="space-y-4">
179+ <Panel title={selStep !== null ? `Page state after step ${selStep}` : "Current page state"} sub="semantic DOM representation given to the planner">
180+ {selPage ? (
181+ <div className="space-y-2">
182+ <div className="flex items-center gap-2 text-xs">
183+ <Tag color="#38e1ff">{selPage.payload.page_type}</Tag>
184+ <span className="mono text-dim">conf {Number(selPage.payload.confidence).toFixed(2)}</span>
185+ <a href={selPage.payload.url} target="_blank" rel="noreferrer" className="ml-auto text-dim hover:text-cyan">
186+ <ExternalLink className="h-3.5 w-3.5" />
187+ </a>
188+ </div>
189+ <div className="text-[11px] text-dim break-all">{selPage.payload.url}</div>
190+ <div className="grid grid-cols-4 gap-2 text-center text-[10.5px] mono">
191+ {[
192+ ["entities", selPage.payload.entities],
193+ ["dom", selPage.payload.dom_entities],
194+ ["network", selPage.payload.network_entities],
195+ ["both", selPage.payload.both_surfaces],
196+ ].map(([k, v]) => (
197+ <div key={k as string} className="rounded bg-bg-2 py-1">
198+ <div className="text-fg">{v as number}</div>
199+ <div className="text-dim">{k as string}</div>
200+ </div>
201+ ))}
202+ </div>
203+ {selPage.payload.field_conflicts?.length > 0 && (
204+ <div className="text-[11px] text-amber">
205+ {selPage.payload.field_conflicts.length} field conflict(s): {selPage.payload.field_conflicts.map((c) => c.field).join(", ")}
206+ </div>
207+ )}
208+ <pre className="mono text-[11px] leading-5 text-fg-2 whitespace-pre-wrap max-h-[520px] overflow-auto scrollbar-thin bg-bg-2 rounded p-3">{selPage.payload.summary}</pre>
209+ </div>
210+ ) : (
211+ <Empty>no page state yet</Empty>
212+ )}
213+ </Panel>
214+ <Panel title="Budget">
215+ {sess?.job?.budget ? (
216+ <div>
217+ <Metric label="max minutes" value={sess.job.budget.max_minutes} />
218+ <Metric label="max actions" value={sess.job.budget.max_actions} />
219+ <Metric label="max profiles opened" value={sess.job.budget.max_profiles} />
220+ <Metric label="max videos opened" value={sess.job.budget.max_videos} />
221+ <Metric label="media level" value={sess.job.media_level ?? 1} />
222+ <div className="mt-3 text-[10.5px] text-dim">steps used</div>
223+ <Bar value={(actions.data?.length ?? 0) / Math.max(1, sess.job.budget.max_actions)} color="#ffb347" />
224+ </div>
225+ ) : (
226+ <Skeleton rows={3} />
227+ )}
228+ </Panel>
229+ </div>
230+ </div>
231+ )}
232+
233+ {tab === "pages" && (
234+ <Panel title="Navigation path" sub="each page the browser reached, classified" pad={false}>
235+ <div className="p-4">
236+ {pages.data?.length ? (
237+ <Table head={["step", "time", "page type", "conf", "url", "entities (dom / net / both)", "agreements", "videos"]} dense>
238+ {pages.data.map((p) => (
239+ <tr key={`${p.step}-${p.ts}`} onClick={() => setSelStep(p.step)} className="cursor-pointer">
240+ <td className="mono">#{p.step}</td>
241+ <td className="mono text-dim">{fmtTime(p.ts)}</td>
242+ <td>
243+ <Tag color="#38e1ff">{p.payload.page_type}</Tag>
244+ </td>
245+ <td className="mono">{Number(p.payload.confidence).toFixed(2)}</td>
246+ <td className="max-w-[420px] truncate text-fg-2">
247+ <a href={p.payload.url} target="_blank" rel="noreferrer" className="hover:text-cyan">
248+ {truncate(p.payload.url, 80)}
249+ </a>
250+ </td>
251+ <td className="mono">
252+ {p.payload.entities} <span className="text-dim">({p.payload.dom_entities} / {p.payload.network_entities} / {p.payload.both_surfaces})</span>
253+ </td>
254+ <td className="mono">
255+ {p.payload.field_agreements}
256+ {p.payload.field_conflicts?.length ? <span className="text-amber"> / {p.payload.field_conflicts.length}✗</span> : ""}
257+ </td>
258+ <td className="mono">{p.payload.videos}</td>
259+ </tr>
260+ ))}
261+ </Table>
262+ ) : (
263+ <Empty>no pages yet</Empty>
264+ )}
265+ </div>
266+ </Panel>
267+ )}
268+
269+ {tab === "entities" && (
270+ <Panel title="Entities observed in this session" sub="every field keeps its provenance (network / dom) and confidence" pad={false}>
271+ <div className="p-4">{entities.loading ? <Skeleton rows={8} /> : entities.data?.length ? <EntityTable rows={entities.data} /> : <Empty>no entities yet</Empty>}</div>
272+ </Panel>
273+ )}
274+
275+ {tab === "runtime apis" && (
276+ <Panel title="Runtime API discovery" sub="response shapes fingerprinted on this platform — no endpoint was hardcoded" pad={false}>
277+ <div className="p-4 space-y-3">
278+ {schemas.loading ? (
279+ <Skeleton rows={6} />
280+ ) : (
281+ (schemas.data ?? []).map((sc) => (
282+ <details key={sc.shape_hash} className="rounded-lg border border-line bg-bg-2/40 open:bg-bg-2/70">
283+ <summary className="cursor-pointer px-3 py-2 flex flex-wrap items-center gap-2 text-sm">
284+ <Tag color="#9d7bff">{sc.fingerprint.method}</Tag>
285+ <span className="mono text-fg">
286+ {sc.fingerprint.hostname}
287+ <span className="text-fg-2">{sc.fingerprint.path_pattern}</span>
288+ </span>
289+ {sc.fingerprint.graphql_operation && <Tag color="#ff7ad9">{sc.fingerprint.graphql_operation}</Tag>}
290+ <span className="ml-auto flex gap-1">
291+ {sc.schema.candidate_entity_types.slice(0, 4).map((c) => (
292+ <Tag key={c.type + c.path} color="#38e1ff">
293+ {c.type} {Number(c.confidence).toFixed(2)}
294+ </Tag>
295+ ))}
296+ </span>
297+ <span className="mono text-xs text-dim">seen {sc.observed_count}× · {sc.schema.object_count} objects · #{sc.shape_hash.slice(0, 8)}</span>
298+ </summary>
299+ <div className="px-3 pb-3">
300+ <div className="text-[11px] text-dim mono mb-2 break-all">sample: {sc.sample_url}</div>
301+ <Table head={["field path", "inferred semantic", "conf", "example"]} dense>
302+ {sc.schema.fields.slice(0, 60).map((f) => (
303+ <tr key={f.path}>
304+ <td className="mono text-[11px] text-fg-2 max-w-[520px] truncate">{f.path}</td>
305+ <td>{f.semantic ? <Tag color="#43e69a">{f.semantic.kind}</Tag> : "—"}</td>
306+ <td className="mono text-[11px]">{f.semantic ? Number(f.semantic.confidence).toFixed(2) : ""}</td>
307+ <td className="mono text-[11px] text-dim max-w-[300px] truncate">{f.example}</td>
308+ </tr>
309+ ))}
310+ </Table>
311+ </div>
312+ </details>
313+ ))
314+ )}
315+ </div>
316+ </Panel>
317+ )}
318+
319+ {tab === "media" && (
320+ <Panel title="Media" sub="videos detected on DOM, network and entity surfaces" pad={false}>
321+ <div className="p-4 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
322+ {media.loading ? (
323+ <Skeleton rows={6} />
324+ ) : media.data?.length ? (
325+ media.data.map((m) => (
326+ <div key={m.fingerprint} className="rounded-lg border border-line bg-bg-2/40 overflow-hidden">
327+ {m.frames?.length ? (
328+ <div className="grid grid-cols-5 gap-px bg-line">
329+ {m.frames.map((f) => (
330+ // eslint-disable-next-line @next/next/no-img-element
331+ <img key={f} src={`/media/${f.split("/media/")[1] ?? ""}`} alt="" className="aspect-video object-cover w-full" />
332+ ))}
333+ </div>
334+ ) : m.thumbnail_url ? (
335+ // eslint-disable-next-line @next/next/no-img-element
336+ <img src={m.thumbnail_url} alt="" className="aspect-video object-cover w-full" />
337+ ) : (
338+ <div className="aspect-video grid place-items-center text-dim text-xs">no frame</div>
339+ )}
340+ <div className="p-3 space-y-1">
341+ <div className="text-sm truncate">{m.title ?? m.platform_media_id}</div>
342+ <div className="text-[11px] text-dim mono flex flex-wrap gap-x-3">
343+ <span>{m.author ?? ""}</span>
344+ <span>{fmtDuration(m.duration_s)}</span>
345+ {m.width ? <span>{m.width}×{m.height}</span> : null}
346+ {m.delivery?.kind && <span>{m.delivery.kind}</span>}
347+ {m.delivery?.hostnames?.length ? <span>{m.delivery.hostnames.slice(0, 2).join(", ")}</span> : null}
348+ </div>
349+ <Link href={`/entities/${encodeURIComponent(m.fingerprint)}`} className="text-[11px] text-cyan">evidence →</Link>
350+ </div>
351+ </div>
352+ ))
353+ ) : (
354+ <Empty>no media yet</Empty>
355+ )}
356+ </div>
357+ </Panel>
358+ )}
359+
360+ {tab === "world model" && (
361+ <Panel title="Social world model" sub="graph of entities and relationships discovered in this session — orange ring = visited by the crawler" pad={false}>
362+ <div className="p-2">{world.data ? <WorldGraph nodes={world.data.nodes} edges={world.data.edges} height={620} /> : <Skeleton rows={8} />}</div>
363+ </Panel>
364+ )}
365+
366+ {tab === "events" && (
367+ <div className="grid grid-cols-1 xl:grid-cols-4 gap-4">
368+ <Panel title="Event mix">
369+ <ul className="space-y-1 text-xs mono">
370+ {(sess?.event_counts ?? [])
371+ .slice()
372+ .sort((a, b) => b.n - a.n)
373+ .map((c) => (
374+ <li key={c.event_type} className="flex justify-between gap-2">
375+ <Tag color={eventColor(c.event_type)}>{c.event_type.toLowerCase()}</Tag>
376+ <span>{c.n}</span>
377+ </li>
378+ ))}
379+ </ul>
380+ </Panel>
381+ <Panel title="Raw observation stream" sub={live ? "live" : "session ended — showing the tail"} className="xl:col-span-3" pad={false}>
382+ <div className="p-2">{live ? <LiveFeed session={id} max={200} height={640} /> : <EventsTail id={id} />}</div>
383+ </Panel>
384+ </div>
385+ )}
386+ </div>
387+ );
388+}
389+
390+function EventsTail({ id }: { id: string }) {
391+ const { data } = useApi<{ event_id: string; event_type: string; step: number; ts: string; payload: Record<string, unknown> }[]>(`/sessions/${id}/events?limit=400`);
392+ if (!data) return <Skeleton rows={8} />;
393+ return (
394+ <ul className="mono text-[11.5px] space-y-0.5 max-h-[640px] overflow-auto scrollbar-thin">
395+ {data.map((e) => (
396+ <li key={e.event_id} className="flex gap-2 leading-5 px-1">
397+ <span className="text-dim">{fmtTime(e.ts)}</span>
398+ <span className="text-dim w-7 text-right">#{e.step ?? 0}</span>
399+ <Tag color={eventColor(e.event_type)}>{e.event_type.replace(/_/g, " ").toLowerCase()}</Tag>
400+ <span className="text-fg-2 truncate">{truncate(JSON.stringify(e.payload), 160)}</span>
401+ </li>
402+ ))}
403+ </ul>
404+ );
405+}
added apps/dashboard/src/app/sessions/page.tsx +69 −0
@@ -0,0 +1,69 @@
1+"use client";
2+
3+import Link from "next/link";
4+import { useApi } from "@/lib/api";
5+import { ago, fmtDate, truncate } from "@/lib/format";
6+import { Empty, Panel, Skeleton, StatusPill, Table, Tag } from "@/components/ui";
7+
8+interface Row {
9+ session_id: string;
10+ platform: string;
11+ account_alias: string;
12+ mode: string | null;
13+ goal: string | null;
14+ started_at: string;
15+ ended_at: string | null;
16+ health: string;
17+ actions: number;
18+ entities: number;
19+ schemas: number;
20+ patterns: number;
21+ ended_because: string | null;
22+}
23+
24+export default function Sessions() {
25+ const { data, loading } = useApi<Row[]>("/sessions?limit=200", { refreshMs: 10_000 });
26+ return (
27+ <div className="space-y-4">
28+ <h1 className="text-2xl font-semibold tracking-tight">Sessions</h1>
29+ <Panel pad={false}>
30+ <div className="p-4">
31+ {loading ? (
32+ <Skeleton rows={8} />
33+ ) : data?.length ? (
34+ <Table head={["status", "platform", "goal", "mode", "steps", "entities", "schemas", "learned", "started", "ended"]}>
35+ {data.map((r) => (
36+ <tr key={r.session_id}>
37+ <td>
38+ <StatusPill status={r.ended_at ? (r.health === "auth_required" ? "auth_required" : "done") : r.health} />
39+ </td>
40+ <td>
41+ <Tag>{r.platform}</Tag>
42+ <div className="text-[10px] text-dim mono">{r.account_alias}</div>
43+ </td>
44+ <td className="max-w-[420px]">
45+ <Link href={`/sessions/${r.session_id}`} className="hover:text-cyan">
46+ {truncate(r.goal ?? "(no goal)", 90)}
47+ </Link>
48+ <div className="text-[10px] text-dim mono">{r.session_id}</div>
49+ </td>
50+ <td className="text-fg-2">{r.mode ?? "—"}</td>
51+ <td className="mono">{r.actions}</td>
52+ <td className="mono">{r.entities}</td>
53+ <td className="mono">{r.schemas}</td>
54+ <td className="mono">{r.patterns}</td>
55+ <td className="text-dim whitespace-nowrap" title={fmtDate(r.started_at)}>
56+ {ago(r.started_at)}
57+ </td>
58+ <td className="text-dim whitespace-nowrap">{r.ended_because ?? (r.ended_at ? "—" : "running")}</td>
59+ </tr>
60+ ))}
61+ </Table>
62+ ) : (
63+ <Empty>No sessions yet.</Empty>
64+ )}
65+ </div>
66+ </Panel>
67+ </div>
68+ );
69+}
added apps/dashboard/src/components/Brand.tsx +33 −0
@@ -0,0 +1,33 @@
1+import clsx from "clsx";
2+
3+/** Social Runtime Crawler mark: an eye-like orbit — the crawler *watches* a runtime rather than fetching pages. */
4+export function Mark({ className }: { className?: string }) {
5+ return (
6+ <svg viewBox="0 0 48 48" className={className} aria-hidden>
7+ <defs>
8+ <linearGradient id="srcg" x1="0" x2="1" y1="0" y2="1">
9+ <stop offset="0" stopColor="#38e1ff" />
10+ <stop offset="1" stopColor="#9d7bff" />
11+ </linearGradient>
12+ </defs>
13+ <ellipse cx="24" cy="24" rx="20" ry="11" fill="none" stroke="url(#srcg)" strokeWidth="2.2" transform="rotate(-24 24 24)" />
14+ <ellipse cx="24" cy="24" rx="20" ry="11" fill="none" stroke="url(#srcg)" strokeWidth="1.2" opacity=".5" transform="rotate(36 24 24)" />
15+ <circle cx="24" cy="24" r="5.5" fill="url(#srcg)" />
16+ <circle cx="41" cy="16" r="2.2" fill="#ffb347" />
17+ </svg>
18+ );
19+}
20+
21+export function Brand({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
22+ return (
23+ <div className={clsx("flex items-center gap-3", size === "lg" && "gap-4")}>
24+ <Mark className={clsx(size === "sm" ? "h-6 w-6" : size === "lg" ? "h-12 w-12" : "h-8 w-8")} />
25+ <div className="leading-tight">
26+ <div className={clsx("font-semibold tracking-tight", size === "lg" ? "text-xl" : "text-sm")}>
27+ Social<span className="text-cyan">Crawl</span>
28+ </div>
29+ <div className={clsx("text-dim uppercase tracking-[0.2em]", size === "lg" ? "text-[11px]" : "text-[9px]")}>Social Runtime Mining</div>
30+ </div>
31+ </div>
32+ );
33+}
added apps/dashboard/src/components/EntityTable.tsx +79 −0
@@ -0,0 +1,79 @@
1+"use client";
2+
3+import Link from "next/link";
4+import { ExternalLink } from "lucide-react";
5+import { fmtDuration, fmtNum, truncate } from "@/lib/format";
6+import { SurfaceChips, Table, TypeTag } from "./ui";
7+
8+export interface EntityRow {
9+ fingerprint: string;
10+ entity_type?: string;
11+ type?: string;
12+ platform: string;
13+ platform_id?: string | null;
14+ url?: string | null;
15+ name?: string | null;
16+ text_excerpt?: string | null;
17+ text?: string | null;
18+ author?: string | null;
19+ metrics?: Record<string, number> | null;
20+ media?: { has_video?: boolean; duration_s?: number; thumbnail_url?: string } | null;
21+ fields?: Record<string, { value: unknown; provenance: { surface: string; confidence: number; detail?: string }[] }> | null;
22+ provenance?: { surface: string; confidence: number; detail?: string }[] | null;
23+ confidence?: number | null;
24+ seen_count?: number;
25+ last_seen?: string;
26+ last_step?: number;
27+}
28+
29+export function provenanceOf(e: EntityRow) {
30+ if (e.provenance?.length) return e.provenance;
31+ if (e.fields) return Object.values(e.fields).flatMap((f) => f.provenance ?? []);
32+ return [];
33+}
34+
35+export function EntityTable({ rows, showPlatform = false, dense = true }: { rows: EntityRow[]; showPlatform?: boolean; dense?: boolean }) {
36+ return (
37+ <Table head={["type", "entity", "author", "metrics", "evidence", "conf", ""]} dense={dense}>
38+ {rows.map((e) => {
39+ const prov = provenanceOf(e);
40+ const conf = e.confidence ?? Math.max(0, ...prov.map((p) => p.confidence));
41+ const type = e.entity_type ?? e.type ?? "?";
42+ return (
43+ <tr key={e.fingerprint}>
44+ <td>
45+ <TypeTag type={type} />
46+ {showPlatform && <div className="text-[10px] text-dim mono mt-0.5">{e.platform}</div>}
47+ </td>
48+ <td className="max-w-[420px]">
49+ <Link href={`/entities/${encodeURIComponent(e.fingerprint)}`} className="text-fg hover:text-cyan">
50+ {truncate(e.name ?? e.text ?? e.text_excerpt ?? e.platform_id ?? e.fingerprint, 90)}
51+ </Link>
52+ {e.media?.has_video && e.media.duration_s ? <span className="ml-2 text-[10.5px] mono text-dim">{fmtDuration(e.media.duration_s)}</span> : null}
53+ <div className="text-[10.5px] mono text-dim truncate">{e.platform_id}</div>
54+ </td>
55+ <td className="text-fg-2 whitespace-nowrap">{truncate(e.author ?? "", 28)}</td>
56+ <td className="mono text-[11px] text-fg-2 whitespace-nowrap">
57+ {e.metrics && Object.keys(e.metrics).length
58+ ? Object.entries(e.metrics)
59+ .map(([k, v]) => `${fmtNum(v)} ${k}`)
60+ .join(" · ")
61+ : "—"}
62+ </td>
63+ <td>
64+ <SurfaceChips provenance={prov} />
65+ </td>
66+ <td className="mono text-[11px]">{conf ? conf.toFixed(2) : "—"}</td>
67+ <td>
68+ {e.url && (
69+ <a href={e.url} target="_blank" rel="noreferrer" className="text-dim hover:text-cyan" title="open on platform">
70+ <ExternalLink className="h-3.5 w-3.5" />
71+ </a>
72+ )}
73+ </td>
74+ </tr>
75+ );
76+ })}
77+ </Table>
78+ );
79+}
added apps/dashboard/src/components/LiveFeed.tsx +78 −0
@@ -0,0 +1,78 @@
1+"use client";
2+
3+import Link from "next/link";
4+import { AnimatePresence, motion } from "framer-motion";
5+import { useLive, type LiveEvent } from "@/lib/api";
6+import { eventColor, fmtTime, truncate } from "@/lib/format";
7+import { Tag } from "./ui";
8+
9+export function describeEvent(e: LiveEvent): string {
10+ const p = e.payload ?? {};
11+ switch (e.event_type) {
12+ case "ACTION_PLANNED": {
13+ const a = p.action as { type?: string; label?: string } | undefined;
14+ return `${a?.type ?? ""} — ${truncate(a?.label, 80)} · gain ${Number(p.expected_information_gain ?? 0).toFixed(3)} · ${truncate(String(p.reason ?? ""), 90)}`;
15+ }
16+ case "ACTION_EXECUTED":
17+ case "ACTION_FAILED": {
18+ const a = p.action as { type?: string } | undefined;
19+ const after = p.after as { new_entities?: number; network_responses?: number } | undefined;
20+ return `${a?.type ?? ""} ${p.ok ? "ok" : "failed"}${p.error ? ` (${truncate(String(p.error), 60)})` : ""} · +${after?.new_entities ?? 0} entities · ${after?.network_responses ?? 0} responses · ${p.duration_ms} ms`;
21+ }
22+ case "PAGE_OPENED":
23+ return p.page_type ? `${p.page_type} (${Number(p.confidence ?? 0).toFixed(2)}) ${truncate(String(p.url ?? ""), 70)} · dom ${p.dom_entities} · net ${p.network_entities} · both ${p.both_surfaces}` : `${truncate(String(p.url ?? ""), 80)}`;
24+ case "NETWORK_RESPONSE_OBSERVED":
25+ return `${p.kind} ${p.method} ${p.hostname}${p.path_pattern}${p.graphql_operation ? ` [${p.graphql_operation}]` : ""} · ${p.entity_count} entities · ${p.body_size} B`;
26+ case "NETWORK_SCHEMA_DISCOVERED": {
27+ const fp = p.fingerprint as { hostname?: string; path_pattern?: string } | undefined;
28+ const sc = p.schema as { candidate_entity_types?: { type: string; confidence: number }[] } | undefined;
29+ return `new shape ${fp?.hostname}${fp?.path_pattern} → ${(sc?.candidate_entity_types ?? []).map((c) => `${c.type} ${c.confidence.toFixed(2)}`).join(", ") || "?"}`;
30+ }
31+ case "CONNECTOR_PATTERN_LEARNED":
32+ return `learned ${p.hostname}${p.path_pattern} → ${Object.keys((p.likely_entity_types as object) ?? {}).join(",")} · conf ${Number(p.confidence ?? 0).toFixed(2)} · seen ${p.observed_count}×`;
33+ case "DOM_CHANGED":
34+ return `+${p.added} −${p.removed} nodes · ${Object.entries((p.regions as Record<string, number>) ?? {}).map(([k, v]) => `${k} ${v}`).join(" ")}${p.modal_opened ? " · modal" : ""}${Number(p.new_videos) ? ` · ${p.new_videos} video` : ""}`;
35+ case "MEDIA_DISCOVERED":
36+ return `${p.media_type} ${truncate(String(p.title ?? p.platform_media_id ?? ""), 60)} · ${p.duration_s ? Math.round(Number(p.duration_s)) + "s" : ""} ${p.width ? `${p.width}×${p.height}` : ""} ${(p.delivery as { kind?: string } | undefined)?.kind ?? ""}`;
37+ case "LOOP_DETECTED":
38+ return `loop ${p.pattern} at ${truncate(String(p.url ?? ""), 60)} — breaking out`;
39+ case "CONNECTOR_DEGRADED":
40+ return `expected ${p.expected} entities on ${p.page_type}, observed ${p.observed} — ${p.action}`;
41+ case "BUDGET_EXHAUSTED":
42+ return `session ended: ${p.reason}`;
43+ default: {
44+ const ent = p.entity as { type?: string; name?: string; platform_id?: string; author?: string } | undefined;
45+ if (ent) return `${ent.type}: ${truncate(ent.name ?? ent.platform_id ?? "", 80)}${ent.author ? ` — ${truncate(ent.author, 30)}` : ""}`;
46+ return truncate(JSON.stringify(p), 120);
47+ }
48+ }
49+}
50+
51+export function LiveFeed({ session, types, max = 60, height = 420 }: { session?: string; types?: string[]; max?: number; height?: number }) {
52+ const { events, connected } = useLive({ session, types, max });
53+ return (
54+ <div className="overflow-hidden" style={{ height }}>
55+ {!connected && events.length === 0 && <div className="text-xs text-dim p-2">connecting to the observation stream…</div>}
56+ {connected && events.length === 0 && <div className="text-xs text-dim p-2">stream connected — waiting for observations (start a mission)</div>}
57+ <ul className="space-y-0.5 mono text-[11.5px]">
58+ <AnimatePresence initial={false}>
59+ {events.map((e) => (
60+ <motion.li key={e.event_id} initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.18 }} className="flex gap-2 items-start leading-5 rounded px-1 hover:bg-white/[0.03]">
61+ <span className="text-dim shrink-0">{fmtTime(e.ts)}</span>
62+ <span className="text-dim shrink-0 w-7 text-right">#{e.step ?? 0}</span>
63+ <Tag color={eventColor(e.event_type)} className="shrink-0">
64+ {e.event_type.replace(/_/g, " ").toLowerCase()}
65+ </Tag>
66+ <span className="text-fg-2 truncate">{describeEvent(e)}</span>
67+ {!session && (
68+ <Link href={`/sessions/${e.session_id}`} className="ml-auto shrink-0 text-dim hover:text-cyan">
69+ {e.platform}
70+ </Link>
71+ )}
72+ </motion.li>
73+ ))}
74+ </AnimatePresence>
75+ </ul>
76+ </div>
77+ );
78+}
added apps/dashboard/src/components/Shell.tsx +78 −0
@@ -0,0 +1,78 @@
1+"use client";
2+
3+import Link from "next/link";
4+import { usePathname } from "next/navigation";
5+import clsx from "clsx";
6+import { Activity, Braces, Database, Eye, Layers, Network, PlayCircle, Radar } from "lucide-react";
7+import { Brand } from "./Brand";
8+import { useApi, useLive } from "@/lib/api";
9+
10+const NAV = [
11+ { href: "/", label: "Observatory", icon: Radar },
12+ { href: "/sessions", label: "Sessions", icon: Eye },
13+ { href: "/entities", label: "Entities", icon: Database },
14+ { href: "/schemas", label: "Runtime APIs", icon: Braces },
15+ { href: "/platforms", label: "Connectors", icon: Layers },
16+ { href: "/jobs", label: "Missions", icon: PlayCircle },
17+];
18+
19+export function Shell({ children }: { children: React.ReactNode }) {
20+ const path = usePathname();
21+ if (path.startsWith("/access")) return <>{children}</>;
22+ return (
23+ <div className="flex min-h-screen">
24+ <aside className="hidden md:flex w-60 shrink-0 flex-col border-r border-line bg-bg-2/60 backdrop-blur sticky top-0 h-screen">
25+ <div className="px-5 py-5 border-b border-line">
26+ <Link href="/">
27+ <Brand />
28+ </Link>
29+ </div>
30+ <nav className="p-3 space-y-1">
31+ {NAV.map((n) => {
32+ const active = n.href === "/" ? path === "/" : path.startsWith(n.href);
33+ return (
34+ <Link key={n.href} href={n.href} className={clsx("flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition", active ? "bg-cyan/10 text-cyan" : "text-fg-2 hover:bg-white/5 hover:text-fg")}>
35+ <n.icon className="h-4 w-4" />
36+ {n.label}
37+ </Link>
38+ );
39+ })}
40+ </nav>
41+ <div className="mt-auto p-4 text-[11px] text-dim leading-relaxed border-t border-line">
42+ <div className="flex items-center gap-2 mb-2">
43+ <Network className="h-3.5 w-3.5" /> MacLustr · read-only crawler
44+ </div>
45+ Observes only what the authenticated account can legitimately see. No likes, follows, comments.
46+ </div>
47+ </aside>
48+ <div className="flex-1 min-w-0 flex flex-col">
49+ <TopBar />
50+ <main className="flex-1 p-4 md:p-6 space-y-6">{children}</main>
51+ </div>
52+ </div>
53+ );
54+}
55+
56+function TopBar() {
57+ const { data } = useApi<{ jobs_running: number; ts: string }>("/health", { refreshMs: 10_000 });
58+ const { connected, rate } = useLive({ max: 1 });
59+ return (
60+ <header className="sticky top-0 z-20 flex items-center gap-4 px-4 md:px-6 h-14 border-b border-line bg-bg/70 backdrop-blur">
61+ <div className="md:hidden">
62+ <Brand size="sm" />
63+ </div>
64+ <div className="ml-auto flex items-center gap-5 text-xs">
65+ <span className="flex items-center gap-2 text-fg-2">
66+ <span className={clsx("relative inline-block h-2 w-2 rounded-full", connected ? "bg-green text-green live-dot" : "bg-dim")} />
67+ {connected ? "live" : "offline"}
68+ <span className="mono text-dim">{rate}/min</span>
69+ </span>
70+ <span className="flex items-center gap-2 text-fg-2">
71+ <Activity className="h-3.5 w-3.5 text-amber" />
72+ <span className="mono">{data?.jobs_running ?? 0}</span> running
73+ </span>
74+ <span className="mono text-dim hidden sm:inline">{data?.ts ? new Date(data.ts).toLocaleTimeString("en-CA", { hour12: false }) : ""}</span>
75+ </div>
76+ </header>
77+ );
78+}
added apps/dashboard/src/components/WorldGraph.tsx +237 −0
@@ -0,0 +1,237 @@
1+"use client";
2+
3+import { useEffect, useMemo, useRef, useState } from "react";
4+import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, type SimulationLinkDatum, type SimulationNodeDatum } from "d3-force";
5+import { TYPE_COLORS, truncate } from "@/lib/format";
6+
7+export interface GraphNode {
8+ fingerprint: string;
9+ type: string;
10+ name?: string | null;
11+ url?: string | null;
12+ visited?: boolean;
13+ seen_count?: number;
14+ surfaces?: string[];
15+}
16+export interface GraphEdge {
17+ from: string;
18+ to: string;
19+ type: string;
20+}
21+
22+interface SimNode extends SimulationNodeDatum, GraphNode {
23+ r: number;
24+}
25+type SimLink = SimulationLinkDatum<SimNode> & { type: string };
26+
27+/**
28+ * Social World Model (§23) rendered on a canvas with a d3-force layout.
29+ * Node size = how often the entity was observed; ring = visited; colour = entity type.
30+ */
31+export function WorldGraph({ nodes, edges, height = 520, onSelect }: { nodes: GraphNode[]; edges: GraphEdge[]; height?: number; onSelect?: (n: GraphNode | null) => void }) {
32+ const canvasRef = useRef<HTMLCanvasElement>(null);
33+ const [hover, setHover] = useState<SimNode | null>(null);
34+ const [selected, setSelected] = useState<SimNode | null>(null);
35+ const simRef = useRef<ReturnType<typeof forceSimulation<SimNode>> | null>(null);
36+ const transform = useRef({ x: 0, y: 0, k: 1 });
37+
38+ const data = useMemo(() => {
39+ const limit = 600;
40+ const ns: SimNode[] = nodes.slice(0, limit).map((n) => ({ ...n, r: 3 + Math.min(9, Math.sqrt(n.seen_count ?? 1) * 1.8) + (n.visited ? 2 : 0) }));
41+ const ids = new Set(ns.map((n) => n.fingerprint));
42+ const ls: SimLink[] = edges.filter((e) => ids.has(e.from) && ids.has(e.to)).map((e) => ({ source: e.from, target: e.to, type: e.type }));
43+ return { ns, ls };
44+ }, [nodes, edges]);
45+
46+ useEffect(() => {
47+ const canvas = canvasRef.current;
48+ if (!canvas) return;
49+ const parent = canvas.parentElement!;
50+ const dpr = window.devicePixelRatio || 1;
51+ const W = parent.clientWidth;
52+ const H = height;
53+ canvas.width = W * dpr;
54+ canvas.height = H * dpr;
55+ canvas.style.width = `${W}px`;
56+ canvas.style.height = `${H}px`;
57+ const ctx = canvas.getContext("2d")!;
58+ ctx.scale(dpr, dpr);
59+
60+ const sim = forceSimulation<SimNode>(data.ns)
61+ .force("link", forceLink<SimNode, SimLink>(data.ls).id((d) => d.fingerprint).distance(38).strength(0.4))
62+ .force("charge", forceManyBody().strength(-45))
63+ .force("center", forceCenter(W / 2, H / 2))
64+ .force("x", forceX(W / 2).strength(0.03))
65+ .force("y", forceY(H / 2).strength(0.03))
66+ .force("collide", forceCollide<SimNode>((d) => d.r + 2))
67+ .alphaDecay(0.03);
68+ simRef.current = sim;
69+
70+ const draw = () => {
71+ const { x: tx, y: ty, k } = transform.current;
72+ ctx.clearRect(0, 0, W, H);
73+ ctx.save();
74+ ctx.translate(tx, ty);
75+ ctx.scale(k, k);
76+ ctx.lineWidth = 0.6 / k;
77+ for (const l of data.ls) {
78+ const s = l.source as SimNode;
79+ const t = l.target as SimNode;
80+ if (s.x === undefined || t.x === undefined) continue;
81+ ctx.strokeStyle = l.type === "AUTHORED" ? "rgba(157,123,255,0.35)" : "rgba(255,255,255,0.08)";
82+ ctx.beginPath();
83+ ctx.moveTo(s.x!, s.y!);
84+ ctx.lineTo(t.x!, t.y!);
85+ ctx.stroke();
86+ }
87+ for (const n of data.ns) {
88+ if (n.x === undefined) continue;
89+ const color = TYPE_COLORS[n.type] ?? "#6b7a8c";
90+ const isSel = selected?.fingerprint === n.fingerprint || hover?.fingerprint === n.fingerprint;
91+ ctx.beginPath();
92+ ctx.arc(n.x!, n.y!, n.r, 0, Math.PI * 2);
93+ ctx.fillStyle = isSel ? color : `${color}cc`;
94+ ctx.fill();
95+ if (n.visited) {
96+ ctx.beginPath();
97+ ctx.arc(n.x!, n.y!, n.r + 3, 0, Math.PI * 2);
98+ ctx.strokeStyle = "#ffb347";
99+ ctx.lineWidth = 1.2 / k;
100+ ctx.stroke();
101+ }
102+ if (isSel || (n.seen_count ?? 0) > 4 || n.visited) {
103+ ctx.fillStyle = isSel ? "#e6edf5" : "rgba(230,237,245,0.7)";
104+ ctx.font = `${11 / k}px ui-monospace, monospace`;
105+ ctx.fillText(truncate(n.name ?? n.fingerprint.split(":").pop() ?? "", 34), n.x! + n.r + 4, n.y! + 3);
106+ }
107+ }
108+ ctx.restore();
109+ };
110+ sim.on("tick", draw);
111+ draw();
112+
113+ const pick = (ev: MouseEvent): SimNode | null => {
114+ const rect = canvas.getBoundingClientRect();
115+ const { x: tx, y: ty, k } = transform.current;
116+ const mx = (ev.clientX - rect.left - tx) / k;
117+ const my = (ev.clientY - rect.top - ty) / k;
118+ let best: SimNode | null = null;
119+ let bd = Infinity;
120+ for (const n of data.ns) {
121+ if (n.x === undefined) continue;
122+ const d = Math.hypot(n.x! - mx, n.y! - my);
123+ if (d < n.r + 4 && d < bd) {
124+ bd = d;
125+ best = n;
126+ }
127+ }
128+ return best;
129+ };
130+ let dragging: SimNode | null = null;
131+ let panning = false;
132+ let last = { x: 0, y: 0 };
133+ const onMove = (ev: MouseEvent) => {
134+ if (dragging) {
135+ const rect = canvas.getBoundingClientRect();
136+ const { x: tx, y: ty, k } = transform.current;
137+ dragging.fx = (ev.clientX - rect.left - tx) / k;
138+ dragging.fy = (ev.clientY - rect.top - ty) / k;
139+ sim.alpha(0.3).restart();
140+ return;
141+ }
142+ if (panning) {
143+ transform.current.x += ev.clientX - last.x;
144+ transform.current.y += ev.clientY - last.y;
145+ last = { x: ev.clientX, y: ev.clientY };
146+ draw();
147+ return;
148+ }
149+ const h = pick(ev);
150+ setHover(h);
151+ canvas.style.cursor = h ? "pointer" : "grab";
152+ draw();
153+ };
154+ const onDown = (ev: MouseEvent) => {
155+ const h = pick(ev);
156+ if (h) {
157+ dragging = h;
158+ } else {
159+ panning = true;
160+ last = { x: ev.clientX, y: ev.clientY };
161+ }
162+ };
163+ const onUp = (ev: MouseEvent) => {
164+ if (dragging) {
165+ dragging.fx = null;
166+ dragging.fy = null;
167+ setSelected(dragging);
168+ onSelect?.(dragging);
169+ dragging = null;
170+ } else if (panning) {
171+ panning = false;
172+ if (Math.hypot(ev.clientX - last.x, ev.clientY - last.y) < 3) {
173+ setSelected(null);
174+ onSelect?.(null);
175+ }
176+ }
177+ };
178+ const onWheel = (ev: WheelEvent) => {
179+ ev.preventDefault();
180+ const rect = canvas.getBoundingClientRect();
181+ const mx = ev.clientX - rect.left;
182+ const my = ev.clientY - rect.top;
183+ const k0 = transform.current.k;
184+ const k1 = Math.max(0.3, Math.min(4, k0 * (ev.deltaY < 0 ? 1.1 : 0.9)));
185+ transform.current.x = mx - ((mx - transform.current.x) * k1) / k0;
186+ transform.current.y = my - ((my - transform.current.y) * k1) / k0;
187+ transform.current.k = k1;
188+ draw();
189+ };
190+ canvas.addEventListener("mousemove", onMove);
191+ canvas.addEventListener("mousedown", onDown);
192+ window.addEventListener("mouseup", onUp);
193+ canvas.addEventListener("wheel", onWheel, { passive: false });
194+ return () => {
195+ sim.stop();
196+ canvas.removeEventListener("mousemove", onMove);
197+ canvas.removeEventListener("mousedown", onDown);
198+ window.removeEventListener("mouseup", onUp);
199+ canvas.removeEventListener("wheel", onWheel);
200+ };
201+ // eslint-disable-next-line react-hooks/exhaustive-deps
202+ }, [data, height]);
203+
204+ const legend = useMemo(() => {
205+ const c: Record<string, number> = {};
206+ for (const n of nodes) c[n.type] = (c[n.type] ?? 0) + 1;
207+ return Object.entries(c).sort((a, b) => b[1] - a[1]);
208+ }, [nodes]);
209+
210+ return (
211+ <div className="relative">
212+ <canvas ref={canvasRef} className="w-full rounded-lg bg-bg-2/60" style={{ height }} />
213+ <div className="absolute left-3 top-3 flex flex-wrap gap-2 text-[10.5px] mono">
214+ {legend.map(([t, n]) => (
215+ <span key={t} className="inline-flex items-center gap-1 rounded bg-bg/70 px-1.5 py-0.5 border border-line">
216+ <span className="h-2 w-2 rounded-full" style={{ background: TYPE_COLORS[t] ?? "#6b7a8c" }} /> {t} {n}
217+ </span>
218+ ))}
219+ <span className="inline-flex items-center gap-1 rounded bg-bg/70 px-1.5 py-0.5 border border-line">
220+ <span className="h-2 w-2 rounded-full border border-amber" /> visited
221+ </span>
222+ </div>
223+ {(hover || selected) && (
224+ <div className="absolute right-3 bottom-3 max-w-xs panel p-3 text-xs space-y-1">
225+ <div className="text-fg font-medium truncate">{(hover ?? selected)!.name ?? (hover ?? selected)!.fingerprint}</div>
226+ <div className="text-dim mono">{(hover ?? selected)!.type} · seen {(hover ?? selected)!.seen_count ?? 1}× · {(hover ?? selected)!.surfaces?.join("+") ?? ""}</div>
227+ {(hover ?? selected)!.url && (
228+ <a className="text-cyan truncate block" href={(hover ?? selected)!.url!} target="_blank" rel="noreferrer">
229+ {(hover ?? selected)!.url}
230+ </a>
231+ )}
232+ </div>
233+ )}
234+ <div className="absolute right-3 top-3 text-[10.5px] text-dim mono">{nodes.length} nodes · {edges.length} edges · scroll to zoom · drag to move</div>
235+ </div>
236+ );
237+}
added apps/dashboard/src/components/charts.tsx +110 −0
@@ -0,0 +1,110 @@
1+"use client";
2+
3+import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
4+import { TYPE_COLORS, fmtNum } from "@/lib/format";
5+
6+const AXIS = { stroke: "#6b7a8c", fontSize: 10.5, fontFamily: "var(--font-mono)" } as const;
7+const TT = { contentStyle: { background: "#0d1219", border: "1px solid #243042", borderRadius: 8, fontSize: 12, fontFamily: "var(--font-mono)" }, labelStyle: { color: "#a9b6c6" }, itemStyle: { color: "#e6edf5" } } as const;
8+
9+export function ActivityChart({ data, height = 220 }: { data: { bucket: string; entities: number; responses: number; actions: number; schemas: number }[]; height?: number }) {
10+ const rows = data.map((d) => ({ ...d, t: new Date(d.bucket).toLocaleTimeString("en-CA", { hour12: false, hour: "2-digit", minute: "2-digit" }) }));
11+ return (
12+ <ResponsiveContainer width="100%" height={height}>
13+ <AreaChart data={rows} margin={{ top: 8, right: 8, left: -14, bottom: 0 }}>
14+ <defs>
15+ <linearGradient id="gEnt" x1="0" x2="0" y1="0" y2="1">
16+ <stop offset="0" stopColor="#38e1ff" stopOpacity={0.5} />
17+ <stop offset="1" stopColor="#38e1ff" stopOpacity={0} />
18+ </linearGradient>
19+ <linearGradient id="gResp" x1="0" x2="0" y1="0" y2="1">
20+ <stop offset="0" stopColor="#9d7bff" stopOpacity={0.4} />
21+ <stop offset="1" stopColor="#9d7bff" stopOpacity={0} />
22+ </linearGradient>
23+ </defs>
24+ <CartesianGrid stroke="#1a2331" vertical={false} />
25+ <XAxis dataKey="t" tick={AXIS} tickLine={false} axisLine={false} minTickGap={40} />
26+ <YAxis tick={AXIS} tickLine={false} axisLine={false} tickFormatter={(v) => fmtNum(v)} />
27+ <Tooltip {...TT} />
28+ <Area type="monotone" dataKey="responses" name="network responses" stroke="#9d7bff" fill="url(#gResp)" strokeWidth={1.5} />
29+ <Area type="monotone" dataKey="entities" name="entities discovered" stroke="#38e1ff" fill="url(#gEnt)" strokeWidth={1.8} />
30+ <Line type="monotone" dataKey="actions" name="actions" stroke="#ffb347" strokeWidth={1.2} dot={false} />
31+ </AreaChart>
32+ </ResponsiveContainer>
33+ );
34+}
35+
36+export function TypeDonut({ data, height = 200 }: { data: { entity_type: string; n: number }[]; height?: number }) {
37+ const agg = new Map<string, number>();
38+ for (const d of data) agg.set(d.entity_type, (agg.get(d.entity_type) ?? 0) + Number(d.n));
39+ const rows = [...agg.entries()].map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value);
40+ return (
41+ <ResponsiveContainer width="100%" height={height}>
42+ <PieChart>
43+ <Pie data={rows} dataKey="value" nameKey="name" innerRadius="58%" outerRadius="85%" paddingAngle={2} stroke="none">
44+ {rows.map((r) => (
45+ <Cell key={r.name} fill={TYPE_COLORS[r.name] ?? "#6b7a8c"} />
46+ ))}
47+ </Pie>
48+ <Tooltip {...TT} />
49+ <Legend iconType="circle" iconSize={7} wrapperStyle={{ fontSize: 11, fontFamily: "var(--font-mono)", color: "#a9b6c6" }} />
50+ </PieChart>
51+ </ResponsiveContainer>
52+ );
53+}
54+
55+export function GainChart({ data, height = 200 }: { data: { step: number; expected_gain: number; novelty: number; relevance: number; action_type: string; new_entities?: number }[]; height?: number }) {
56+ return (
57+ <ResponsiveContainer width="100%" height={height}>
58+ <LineChart data={data} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
59+ <CartesianGrid stroke="#1a2331" vertical={false} />
60+ <XAxis dataKey="step" tick={AXIS} tickLine={false} axisLine={false} />
61+ <YAxis tick={AXIS} tickLine={false} axisLine={false} domain={[0, 1]} />
62+ <Tooltip {...TT} labelFormatter={(l) => `step ${l}`} />
63+ <Line type="monotone" dataKey="expected_gain" name="information gain" stroke="#ffb347" strokeWidth={2} dot={{ r: 2 }} />
64+ <Line type="monotone" dataKey="novelty" name="novelty" stroke="#38e1ff" strokeWidth={1.2} dot={false} />
65+ <Line type="monotone" dataKey="relevance" name="relevance" stroke="#9d7bff" strokeWidth={1.2} dot={false} />
66+ </LineChart>
67+ </ResponsiveContainer>
68+ );
69+}
70+
71+export function YieldBars({ data, height = 200 }: { data: { action_type: string; n: number; avg_new_entities: number | null; success_rate: number | null }[]; height?: number }) {
72+ const rows = data.filter((d) => d.n > 0).map((d) => ({ ...d, avg_new_entities: Number(d.avg_new_entities ?? 0), label: d.action_type.replace(/_/g, " ").toLowerCase() }));
73+ return (
74+ <ResponsiveContainer width="100%" height={height}>
75+ <BarChart data={rows} layout="vertical" margin={{ top: 4, right: 12, left: 10, bottom: 0 }}>
76+ <CartesianGrid stroke="#1a2331" horizontal={false} />
77+ <XAxis type="number" tick={AXIS} tickLine={false} axisLine={false} />
78+ <YAxis type="category" dataKey="label" tick={AXIS} tickLine={false} axisLine={false} width={110} />
79+ <Tooltip {...TT} />
80+ <Bar dataKey="avg_new_entities" name="avg new entities / action" radius={[0, 4, 4, 0]}>
81+ {rows.map((r) => (
82+ <Cell key={r.action_type} fill={r.action_type.startsWith("OPEN") ? "#38e1ff" : r.action_type === "SEARCH" ? "#ffb347" : "#9d7bff"} />
83+ ))}
84+ </Bar>
85+ </BarChart>
86+ </ResponsiveContainer>
87+ );
88+}
89+
90+export function SurfaceBars({ dom, network, both, height = 120 }: { dom: number; network: number; both: number; height?: number }) {
91+ const rows = [
92+ { name: "DOM only", value: Math.max(0, dom - both), fill: "#43e69a" },
93+ { name: "Both surfaces", value: both, fill: "#38e1ff" },
94+ { name: "Network only", value: Math.max(0, network - both), fill: "#9d7bff" },
95+ ];
96+ return (
97+ <ResponsiveContainer width="100%" height={height}>
98+ <BarChart data={rows} margin={{ top: 4, right: 8, left: -18, bottom: 0 }}>
99+ <XAxis dataKey="name" tick={AXIS} tickLine={false} axisLine={false} />
100+ <YAxis tick={AXIS} tickLine={false} axisLine={false} tickFormatter={(v) => fmtNum(v)} />
101+ <Tooltip {...TT} />
102+ <Bar dataKey="value" radius={[4, 4, 0, 0]}>
103+ {rows.map((r) => (
104+ <Cell key={r.name} fill={r.fill} />
105+ ))}
106+ </Bar>
107+ </BarChart>
108+ </ResponsiveContainer>
109+ );
110+}
added apps/dashboard/src/components/ui.tsx +135 −0
@@ -0,0 +1,135 @@
1+"use client";
2+
3+import clsx from "clsx";
4+import type { ReactNode } from "react";
5+import { TYPE_COLORS } from "@/lib/format";
6+
7+export function Panel({ title, sub, right, children, className, pad = true }: { title?: ReactNode; sub?: ReactNode; right?: ReactNode; children: ReactNode; className?: string; pad?: boolean }) {
8+ return (
9+ <section className={clsx("panel flex flex-col min-w-0", className)}>
10+ {(title || right) && (
11+ <header className="flex items-center gap-3 px-4 py-3 border-b border-line">
12+ <div className="min-w-0">
13+ {title && <h2 className="text-[11px] uppercase tracking-[0.18em] text-fg-2 font-medium">{title}</h2>}
14+ {sub && <p className="text-xs text-dim truncate">{sub}</p>}
15+ </div>
16+ {right && <div className="ml-auto shrink-0 text-xs text-dim">{right}</div>}
17+ </header>
18+ )}
19+ <div className={clsx("min-w-0 flex-1", pad && "p-4")}>{children}</div>
20+ </section>
21+ );
22+}
23+
24+export function Kpi({ label, value, hint, accent = "cyan", spark }: { label: string; value: ReactNode; hint?: ReactNode; accent?: "cyan" | "amber" | "green" | "violet" | "pink" | "red" | "fg"; spark?: number[] }) {
25+ const color = { cyan: "text-cyan", amber: "text-amber", green: "text-green", violet: "text-violet", pink: "text-pink", red: "text-red", fg: "text-fg" }[accent];
26+ return (
27+ <div className="panel p-4 relative overflow-hidden">
28+ <div className="text-[11px] uppercase tracking-[0.18em] text-dim">{label}</div>
29+ <div className={clsx("mono text-3xl font-semibold mt-1 tabular-nums", color)}>{value}</div>
30+ {hint && <div className="text-xs text-fg-2 mt-1">{hint}</div>}
31+ {spark && spark.length > 1 && <Spark values={spark} className="absolute right-3 bottom-3 h-8 w-24 opacity-70" />}
32+ </div>
33+ );
34+}
35+
36+export function Spark({ values, className, color = "currentColor" }: { values: number[]; className?: string; color?: string }) {
37+ const max = Math.max(1, ...values);
38+ const w = 100;
39+ const h = 30;
40+ const pts = values.map((v, i) => `${(i / Math.max(1, values.length - 1)) * w},${h - (v / max) * (h - 2) - 1}`).join(" ");
41+ return (
42+ <svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className={className}>
43+ <polyline points={pts} fill="none" stroke={color} strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
44+ </svg>
45+ );
46+}
47+
48+export function Tag({ children, color, className, title }: { children: ReactNode; color?: string; className?: string; title?: string }) {
49+ const c = color ?? "#6b7a8c";
50+ return (
51+ <span title={title} className={clsx("inline-flex items-center rounded-md px-1.5 py-0.5 text-[10.5px] font-medium mono leading-4 whitespace-nowrap", className)} style={{ color: c, background: `${c}1f`, border: `1px solid ${c}33` }}>
52+ {children}
53+ </span>
54+ );
55+}
56+
57+export function TypeTag({ type }: { type: string }) {
58+ return <Tag color={TYPE_COLORS[type] ?? "#6b7a8c"}>{type}</Tag>;
59+}
60+
61+export function SurfaceChips({ provenance }: { provenance?: { surface: string; confidence: number; detail?: string }[] | null }) {
62+ if (!provenance?.length) return <span className="text-dim">—</span>;
63+ const best = new Map<string, { confidence: number; detail?: string }>();
64+ for (const p of provenance) if (!best.has(p.surface) || best.get(p.surface)!.confidence < p.confidence) best.set(p.surface, p);
65+ const colors: Record<string, string> = { network: "#38e1ff", dom: "#43e69a", visual: "#ff7ad9", media: "#9d7bff", accessibility: "#ffb347", navigation: "#6b7a8c" };
66+ return (
67+ <span className="inline-flex gap-1 flex-wrap">
68+ {[...best.entries()].map(([s, p]) => (
69+ <Tag key={s} color={colors[s] ?? "#6b7a8c"} title={p.detail}>
70+ {s} {Math.round(p.confidence * 100)}
71+ </Tag>
72+ ))}
73+ </span>
74+ );
75+}
76+
77+export function Bar({ value, color = "#38e1ff", className }: { value: number; color?: string; className?: string }) {
78+ return (
79+ <div className={clsx("h-1.5 rounded-full bg-white/5 overflow-hidden", className)}>
80+ <div className="h-full rounded-full transition-all" style={{ width: `${Math.max(0, Math.min(100, value * 100))}%`, background: color }} />
81+ </div>
82+ );
83+}
84+
85+export function Empty({ children }: { children: ReactNode }) {
86+ return <div className="text-sm text-dim py-10 text-center">{children}</div>;
87+}
88+
89+export function Skeleton({ rows = 4 }: { rows?: number }) {
90+ return (
91+ <div className="space-y-2">
92+ {Array.from({ length: rows }).map((_, i) => (
93+ <div key={i} className="skeleton h-4" style={{ width: `${70 + ((i * 13) % 30)}%` }} />
94+ ))}
95+ </div>
96+ );
97+}
98+
99+export function Table({ head, children, dense }: { head: ReactNode[]; children: ReactNode; dense?: boolean }) {
100+ return (
101+ <div className="overflow-auto scrollbar-thin -mx-4 px-4">
102+ <table className={clsx("w-full text-sm", dense ? "text-[12.5px]" : "")}>
103+ <thead>
104+ <tr className="text-left text-[10.5px] uppercase tracking-widest text-dim">
105+ {head.map((h, i) => (
106+ <th key={i} className="px-2 py-2 font-medium border-b border-line whitespace-nowrap">
107+ {h}
108+ </th>
109+ ))}
110+ </tr>
111+ </thead>
112+ <tbody className="[&>tr]:border-b [&>tr]:border-line/60 [&>tr:hover]:bg-white/[0.025] [&>tr>td]:px-2 [&>tr>td]:py-1.5 [&>tr>td]:align-top">{children}</tbody>
113+ </table>
114+ </div>
115+ );
116+}
117+
118+export function Metric({ label, value }: { label: string; value: ReactNode }) {
119+ return (
120+ <div className="flex items-baseline justify-between gap-3 text-sm border-b border-line/60 py-1.5">
121+ <span className="text-dim">{label}</span>
122+ <span className="mono text-fg">{value}</span>
123+ </div>
124+ );
125+}
126+
127+export function StatusPill({ status }: { status: string }) {
128+ const map: Record<string, string> = { running: "#ffb347", healthy: "#ffb347", queued: "#6b7a8c", done: "#43e69a", stopped: "#6b7a8c", failed: "#ff5c7a", auth_required: "#ff5c7a", crashed: "#ff5c7a", degraded: "#ff5c7a" };
129+ return (
130+ <Tag color={map[status] ?? "#6b7a8c"}>
131+ {status === "running" || status === "healthy" ? <span className="relative inline-block h-1.5 w-1.5 rounded-full bg-amber text-amber live-dot mr-1" /> : null}
132+ {status}
133+ </Tag>
134+ );
135+}
added apps/dashboard/src/lib/api.ts +94 −0
@@ -0,0 +1,94 @@
1+"use client";
2+
3+import { useCallback, useEffect, useRef, useState } from "react";
4+
5+/** Client-side data layer: every call goes through the Next proxy (/api/*) which adds the API token. */
6+export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
7+ const res = await fetch(`/api${path}`, { ...init, headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, cache: "no-store" });
8+ if (!res.ok) {
9+ let msg = `${res.status}`;
10+ try {
11+ msg = ((await res.json()) as { error?: string }).error ?? msg;
12+ } catch {
13+ /* ignore */
14+ }
15+ throw new Error(msg);
16+ }
17+ return (await res.json()) as T;
18+}
19+
20+export function useApi<T>(path: string | null, opts: { refreshMs?: number; deps?: unknown[] } = {}) {
21+ const [data, setData] = useState<T | null>(null);
22+ const [error, setError] = useState<string | null>(null);
23+ const [loading, setLoading] = useState(!!path);
24+ const [tick, setTick] = useState(0);
25+ const refresh = useCallback(() => setTick((t) => t + 1), []);
26+ useEffect(() => {
27+ if (!path) return;
28+ let alive = true;
29+ api<T>(path)
30+ .then((d) => {
31+ if (!alive) return;
32+ setData(d);
33+ setError(null);
34+ })
35+ .catch((e: Error) => alive && setError(e.message))
36+ .finally(() => alive && setLoading(false));
37+ const id = opts.refreshMs ? setInterval(() => setTick((t) => t + 1), opts.refreshMs) : undefined;
38+ return () => {
39+ alive = false;
40+ if (id) clearInterval(id);
41+ };
42+ // eslint-disable-next-line react-hooks/exhaustive-deps
43+ }, [path, tick, ...(opts.deps ?? [])]);
44+ return { data, error, loading, refresh };
45+}
46+
47+export interface LiveEvent {
48+ event_id: string;
49+ session_id: string;
50+ platform: string;
51+ event_type: string;
52+ step: number | null;
53+ ts: string;
54+ payload: Record<string, unknown>;
55+ provenance?: { surface: string; confidence: number }[] | null;
56+}
57+
58+/** Server-Sent Events subscription (live feed). Keeps the last `max` events. */
59+export function useLive(opts: { session?: string; types?: string[]; max?: number; enabled?: boolean } = {}) {
60+ const [events, setEvents] = useState<LiveEvent[]>([]);
61+ const [connected, setConnected] = useState(false);
62+ const [rate, setRate] = useState(0); // events per minute (rolling)
63+ const stamps = useRef<number[]>([]);
64+ const max = opts.max ?? 250;
65+ const enabled = opts.enabled !== false;
66+ useEffect(() => {
67+ if (!enabled) return;
68+ const params = new URLSearchParams();
69+ if (opts.session) params.set("session", opts.session);
70+ if (opts.types?.length) params.set("types", opts.types.join(","));
71+ const es = new EventSource(`/api/stream?${params}`);
72+ es.onopen = () => setConnected(true);
73+ es.onerror = () => setConnected(false);
74+ es.addEventListener("observation", (m) => {
75+ const ev = JSON.parse((m as MessageEvent).data) as LiveEvent;
76+ const now = Date.now();
77+ stamps.current.push(now);
78+ stamps.current = stamps.current.filter((t) => now - t < 60_000);
79+ setRate(stamps.current.length);
80+ setEvents((prev) => [ev, ...prev].slice(0, max));
81+ });
82+ const id = setInterval(() => {
83+ const now = Date.now();
84+ stamps.current = stamps.current.filter((t) => now - t < 60_000);
85+ setRate(stamps.current.length);
86+ }, 5000);
87+ return () => {
88+ es.close();
89+ clearInterval(id);
90+ };
91+ // eslint-disable-next-line react-hooks/exhaustive-deps
92+ }, [opts.session, opts.types?.join(","), enabled, max]);
93+ return { events, connected, rate };
94+}
added apps/dashboard/src/lib/auth.ts +36 −0
@@ -0,0 +1,36 @@
1+import { createHmac, timingSafeEqual } from "node:crypto";
2+
3+/**
4+ * Passcode gate. The console is public on the Internet (www.socialcrawl.co) and can launch browsers
5+ * on a cluster node, so every page and API call requires a signed cookie obtained with SRC_DASHBOARD_PASSCODE.
6+ */
7+export const COOKIE = "src_access";
8+const SECRET = process.env.SRC_DASHBOARD_SECRET ?? process.env.SRC_API_TOKEN ?? "dev-secret";
9+const PASSCODE = process.env.SRC_DASHBOARD_PASSCODE ?? "";
10+
11+export function gateEnabled(): boolean {
12+ return PASSCODE.length > 0;
13+}
14+
15+export function checkPasscode(input: string): boolean {
16+ if (!PASSCODE) return true;
17+ const a = Buffer.from(input);
18+ const b = Buffer.from(PASSCODE);
19+ return a.length === b.length && timingSafeEqual(a, b);
20+}
21+
22+export function makeToken(): string {
23+ const exp = Date.now() + 30 * 24 * 3600 * 1000;
24+ const body = `${exp}`;
25+ return `${body}.${createHmac("sha256", SECRET).update(body).digest("hex")}`;
26+}
27+
28+export function verifyToken(token: string | undefined): boolean {
29+ if (!PASSCODE) return true;
30+ if (!token) return false;
31+ const [body, sig] = token.split(".");
32+ if (!body || !sig) return false;
33+ const expect = createHmac("sha256", SECRET).update(body).digest("hex");
34+ if (expect.length !== sig.length || !timingSafeEqual(Buffer.from(expect), Buffer.from(sig))) return false;
35+ return Number(body) > Date.now();
36+}
added apps/dashboard/src/lib/format.ts +84 −0
@@ -0,0 +1,84 @@
1+export function fmtNum(n: number | string | null | undefined, digits = 0): string {
2+ if (n === null || n === undefined || n === "") return "—";
3+ const v = Number(n);
4+ if (Number.isNaN(v)) return String(n);
5+ if (Math.abs(v) >= 1e9) return (v / 1e9).toFixed(1) + "B";
6+ if (Math.abs(v) >= 1e6) return (v / 1e6).toFixed(1) + "M";
7+ if (Math.abs(v) >= 1e4) return (v / 1e3).toFixed(1) + "k";
8+ return v.toLocaleString("en-US", { maximumFractionDigits: digits });
9+}
10+
11+export function fmtPct(n: number | null | undefined, digits = 0): string {
12+ if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";
13+ return `${(Number(n) * 100).toFixed(digits)}%`;
14+}
15+
16+export function fmtDuration(s: number | null | undefined): string {
17+ if (!s && s !== 0) return "—";
18+ const v = Math.round(Number(s));
19+ const h = Math.floor(v / 3600);
20+ const m = Math.floor((v % 3600) / 60);
21+ const sec = v % 60;
22+ return h ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` : `${m}:${String(sec).padStart(2, "0")}`;
23+}
24+
25+export function fmtTime(ts: string | Date | null | undefined): string {
26+ if (!ts) return "—";
27+ const d = new Date(ts);
28+ return d.toLocaleTimeString("en-CA", { hour12: false });
29+}
30+
31+export function fmtDate(ts: string | Date | null | undefined): string {
32+ if (!ts) return "—";
33+ const d = new Date(ts);
34+ return d.toLocaleString("en-CA", { hour12: false, month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
35+}
36+
37+export function ago(ts: string | Date | null | undefined): string {
38+ if (!ts) return "—";
39+ const diff = (Date.now() - new Date(ts).getTime()) / 1000;
40+ if (diff < 60) return `${Math.max(0, Math.round(diff))}s ago`;
41+ if (diff < 3600) return `${Math.round(diff / 60)}m ago`;
42+ if (diff < 86400) return `${Math.round(diff / 3600)}h ago`;
43+ return `${Math.round(diff / 86400)}d ago`;
44+}
45+
46+export function truncate(s: string | null | undefined, n: number): string {
47+ if (!s) return "";
48+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
49+}
50+
51+export const TYPE_COLORS: Record<string, string> = {
52+ video: "#38e1ff",
53+ channel: "#9d7bff",
54+ profile: "#9d7bff",
55+ person: "#9d7bff",
56+ post: "#ffb347",
57+ comment: "#ff7ad9",
58+ community: "#43e69a",
59+ page: "#43e69a",
60+ hashtag: "#6b7a8c",
61+ image: "#38e1ff",
62+ organization: "#43e69a",
63+};
64+
65+export const EVENT_COLORS: Record<string, string> = {
66+ ACTION_PLANNED: "#ffb347",
67+ ACTION_EXECUTED: "#43e69a",
68+ ACTION_FAILED: "#ff5c7a",
69+ PAGE_OPENED: "#38e1ff",
70+ NETWORK_SCHEMA_DISCOVERED: "#9d7bff",
71+ CONNECTOR_PATTERN_LEARNED: "#9d7bff",
72+ CONNECTOR_DEGRADED: "#ff5c7a",
73+ CONNECTOR_REPAIRED: "#43e69a",
74+ LOOP_DETECTED: "#ff5c7a",
75+ AUTH_REQUIRED: "#ff5c7a",
76+ MEDIA_DISCOVERED: "#ff7ad9",
77+ VIDEO_DISCOVERED: "#38e1ff",
78+ POST_DISCOVERED: "#ffb347",
79+ PROFILE_DISCOVERED: "#9d7bff",
80+};
81+
82+export function eventColor(t: string): string {
83+ return EVENT_COLORS[t] ?? "#6b7a8c";
84+}
added apps/dashboard/src/proxy.ts +24 −0
@@ -0,0 +1,24 @@
1+import { NextResponse, type NextRequest } from "next/server";
2+
3+/**
4+ * Edge gate: redirect unauthenticated visitors to /access. Token verification (HMAC) happens in the
5+ * Node route handlers; here we only check presence + expiry to keep the edge runtime crypto-free.
6+ */
7+export function proxy(req: NextRequest) {
8+ const { pathname } = req.nextUrl;
9+ if (pathname.startsWith("/access") || pathname.startsWith("/_next") || pathname === "/favicon.ico" || pathname.startsWith("/brand")) return NextResponse.next();
10+ const gate = process.env.SRC_DASHBOARD_PASSCODE;
11+ if (!gate) return NextResponse.next();
12+ const token = req.cookies.get("src_access")?.value;
13+ const exp = token ? Number(token.split(".")[0]) : 0;
14+ if (!token || !(exp > Date.now())) {
15+ if (pathname.startsWith("/api")) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
16+ const url = req.nextUrl.clone();
17+ url.pathname = "/access";
18+ url.searchParams.set("next", pathname);
19+ return NextResponse.redirect(url);
20+ }
21+ return NextResponse.next();
22+}
23+
24+export const config = { matcher: ["/((?!_next/static|_next/image).*)"] };
added apps/dashboard/tsconfig.json +41 −0
@@ -0,0 +1,41 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "lib": [
5+ "dom",
6+ "dom.iterable",
7+ "esnext"
8+ ],
9+ "allowJs": true,
10+ "skipLibCheck": true,
11+ "strict": true,
12+ "noEmit": true,
13+ "esModuleInterop": true,
14+ "module": "esnext",
15+ "moduleResolution": "bundler",
16+ "resolveJsonModule": true,
17+ "isolatedModules": true,
18+ "jsx": "react-jsx",
19+ "incremental": true,
20+ "plugins": [
21+ {
22+ "name": "next"
23+ }
24+ ],
25+ "paths": {
26+ "@/*": [
27+ "./src/*"
28+ ]
29+ }
30+ },
31+ "include": [
32+ "next-env.d.ts",
33+ "**/*.ts",
34+ "**/*.tsx",
35+ ".next/types/**/*.ts",
36+ ".next/dev/types/**/*.ts"
37+ ],
38+ "exclude": [
39+ "node_modules"
40+ ]
41+}
added apps/worker/qa/screenshots.mjs +12 −0
@@ -0,0 +1,12 @@
1+import { chromium } from "playwright";
2+const b = await chromium.launch(); const p = await b.newPage({ viewport: { width: 1500, height: 1000 } });
3+await p.goto("http://localhost:8351/access"); await p.fill('input[name=passcode]', "test1234"); await p.click("button"); await p.waitForURL("http://localhost:8351/");
4+await p.waitForTimeout(3500); await p.screenshot({ path: "/tmp/shot-home.png", fullPage: true });
5+const sid = (await (await fetch("http://127.0.0.1:8350/api/sessions?limit=1")).json())[0].session_id;
6+await p.goto(`http://localhost:8351/sessions/${sid}`); await p.waitForTimeout(3000); await p.screenshot({ path: "/tmp/shot-session.png", fullPage: true });
7+await p.click("text=world model"); await p.waitForTimeout(3500); await p.screenshot({ path: "/tmp/shot-world.png" });
8+await p.goto(`http://localhost:8351/platforms/youtube`); await p.waitForTimeout(2500); await p.screenshot({ path: "/tmp/shot-platform.png", fullPage: true });
9+await p.goto(`http://localhost:8351/jobs`); await p.waitForTimeout(2500); await p.screenshot({ path: "/tmp/shot-jobs.png" });
10+const errs = []; p.on("console", m => { if (m.type()==="error") errs.push(m.text()); });
11+await p.goto(`http://localhost:8351/entities`); await p.waitForTimeout(2500); await p.screenshot({ path: "/tmp/shot-entities.png" });
12+console.log("errors:", errs.slice(0,5)); await b.close();
modified apps/worker/src/cli.ts +1 −1
@@ -69,7 +69,7 @@ async function main() {
69 const goal = str("goal") ?? (isLearn ? `Learn how ${platform} surfaces content: page types, entity structures, response schemas, navigation` : query ? `Discover public content about “${query}”` : `Observe the ${platform} feed`); 69 const goal = str("goal") ?? (isLearn ? `Learn how ${platform} surfaces content: page types, entity structures, response schemas, navigation` : query ? `Discover public content about “${query}”` : `Observe the ${platform} feed`);
70 if (args.flags.headless) cfg.headless = true; 70 if (args.flags.headless) cfg.headless = true;
71 const job: CrawlJob = { 71 const job: CrawlJob = {
72− job_id: newId("job"), 72+ job_id: str("job") ?? newId("job"),
73 platform, 73 platform,
74 account_alias: str("account", "research-01")!, 74 account_alias: str("account", "research-01")!,
75 mode, 75 mode,
modified apps/worker/src/engine.ts +1 −0
@@ -76,6 +76,7 @@ export class CrawlEngine {
76 this.store = await openStore(cfg.databaseUrl); 76 this.store = await openStore(cfg.databaseUrl);
77 this.store?.attach(this.bus); 77 this.store?.attach(this.bus);
78 fs.writeFileSync(path.join(this.sessionDir, "job.json"), JSON.stringify({ ...job, session_id: this.session.id }, null, 2)); 78 fs.writeFileSync(path.join(this.sessionDir, "job.json"), JSON.stringify({ ...job, session_id: this.session.id }, null, 2));
79+ await this.store?.linkJob(job, this.session.id).catch(() => {});
79 80
80 if (!this.session.hasProfile()) log.warn(`No saved browser profile for ${job.platform}/${job.account_alias}. Run: pnpm login ${job.platform} --account ${job.account_alias}`); 81 if (!this.session.hasProfile()) log.warn(`No saved browser profile for ${job.platform}/${job.account_alias}. Run: pnpm login ${job.platform} --account ${job.account_alias}`);
81 82
modified apps/worker/src/executor.ts +0 −0

Binary file not shown.

modified connectors/youtube/manifest.json +2993 −14
@@ -1,14 +1,15 @@
1 { 1 {
2 "platform": "youtube", 2 "platform": "youtube",
3− "compiled_at": "2026-09-11T21:32:58.338Z", 3+ "compiled_at": "2026-09-12T02:35:03.398Z",
4− "learned_from_sessions": 4, 4+ "learned_from_sessions": 5,
5 "confidence": 45, 5 "confidence": 45,
6 "page_types": [ 6 "page_types": [
7 { 7 {
8 "page_type": "UNKNOWN", 8 "page_type": "UNKNOWN",
9 "url_patterns": [ 9 "url_patterns": [
10 "/", 10 "/",
11− "/results" 11+ "/results",
12+ "/watch"
12 ], 13 ],
13 "avg_entities": 0 14 "avg_entities": 0
14 }, 15 },
@@ -29,12 +30,2971 @@
29 { 30 {
30 "page_type": "VIDEO_DETAIL", 31 "page_type": "VIDEO_DETAIL",
31 "url_patterns": [ 32 "url_patterns": [
32− "/watch" 33+ "/watch",
34+ "/shorts/*"
33 ], 35 ],
34− "avg_entities": 39 36+ "avg_entities": 34.1
37+ }
38+ ],
39+ "network_patterns": [
40+ {
41+ "shape_hash": "e716c418dfe5cbd3",
42+ "status": "provisional",
43+ "hostname": "www.youtube.com",
44+ "path_pattern": "/youtubei/v1/search",
45+ "method": "POST",
46+ "likely_entity_types": {
47+ "video": 2,
48+ "post": 2,
49+ "channel": 2
50+ },
51+ "confidence": 0.39,
52+ "triggered_by": {
53+ "SEARCH": 2
54+ },
55+ "key_fields": [
56+ {
57+ "path": "responseContext.mainAppWebResponseContext.loggedOut",
58+ "semantic": {
59+ "kind": "boolean",
60+ "confidence": 0.95
61+ },
62+ "example": "true"
63+ },
64+ {
65+ "path": "responseContext.responseId",
66+ "semantic": {
67+ "kind": "identifier",
68+ "confidence": 0.95
69+ },
70+ "example": "IhMI486x5bznlgMVYhaRAR0OOzWR"
71+ },
72+ {
73+ "path": "responseContext.webResponseContextExtensionData.hasDecorated",
74+ "semantic": {
75+ "kind": "boolean",
76+ "confidence": 0.95
77+ },
78+ "example": "true"
79+ },
80+ {
81+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.videoId",
82+ "semantic": {
83+ "kind": "identifier",
84+ "confidence": 0.95
85+ },
86+ "example": "1RqbM2LW3wE"
87+ },
88+ {
89+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.thumbnail.thumbnails.[].url",
90+ "semantic": {
91+ "kind": "thumbnail_url",
92+ "confidence": 0.92
93+ },
94+ "example": "https://i.ytimg.com/vi/1RqbM2LW3wE/hq720.jpg?sqp=-oaymwEnCOgCEMoBSFryq4qpAxkIARU"
95+ },
96+ {
97+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.title.runs.[].text",
98+ "semantic": {
99+ "kind": "text",
100+ "confidence": 0.85
101+ },
102+ "example": "Intelligence artificielle: que nous réserve 2026?"
103+ },
104+ {
105+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.longBylineText.runs.[].text",
106+ "semantic": {
107+ "kind": "text",
108+ "confidence": 0.85
109+ },
110+ "example": "TVA Nouvelles"
111+ },
112+ {
113+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.longBylineText.runs.[].navigationEndpoint.browseEndpoint.browseId",
114+ "semantic": {
115+ "kind": "identifier",
116+ "confidence": 0.95
117+ },
118+ "example": "UCgKzdfWnTie2OD4bfaZKaQw"
119+ },
120+ {
121+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.publishedTimeText.simpleText",
122+ "semantic": {
123+ "kind": "text",
124+ "confidence": 0.85
125+ },
126+ "example": "il y a 8 mois"
127+ },
128+ {
129+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.lengthText.simpleText",
130+ "semantic": {
131+ "kind": "duration",
132+ "confidence": 0.9
133+ },
134+ "example": "9:18"
135+ },
136+ {
137+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.viewCountText.simpleText",
138+ "semantic": {
139+ "kind": "text",
140+ "confidence": 0.85
141+ },
142+ "example": "17 686 visionnements"
143+ },
144+ {
145+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.navigationEndpoint.watchEndpoint.videoId",
146+ "semantic": {
147+ "kind": "identifier",
148+ "confidence": 0.95
149+ },
150+ "example": "1RqbM2LW3wE"
151+ },
152+ {
153+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.navigationEndpoint.watchEndpoint.watchEndpointSupportedOnesieConfig.html5PlaybackOnesieConfig.commonConfig.url",
154+ "semantic": {
155+ "kind": "url",
156+ "confidence": 0.9
157+ },
158+ "example": "https://rr1---sn-cxaaj5o5q5-t0ay.googlevideo.com/initplayback?source=youtube&oei"
159+ },
160+ {
161+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.ownerText.runs.[].text",
162+ "semantic": {
163+ "kind": "text",
164+ "confidence": 0.85
165+ },
166+ "example": "TVA Nouvelles"
167+ },
168+ {
169+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.ownerText.runs.[].navigationEndpoint.browseEndpoint.browseId",
170+ "semantic": {
171+ "kind": "identifier",
172+ "confidence": 0.95
173+ },
174+ "example": "UCgKzdfWnTie2OD4bfaZKaQw"
175+ },
176+ {
177+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.shortBylineText.runs.[].text",
178+ "semantic": {
179+ "kind": "text",
180+ "confidence": 0.85
181+ },
182+ "example": "TVA Nouvelles"
183+ },
184+ {
185+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.shortBylineText.runs.[].navigationEndpoint.browseEndpoint.browseId",
186+ "semantic": {
187+ "kind": "identifier",
188+ "confidence": 0.95
189+ },
190+ "example": "UCgKzdfWnTie2OD4bfaZKaQw"
191+ },
192+ {
193+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.showActionMenu",
194+ "semantic": {
195+ "kind": "boolean",
196+ "confidence": 0.95
197+ },
198+ "example": "false"
199+ },
200+ {
201+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.shortViewCountText.simpleText",
202+ "semantic": {
203+ "kind": "text",
204+ "confidence": 0.85
205+ },
206+ "example": "17 k visionnements"
207+ },
208+ {
209+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.menu.menuRenderer.items.[].menuServiceItemRenderer.text.runs.[].text",
210+ "semantic": {
211+ "kind": "text",
212+ "confidence": 0.85
213+ },
214+ "example": "Ajouter à la file d'attente"
215+ },
216+ {
217+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.menu.menuRenderer.items.[].menuServiceItemRenderer.serviceEndpoint.commandMetadata.webCommandMetadata.sendPost",
218+ "semantic": {
219+ "kind": "boolean",
220+ "confidence": 0.95
221+ },
222+ "example": "true"
223+ },
224+ {
225+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.menu.menuRenderer.items.[].menuServiceItemRenderer.serviceEndpoint.signalServiceEndpoint.actions.[].addToPlaylistCommand.openMiniplayer",
226+ "semantic": {
227+ "kind": "boolean",
228+ "confidence": 0.95
229+ },
230+ "example": "true"
231+ },
232+ {
233+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.menu.menuRenderer.items.[].menuServiceItemRenderer.serviceEndpoint.signalServiceEndpoint.actions.[].addToPlaylistCommand.videoId",
234+ "semantic": {
235+ "kind": "identifier",
236+ "confidence": 0.95
237+ },
238+ "example": "1RqbM2LW3wE"
239+ },
240+ {
241+ "path": "contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.[].itemSectionRenderer.contents.[].videoRenderer.menu.menuRenderer.items.[].menuServiceItemRenderer.serviceEndpoint.signalServiceEndpoint.actions.[].addToPlaylistCommand.onCreateListCommand.commandMetadata.webCommandMetadata.sendPost",
242+ "semantic": {
243+ "kind": "boolean",
244+ "confidence": 0.95
245+ },
246+ "example": "true"
247+ }
248+ ]
249+ },
250+ {
251+ "shape_hash": "ad92d388ecab3c2a",
252+ "status": "provisional",
253+ "hostname": "www.youtube.com",
254+ "path_pattern": "/youtubei/v1/get_watch",
255+ "method": "POST",
256+ "likely_entity_types": {
257+ "video": 2,
258+ "channel": 2,
259+ "post": 2
260+ },
261+ "confidence": 0.39,
262+ "triggered_by": {
263+ "OPEN_VIDEO": 2
264+ },
265+ "key_fields": [
266+ {
267+ "path": "[].responseContext.responseId",
268+ "semantic": {
269+ "kind": "identifier",
270+ "confidence": 0.95
271+ },
272+ "example": "IhMIlbyp5rznlgMV4NSUCR0s4BSn"
273+ },
274+ {
275+ "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
276+ "semantic": {
277+ "kind": "boolean",
278+ "confidence": 0.95
279+ },
280+ "example": "true"
281+ },
282+ {
283+ "path": "[].playerResponse.responseContext.responseId",
284+ "semantic": {
285+ "kind": "identifier",
286+ "confidence": 0.95
287+ },
288+ "example": "IhMIlbyp5rznlgMV4NSUCR0s4BSn"
289+ },
290+ {
291+ "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
292+ "semantic": {
293+ "kind": "boolean",
294+ "confidence": 0.95
295+ },
296+ "example": "true"
297+ },
298+ {
299+ "path": "[].playerResponse.playabilityStatus.playableInEmbed",
300+ "semantic": {
301+ "kind": "boolean",
302+ "confidence": 0.95
303+ },
304+ "example": "true"
305+ },
306+ {
307+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
308+ "semantic": {
309+ "kind": "duration",
310+ "confidence": 0.75
311+ },
312+ "example": "46763300"
313+ },
314+ {
315+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
316+ "semantic": {
317+ "kind": "duration",
318+ "confidence": 0.75
319+ },
320+ "example": "358591"
321+ },
322+ {
323+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
324+ "semantic": {
325+ "kind": "boolean",
326+ "confidence": 0.95
327+ },
328+ "example": "true"
329+ },
330+ {
331+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
332+ "semantic": {
333+ "kind": "boolean",
334+ "confidence": 0.95
335+ },
336+ "example": "true"
337+ },
338+ {
339+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
340+ "semantic": {
341+ "kind": "boolean",
342+ "confidence": 0.95
343+ },
344+ "example": "true"
345+ },
346+ {
347+ "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
348+ "semantic": {
349+ "kind": "media_url",
350+ "confidence": 0.93
351+ },
352+ "example": "https://rr2---sn-cxaaj5o5q5-t0ay.googlevideo.com/videoplayback?expire=1789183857"
353+ },
354+ {
355+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
356+ "semantic": {
357+ "kind": "boolean",
358+ "confidence": 0.95
359+ },
360+ "example": "true"
361+ },
362+ {
363+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
364+ "semantic": {
365+ "kind": "boolean",
366+ "confidence": 0.95
367+ },
368+ "example": "true"
369+ },
370+ {
371+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
372+ "semantic": {
373+ "kind": "boolean",
374+ "confidence": 0.95
375+ },
376+ "example": "true"
377+ },
378+ {
379+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
380+ "semantic": {
381+ "kind": "boolean",
382+ "confidence": 0.95
383+ },
384+ "example": "true"
385+ },
386+ {
387+ "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
388+ "semantic": {
389+ "kind": "url",
390+ "confidence": 0.9
391+ },
392+ "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=TtU427br-sk&ei=EXOka"
393+ },
394+ {
395+ "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
396+ "semantic": {
397+ "kind": "url",
398+ "confidence": 0.9
399+ },
400+ "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=TtU427br-sk&ei=EXOk"
401+ },
402+ {
403+ "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
404+ "semantic": {
405+ "kind": "url",
406+ "confidence": 0.9
407+ },
408+ "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=TtU427br-sk&ei=EXOk"
409+ },
410+ {
411+ "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
412+ "semantic": {
413+ "kind": "url",
414+ "confidence": 0.9
415+ },
416+ "example": "https://www.youtube.com/ptracking?ei=EXOkatXvEOCp0_wPrMDTuAo&oid=m-Wz5fxH9AWP_fn"
417+ },
418+ {
419+ "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
420+ "semantic": {
421+ "kind": "url",
422+ "confidence": 0.9
423+ },
424+ "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=TtU427br-sk&ei=EXOkatXvEO"
425+ },
426+ {
427+ "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
428+ "semantic": {
429+ "kind": "url",
430+ "confidence": 0.9
431+ },
432+ "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=TtU427br-sk&ei=EXOkatXvEOCp0_wPr"
433+ },
434+ {
435+ "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
436+ "semantic": {
437+ "kind": "timestamp",
438+ "confidence": 0.7
439+ },
440+ "example": "5"
441+ },
442+ {
443+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
444+ "semantic": {
445+ "kind": "url",
446+ "confidence": 0.9
447+ },
448+ "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
449+ },
450+ {
451+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
452+ "semantic": {
453+ "kind": "timestamp",
454+ "confidence": 0.7
455+ },
456+ "example": "0"
457+ },
458+ {
459+ "path": "[].playerResponse.playbackTracking.googleRemarketingUrl.baseUrl",
460+ "semantic": {
461+ "kind": "url",
462+ "confidence": 0.9
463+ },
464+ "example": "https://www.google.com/pagead/1p-user-list/962985656/?backend=innertube&cname=1&"
465+ }
466+ ]
467+ },
468+ {
469+ "shape_hash": "b8cf160ca9b65b8a",
470+ "status": "provisional",
471+ "hostname": "www.youtube.com",
472+ "path_pattern": "/youtubei/v1/next",
473+ "method": "POST",
474+ "likely_entity_types": {
475+ "post": 2,
476+ "comment": 2,
477+ "channel": 2,
478+ "video": 2
479+ },
480+ "confidence": 0.39,
481+ "triggered_by": {
482+ "OPEN_VIDEO": 2
483+ },
484+ "key_fields": [
485+ {
486+ "path": "responseContext.mainAppWebResponseContext.loggedOut",
487+ "semantic": {
488+ "kind": "boolean",
489+ "confidence": 0.95
490+ },
491+ "example": "true"
492+ },
493+ {
494+ "path": "responseContext.responseId",
495+ "semantic": {
496+ "kind": "identifier",
497+ "confidence": 0.95
498+ },
499+ "example": "IhMI3--M57znlgMVqRHLBB0OIjPf"
500+ },
501+ {
502+ "path": "responseContext.webResponseContextExtensionData.hasDecorated",
503+ "semantic": {
504+ "kind": "boolean",
505+ "confidence": 0.95
506+ },
507+ "example": "true"
508+ },
509+ {
510+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.targetId",
511+ "semantic": {
512+ "kind": "identifier",
513+ "confidence": 0.95
514+ },
515+ "example": "comments-section"
516+ },
517+ {
518+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.authorThumbnail.thumbnails.[].url",
519+ "semantic": {
520+ "kind": "url",
521+ "confidence": 0.9
522+ },
523+ "example": "https://yt3.ggpht.com/a/default-user=s48-c-k-c0x00ffffff-no-rj"
524+ },
525+ {
526+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.placeholderText.runs.[].text",
527+ "semantic": {
528+ "kind": "text",
529+ "confidence": 0.85
530+ },
531+ "example": "Ajouter un commentaire…"
532+ },
533+ {
534+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.commandMetadata.webCommandMetadata.ignoreNavigation",
535+ "semantic": {
536+ "kind": "boolean",
537+ "confidence": 0.95
538+ },
539+ "example": "true"
540+ },
541+ {
542+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.modalEndpoint.modal.modalWithTitleAndButtonRenderer.title.runs.[].text",
543+ "semantic": {
544+ "kind": "text",
545+ "confidence": 0.85
546+ },
547+ "example": "Vous voulez vous joindre à la conversation?"
548+ },
549+ {
550+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.modalEndpoint.modal.modalWithTitleAndButtonRenderer.content.runs.[].text",
551+ "semantic": {
552+ "kind": "text",
553+ "confidence": 0.85
554+ },
555+ "example": "Connectez-vous pour continuer"
556+ },
557+ {
558+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.modalEndpoint.modal.modalWithTitleAndButtonRenderer.button.buttonRenderer.isDisabled",
559+ "semantic": {
560+ "kind": "boolean",
561+ "confidence": 0.95
562+ },
563+ "example": "false"
564+ },
565+ {
566+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.modalEndpoint.modal.modalWithTitleAndButtonRenderer.button.buttonRenderer.navigationEndpoint.commandMetadata.webCommandMetadata.url",
567+ "semantic": {
568+ "kind": "url",
569+ "confidence": 0.9
570+ },
571+ "example": "https://accounts.google.com/ServiceLogin?service=youtube&uilel=3&passive=true&co"
572+ },
573+ {
574+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.prepareAccountEndpoint.modalEndpoint.modal.modalWithTitleAndButtonRenderer.button.buttonRenderer.navigationEndpoint.signInEndpoint.hack",
575+ "semantic": {
576+ "kind": "boolean",
577+ "confidence": 0.95
578+ },
579+ "example": "true"
580+ },
581+ {
582+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.emojiPicker.emojiPickerRenderer.categories.[].emojiPickerCategoryRenderer.imageLoadingLazy",
583+ "semantic": {
584+ "kind": "boolean",
585+ "confidence": 0.95
586+ },
587+ "example": "true"
588+ },
589+ {
590+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.emojiPicker.emojiPickerRenderer.categoryButtons.[].emojiPickerCategoryButtonRenderer.targetId",
591+ "semantic": {
592+ "kind": "identifier",
593+ "confidence": 0.95
594+ },
595+ "example": "emoji-picker-category-button-people"
596+ },
597+ {
598+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.emojiPicker.emojiPickerRenderer.searchPlaceholderText.runs.[].text",
599+ "semantic": {
600+ "kind": "text",
601+ "confidence": 0.85
602+ },
603+ "example": "Rechercher un emoji"
604+ },
605+ {
606+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.emojiPicker.emojiPickerRenderer.searchNoResultsText.runs.[].text",
607+ "semantic": {
608+ "kind": "text",
609+ "confidence": 0.85
610+ },
611+ "example": "Aucun émoji trouvé"
612+ },
613+ {
614+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.emojiPicker.emojiPickerRenderer.pickSkinToneText.runs.[].text",
615+ "semantic": {
616+ "kind": "text",
617+ "confidence": 0.85
618+ },
619+ "example": "Sélectionner le teint de l'emoji"
620+ },
621+ {
622+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.disabledText",
623+ "semantic": {
624+ "kind": "text",
625+ "confidence": 0.85
626+ },
627+ "example": "Les commentaires sont désactivés."
628+ },
629+ {
630+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.createRenderer.commentSimpleboxRenderer.disabledTextUrl",
631+ "semantic": {
632+ "kind": "url",
633+ "confidence": 0.9
634+ },
635+ "example": "https://support.google.com/youtube/answer/9706180"
636+ },
637+ {
638+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.subMenuItems.[].title",
639+ "semantic": {
640+ "kind": "title",
641+ "confidence": 0.85
642+ },
643+ "example": "Principaux"
644+ },
645+ {
646+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.subMenuItems.[].selected",
647+ "semantic": {
648+ "kind": "boolean",
649+ "confidence": 0.95
650+ },
651+ "example": "true"
652+ },
653+ {
654+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.subMenuItems.[].serviceEndpoint.commandMetadata.webCommandMetadata.sendPost",
655+ "semantic": {
656+ "kind": "boolean",
657+ "confidence": 0.95
658+ },
659+ "example": "true"
660+ },
661+ {
662+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.subMenuItems.[].serviceEndpoint.continuationCommand.command.showReloadUiCommand.targetId",
663+ "semantic": {
664+ "kind": "identifier",
665+ "confidence": 0.95
666+ },
667+ "example": "comments-section"
668+ },
669+ {
670+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.subMenuItems.[].subtitle",
671+ "semantic": {
672+ "kind": "title",
673+ "confidence": 0.85
674+ },
675+ "example": "Affiche les commentaires en vedette"
676+ },
677+ {
678+ "path": "onResponseReceivedEndpoints.[].reloadContinuationItemsCommand.continuationItems.[].commentsHeaderRenderer.sortMenu.sortFilterSubMenuRenderer.title",
679+ "semantic": {
680+ "kind": "title",
681+ "confidence": 0.85
682+ },
683+ "example": "Trier par"
684+ }
685+ ]
686+ },
687+ {
688+ "shape_hash": "094367e8f9ba7873",
689+ "status": "provisional",
690+ "hostname": "www.youtube.com",
691+ "path_pattern": "/youtubei/v1/get_watch",
692+ "method": "POST",
693+ "likely_entity_types": {
694+ "video": 2,
695+ "channel": 2,
696+ "post": 2
697+ },
698+ "confidence": 0.39,
699+ "triggered_by": {
700+ "OPEN_VIDEO": 2
701+ },
702+ "key_fields": [
703+ {
704+ "path": "[].responseContext.responseId",
705+ "semantic": {
706+ "kind": "identifier",
707+ "confidence": 0.95
708+ },
709+ "example": "IhMIvqLNhb3nlgMVloTkBh0Cth8C"
710+ },
711+ {
712+ "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
713+ "semantic": {
714+ "kind": "boolean",
715+ "confidence": 0.95
716+ },
717+ "example": "true"
718+ },
719+ {
720+ "path": "[].playerResponse.responseContext.responseId",
721+ "semantic": {
722+ "kind": "identifier",
723+ "confidence": 0.95
724+ },
725+ "example": "IhMIvqLNhb3nlgMVloTkBh0Cth8C"
726+ },
727+ {
728+ "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
729+ "semantic": {
730+ "kind": "boolean",
731+ "confidence": 0.95
732+ },
733+ "example": "true"
734+ },
735+ {
736+ "path": "[].playerResponse.playabilityStatus.playableInEmbed",
737+ "semantic": {
738+ "kind": "boolean",
739+ "confidence": 0.95
740+ },
741+ "example": "true"
742+ },
743+ {
744+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
745+ "semantic": {
746+ "kind": "duration",
747+ "confidence": 0.75
748+ },
749+ "example": "54128576"
750+ },
751+ {
752+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
753+ "semantic": {
754+ "kind": "duration",
755+ "confidence": 0.75
756+ },
757+ "example": "184050"
758+ },
759+ {
760+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
761+ "semantic": {
762+ "kind": "boolean",
763+ "confidence": 0.95
764+ },
765+ "example": "true"
766+ },
767+ {
768+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
769+ "semantic": {
770+ "kind": "boolean",
771+ "confidence": 0.95
772+ },
773+ "example": "true"
774+ },
775+ {
776+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
777+ "semantic": {
778+ "kind": "boolean",
779+ "confidence": 0.95
780+ },
781+ "example": "true"
782+ },
783+ {
784+ "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
785+ "semantic": {
786+ "kind": "media_url",
787+ "confidence": 0.93
788+ },
789+ "example": "https://rr1---sn-cxaaj5o5q5-t0ar.googlevideo.com/videoplayback?expire=1789183922"
790+ },
791+ {
792+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
793+ "semantic": {
794+ "kind": "boolean",
795+ "confidence": 0.95
796+ },
797+ "example": "true"
798+ },
799+ {
800+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
801+ "semantic": {
802+ "kind": "boolean",
803+ "confidence": 0.95
804+ },
805+ "example": "true"
806+ },
807+ {
808+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
809+ "semantic": {
810+ "kind": "boolean",
811+ "confidence": 0.95
812+ },
813+ "example": "true"
814+ },
815+ {
816+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
817+ "semantic": {
818+ "kind": "boolean",
819+ "confidence": 0.95
820+ },
821+ "example": "true"
822+ },
823+ {
824+ "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
825+ "semantic": {
826+ "kind": "url",
827+ "confidence": 0.9
828+ },
829+ "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=hmG52cE7S5U&ei=UnOka"
830+ },
831+ {
832+ "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
833+ "semantic": {
834+ "kind": "url",
835+ "confidence": 0.9
836+ },
837+ "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=hmG52cE7S5U&ei=UnOk"
838+ },
839+ {
840+ "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
841+ "semantic": {
842+ "kind": "url",
843+ "confidence": 0.9
844+ },
845+ "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=hmG52cE7S5U&ei=UnOk"
846+ },
847+ {
848+ "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
849+ "semantic": {
850+ "kind": "url",
851+ "confidence": 0.9
852+ },
853+ "example": "https://www.youtube.com/ptracking?ei=UnOkar6xNZaJkucPguz-EA&oid=WLeG0gE_kkpZY0oK"
854+ },
855+ {
856+ "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
857+ "semantic": {
858+ "kind": "url",
859+ "confidence": 0.9
860+ },
861+ "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=hmG52cE7S5U&ei=UnOkar6xNZ"
862+ },
863+ {
864+ "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
865+ "semantic": {
866+ "kind": "url",
867+ "confidence": 0.9
868+ },
869+ "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=hmG52cE7S5U&ei=UnOkar6xNZaJkucPg"
870+ },
871+ {
872+ "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
873+ "semantic": {
874+ "kind": "timestamp",
875+ "confidence": 0.7
876+ },
877+ "example": "5"
878+ },
879+ {
880+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
881+ "semantic": {
882+ "kind": "url",
883+ "confidence": 0.9
884+ },
885+ "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
886+ },
887+ {
888+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
889+ "semantic": {
890+ "kind": "timestamp",
891+ "confidence": 0.7
892+ },
893+ "example": "0"
894+ },
895+ {
896+ "path": "[].playerResponse.playbackTracking.googleRemarketingUrl.baseUrl",
897+ "semantic": {
898+ "kind": "url",
899+ "confidence": 0.9
900+ },
901+ "example": "https://www.google.com/pagead/1p-user-list/962985656/?backend=innertube&cname=1&"
902+ }
903+ ]
904+ },
905+ {
906+ "shape_hash": "ffec634da7d6e380",
907+ "status": "provisional",
908+ "hostname": "www.youtube.com",
909+ "path_pattern": "/youtubei/v1/get_watch",
910+ "method": "POST",
911+ "likely_entity_types": {
912+ "video": 2,
913+ "channel": 2,
914+ "post": 2
915+ },
916+ "confidence": 0.39,
917+ "triggered_by": {
918+ "OPEN_VIDEO": 2
919+ },
920+ "key_fields": [
921+ {
922+ "path": "[].responseContext.responseId",
923+ "semantic": {
924+ "kind": "identifier",
925+ "confidence": 0.95
926+ },
927+ "example": "IhMI2by7kr3nlgMVNYHkBh23ayjA"
928+ },
929+ {
930+ "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
931+ "semantic": {
932+ "kind": "boolean",
933+ "confidence": 0.95
934+ },
935+ "example": "true"
936+ },
937+ {
938+ "path": "[].playerResponse.responseContext.responseId",
939+ "semantic": {
940+ "kind": "identifier",
941+ "confidence": 0.95
942+ },
943+ "example": "IhMI2by7kr3nlgMVNYHkBh23ayjA"
944+ },
945+ {
946+ "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
947+ "semantic": {
948+ "kind": "boolean",
949+ "confidence": 0.95
950+ },
951+ "example": "true"
952+ },
953+ {
954+ "path": "[].playerResponse.playabilityStatus.playableInEmbed",
955+ "semantic": {
956+ "kind": "boolean",
957+ "confidence": 0.95
958+ },
959+ "example": "true"
960+ },
961+ {
962+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
963+ "semantic": {
964+ "kind": "duration",
965+ "confidence": 0.75
966+ },
967+ "example": "523368691"
968+ },
969+ {
970+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
971+ "semantic": {
972+ "kind": "duration",
973+ "confidence": 0.75
974+ },
975+ "example": "2348466"
976+ },
977+ {
978+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
979+ "semantic": {
980+ "kind": "boolean",
981+ "confidence": 0.95
982+ },
983+ "example": "true"
984+ },
985+ {
986+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
987+ "semantic": {
988+ "kind": "boolean",
989+ "confidence": 0.95
990+ },
991+ "example": "true"
992+ },
993+ {
994+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
995+ "semantic": {
996+ "kind": "boolean",
997+ "confidence": 0.95
998+ },
999+ "example": "true"
1000+ },
1001+ {
1002+ "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
1003+ "semantic": {
1004+ "kind": "media_url",
1005+ "confidence": 0.93
1006+ },
1007+ "example": "https://rr4---sn-cxaaj5o5q5-t0ay.googlevideo.com/videoplayback?expire=1789183949"
1008+ },
1009+ {
1010+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
1011+ "semantic": {
1012+ "kind": "boolean",
1013+ "confidence": 0.95
1014+ },
1015+ "example": "true"
1016+ },
1017+ {
1018+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
1019+ "semantic": {
1020+ "kind": "boolean",
1021+ "confidence": 0.95
1022+ },
1023+ "example": "true"
1024+ },
1025+ {
1026+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
1027+ "semantic": {
1028+ "kind": "boolean",
1029+ "confidence": 0.95
1030+ },
1031+ "example": "true"
1032+ },
1033+ {
1034+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
1035+ "semantic": {
1036+ "kind": "boolean",
1037+ "confidence": 0.95
1038+ },
1039+ "example": "true"
1040+ },
1041+ {
1042+ "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
1043+ "semantic": {
1044+ "kind": "url",
1045+ "confidence": 0.9
1046+ },
1047+ "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=QYRiiWEsTeI&ei=bXOka"
1048+ },
1049+ {
1050+ "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
1051+ "semantic": {
1052+ "kind": "url",
1053+ "confidence": 0.9
1054+ },
1055+ "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=QYRiiWEsTeI&ei=bXOk"
1056+ },
1057+ {
1058+ "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
1059+ "semantic": {
1060+ "kind": "url",
1061+ "confidence": 0.9
1062+ },
1063+ "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=QYRiiWEsTeI&ei=bXOk"
1064+ },
1065+ {
1066+ "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
1067+ "semantic": {
1068+ "kind": "url",
1069+ "confidence": 0.9
1070+ },
1071+ "example": "https://www.youtube.com/ptracking?ei=bXOkapnSM7WCkucPt9ehgQw&oid=XG4oyHHzHGAFkGU"
1072+ },
1073+ {
1074+ "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
1075+ "semantic": {
1076+ "kind": "url",
1077+ "confidence": 0.9
1078+ },
1079+ "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=QYRiiWEsTeI&ei=bXOkapnSM7"
1080+ },
1081+ {
1082+ "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
1083+ "semantic": {
1084+ "kind": "url",
1085+ "confidence": 0.9
1086+ },
1087+ "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=QYRiiWEsTeI&ei=bXOkapnSM7WCkucPt"
1088+ },
1089+ {
1090+ "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
1091+ "semantic": {
1092+ "kind": "timestamp",
1093+ "confidence": 0.7
1094+ },
1095+ "example": "5"
1096+ },
1097+ {
1098+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
1099+ "semantic": {
1100+ "kind": "url",
1101+ "confidence": 0.9
1102+ },
1103+ "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
1104+ },
1105+ {
1106+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
1107+ "semantic": {
1108+ "kind": "timestamp",
1109+ "confidence": 0.7
1110+ },
1111+ "example": "0"
1112+ },
1113+ {
1114+ "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].baseUrl",
1115+ "semantic": {
1116+ "kind": "url",
1117+ "confidence": 0.9
1118+ },
1119+ "example": "https://www.youtube.com/api/timedtext?v=QYRiiWEsTeI&ei=bXOkapnSM7WCkucPt9ehgQw&c"
1120+ }
1121+ ]
1122+ },
1123+ {
1124+ "shape_hash": "9a96a965bd5dc746",
1125+ "status": "provisional",
1126+ "hostname": "www.youtube.com",
1127+ "path_pattern": "/youtubei/v1/guide",
1128+ "method": "POST",
1129+ "likely_entity_types": {
1130+ "video": 3,
1131+ "post": 3
1132+ },
1133+ "confidence": 0.29,
1134+ "triggered_by": {
1135+ "RETURN_TO_FEED": 2,
1136+ "OPEN_CHANNEL": 1
1137+ },
1138+ "key_fields": [
1139+ {
1140+ "path": "responseContext.mainAppWebResponseContext.loggedOut",
1141+ "semantic": {
1142+ "kind": "boolean",
1143+ "confidence": 0.95
1144+ },
1145+ "example": "true"
1146+ },
1147+ {
1148+ "path": "responseContext.responseId",
1149+ "semantic": {
1150+ "kind": "identifier",
1151+ "confidence": 0.95
1152+ },
1153+ "example": "IhMI2IilqbznlgMVio_lBx1nLSES"
1154+ },
1155+ {
1156+ "path": "responseContext.webResponseContextExtensionData.hasDecorated",
1157+ "semantic": {
1158+ "kind": "boolean",
1159+ "confidence": 0.95
1160+ },
1161+ "example": "true"
1162+ },
1163+ {
1164+ "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.navigationEndpoint.browseEndpoint.browseId",
1165+ "semantic": {
1166+ "kind": "identifier",
1167+ "confidence": 0.95
1168+ },
1169+ "example": "FEwhat_to_watch"
1170+ },
1171+ {
1172+ "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.isPrimary",
1173+ "semantic": {
1174+ "kind": "boolean",
1175+ "confidence": 0.95
1176+ },
1177+ "example": "true"
1178+ },
1179+ {
1180+ "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.serviceEndpoint.reelWatchEndpoint.updateKey",
1181+ "semantic": {
1182+ "kind": "timestamp",
1183+ "confidence": 0.7
1184+ },
1185+ "example": "EhhTSE9SVFNfU0VFRExFU1NfRU5EUE9JTlQg5gEoAQ%3D%3D"
1186+ },
1187+ {
1188+ "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.entryData.guideEntryData.guideEntryId",
1189+ "semantic": {
1190+ "kind": "identifier",
1191+ "confidence": 0.95
1192+ },
1193+ "example": "subscriptions-channels-header"
1194+ },
1195+ {
1196+ "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.targetId",
1197+ "semantic": {
1198+ "kind": "identifier",
1199+ "confidence": 0.95
1200+ },
1201+ "example": "subscriptions-guide-item"
1202+ },
1203+ {
1204+ "path": "items.[].guideSigninPromoRenderer.descriptiveText.simpleText",
1205+ "semantic": {
1206+ "kind": "text",
1207+ "confidence": 0.85
1208+ },
1209+ "example": "Connectez-vous pour aimer des vidéos, publier des commentaires et vous abonner."
1210+ },
1211+ {
1212+ "path": "items.[].guideSigninPromoRenderer.signInButton.buttonRenderer.isDisabled",
1213+ "semantic": {
1214+ "kind": "boolean",
1215+ "confidence": 0.95
1216+ },
1217+ "example": "false"
1218+ },
1219+ {
1220+ "path": "items.[].guideSigninPromoRenderer.signInButton.buttonRenderer.navigationEndpoint.commandMetadata.webCommandMetadata.url",
1221+ "semantic": {
1222+ "kind": "url",
1223+ "confidence": 0.9
1224+ },
1225+ "example": "https://accounts.google.com/ServiceLogin?service=youtube&uilel=3&passive=true&co"
1226+ },
1227+ {
1228+ "path": "items.[].guideSigninPromoRenderer.signInButton.buttonRenderer.navigationEndpoint.signInEndpoint.hack",
1229+ "semantic": {
1230+ "kind": "boolean",
1231+ "confidence": 0.95
1232+ },
1233+ "example": "true"
1234+ },
1235+ {
1236+ "path": "items.[].guideSectionRenderer.items.[].guideCollapsibleEntryRenderer.expandableItems.[].guideEntryRenderer.navigationEndpoint.browseEndpoint.browseId",
1237+ "semantic": {
1238+ "kind": "identifier",
1239+ "confidence": 0.95
1240+ },
1241+ "example": "UCYfdidRxbB8Qhf0Nx7ioOYw"
1242+ },
1243+ {
1244+ "path": "items.[].guideSectionRenderer.formattedTitle.simpleText",
1245+ "semantic": {
1246+ "kind": "text",
1247+ "confidence": 0.85
1248+ },
1249+ "example": "Explorer"
1250+ },
1251+ {
1252+ "path": "items.[].guideSectionRenderer.items.[].guideEntryRenderer.navigationEndpoint.urlEndpoint.url",
1253+ "semantic": {
1254+ "kind": "url",
1255+ "confidence": 0.9
1256+ },
1257+ "example": "https://music.youtube.com"
1258+ }
1259+ ]
1260+ },
1261+ {
1262+ "shape_hash": "3c89ecaf0eb838d0",
1263+ "status": "provisional",
1264+ "hostname": "www.youtube.com",
1265+ "path_pattern": "/youtubei/v1/get_watch",
1266+ "method": "POST",
1267+ "likely_entity_types": {
1268+ "video": 1,
1269+ "channel": 1,
1270+ "post": 1
1271+ },
1272+ "confidence": 0.22,
1273+ "triggered_by": {
1274+ "OPEN_VIDEO": 1
1275+ },
1276+ "key_fields": [
1277+ {
1278+ "path": "[].responseContext.responseId",
1279+ "semantic": {
1280+ "kind": "identifier",
1281+ "confidence": 0.95
1282+ },
1283+ "example": "IhMI89fHi73nlgMVDprkBh0vlBK2"
1284+ },
1285+ {
1286+ "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
1287+ "semantic": {
1288+ "kind": "boolean",
1289+ "confidence": 0.95
1290+ },
1291+ "example": "true"
1292+ },
1293+ {
1294+ "path": "[].playerResponse.responseContext.responseId",
1295+ "semantic": {
1296+ "kind": "identifier",
1297+ "confidence": 0.95
1298+ },
1299+ "example": "IhMI89fHi73nlgMVDprkBh0vlBK2"
1300+ },
1301+ {
1302+ "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
1303+ "semantic": {
1304+ "kind": "boolean",
1305+ "confidence": 0.95
1306+ },
1307+ "example": "true"
1308+ },
1309+ {
1310+ "path": "[].playerResponse.playabilityStatus.playableInEmbed",
1311+ "semantic": {
1312+ "kind": "boolean",
1313+ "confidence": 0.95
1314+ },
1315+ "example": "true"
1316+ },
1317+ {
1318+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
1319+ "semantic": {
1320+ "kind": "duration",
1321+ "confidence": 0.75
1322+ },
1323+ "example": "3008965991"
1324+ },
1325+ {
1326+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
1327+ "semantic": {
1328+ "kind": "duration",
1329+ "confidence": 0.75
1330+ },
1331+ "example": "1952742"
1332+ },
1333+ {
1334+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
1335+ "semantic": {
1336+ "kind": "boolean",
1337+ "confidence": 0.95
1338+ },
1339+ "example": "true"
1340+ },
1341+ {
1342+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].audioTrack.displayName",
1343+ "semantic": {
1344+ "kind": "display_name",
1345+ "confidence": 0.7
1346+ },
1347+ "example": "Anglais (US)"
1348+ },
1349+ {
1350+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].audioTrack.audioIsDefault",
1351+ "semantic": {
1352+ "kind": "boolean",
1353+ "confidence": 0.95
1354+ },
1355+ "example": "false"
1356+ },
1357+ {
1358+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].audioTrack.isAutoDubbed",
1359+ "semantic": {
1360+ "kind": "boolean",
1361+ "confidence": 0.95
1362+ },
1363+ "example": "true"
1364+ },
1365+ {
1366+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
1367+ "semantic": {
1368+ "kind": "boolean",
1369+ "confidence": 0.95
1370+ },
1371+ "example": "true"
1372+ },
1373+ {
1374+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
1375+ "semantic": {
1376+ "kind": "boolean",
1377+ "confidence": 0.95
1378+ },
1379+ "example": "true"
1380+ },
1381+ {
1382+ "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
1383+ "semantic": {
1384+ "kind": "media_url",
1385+ "confidence": 0.93
1386+ },
1387+ "example": "https://rr1---sn-cxaaj5o5q5-t0ar.googlevideo.com/videoplayback?expire=1789183935"
1388+ },
1389+ {
1390+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
1391+ "semantic": {
1392+ "kind": "boolean",
1393+ "confidence": 0.95
1394+ },
1395+ "example": "true"
1396+ },
1397+ {
1398+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
1399+ "semantic": {
1400+ "kind": "boolean",
1401+ "confidence": 0.95
1402+ },
1403+ "example": "true"
1404+ },
1405+ {
1406+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
1407+ "semantic": {
1408+ "kind": "boolean",
1409+ "confidence": 0.95
1410+ },
1411+ "example": "true"
1412+ },
1413+ {
1414+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
1415+ "semantic": {
1416+ "kind": "boolean",
1417+ "confidence": 0.95
1418+ },
1419+ "example": "true"
1420+ },
1421+ {
1422+ "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
1423+ "semantic": {
1424+ "kind": "url",
1425+ "confidence": 0.9
1426+ },
1427+ "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=lVmAzyP67oE&ei=X3Oka"
1428+ },
1429+ {
1430+ "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
1431+ "semantic": {
1432+ "kind": "url",
1433+ "confidence": 0.9
1434+ },
1435+ "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=lVmAzyP67oE&ei=X3Ok"
1436+ },
1437+ {
1438+ "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
1439+ "semantic": {
1440+ "kind": "url",
1441+ "confidence": 0.9
1442+ },
1443+ "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=lVmAzyP67oE&ei=X3Ok"
1444+ },
1445+ {
1446+ "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
1447+ "semantic": {
1448+ "kind": "url",
1449+ "confidence": 0.9
1450+ },
1451+ "example": "https://www.youtube.com/ptracking?ei=X3OkarOsFo60kucPr6jKsAs&oid=ef6_Stpd9GrV7UO"
1452+ },
1453+ {
1454+ "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
1455+ "semantic": {
1456+ "kind": "url",
1457+ "confidence": 0.9
1458+ },
1459+ "example": "https://s.youtube.com/api/stats/qoe?cat=mta&cl=974780265&docid=lVmAzyP67oE&ei=X3"
1460+ },
1461+ {
1462+ "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
1463+ "semantic": {
1464+ "kind": "url",
1465+ "confidence": 0.9
1466+ },
1467+ "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=lVmAzyP67oE&ei=X3OkarOsFo60kucPr"
1468+ },
1469+ {
1470+ "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
1471+ "semantic": {
1472+ "kind": "timestamp",
1473+ "confidence": 0.7
1474+ },
1475+ "example": "5"
1476+ }
1477+ ]
1478+ },
1479+ {
1480+ "shape_hash": "7602103111a512bc",
1481+ "status": "provisional",
1482+ "hostname": "www.youtube.com",
1483+ "path_pattern": "/youtubei/v1/get_watch",
1484+ "method": "POST",
1485+ "likely_entity_types": {
1486+ "video": 1,
1487+ "channel": 1,
1488+ "post": 1
1489+ },
1490+ "confidence": 0.22,
1491+ "triggered_by": {
1492+ "OPEN_VIDEO": 1
1493+ },
1494+ "key_fields": [
1495+ {
1496+ "path": "[].responseContext.responseId",
1497+ "semantic": {
1498+ "kind": "identifier",
1499+ "confidence": 0.95
1500+ },
1501+ "example": "IhMI-_r6jb3nlgMVDYXkBh3m1w07"
1502+ },
1503+ {
1504+ "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
1505+ "semantic": {
1506+ "kind": "boolean",
1507+ "confidence": 0.95
1508+ },
1509+ "example": "true"
1510+ },
1511+ {
1512+ "path": "[].playerResponse.responseContext.responseId",
1513+ "semantic": {
1514+ "kind": "identifier",
1515+ "confidence": 0.95
1516+ },
1517+ "example": "IhMI-_r6jb3nlgMVDYXkBh3m1w07"
1518+ },
1519+ {
1520+ "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
1521+ "semantic": {
1522+ "kind": "boolean",
1523+ "confidence": 0.95
1524+ },
1525+ "example": "true"
1526+ },
1527+ {
1528+ "path": "[].playerResponse.playabilityStatus.playableInEmbed",
1529+ "semantic": {
1530+ "kind": "boolean",
1531+ "confidence": 0.95
1532+ },
1533+ "example": "true"
1534+ },
1535+ {
1536+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
1537+ "semantic": {
1538+ "kind": "duration",
1539+ "confidence": 0.75
1540+ },
1541+ "example": "923323161"
1542+ },
1543+ {
1544+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
1545+ "semantic": {
1546+ "kind": "duration",
1547+ "confidence": 0.75
1548+ },
1549+ "example": "3068880"
1550+ },
1551+ {
1552+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
1553+ "semantic": {
1554+ "kind": "boolean",
1555+ "confidence": 0.95
1556+ },
1557+ "example": "true"
1558+ },
1559+ {
1560+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].audioTrack.displayName",
1561+ "semantic": {
1562+ "kind": "display_name",
1563+ "confidence": 0.7
1564+ },
1565+ "example": "Anglais"
1566+ },
1567+ {
1568+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].audioTrack.audioIsDefault",
1569+ "semantic": {
1570+ "kind": "boolean",
1571+ "confidence": 0.95
1572+ },
1573+ "example": "false"
1574+ },
1575+ {
1576+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
1577+ "semantic": {
1578+ "kind": "boolean",
1579+ "confidence": 0.95
1580+ },
1581+ "example": "true"
1582+ },
1583+ {
1584+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
1585+ "semantic": {
1586+ "kind": "boolean",
1587+ "confidence": 0.95
1588+ },
1589+ "example": "true"
1590+ },
1591+ {
1592+ "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
1593+ "semantic": {
1594+ "kind": "media_url",
1595+ "confidence": 0.93
1596+ },
1597+ "example": "https://rr1---sn-cxaaj5o5q5-t0a6.googlevideo.com/videoplayback?expire=1789183940"
1598+ },
1599+ {
1600+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
1601+ "semantic": {
1602+ "kind": "boolean",
1603+ "confidence": 0.95
1604+ },
1605+ "example": "true"
1606+ },
1607+ {
1608+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
1609+ "semantic": {
1610+ "kind": "boolean",
1611+ "confidence": 0.95
1612+ },
1613+ "example": "true"
1614+ },
1615+ {
1616+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
1617+ "semantic": {
1618+ "kind": "boolean",
1619+ "confidence": 0.95
1620+ },
1621+ "example": "true"
1622+ },
1623+ {
1624+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
1625+ "semantic": {
1626+ "kind": "boolean",
1627+ "confidence": 0.95
1628+ },
1629+ "example": "true"
1630+ },
1631+ {
1632+ "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
1633+ "semantic": {
1634+ "kind": "url",
1635+ "confidence": 0.9
1636+ },
1637+ "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=XZWRcP5LWoM&ei=ZHOka"
1638+ },
1639+ {
1640+ "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
1641+ "semantic": {
1642+ "kind": "url",
1643+ "confidence": 0.9
1644+ },
1645+ "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=XZWRcP5LWoM&ei=ZHOk"
1646+ },
1647+ {
1648+ "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
1649+ "semantic": {
1650+ "kind": "url",
1651+ "confidence": 0.9
1652+ },
1653+ "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=XZWRcP5LWoM&ei=ZHOk"
1654+ },
1655+ {
1656+ "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
1657+ "semantic": {
1658+ "kind": "url",
1659+ "confidence": 0.9
1660+ },
1661+ "example": "https://www.youtube.com/ptracking?ei=ZHOkavu4GI2KkucP5q-32AM&oid=AkXOB7W-CcJiy62"
1662+ },
1663+ {
1664+ "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
1665+ "semantic": {
1666+ "kind": "url",
1667+ "confidence": 0.9
1668+ },
1669+ "example": "https://s.youtube.com/api/stats/qoe?cat=mta&cl=974780265&docid=XZWRcP5LWoM&ei=ZH"
1670+ },
1671+ {
1672+ "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
1673+ "semantic": {
1674+ "kind": "url",
1675+ "confidence": 0.9
1676+ },
1677+ "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=XZWRcP5LWoM&ei=ZHOkavu4GI2KkucP5"
1678+ },
1679+ {
1680+ "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
1681+ "semantic": {
1682+ "kind": "timestamp",
1683+ "confidence": 0.7
1684+ },
1685+ "example": "5"
1686+ },
1687+ {
1688+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
1689+ "semantic": {
1690+ "kind": "url",
1691+ "confidence": 0.9
1692+ },
1693+ "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
1694+ }
1695+ ]
1696+ },
1697+ {
1698+ "shape_hash": "68e05e1c3c83f379",
1699+ "status": "provisional",
1700+ "hostname": "www.youtube.com",
1701+ "path_pattern": "/youtubei/v1/next",
1702+ "method": "POST",
1703+ "likely_entity_types": {
1704+ "comment": 1,
1705+ "channel": 1,
1706+ "video": 1
1707+ },
1708+ "confidence": 0.22,
1709+ "triggered_by": {
1710+ "OPEN_VIDEO": 1
1711+ },
1712+ "key_fields": [
1713+ {
1714+ "path": "responseContext.mainAppWebResponseContext.loggedOut",
1715+ "semantic": {
1716+ "kind": "boolean",
1717+ "confidence": 0.95
1718+ },
1719+ "example": "true"
1720+ },
1721+ {
1722+ "path": "responseContext.responseId",
1723+ "semantic": {
1724+ "kind": "identifier",
1725+ "confidence": 0.95
1726+ },
1727+ "example": "IhMI186jkL3nlgMVORXLBB2u3zne"
1728+ },
1729+ {
1730+ "path": "responseContext.webResponseContextExtensionData.hasDecorated",
1731+ "semantic": {
1732+ "kind": "boolean",
1733+ "confidence": 0.95
1734+ },
1735+ "example": "true"
1736+ },
1737+ {
1738+ "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].commentThreadRenderer.isModeratedElqComment",
1739+ "semantic": {
1740+ "kind": "boolean",
1741+ "confidence": 0.95
1742+ },
1743+ "example": "false"
1744+ },
1745+ {
1746+ "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].commentThreadRenderer.commentViewModel.commentViewModel.commentId",
1747+ "semantic": {
1748+ "kind": "identifier",
1749+ "confidence": 0.95
1750+ },
1751+ "example": "UgwXrux_QAEMRaAD0w54AaABAg"
1752+ },
1753+ {
1754+ "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].commentThreadRenderer.replies.commentRepliesRenderer.hideReplies.buttonRenderer.text.runs.[].text",
1755+ "semantic": {
1756+ "kind": "text",
1757+ "confidence": 0.85
1758+ },
1759+ "example": "Masquer les réponses"
1760+ },
1761+ {
1762+ "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].commentThreadRenderer.replies.commentRepliesRenderer.targetId",
1763+ "semantic": {
1764+ "kind": "identifier",
1765+ "confidence": 0.95
1766+ },
1767+ "example": "comment-replies-item-Ugzg0kiZiRZUEQ1l0RB4AaABAg"
1768+ },
1769+ {
1770+ "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].commentThreadRenderer.replies.commentRepliesRenderer.subThreads.[].continuationItemRenderer.continuationEndpoint.commandMetadata.webCommandMetadata.sendPost",
1771+ "semantic": {
1772+ "kind": "boolean",
1773+ "confidence": 0.95
1774+ },
1775+ "example": "true"
1776+ },
1777+ {
1778+ "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.continuationItems.[].continuationItemRenderer.continuationEndpoint.commandMetadata.webCommandMetadata.sendPost",
1779+ "semantic": {
1780+ "kind": "boolean",
1781+ "confidence": 0.95
1782+ },
1783+ "example": "true"
1784+ },
1785+ {
1786+ "path": "onResponseReceivedEndpoints.[].appendContinuationItemsAction.targetId",
1787+ "semantic": {
1788+ "kind": "identifier",
1789+ "confidence": 0.95
1790+ },
1791+ "example": "comments-section"
1792+ },
1793+ {
1794+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.properties.commentId",
1795+ "semantic": {
1796+ "kind": "identifier",
1797+ "confidence": 0.95
1798+ },
1799+ "example": "UgwXrux_QAEMRaAD0w54AaABAg"
1800+ },
1801+ {
1802+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.properties.content.content",
1803+ "semantic": {
1804+ "kind": "text",
1805+ "confidence": 0.85
1806+ },
1807+ "example": "Adorei o seu vidio."
1808+ },
1809+ {
1810+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.properties.publishedTime",
1811+ "semantic": {
1812+ "kind": "timestamp",
1813+ "confidence": 0.7
1814+ },
1815+ "example": "il y a 3 mois"
1816+ },
1817+ {
1818+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.channelId",
1819+ "semantic": {
1820+ "kind": "identifier",
1821+ "confidence": 0.95
1822+ },
1823+ "example": "UCI0tDQv_Tt-jnPAuK6FPmfA"
1824+ },
1825+ {
1826+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.displayName",
1827+ "semantic": {
1828+ "kind": "username",
1829+ "confidence": 0.7
1830+ },
1831+ "example": "@AdrianConceição-p8t"
1832+ },
1833+ {
1834+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.avatarThumbnailUrl",
1835+ "semantic": {
1836+ "kind": "url",
1837+ "confidence": 0.9
1838+ },
1839+ "example": "https://yt3.ggpht.com/3y6bu5PDGsPKf4sfHi6k2GhKeU5QWX1VjlbuEymaj7vJlmov4Wye-gws0N"
1840+ },
1841+ {
1842+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.isVerified",
1843+ "semantic": {
1844+ "kind": "boolean",
1845+ "confidence": 0.95
1846+ },
1847+ "example": "false"
1848+ },
1849+ {
1850+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.isCurrentUser",
1851+ "semantic": {
1852+ "kind": "boolean",
1853+ "confidence": 0.95
1854+ },
1855+ "example": "false"
1856+ },
1857+ {
1858+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.isCreator",
1859+ "semantic": {
1860+ "kind": "boolean",
1861+ "confidence": 0.95
1862+ },
1863+ "example": "false"
1864+ },
1865+ {
1866+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.channelCommand.innertubeCommand.browseEndpoint.browseId",
1867+ "semantic": {
1868+ "kind": "identifier",
1869+ "confidence": 0.95
1870+ },
1871+ "example": "UCI0tDQv_Tt-jnPAuK6FPmfA"
1872+ },
1873+ {
1874+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.author.isArtist",
1875+ "semantic": {
1876+ "kind": "boolean",
1877+ "confidence": 0.95
1878+ },
1879+ "example": "false"
1880+ },
1881+ {
1882+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.toolbar.likeCountLiked",
1883+ "semantic": {
1884+ "kind": "count",
1885+ "confidence": 0.9
1886+ },
1887+ "example": "3"
1888+ },
1889+ {
1890+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.toolbar.likeCountNotliked",
1891+ "semantic": {
1892+ "kind": "count",
1893+ "confidence": 0.9
1894+ },
1895+ "example": "2"
1896+ },
1897+ {
1898+ "path": "frameworkUpdates.entityBatchUpdate.mutations.[].payload.commentEntityPayload.toolbar.likeCountA11y",
1899+ "semantic": {
1900+ "kind": "count",
1901+ "confidence": 0.9
1902+ },
1903+ "example": "2 mentions J'aime"
1904+ }
1905+ ]
1906+ },
1907+ {
1908+ "shape_hash": "1a02f0ac8f8d4371",
1909+ "status": "provisional",
1910+ "hostname": "www.youtube.com",
1911+ "path_pattern": "/youtubei/v1/get_watch",
1912+ "method": "POST",
1913+ "likely_entity_types": {
1914+ "video": 1,
1915+ "post": 1,
1916+ "channel": 1
1917+ },
1918+ "confidence": 0.22,
1919+ "triggered_by": {
1920+ "OPEN_VIDEO": 1
1921+ },
1922+ "key_fields": [
1923+ {
1924+ "path": "[].responseContext.responseId",
1925+ "semantic": {
1926+ "kind": "identifier",
1927+ "confidence": 0.95
1928+ },
1929+ "example": "IhMIxI2kkL3nlgMVn4HkBh1GThmw"
1930+ },
1931+ {
1932+ "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
1933+ "semantic": {
1934+ "kind": "boolean",
1935+ "confidence": 0.95
1936+ },
1937+ "example": "true"
1938+ },
1939+ {
1940+ "path": "[].playerResponse.responseContext.responseId",
1941+ "semantic": {
1942+ "kind": "identifier",
1943+ "confidence": 0.95
1944+ },
1945+ "example": "IhMIxI2kkL3nlgMVn4HkBh1GThmw"
1946+ },
1947+ {
1948+ "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
1949+ "semantic": {
1950+ "kind": "boolean",
1951+ "confidence": 0.95
1952+ },
1953+ "example": "true"
1954+ },
1955+ {
1956+ "path": "[].playerResponse.playabilityStatus.playableInEmbed",
1957+ "semantic": {
1958+ "kind": "boolean",
1959+ "confidence": 0.95
1960+ },
1961+ "example": "true"
1962+ },
1963+ {
1964+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
1965+ "semantic": {
1966+ "kind": "duration",
1967+ "confidence": 0.75
1968+ },
1969+ "example": "176692547"
1970+ },
1971+ {
1972+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
1973+ "semantic": {
1974+ "kind": "duration",
1975+ "confidence": 0.75
1976+ },
1977+ "example": "3509572"
1978+ },
1979+ {
1980+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
1981+ "semantic": {
1982+ "kind": "boolean",
1983+ "confidence": 0.95
1984+ },
1985+ "example": "true"
1986+ },
1987+ {
1988+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
1989+ "semantic": {
1990+ "kind": "boolean",
1991+ "confidence": 0.95
1992+ },
1993+ "example": "true"
1994+ },
1995+ {
1996+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
1997+ "semantic": {
1998+ "kind": "boolean",
1999+ "confidence": 0.95
2000+ },
2001+ "example": "true"
2002+ },
2003+ {
2004+ "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
2005+ "semantic": {
2006+ "kind": "media_url",
2007+ "confidence": 0.93
2008+ },
2009+ "example": "https://rr5---sn-cxaaj5o5q5-t0ay.googlevideo.com/videoplayback?expire=1789183945"
2010+ },
2011+ {
2012+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
2013+ "semantic": {
2014+ "kind": "boolean",
2015+ "confidence": 0.95
2016+ },
2017+ "example": "true"
2018+ },
2019+ {
2020+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
2021+ "semantic": {
2022+ "kind": "boolean",
2023+ "confidence": 0.95
2024+ },
2025+ "example": "true"
2026+ },
2027+ {
2028+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
2029+ "semantic": {
2030+ "kind": "boolean",
2031+ "confidence": 0.95
2032+ },
2033+ "example": "true"
2034+ },
2035+ {
2036+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
2037+ "semantic": {
2038+ "kind": "boolean",
2039+ "confidence": 0.95
2040+ },
2041+ "example": "true"
2042+ },
2043+ {
2044+ "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
2045+ "semantic": {
2046+ "kind": "url",
2047+ "confidence": 0.9
2048+ },
2049+ "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=jL4ANKq2MHQ&ei=aXOka"
2050+ },
2051+ {
2052+ "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
2053+ "semantic": {
2054+ "kind": "url",
2055+ "confidence": 0.9
2056+ },
2057+ "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=jL4ANKq2MHQ&ei=aXOk"
2058+ },
2059+ {
2060+ "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
2061+ "semantic": {
2062+ "kind": "url",
2063+ "confidence": 0.9
2064+ },
2065+ "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=jL4ANKq2MHQ&ei=aXOk"
2066+ },
2067+ {
2068+ "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
2069+ "semantic": {
2070+ "kind": "url",
2071+ "confidence": 0.9
2072+ },
2073+ "example": "https://www.youtube.com/ptracking?ei=aXOkaoS1EJ-DkucPxpzlgAs&oid=jMMLR90nQCMutT8"
2074+ },
2075+ {
2076+ "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
2077+ "semantic": {
2078+ "kind": "url",
2079+ "confidence": 0.9
2080+ },
2081+ "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=jL4ANKq2MHQ&ei=aXOkaoS1EJ"
2082+ },
2083+ {
2084+ "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
2085+ "semantic": {
2086+ "kind": "url",
2087+ "confidence": 0.9
2088+ },
2089+ "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=jL4ANKq2MHQ&ei=aXOkaoS1EJ-DkucPx"
2090+ },
2091+ {
2092+ "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
2093+ "semantic": {
2094+ "kind": "timestamp",
2095+ "confidence": 0.7
2096+ },
2097+ "example": "5"
2098+ },
2099+ {
2100+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
2101+ "semantic": {
2102+ "kind": "url",
2103+ "confidence": 0.9
2104+ },
2105+ "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
2106+ },
2107+ {
2108+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
2109+ "semantic": {
2110+ "kind": "timestamp",
2111+ "confidence": 0.7
2112+ },
2113+ "example": "0"
2114+ },
2115+ {
2116+ "path": "[].playerResponse.playbackTracking.googleRemarketingUrl.baseUrl",
2117+ "semantic": {
2118+ "kind": "url",
2119+ "confidence": 0.9
2120+ },
2121+ "example": "https://www.google.com/pagead/1p-user-list/962985656/?backend=innertube&cname=1&"
2122+ }
2123+ ]
2124+ },
2125+ {
2126+ "shape_hash": "ae99380e3e3aef0c",
2127+ "status": "provisional",
2128+ "hostname": "www.youtube.com",
2129+ "path_pattern": "/youtubei/v1/get_watch",
2130+ "method": "POST",
2131+ "likely_entity_types": {
2132+ "video": 1,
2133+ "channel": 1,
2134+ "post": 1
2135+ },
2136+ "confidence": 0.22,
2137+ "triggered_by": {
2138+ "OPEN_VIDEO": 1
2139+ },
2140+ "key_fields": [
2141+ {
2142+ "path": "[].responseContext.responseId",
2143+ "semantic": {
2144+ "kind": "identifier",
2145+ "confidence": 0.95
2146+ },
2147+ "example": "IhMI9uTVlL3nlgMVE4auBR3LsgFe"
2148+ },
2149+ {
2150+ "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
2151+ "semantic": {
2152+ "kind": "boolean",
2153+ "confidence": 0.95
2154+ },
2155+ "example": "true"
2156+ },
2157+ {
2158+ "path": "[].playerResponse.responseContext.responseId",
2159+ "semantic": {
2160+ "kind": "identifier",
2161+ "confidence": 0.95
2162+ },
2163+ "example": "IhMI9uTVlL3nlgMVE4auBR3LsgFe"
2164+ },
2165+ {
2166+ "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
2167+ "semantic": {
2168+ "kind": "boolean",
2169+ "confidence": 0.95
2170+ },
2171+ "example": "true"
2172+ },
2173+ {
2174+ "path": "[].playerResponse.playabilityStatus.playableInEmbed",
2175+ "semantic": {
2176+ "kind": "boolean",
2177+ "confidence": 0.95
2178+ },
2179+ "example": "true"
2180+ },
2181+ {
2182+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
2183+ "semantic": {
2184+ "kind": "duration",
2185+ "confidence": 0.75
2186+ },
2187+ "example": "734584581"
2188+ },
2189+ {
2190+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
2191+ "semantic": {
2192+ "kind": "duration",
2193+ "confidence": 0.75
2194+ },
2195+ "example": "7197823"
2196+ },
2197+ {
2198+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
2199+ "semantic": {
2200+ "kind": "boolean",
2201+ "confidence": 0.95
2202+ },
2203+ "example": "true"
2204+ },
2205+ {
2206+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
2207+ "semantic": {
2208+ "kind": "boolean",
2209+ "confidence": 0.95
2210+ },
2211+ "example": "true"
2212+ },
2213+ {
2214+ "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
2215+ "semantic": {
2216+ "kind": "media_url",
2217+ "confidence": 0.93
2218+ },
2219+ "example": "https://rr1---sn-cxaaj5o5q5-t0ay.googlevideo.com/videoplayback?expire=1789183954"
2220+ },
2221+ {
2222+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
2223+ "semantic": {
2224+ "kind": "boolean",
2225+ "confidence": 0.95
2226+ },
2227+ "example": "true"
2228+ },
2229+ {
2230+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
2231+ "semantic": {
2232+ "kind": "boolean",
2233+ "confidence": 0.95
2234+ },
2235+ "example": "true"
2236+ },
2237+ {
2238+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
2239+ "semantic": {
2240+ "kind": "boolean",
2241+ "confidence": 0.95
2242+ },
2243+ "example": "true"
2244+ },
2245+ {
2246+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
2247+ "semantic": {
2248+ "kind": "boolean",
2249+ "confidence": 0.95
2250+ },
2251+ "example": "true"
2252+ },
2253+ {
2254+ "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
2255+ "semantic": {
2256+ "kind": "url",
2257+ "confidence": 0.9
2258+ },
2259+ "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=Fa2sDP_RYsA&ei=cnOka"
2260+ },
2261+ {
2262+ "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
2263+ "semantic": {
2264+ "kind": "url",
2265+ "confidence": 0.9
2266+ },
2267+ "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=Fa2sDP_RYsA&ei=cnOk"
2268+ },
2269+ {
2270+ "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
2271+ "semantic": {
2272+ "kind": "url",
2273+ "confidence": 0.9
2274+ },
2275+ "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=Fa2sDP_RYsA&ei=cnOk"
2276+ },
2277+ {
2278+ "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
2279+ "semantic": {
2280+ "kind": "url",
2281+ "confidence": 0.9
2282+ },
2283+ "example": "https://www.youtube.com/ptracking?ei=cnOkavbjHJOMut0Py-WG8AU&oid=WLeG0gE_kkpZY0o"
2284+ },
2285+ {
2286+ "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
2287+ "semantic": {
2288+ "kind": "url",
2289+ "confidence": 0.9
2290+ },
2291+ "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=Fa2sDP_RYsA&ei=cnOkavbjHJ"
2292+ },
2293+ {
2294+ "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
2295+ "semantic": {
2296+ "kind": "url",
2297+ "confidence": 0.9
2298+ },
2299+ "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=Fa2sDP_RYsA&ei=cnOkavbjHJOMut0Py"
2300+ },
2301+ {
2302+ "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
2303+ "semantic": {
2304+ "kind": "timestamp",
2305+ "confidence": 0.7
2306+ },
2307+ "example": "5"
2308+ },
2309+ {
2310+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
2311+ "semantic": {
2312+ "kind": "url",
2313+ "confidence": 0.9
2314+ },
2315+ "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
2316+ },
2317+ {
2318+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
2319+ "semantic": {
2320+ "kind": "timestamp",
2321+ "confidence": 0.7
2322+ },
2323+ "example": "0"
2324+ },
2325+ {
2326+ "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].baseUrl",
2327+ "semantic": {
2328+ "kind": "url",
2329+ "confidence": 0.9
2330+ },
2331+ "example": "https://www.youtube.com/api/timedtext?v=Fa2sDP_RYsA&ei=cnOkavbjHJOMut0Py-WG8AU&c"
2332+ },
2333+ {
2334+ "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].name.simpleText",
2335+ "semantic": {
2336+ "kind": "text",
2337+ "confidence": 0.85
2338+ },
2339+ "example": "Français (générés automatiquement)"
2340+ }
2341+ ]
2342+ },
2343+ {
2344+ "shape_hash": "5d481f850a3bd8ee",
2345+ "status": "provisional",
2346+ "hostname": "www.youtube.com",
2347+ "path_pattern": "/youtubei/v1/get_watch",
2348+ "method": "POST",
2349+ "likely_entity_types": {
2350+ "video": 1,
2351+ "channel": 1,
2352+ "post": 1
2353+ },
2354+ "confidence": 0.22,
2355+ "triggered_by": {
2356+ "OPEN_VIDEO": 1
2357+ },
2358+ "key_fields": [
2359+ {
2360+ "path": "[].responseContext.responseId",
2361+ "semantic": {
2362+ "kind": "identifier",
2363+ "confidence": 0.95
2364+ },
2365+ "example": "IhMI7I_0lr3nlgMVPKHkBh3E-zwt"
2366+ },
2367+ {
2368+ "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
2369+ "semantic": {
2370+ "kind": "boolean",
2371+ "confidence": 0.95
2372+ },
2373+ "example": "true"
2374+ },
2375+ {
2376+ "path": "[].playerResponse.responseContext.responseId",
2377+ "semantic": {
2378+ "kind": "identifier",
2379+ "confidence": 0.95
2380+ },
2381+ "example": "IhMI7I_0lr3nlgMVPKHkBh3E-zwt"
2382+ },
2383+ {
2384+ "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
2385+ "semantic": {
2386+ "kind": "boolean",
2387+ "confidence": 0.95
2388+ },
2389+ "example": "true"
2390+ },
2391+ {
2392+ "path": "[].playerResponse.playabilityStatus.playableInEmbed",
2393+ "semantic": {
2394+ "kind": "boolean",
2395+ "confidence": 0.95
2396+ },
2397+ "example": "true"
2398+ },
2399+ {
2400+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
2401+ "semantic": {
2402+ "kind": "duration",
2403+ "confidence": 0.75
2404+ },
2405+ "example": "33822999"
2406+ },
2407+ {
2408+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
2409+ "semantic": {
2410+ "kind": "duration",
2411+ "confidence": 0.75
2412+ },
2413+ "example": "1560025"
2414+ },
2415+ {
2416+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
2417+ "semantic": {
2418+ "kind": "boolean",
2419+ "confidence": 0.95
2420+ },
2421+ "example": "true"
2422+ },
2423+ {
2424+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
2425+ "semantic": {
2426+ "kind": "boolean",
2427+ "confidence": 0.95
2428+ },
2429+ "example": "true"
2430+ },
2431+ {
2432+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
2433+ "semantic": {
2434+ "kind": "boolean",
2435+ "confidence": 0.95
2436+ },
2437+ "example": "true"
2438+ },
2439+ {
2440+ "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
2441+ "semantic": {
2442+ "kind": "media_url",
2443+ "confidence": 0.93
2444+ },
2445+ "example": "https://rr1---sn-cxaaj5o5q5-t0ar.googlevideo.com/videoplayback?expire=1789183959"
2446+ },
2447+ {
2448+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
2449+ "semantic": {
2450+ "kind": "boolean",
2451+ "confidence": 0.95
2452+ },
2453+ "example": "true"
2454+ },
2455+ {
2456+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
2457+ "semantic": {
2458+ "kind": "boolean",
2459+ "confidence": 0.95
2460+ },
2461+ "example": "true"
2462+ },
2463+ {
2464+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
2465+ "semantic": {
2466+ "kind": "boolean",
2467+ "confidence": 0.95
2468+ },
2469+ "example": "true"
2470+ },
2471+ {
2472+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
2473+ "semantic": {
2474+ "kind": "boolean",
2475+ "confidence": 0.95
2476+ },
2477+ "example": "true"
2478+ },
2479+ {
2480+ "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
2481+ "semantic": {
2482+ "kind": "url",
2483+ "confidence": 0.9
2484+ },
2485+ "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=upq306ci98I&ei=d3Oka"
2486+ },
2487+ {
2488+ "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
2489+ "semantic": {
2490+ "kind": "url",
2491+ "confidence": 0.9
2492+ },
2493+ "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=upq306ci98I&ei=d3Ok"
2494+ },
2495+ {
2496+ "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
2497+ "semantic": {
2498+ "kind": "url",
2499+ "confidence": 0.9
2500+ },
2501+ "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=upq306ci98I&ei=d3Ok"
2502+ },
2503+ {
2504+ "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
2505+ "semantic": {
2506+ "kind": "url",
2507+ "confidence": 0.9
2508+ },
2509+ "example": "https://www.youtube.com/ptracking?ei=d3Okaqz4CbzCkucPxPfz6QI&oid=EwVG-FSZ-fJALhv"
2510+ },
2511+ {
2512+ "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
2513+ "semantic": {
2514+ "kind": "url",
2515+ "confidence": 0.9
2516+ },
2517+ "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=upq306ci98I&ei=d3Okaqz4Cb"
2518+ },
2519+ {
2520+ "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
2521+ "semantic": {
2522+ "kind": "url",
2523+ "confidence": 0.9
2524+ },
2525+ "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=upq306ci98I&ei=d3Okaqz4CbzCkucPx"
2526+ },
2527+ {
2528+ "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
2529+ "semantic": {
2530+ "kind": "timestamp",
2531+ "confidence": 0.7
2532+ },
2533+ "example": "5"
2534+ },
2535+ {
2536+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.baseUrl",
2537+ "semantic": {
2538+ "kind": "url",
2539+ "confidence": 0.9
2540+ },
2541+ "example": "https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertub"
2542+ },
2543+ {
2544+ "path": "[].playerResponse.playbackTracking.youtubeRemarketingUrl.elapsedMediaTimeSeconds",
2545+ "semantic": {
2546+ "kind": "timestamp",
2547+ "confidence": 0.7
2548+ },
2549+ "example": "0"
2550+ },
2551+ {
2552+ "path": "[].playerResponse.playbackTracking.googleRemarketingUrl.baseUrl",
2553+ "semantic": {
2554+ "kind": "url",
2555+ "confidence": 0.9
2556+ },
2557+ "example": "https://www.google.com/pagead/1p-user-list/962985656/?backend=innertube&cname=1&"
2558+ }
2559+ ]
2560+ },
2561+ {
2562+ "shape_hash": "ec24830f0e0f4c6a",
2563+ "status": "provisional",
2564+ "hostname": "www.youtube.com",
2565+ "path_pattern": "/youtubei/v1/get_watch",
2566+ "method": "POST",
2567+ "likely_entity_types": {
2568+ "video": 1,
2569+ "post": 1,
2570+ "channel": 1
2571+ },
2572+ "confidence": 0.22,
2573+ "triggered_by": {
2574+ "OPEN_VIDEO": 1
2575+ },
2576+ "key_fields": [
2577+ {
2578+ "path": "[].responseContext.responseId",
2579+ "semantic": {
2580+ "kind": "identifier",
2581+ "confidence": 0.95
2582+ },
2583+ "example": "IhMIsti_m73nlgMVbazkBh0L1Sz8"
2584+ },
2585+ {
2586+ "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
2587+ "semantic": {
2588+ "kind": "boolean",
2589+ "confidence": 0.95
2590+ },
2591+ "example": "true"
2592+ },
2593+ {
2594+ "path": "[].playerResponse.responseContext.responseId",
2595+ "semantic": {
2596+ "kind": "identifier",
2597+ "confidence": 0.95
2598+ },
2599+ "example": "IhMIsti_m73nlgMVbazkBh0L1Sz8"
2600+ },
2601+ {
2602+ "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
2603+ "semantic": {
2604+ "kind": "boolean",
2605+ "confidence": 0.95
2606+ },
2607+ "example": "true"
2608+ },
2609+ {
2610+ "path": "[].playerResponse.playabilityStatus.playableInEmbed",
2611+ "semantic": {
2612+ "kind": "boolean",
2613+ "confidence": 0.95
2614+ },
2615+ "example": "true"
2616+ },
2617+ {
2618+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
2619+ "semantic": {
2620+ "kind": "duration",
2621+ "confidence": 0.75
2622+ },
2623+ "example": "2139831769"
2624+ },
2625+ {
2626+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
2627+ "semantic": {
2628+ "kind": "duration",
2629+ "confidence": 0.75
2630+ },
2631+ "example": "1125191"
2632+ },
2633+ {
2634+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
2635+ "semantic": {
2636+ "kind": "boolean",
2637+ "confidence": 0.95
2638+ },
2639+ "example": "true"
2640+ },
2641+ {
2642+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
2643+ "semantic": {
2644+ "kind": "boolean",
2645+ "confidence": 0.95
2646+ },
2647+ "example": "true"
2648+ },
2649+ {
2650+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
2651+ "semantic": {
2652+ "kind": "boolean",
2653+ "confidence": 0.95
2654+ },
2655+ "example": "true"
2656+ },
2657+ {
2658+ "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
2659+ "semantic": {
2660+ "kind": "media_url",
2661+ "confidence": 0.93
2662+ },
2663+ "example": "https://rr1---sn-cxaaj5o5q5-t0ar.googlevideo.com/videoplayback?expire=1789183968"
2664+ },
2665+ {
2666+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
2667+ "semantic": {
2668+ "kind": "boolean",
2669+ "confidence": 0.95
2670+ },
2671+ "example": "true"
2672+ },
2673+ {
2674+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
2675+ "semantic": {
2676+ "kind": "boolean",
2677+ "confidence": 0.95
2678+ },
2679+ "example": "true"
2680+ },
2681+ {
2682+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
2683+ "semantic": {
2684+ "kind": "boolean",
2685+ "confidence": 0.95
2686+ },
2687+ "example": "true"
2688+ },
2689+ {
2690+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
2691+ "semantic": {
2692+ "kind": "boolean",
2693+ "confidence": 0.95
2694+ },
2695+ "example": "true"
2696+ },
2697+ {
2698+ "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
2699+ "semantic": {
2700+ "kind": "url",
2701+ "confidence": 0.9
2702+ },
2703+ "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=AOyfVBVEIt4&ei=gHOka"
2704+ },
2705+ {
2706+ "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
2707+ "semantic": {
2708+ "kind": "url",
2709+ "confidence": 0.9
2710+ },
2711+ "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=AOyfVBVEIt4&ei=gHOk"
2712+ },
2713+ {
2714+ "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
2715+ "semantic": {
2716+ "kind": "url",
2717+ "confidence": 0.9
2718+ },
2719+ "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=AOyfVBVEIt4&ei=gHOk"
2720+ },
2721+ {
2722+ "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
2723+ "semantic": {
2724+ "kind": "url",
2725+ "confidence": 0.9
2726+ },
2727+ "example": "https://www.youtube.com/ptracking?ei=gHOkarKYMO3YkucPi6qz4Q8&oid=S9JN2l7skMZq-F8"
2728+ },
2729+ {
2730+ "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
2731+ "semantic": {
2732+ "kind": "url",
2733+ "confidence": 0.9
2734+ },
2735+ "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=AOyfVBVEIt4&ei=gHOkarKYMO"
2736+ },
2737+ {
2738+ "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
2739+ "semantic": {
2740+ "kind": "url",
2741+ "confidence": 0.9
2742+ },
2743+ "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=AOyfVBVEIt4&ei=gHOkarKYMO3YkucPi"
2744+ },
2745+ {
2746+ "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
2747+ "semantic": {
2748+ "kind": "timestamp",
2749+ "confidence": 0.7
2750+ },
2751+ "example": "5"
2752+ },
2753+ {
2754+ "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].baseUrl",
2755+ "semantic": {
2756+ "kind": "url",
2757+ "confidence": 0.9
2758+ },
2759+ "example": "https://www.youtube.com/api/timedtext?v=AOyfVBVEIt4&ei=gHOkarKYMO3YkucPi6qz4Q8&c"
2760+ },
2761+ {
2762+ "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].name.simpleText",
2763+ "semantic": {
2764+ "kind": "text",
2765+ "confidence": 0.85
2766+ },
2767+ "example": "Anglais (générés automatiquement)"
2768+ },
2769+ {
2770+ "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].isTranslatable",
2771+ "semantic": {
2772+ "kind": "boolean",
2773+ "confidence": 0.95
2774+ },
2775+ "example": "true"
2776+ }
2777+ ]
2778+ },
2779+ {
2780+ "shape_hash": "ef8c8cb030c1e3ed",
2781+ "status": "provisional",
2782+ "hostname": "www.youtube.com",
2783+ "path_pattern": "/youtubei/v1/get_watch",
2784+ "method": "POST",
2785+ "likely_entity_types": {
2786+ "video": 1,
2787+ "channel": 1,
2788+ "post": 1
2789+ },
2790+ "confidence": 0.22,
2791+ "triggered_by": {
2792+ "OPEN_VIDEO": 1
2793+ },
2794+ "key_fields": [
2795+ {
2796+ "path": "[].responseContext.responseId",
2797+ "semantic": {
2798+ "kind": "identifier",
2799+ "confidence": 0.95
2800+ },
2801+ "example": "IhMIk9Hynb3nlgMV8YDkBh3UOQxb"
2802+ },
2803+ {
2804+ "path": "[].playerResponse.responseContext.mainAppWebResponseContext.loggedOut",
2805+ "semantic": {
2806+ "kind": "boolean",
2807+ "confidence": 0.95
2808+ },
2809+ "example": "true"
2810+ },
2811+ {
2812+ "path": "[].playerResponse.responseContext.responseId",
2813+ "semantic": {
2814+ "kind": "identifier",
2815+ "confidence": 0.95
2816+ },
2817+ "example": "IhMIk9Hynb3nlgMV8YDkBh3UOQxb"
2818+ },
2819+ {
2820+ "path": "[].playerResponse.responseContext.webResponseContextExtensionData.hasDecorated",
2821+ "semantic": {
2822+ "kind": "boolean",
2823+ "confidence": 0.95
2824+ },
2825+ "example": "true"
2826+ },
2827+ {
2828+ "path": "[].playerResponse.playabilityStatus.playableInEmbed",
2829+ "semantic": {
2830+ "kind": "boolean",
2831+ "confidence": 0.95
2832+ },
2833+ "example": "true"
2834+ },
2835+ {
2836+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].contentLength",
2837+ "semantic": {
2838+ "kind": "duration",
2839+ "confidence": 0.75
2840+ },
2841+ "example": "1450852620"
2842+ },
2843+ {
2844+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].approxDurationMs",
2845+ "semantic": {
2846+ "kind": "duration",
2847+ "confidence": 0.75
2848+ },
2849+ "example": "806806"
2850+ },
2851+ {
2852+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].highReplication",
2853+ "semantic": {
2854+ "kind": "boolean",
2855+ "confidence": 0.95
2856+ },
2857+ "example": "true"
2858+ },
2859+ {
2860+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isDrc",
2861+ "semantic": {
2862+ "kind": "boolean",
2863+ "confidence": 0.95
2864+ },
2865+ "example": "true"
2866+ },
2867+ {
2868+ "path": "[].playerResponse.streamingData.adaptiveFormats.[].isVb",
2869+ "semantic": {
2870+ "kind": "boolean",
2871+ "confidence": 0.95
2872+ },
2873+ "example": "true"
2874+ },
2875+ {
2876+ "path": "[].playerResponse.streamingData.serverAbrStreamingUrl",
2877+ "semantic": {
2878+ "kind": "media_url",
2879+ "confidence": 0.93
2880+ },
2881+ "example": "https://rr1---sn-cxaaj5o5q5-t0ad.googlevideo.com/videoplayback?expire=1789183973"
2882+ },
2883+ {
2884+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.playerAdParams.showContentThumbnail",
2885+ "semantic": {
2886+ "kind": "boolean",
2887+ "confidence": 0.95
2888+ },
2889+ "example": "true"
2890+ },
2891+ {
2892+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showCompanion",
2893+ "semantic": {
2894+ "kind": "boolean",
2895+ "confidence": 0.95
2896+ },
2897+ "example": "true"
2898+ },
2899+ {
2900+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.showInstream",
2901+ "semantic": {
2902+ "kind": "boolean",
2903+ "confidence": 0.95
2904+ },
2905+ "example": "true"
2906+ },
2907+ {
2908+ "path": "[].playerResponse.playerAds.[].playerLegacyDesktopWatchAdsRenderer.useGut",
2909+ "semantic": {
2910+ "kind": "boolean",
2911+ "confidence": 0.95
2912+ },
2913+ "example": "true"
2914+ },
2915+ {
2916+ "path": "[].playerResponse.playbackTracking.videostatsPlaybackUrl.baseUrl",
2917+ "semantic": {
2918+ "kind": "url",
2919+ "confidence": 0.9
2920+ },
2921+ "example": "https://s.youtube.com/api/stats/playback?cl=974780265&docid=64fmQxx7hiU&ei=hXOka"
2922+ },
2923+ {
2924+ "path": "[].playerResponse.playbackTracking.videostatsDelayplayUrl.baseUrl",
2925+ "semantic": {
2926+ "kind": "url",
2927+ "confidence": 0.9
2928+ },
2929+ "example": "https://s.youtube.com/api/stats/delayplay?cl=974780265&docid=64fmQxx7hiU&ei=hXOk"
2930+ },
2931+ {
2932+ "path": "[].playerResponse.playbackTracking.videostatsWatchtimeUrl.baseUrl",
2933+ "semantic": {
2934+ "kind": "url",
2935+ "confidence": 0.9
2936+ },
2937+ "example": "https://s.youtube.com/api/stats/watchtime?cl=974780265&docid=64fmQxx7hiU&ei=hXOk"
2938+ },
2939+ {
2940+ "path": "[].playerResponse.playbackTracking.ptrackingUrl.baseUrl",
2941+ "semantic": {
2942+ "kind": "url",
2943+ "confidence": 0.9
2944+ },
2945+ "example": "https://www.youtube.com/ptracking?ei=hXOkatP6MfGBkucP1POw2AU&oid=1krK__BKXwDOY6x"
2946+ },
2947+ {
2948+ "path": "[].playerResponse.playbackTracking.qoeUrl.baseUrl",
2949+ "semantic": {
2950+ "kind": "url",
2951+ "confidence": 0.9
2952+ },
2953+ "example": "https://s.youtube.com/api/stats/qoe?cl=974780265&docid=64fmQxx7hiU&ei=hXOkatP6Mf"
2954+ },
2955+ {
2956+ "path": "[].playerResponse.playbackTracking.atrUrl.baseUrl",
2957+ "semantic": {
2958+ "kind": "url",
2959+ "confidence": 0.9
2960+ },
2961+ "example": "https://s.youtube.com/api/stats/atr?c=WEB&docid=64fmQxx7hiU&ei=hXOkatP6MfGBkucP1"
2962+ },
2963+ {
2964+ "path": "[].playerResponse.playbackTracking.atrUrl.elapsedMediaTimeSeconds",
2965+ "semantic": {
2966+ "kind": "timestamp",
2967+ "confidence": 0.7
2968+ },
2969+ "example": "5"
2970+ },
2971+ {
2972+ "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].baseUrl",
2973+ "semantic": {
2974+ "kind": "url",
2975+ "confidence": 0.9
2976+ },
2977+ "example": "https://www.youtube.com/api/timedtext?v=64fmQxx7hiU&ei=hXOkatP6MfGBkucP1POw2AU&c"
2978+ },
2979+ {
2980+ "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].name.simpleText",
2981+ "semantic": {
2982+ "kind": "text",
2983+ "confidence": 0.85
2984+ },
2985+ "example": "Anglais (générés automatiquement)"
2986+ },
2987+ {
2988+ "path": "[].playerResponse.captions.playerCaptionsTracklistRenderer.captionTracks.[].isTranslatable",
2989+ "semantic": {
2990+ "kind": "boolean",
2991+ "confidence": 0.95
2992+ },
2993+ "example": "true"
2994+ }
2995+ ]
35 } 2996 }
36 ], 2997 ],
37− "network_patterns": [],
38 "navigation": [ 2998 "navigation": [
39 { 2999 {
40 "action": "WAIT_FOR_CONTENT", 3000 "action": "WAIT_FOR_CONTENT",
@@ -76,19 +3036,19 @@
76 "action": "OPEN_VIDEO", 3036 "action": "OPEN_VIDEO",
77 "from": "SEARCH_RESULTS", 3037 "from": "SEARCH_RESULTS",
78 "to": { 3038 "to": {
79− "VIDEO_DETAIL": 3 3039+ "VIDEO_DETAIL": 4
80 }, 3040 },
81− "avg_new_entities": 25.7, 3041+ "avg_new_entities": 19.8,
82− "observed": 3 3042+ "observed": 4
83 }, 3043 },
84 { 3044 {
85 "action": "OPEN_VIDEO", 3045 "action": "OPEN_VIDEO",
86 "from": "VIDEO_DETAIL", 3046 "from": "VIDEO_DETAIL",
87 "to": { 3047 "to": {
88− "VIDEO_DETAIL": 10 3048+ "VIDEO_DETAIL": 11
89 }, 3049 },
90− "avg_new_entities": 29.3, 3050+ "avg_new_entities": 26.6,
91− "observed": 10 3051+ "observed": 11
92 }, 3052 },
93 { 3053 {
94 "action": "SEARCH", 3054 "action": "SEARCH",
@@ -98,13 +3058,22 @@
98 }, 3058 },
99 "avg_new_entities": 1, 3059 "avg_new_entities": 1,
100 "observed": 1 3060 "observed": 1
3061+ },
3062+ {
3063+ "action": "PLAY_VIDEO",
3064+ "from": "VIDEO_DETAIL",
3065+ "to": {
3066+ "UNKNOWN": 1
3067+ },
3068+ "avg_new_entities": 0,
3069+ "observed": 1
101 } 3070 }
102 ], 3071 ],
103 "media": [ 3072 "media": [
104 { 3073 {
105 "hostname": "www.youtube.com", 3074 "hostname": "www.youtube.com",
106 "kind": "media_segment", 3075 "kind": "media_segment",
107− "observed_count": 12 3076+ "observed_count": 16
108 }, 3077 },
109 { 3078 {
110 "hostname": "rr4---sn-q4fl6nde.googlevideo.com", 3079 "hostname": "rr4---sn-q4fl6nde.googlevideo.com",
@@ -114,7 +3083,7 @@
114 { 3083 {
115 "hostname": "i.ytimg.com", 3084 "hostname": "i.ytimg.com",
116 "kind": "image", 3085 "kind": "image",
117− "observed_count": 172 3086+ "observed_count": 181
118 }, 3087 },
119 { 3088 {
120 "hostname": "rr5---sn-q4fl6n6z.googlevideo.com", 3089 "hostname": "rr5---sn-q4fl6n6z.googlevideo.com",
@@ -160,6 +3129,16 @@
160 "hostname": "rr1---sn-cxaaj5o5q5-t0ad.googlevideo.com", 3129 "hostname": "rr1---sn-cxaaj5o5q5-t0ad.googlevideo.com",
161 "kind": "media_segment", 3130 "kind": "media_segment",
162 "observed_count": 1 3131 "observed_count": 1
3132+ },
3133+ {
3134+ "hostname": "rr1---sn-cxaaj5o5q5-t9cs.googlevideo.com",
3135+ "kind": "media_segment",
3136+ "observed_count": 2
3137+ },
3138+ {
3139+ "hostname": "rr2---sn-ntqe6n7k.googlevideo.com",
3140+ "kind": "media_segment",
3141+ "observed_count": 6
163 } 3142 }
164 ] 3143 ]
165 } 3144 }
\ No newline at end of file
added deploy/social-runtime-crawler.mld.json +102 −0
@@ -0,0 +1,102 @@
1+{
2+ "app": "social-runtime-crawler",
3+ "label": "SocialCrawl — Social Runtime Crawler (research console + control plane + browser worker)",
4+ "domain": "www.socialcrawl.co",
5+ "port": 8351,
6+ "health_path": "/access",
7+ "dir": "~/apps/social-runtime-crawler",
8+ "extra_paths": [],
9+ "sync_excludes": [
10+ "node_modules/",
11+ ".git/",
12+ ".env",
13+ ".env.*",
14+ "!.env.example",
15+ "*.tsbuildinfo",
16+ ".DS_Store",
17+ ".claude/",
18+ "data/",
19+ "apps/dashboard/.next/",
20+ "apps/dashboard/next-env.d.ts",
21+ "deploy/"
22+ ],
23+ "requires": {
24+ "runtimes": ["pm2", "node", "pnpm", "postgresql@17"],
25+ "ram_gb": 8,
26+ "ports": [8350, 8351]
27+ },
28+ "ram_mb_observed": 1500,
29+ "size_mb": 40,
30+ "placement": {
31+ "pin": null,
32+ "prefer": "M3U96a",
33+ "avoid": ["M1M32", "M3U96b", "M2U64", "M1M64", "m2m16b", "m1m16", "m2m16c", "m4me", "m4mf", "m4mg", "m4mh", "m1m16b", "m4mi", "m4mj", "m4mk", "m2m8a", "m2m8b"],
34+ "reason": "Chromium headless par mission (≈1–2 Go chacun) + Postgres 17 local ; nœud LAN avec session graphique pour les logins humains (Partage d'écran) ; M2U64 sans session graphique évité"
35+ },
36+ "processes": [
37+ {
38+ "name": "socialcrawl-api",
39+ "manager": "pm2",
40+ "script": "/opt/homebrew/bin/node",
41+ "args": ["node_modules/tsx/dist/cli.mjs", "apps/api/src/server.ts"],
42+ "interpreter": null,
43+ "cwd": "{{HOME}}/apps/social-runtime-crawler",
44+ "env": {
45+ "NODE_ENV": "production",
46+ "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
47+ "SRC_DATABASE_URL": "postgres://localhost:5432/social_runtime",
48+ "SRC_DATA_DIR": "{{HOME}}/apps/social-runtime-crawler/data",
49+ "SRC_API_PORT": "8350",
50+ "SRC_API_TOKEN": "e9618b61c36567d8f01beb39218ab3108e8042c438d31ee4",
51+ "SRC_MAX_JOBS": "2",
52+ "SRC_HEADLESS": "true",
53+ "SRC_BROWSER_CHANNEL": "chromium",
54+ "SRC_LLM_PROVIDER": "none",
55+ "SRC_LOG_LEVEL": "info",
56+ "SRC_LOG_PRETTY": "1"
57+ },
58+ "cron_restart": null,
59+ "autorestart": true,
60+ "max_memory_restart": "1G"
61+ },
62+ {
63+ "name": "socialcrawl-web",
64+ "manager": "pm2",
65+ "script": "/opt/homebrew/bin/node",
66+ "args": ["node_modules/next/dist/bin/next", "start", "-p", "8351", "-H", "0.0.0.0"],
67+ "interpreter": null,
68+ "cwd": "{{HOME}}/apps/social-runtime-crawler/apps/dashboard",
69+ "env": {
70+ "NODE_ENV": "production",
71+ "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
72+ "API_URL": "http://127.0.0.1:8350",
73+ "SRC_API_TOKEN": "e9618b61c36567d8f01beb39218ab3108e8042c438d31ee4",
74+ "SRC_DASHBOARD_PASSCODE": "OZVCyj4m0Gepql",
75+ "SRC_DASHBOARD_SECRET": "2b150a27dcbcf0cfdfd275cdfc85587bb15e9a29b864845f2a59e424e3e0e332",
76+ "NEXT_TELEMETRY_DISABLED": "1"
77+ },
78+ "cron_restart": null,
79+ "autorestart": true,
80+ "max_memory_restart": "1G"
81+ }
82+ ],
83+ "tunnel": {
84+ "domain": "www.socialcrawl.co",
85+ "gateway": "BHS64",
86+ "redirects": ["socialcrawl.co"],
87+ "websocket": false,
88+ "note": "SSE (/api/stream) passe par Caddy sans config spéciale ; apex socialcrawl.co n'a pas encore d'enregistrement A (à ajouter chez GoDaddy pour la redirection)"
89+ },
90+ "launchd": [],
91+ "env_overrides": {},
92+ "hooks": {
93+ "post_sync": [
94+ "export PATH=\"/opt/homebrew/bin:$PATH\"; pnpm install --frozen-lockfile --silent && echo ' deps ok'",
95+ "export PATH=\"/opt/homebrew/bin:$PATH\"; (psql -d postgres -Atc \"select 1 from pg_database where datname='social_runtime'\" | grep -q 1 || createdb social_runtime) && SRC_DATABASE_URL=postgres://localhost:5432/social_runtime pnpm db:migrate 2>&1 | tail -1 && echo ' db ok'",
96+ "export PATH=\"/opt/homebrew/bin:$PATH\"; pnpm --filter @src/worker exec playwright install chromium 2>&1 | tail -1; echo ' chromium ok'",
97+ "export PATH=\"/opt/homebrew/bin:$PATH\"; mkdir -p data/browser_profiles data/sessions data/platform_model data/media data/jobs; export NEXT_TELEMETRY_DISABLED=1 API_URL=http://127.0.0.1:8350; pnpm --filter @src/dashboard build 2>&1 | tail -3 && echo ' web build ok'"
98+ ],
99+ "post_start": []
100+ },
101+ "notes": "v0.1 (2026-09-12) : console de recherche Next 16 (gate par code d'accès SRC_DASHBOARD_PASSCODE, proxy /api → API loopback 8350 avec SRC_API_TOKEN), API de contrôle node:http (missions = processus worker Playwright enfants, SSE /api/stream), Postgres 17 local `social_runtime`. Les profils navigateur authentifiés vivent dans data/browser_profiles sur le nœud : login humain via Partage d'écran + `pnpm login <platform>`. Adaptateurs YouTube + Reddit. Lecture seule."
102+}
modified package.json +5 −2
@@ -14,11 +14,14 @@
14 "crawl": "tsx apps/worker/src/cli.ts crawl", 14 "crawl": "tsx apps/worker/src/cli.ts crawl",
15 "learn": "tsx apps/worker/src/cli.ts learn", 15 "learn": "tsx apps/worker/src/cli.ts learn",
16 "replay": "tsx apps/worker/src/cli.ts replay", 16 "replay": "tsx apps/worker/src/cli.ts replay",
17− "dashboard": "tsx apps/api/src/server.ts",
18 "db:migrate": "tsx packages/storage/src/migrate.ts", 17 "db:migrate": "tsx packages/storage/src/migrate.ts",
19 "typecheck": "tsc -p tsconfig.json --noEmit", 18 "typecheck": "tsc -p tsconfig.json --noEmit",
20 "test": "vitest run", 19 "test": "vitest run",
21− "test:watch": "vitest" 20+ "test:watch": "vitest",
21+ "api": "tsx apps/api/src/server.ts",
22+ "dashboard:dev": "pnpm --filter @src/dashboard dev",
23+ "dashboard:build": "pnpm --filter @src/dashboard build",
24+ "dashboard:start": "pnpm --filter @src/dashboard start"
22 }, 25 },
23 "devDependencies": { 26 "devDependencies": {
24 "@types/node": "^24.0.0", 27 "@types/node": "^24.0.0",
modified packages/media/src/index.ts +2 −0
@@ -97,7 +97,9 @@ export async function sampleVideoFrames(page: Page, media: ObservedMedia, mediaD
97 const duration = media.duration_s ?? (await video.evaluate((v: HTMLVideoElement) => (Number.isFinite(v.duration) ? v.duration : 0)).catch(() => 0)); 97 const duration = media.duration_s ?? (await video.evaluate((v: HTMLVideoElement) => (Number.isFinite(v.duration) ? v.duration : 0)).catch(() => 0));
98 const positions = [0, 0.25, 0.5, 0.75, 0.98].slice(0, maxFrames); 98 const positions = [0, 0.25, 0.5, 0.75, 0.98].slice(0, maxFrames);
99 const files: string[] = []; 99 const files: string[] = [];
100+ const deadline = Date.now() + 20_000; // frame sampling is best-effort and bounded
100 for (const p of positions) { 101 for (const p of positions) {
102+ if (Date.now() > deadline) break;
101 try { 103 try {
102 if (duration > 2) { 104 if (duration > 2) {
103 await video.evaluate((v: HTMLVideoElement, t: number) => { 105 await video.evaluate((v: HTMLVideoElement, t: number) => {
modified packages/storage/src/index.ts +0 −0

Binary file not shown.

modified pnpm-lock.yaml +1358 −12
@@ -19,10 +19,13 @@ importers:
19 version: 5.9.3 19 version: 5.9.3
20 vitest: 20 vitest:
21 specifier: ^3.2.0 21 specifier: ^3.2.0
22− version: 3.2.7(@types/node@24.13.4)(tsx@4.23.13) 22+ version: 3.2.7(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
23 23
24 apps/api: 24 apps/api:
25 dependencies: 25 dependencies:
26+ '@src/platform-model':
27+ specifier: workspace:*
28+ version: link:../../packages/platform-model
26 '@src/shared': 29 '@src/shared':
27 specifier: workspace:* 30 specifier: workspace:*
28 version: link:../../packages/shared 31 version: link:../../packages/shared
@@ -30,6 +33,55 @@ importers:
30 specifier: workspace:* 33 specifier: workspace:*
31 version: link:../../packages/storage 34 version: link:../../packages/storage
32 35
36+ apps/dashboard:
37+ dependencies:
38+ clsx:
39+ specifier: ^2.1.1
40+ version: 2.1.1
41+ d3-force:
42+ specifier: ^3.0.0
43+ version: 3.0.0
44+ framer-motion:
45+ specifier: ^12.23.0
46+ version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
47+ lucide-react:
48+ specifier: ^1.0.0
49+ version: 1.44.0(react@19.2.8)
50+ next:
51+ specifier: 16.3.4
52+ version: 16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
53+ react:
54+ specifier: 19.2.8
55+ version: 19.2.8
56+ react-dom:
57+ specifier: 19.2.8
58+ version: 19.2.8(react@19.2.8)
59+ recharts:
60+ specifier: ^3.0.0
61+ version: 3.10.1(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1)
62+ devDependencies:
63+ '@tailwindcss/postcss':
64+ specifier: ^4
65+ version: 4.3.3
66+ '@types/d3-force':
67+ specifier: ^3.0.10
68+ version: 3.0.10
69+ '@types/node':
70+ specifier: ^24.0.0
71+ version: 24.13.4
72+ '@types/react':
73+ specifier: ^19
74+ version: 19.3.0
75+ '@types/react-dom':
76+ specifier: ^19
77+ version: 19.3.0(@types/react@19.3.0)
78+ tailwindcss:
79+ specifier: ^4
80+ version: 4.3.3
81+ typescript:
82+ specifier: ^5.9.3
83+ version: 5.9.3
84+
33 apps/worker: 85 apps/worker:
34 dependencies: 86 dependencies:
35 '@src/agent': 87 '@src/agent':
@@ -170,6 +222,10 @@ importers:
170 222
171 packages: 223 packages:
172 224
225+ '@alloc/quick-lru@5.3.0':
226+ resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
227+ engines: {node: '>=10'}
228+
173 '@anthropic-ai/sdk@0.125.0': 229 '@anthropic-ai/sdk@0.125.0':
174 resolution: {integrity: sha512-Hq5wYlXupzJ9M1Fzqjqa3hObcuigEXVZJqSYDgeZGM3wF4qrNERM5Z1OOAeoT9rlod8awJ3uYyJjbQXp1ckIGg==} 230 resolution: {integrity: sha512-Hq5wYlXupzJ9M1Fzqjqa3hObcuigEXVZJqSYDgeZGM3wF4qrNERM5Z1OOAeoT9rlod8awJ3uYyJjbQXp1ckIGg==}
175 hasBin: true 231 hasBin: true
@@ -183,6 +239,9 @@ packages:
183 resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} 239 resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
184 engines: {node: '>=6.9.0'} 240 engines: {node: '>=6.9.0'}
185 241
242+ '@emnapi/runtime@1.11.3':
243+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
244+
186 '@esbuild/aix-ppc64@0.28.2': 245 '@esbuild/aix-ppc64@0.28.2':
187 resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} 246 resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
188 engines: {node: '>=18'} 247 engines: {node: '>=18'}
@@ -339,9 +398,184 @@ packages:
339 cpu: [x64] 398 cpu: [x64]
340 os: [win32] 399 os: [win32]
341 400
401+ '@img/colour@1.1.0':
402+ resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
403+ engines: {node: '>=18'}
404+
405+ '@img/sharp-darwin-arm64@0.35.4':
406+ resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
407+ engines: {node: '>=20.9.0'}
408+ cpu: [arm64]
409+ os: [darwin]
410+
411+ '@img/sharp-darwin-x64@0.35.4':
412+ resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
413+ engines: {node: '>=20.9.0'}
414+ cpu: [x64]
415+ os: [darwin]
416+
417+ '@img/sharp-freebsd-wasm32@0.35.4':
418+ resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
419+ engines: {node: '>=20.9.0'}
420+ os: [freebsd]
421+
422+ '@img/sharp-libvips-darwin-arm64@1.3.3':
423+ resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
424+ cpu: [arm64]
425+ os: [darwin]
426+
427+ '@img/sharp-libvips-darwin-x64@1.3.3':
428+ resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
429+ cpu: [x64]
430+ os: [darwin]
431+
432+ '@img/sharp-libvips-linux-arm64@1.3.3':
433+ resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
434+ cpu: [arm64]
435+ os: [linux]
436+ libc: [glibc]
437+
438+ '@img/sharp-libvips-linux-arm@1.3.3':
439+ resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
440+ cpu: [arm]
441+ os: [linux]
442+ libc: [glibc]
443+
444+ '@img/sharp-libvips-linux-ppc64@1.3.3':
445+ resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
446+ cpu: [ppc64]
447+ os: [linux]
448+ libc: [glibc]
449+
450+ '@img/sharp-libvips-linux-riscv64@1.3.3':
451+ resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
452+ cpu: [riscv64]
453+ os: [linux]
454+ libc: [glibc]
455+
456+ '@img/sharp-libvips-linux-s390x@1.3.3':
457+ resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
458+ cpu: [s390x]
459+ os: [linux]
460+ libc: [glibc]
461+
462+ '@img/sharp-libvips-linux-x64@1.3.3':
463+ resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
464+ cpu: [x64]
465+ os: [linux]
466+ libc: [glibc]
467+
468+ '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
469+ resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
470+ cpu: [arm64]
471+ os: [linux]
472+ libc: [musl]
473+
474+ '@img/sharp-libvips-linuxmusl-x64@1.3.3':
475+ resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
476+ cpu: [x64]
477+ os: [linux]
478+ libc: [musl]
479+
480+ '@img/sharp-linux-arm64@0.35.4':
481+ resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
482+ engines: {node: '>=20.9.0'}
483+ cpu: [arm64]
484+ os: [linux]
485+ libc: [glibc]
486+
487+ '@img/sharp-linux-arm@0.35.4':
488+ resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
489+ engines: {node: '>=20.9.0'}
490+ cpu: [arm]
491+ os: [linux]
492+ libc: [glibc]
493+
494+ '@img/sharp-linux-ppc64@0.35.4':
495+ resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
496+ engines: {node: '>=20.9.0'}
497+ cpu: [ppc64]
498+ os: [linux]
499+ libc: [glibc]
500+
501+ '@img/sharp-linux-riscv64@0.35.4':
502+ resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
503+ engines: {node: '>=20.9.0'}
504+ cpu: [riscv64]
505+ os: [linux]
506+ libc: [glibc]
507+
508+ '@img/sharp-linux-s390x@0.35.4':
509+ resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
510+ engines: {node: '>=20.9.0'}
511+ cpu: [s390x]
512+ os: [linux]
513+ libc: [glibc]
514+
515+ '@img/sharp-linux-x64@0.35.4':
516+ resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
517+ engines: {node: '>=20.9.0'}
518+ cpu: [x64]
519+ os: [linux]
520+ libc: [glibc]
521+
522+ '@img/sharp-linuxmusl-arm64@0.35.4':
523+ resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
524+ engines: {node: '>=20.9.0'}
525+ cpu: [arm64]
526+ os: [linux]
527+ libc: [musl]
528+
529+ '@img/sharp-linuxmusl-x64@0.35.4':
530+ resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
531+ engines: {node: '>=20.9.0'}
532+ cpu: [x64]
533+ os: [linux]
534+ libc: [musl]
535+
536+ '@img/sharp-wasm32@0.35.4':
537+ resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
538+ engines: {node: '>=20.9.0'}
539+
540+ '@img/sharp-webcontainers-wasm32@0.35.4':
541+ resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
542+ engines: {node: '>=20.9.0'}
543+ cpu: [wasm32]
544+
545+ '@img/sharp-win32-arm64@0.35.4':
546+ resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
547+ engines: {node: '>=20.9.0'}
548+ cpu: [arm64]
549+ os: [win32]
550+
551+ '@img/sharp-win32-ia32@0.35.4':
552+ resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
553+ engines: {node: ^20.9.0}
554+ cpu: [ia32]
555+ os: [win32]
556+
557+ '@img/sharp-win32-x64@0.35.4':
558+ resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
559+ engines: {node: '>=20.9.0'}
560+ cpu: [x64]
561+ os: [win32]
562+
563+ '@jridgewell/gen-mapping@0.3.13':
564+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
565+
566+ '@jridgewell/remapping@2.3.5':
567+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
568+
569+ '@jridgewell/resolve-uri@3.1.2':
570+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
571+ engines: {node: '>=6.0.0'}
572+
342 '@jridgewell/sourcemap-codec@1.6.0': 573 '@jridgewell/sourcemap-codec@1.6.0':
343 resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} 574 resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
344 575
576+ '@jridgewell/trace-mapping@0.3.31':
577+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
578+
345 '@napi-rs/lzma-linux-x64-gnu@1.5.1': 579 '@napi-rs/lzma-linux-x64-gnu@1.5.1':
346 resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} 580 resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
347 engines: {node: ^22.20 || ^24.12 || >=25} 581 engines: {node: ^22.20 || ^24.12 || >=25}
@@ -349,6 +583,72 @@ packages:
349 os: [linux] 583 os: [linux]
350 libc: [glibc] 584 libc: [glibc]
351 585
586+ '@next/env@16.3.4':
587+ resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==}
588+
589+ '@next/swc-darwin-arm64@16.3.4':
590+ resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==}
591+ engines: {node: '>= 10'}
592+ cpu: [arm64]
593+ os: [darwin]
594+
595+ '@next/swc-darwin-x64@16.3.4':
596+ resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==}
597+ engines: {node: '>= 10'}
598+ cpu: [x64]
599+ os: [darwin]
600+
601+ '@next/swc-linux-arm64-gnu@16.3.4':
602+ resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==}
603+ engines: {node: '>= 10'}
604+ cpu: [arm64]
605+ os: [linux]
606+ libc: [glibc]
607+
608+ '@next/swc-linux-arm64-musl@16.3.4':
609+ resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==}
610+ engines: {node: '>= 10'}
611+ cpu: [arm64]
612+ os: [linux]
613+ libc: [musl]
614+
615+ '@next/swc-linux-x64-gnu@16.3.4':
616+ resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==}
617+ engines: {node: '>= 10'}
618+ cpu: [x64]
619+ os: [linux]
620+ libc: [glibc]
621+
622+ '@next/swc-linux-x64-musl@16.3.4':
623+ resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==}
624+ engines: {node: '>= 10'}
625+ cpu: [x64]
626+ os: [linux]
627+ libc: [musl]
628+
629+ '@next/swc-win32-arm64-msvc@16.3.4':
630+ resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==}
631+ engines: {node: '>= 10'}
632+ cpu: [arm64]
633+ os: [win32]
634+
635+ '@next/swc-win32-x64-msvc@16.3.4':
636+ resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==}
637+ engines: {node: '>= 10'}
638+ cpu: [x64]
639+ os: [win32]
640+
641+ '@reduxjs/toolkit@2.12.0':
642+ resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
643+ peerDependencies:
644+ react: ^16.9.0 || ^17.0.0 || ^18 || ^19
645+ react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
646+ peerDependenciesMeta:
647+ react:
648+ optional: true
649+ react-redux:
650+ optional: true
651+
352 '@rollup/rollup-android-arm-eabi@4.63.1': 652 '@rollup/rollup-android-arm-eabi@4.63.1':
353 resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} 653 resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==}
354 cpu: [arm] 654 cpu: [arm]
@@ -490,9 +790,140 @@ packages:
490 '@stablelib/base64@1.0.1': 790 '@stablelib/base64@1.0.1':
491 resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} 791 resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
492 792
793+ '@standard-schema/spec@1.1.0':
794+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
795+
796+ '@standard-schema/utils@0.3.0':
797+ resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
798+
799+ '@swc/helpers@0.5.23':
800+ resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
801+
802+ '@tailwindcss/node@4.3.3':
803+ resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
804+
805+ '@tailwindcss/oxide-android-arm64@4.3.3':
806+ resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
807+ engines: {node: '>= 20'}
808+ cpu: [arm64]
809+ os: [android]
810+
811+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
812+ resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
813+ engines: {node: '>= 20'}
814+ cpu: [arm64]
815+ os: [darwin]
816+
817+ '@tailwindcss/oxide-darwin-x64@4.3.3':
818+ resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
819+ engines: {node: '>= 20'}
820+ cpu: [x64]
821+ os: [darwin]
822+
823+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
824+ resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
825+ engines: {node: '>= 20'}
826+ cpu: [x64]
827+ os: [freebsd]
828+
829+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
830+ resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
831+ engines: {node: '>= 20'}
832+ cpu: [arm]
833+ os: [linux]
834+
835+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
836+ resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
837+ engines: {node: '>= 20'}
838+ cpu: [arm64]
839+ os: [linux]
840+ libc: [glibc]
841+
842+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
843+ resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
844+ engines: {node: '>= 20'}
845+ cpu: [arm64]
846+ os: [linux]
847+ libc: [musl]
848+
849+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
850+ resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
851+ engines: {node: '>= 20'}
852+ cpu: [x64]
853+ os: [linux]
854+ libc: [glibc]
855+
856+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
857+ resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
858+ engines: {node: '>= 20'}
859+ cpu: [x64]
860+ os: [linux]
861+ libc: [musl]
862+
863+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
864+ resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
865+ engines: {node: '>=14.0.0'}
866+ cpu: [wasm32]
867+ bundledDependencies:
868+ - '@napi-rs/wasm-runtime'
869+ - '@emnapi/core'
870+ - '@emnapi/runtime'
871+ - '@tybys/wasm-util'
872+ - '@emnapi/wasi-threads'
873+ - tslib
874+
875+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
876+ resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
877+ engines: {node: '>= 20'}
878+ cpu: [arm64]
879+ os: [win32]
880+
881+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
882+ resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
883+ engines: {node: '>= 20'}
884+ cpu: [x64]
885+ os: [win32]
886+
887+ '@tailwindcss/oxide@4.3.3':
888+ resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
889+ engines: {node: '>= 20'}
890+
891+ '@tailwindcss/postcss@4.3.3':
892+ resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
893+
493 '@types/chai@5.2.3': 894 '@types/chai@5.2.3':
494 resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} 895 resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
495 896
897+ '@types/d3-array@3.2.2':
898+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
899+
900+ '@types/d3-color@3.1.3':
901+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
902+
903+ '@types/d3-ease@3.0.2':
904+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
905+
906+ '@types/d3-force@3.0.10':
907+ resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
908+
909+ '@types/d3-interpolate@3.0.4':
910+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
911+
912+ '@types/d3-path@3.1.1':
913+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
914+
915+ '@types/d3-scale@4.0.9':
916+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
917+
918+ '@types/d3-shape@3.2.0':
919+ resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==}
920+
921+ '@types/d3-time@3.0.4':
922+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
923+
924+ '@types/d3-timer@3.0.2':
925+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
926+
496 '@types/deep-eql@4.0.2': 927 '@types/deep-eql@4.0.2':
497 resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} 928 resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
498 929
@@ -505,6 +936,17 @@ packages:
505 '@types/pg@8.23.1': 936 '@types/pg@8.23.1':
506 resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} 937 resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
507 938
939+ '@types/react-dom@19.3.0':
940+ resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
941+ peerDependencies:
942+ '@types/react': ^19.3.0
943+
944+ '@types/react@19.3.0':
945+ resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
946+
947+ '@types/use-sync-external-store@0.0.6':
948+ resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
949+
508 '@vitest/expect@3.2.7': 950 '@vitest/expect@3.2.7':
509 resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} 951 resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
510 952
@@ -538,10 +980,18 @@ packages:
538 resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} 980 resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
539 engines: {node: '>=12'} 981 engines: {node: '>=12'}
540 982
983+ baseline-browser-mapping@2.11.22:
984+ resolution: {integrity: sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==}
985+ engines: {node: '>=6.0.0'}
986+ hasBin: true
987+
541 cac@6.7.14: 988 cac@6.7.14:
542 resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} 989 resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
543 engines: {node: '>=8'} 990 engines: {node: '>=8'}
544 991
992+ caniuse-lite@1.0.30001810:
993+ resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
994+
545 chai@5.3.3: 995 chai@5.3.3:
546 resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} 996 resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
547 engines: {node: '>=18'} 997 engines: {node: '>=18'}
@@ -550,6 +1000,72 @@ packages:
550 resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} 1000 resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
551 engines: {node: '>= 16'} 1001 engines: {node: '>= 16'}
552 1002
1003+ client-only@0.0.1:
1004+ resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
1005+
1006+ clsx@2.1.1:
1007+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
1008+ engines: {node: '>=6'}
1009+
1010+ csstype@3.2.3:
1011+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
1012+
1013+ d3-array@3.2.4:
1014+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
1015+ engines: {node: '>=12'}
1016+
1017+ d3-color@3.1.0:
1018+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
1019+ engines: {node: '>=12'}
1020+
1021+ d3-dispatch@3.0.1:
1022+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
1023+ engines: {node: '>=12'}
1024+
1025+ d3-ease@3.0.1:
1026+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
1027+ engines: {node: '>=12'}
1028+
1029+ d3-force@3.0.0:
1030+ resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
1031+ engines: {node: '>=12'}
1032+
1033+ d3-format@3.1.2:
1034+ resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
1035+ engines: {node: '>=12'}
1036+
1037+ d3-interpolate@3.0.1:
1038+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
1039+ engines: {node: '>=12'}
1040+
1041+ d3-path@3.1.0:
1042+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
1043+ engines: {node: '>=12'}
1044+
1045+ d3-quadtree@3.0.1:
1046+ resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
1047+ engines: {node: '>=12'}
1048+
1049+ d3-scale@4.0.2:
1050+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
1051+ engines: {node: '>=12'}
1052+
1053+ d3-shape@3.2.0:
1054+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
1055+ engines: {node: '>=12'}
1056+
1057+ d3-time-format@4.1.0:
1058+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
1059+ engines: {node: '>=12'}
1060+
1061+ d3-time@3.1.0:
1062+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
1063+ engines: {node: '>=12'}
1064+
1065+ d3-timer@3.0.1:
1066+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
1067+ engines: {node: '>=12'}
1068+
553 debug@4.4.3: 1069 debug@4.4.3:
554 resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} 1070 resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
555 engines: {node: '>=6.0'} 1071 engines: {node: '>=6.0'}
@@ -559,13 +1075,27 @@ packages:
559 supports-color: 1075 supports-color:
560 optional: true 1076 optional: true
561 1077
1078+ decimal.js-light@2.5.1:
1079+ resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
1080+
562 deep-eql@5.0.2: 1081 deep-eql@5.0.2:
563 resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} 1082 resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
564 engines: {node: '>=6'} 1083 engines: {node: '>=6'}
565 1084
1085+ detect-libc@2.1.2:
1086+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
1087+ engines: {node: '>=8'}
1088+
1089+ enhanced-resolve@5.24.5:
1090+ resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
1091+ engines: {node: '>=10.13.0'}
1092+
566 es-module-lexer@1.7.0: 1093 es-module-lexer@1.7.0:
567 resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} 1094 resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
568 1095
1096+ es-toolkit@1.52.0:
1097+ resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==}
1098+
569 esbuild@0.28.2: 1099 esbuild@0.28.2:
570 resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} 1100 resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
571 engines: {node: '>=18'} 1101 engines: {node: '>=18'}
@@ -574,6 +1104,9 @@ packages:
574 estree-walker@3.0.3: 1104 estree-walker@3.0.3:
575 resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} 1105 resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
576 1106
1107+ eventemitter3@5.0.4:
1108+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
1109+
577 expect-type@1.4.0: 1110 expect-type@1.4.0:
578 resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} 1111 resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
579 engines: {node: '>=12.0.0'} 1112 engines: {node: '>=12.0.0'}
@@ -590,11 +1123,39 @@ packages:
590 picomatch: 1123 picomatch:
591 optional: true 1124 optional: true
592 1125
1126+ framer-motion@12.43.0:
1127+ resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==}
1128+ peerDependencies:
1129+ '@emotion/is-prop-valid': '*'
1130+ react: ^18.0.0 || ^19.0.0
1131+ react-dom: ^18.0.0 || ^19.0.0
1132+ peerDependenciesMeta:
1133+ '@emotion/is-prop-valid':
1134+ optional: true
1135+ react:
1136+ optional: true
1137+ react-dom:
1138+ optional: true
1139+
593 fsevents@2.3.3: 1140 fsevents@2.3.3:
594 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 1141 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
595 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 1142 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
596 os: [darwin] 1143 os: [darwin]
597 1144
1145+ graceful-fs@4.2.11:
1146+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
1147+
1148+ immer@11.1.18:
1149+ resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==}
1150+
1151+ internmap@2.0.3:
1152+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
1153+ engines: {node: '>=12'}
1154+
1155+ jiti@2.7.0:
1156+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
1157+ hasBin: true
1158+
598 js-tokens@9.0.1: 1159 js-tokens@9.0.1:
599 resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} 1160 resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
600 1161
@@ -602,12 +1163,97 @@ packages:
602 resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} 1163 resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
603 engines: {node: '>=16'} 1164 engines: {node: '>=16'}
604 1165
605− loupe@3.2.1: 1166+ lightningcss-android-arm64@1.32.0:
606− resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} 1167+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
1168+ engines: {node: '>= 12.0.0'}
1169+ cpu: [arm64]
1170+ os: [android]
1171+
1172+ lightningcss-darwin-arm64@1.32.0:
1173+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
1174+ engines: {node: '>= 12.0.0'}
1175+ cpu: [arm64]
1176+ os: [darwin]
1177+
1178+ lightningcss-darwin-x64@1.32.0:
1179+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
1180+ engines: {node: '>= 12.0.0'}
1181+ cpu: [x64]
1182+ os: [darwin]
1183+
1184+ lightningcss-freebsd-x64@1.32.0:
1185+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
1186+ engines: {node: '>= 12.0.0'}
1187+ cpu: [x64]
1188+ os: [freebsd]
1189+
1190+ lightningcss-linux-arm-gnueabihf@1.32.0:
1191+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
1192+ engines: {node: '>= 12.0.0'}
1193+ cpu: [arm]
1194+ os: [linux]
1195+
1196+ lightningcss-linux-arm64-gnu@1.32.0:
1197+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
1198+ engines: {node: '>= 12.0.0'}
1199+ cpu: [arm64]
1200+ os: [linux]
1201+ libc: [glibc]
1202+
1203+ lightningcss-linux-arm64-musl@1.32.0:
1204+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
1205+ engines: {node: '>= 12.0.0'}
1206+ cpu: [arm64]
1207+ os: [linux]
1208+ libc: [musl]
1209+
1210+ lightningcss-linux-x64-gnu@1.32.0:
1211+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
1212+ engines: {node: '>= 12.0.0'}
1213+ cpu: [x64]
1214+ os: [linux]
1215+ libc: [glibc]
1216+
1217+ lightningcss-linux-x64-musl@1.32.0:
1218+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
1219+ engines: {node: '>= 12.0.0'}
1220+ cpu: [x64]
1221+ os: [linux]
1222+ libc: [musl]
1223+
1224+ lightningcss-win32-arm64-msvc@1.32.0:
1225+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
1226+ engines: {node: '>= 12.0.0'}
1227+ cpu: [arm64]
1228+ os: [win32]
1229+
1230+ lightningcss-win32-x64-msvc@1.32.0:
1231+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
1232+ engines: {node: '>= 12.0.0'}
1233+ cpu: [x64]
1234+ os: [win32]
1235+
1236+ lightningcss@1.32.0:
1237+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
1238+ engines: {node: '>= 12.0.0'}
1239+
1240+ loupe@3.2.1:
1241+ resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
1242+
1243+ lucide-react@1.44.0:
1244+ resolution: {integrity: sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==}
1245+ peerDependencies:
1246+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
607 1247
608 magic-string@0.30.21: 1248 magic-string@0.30.21:
609 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 1249 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
610 1250
1251+ motion-dom@12.43.0:
1252+ resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==}
1253+
1254+ motion-utils@12.39.0:
1255+ resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
1256+
611 ms@2.1.3: 1257 ms@2.1.3:
612 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 1258 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
613 1259
@@ -616,6 +1262,27 @@ packages:
616 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 1262 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
617 hasBin: true 1263 hasBin: true
618 1264
1265+ next@16.3.4:
1266+ resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==}
1267+ engines: {node: '>=20.9.0'}
1268+ hasBin: true
1269+ peerDependencies:
1270+ '@opentelemetry/api': ^1.1.0
1271+ '@playwright/test': ^1.51.1
1272+ babel-plugin-react-compiler: '*'
1273+ react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1274+ react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1275+ sass: ^1.3.0
1276+ peerDependenciesMeta:
1277+ '@opentelemetry/api':
1278+ optional: true
1279+ '@playwright/test':
1280+ optional: true
1281+ babel-plugin-react-compiler:
1282+ optional: true
1283+ sass:
1284+ optional: true
1285+
619 pathe@2.0.3: 1286 pathe@2.0.3:
620 resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} 1287 resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
621 1288
@@ -674,6 +1341,10 @@ packages:
674 engines: {node: '>=20'} 1341 engines: {node: '>=20'}
675 hasBin: true 1342 hasBin: true
676 1343
1344+ postcss@8.5.23:
1345+ resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
1346+ engines: {node: ^10 || ^12 || >=14}
1347+
677 postcss@8.5.28: 1348 postcss@8.5.28:
678 resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} 1349 resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
679 engines: {node: ^10 || ^12 || >=14} 1350 engines: {node: ^10 || ^12 || >=14}
@@ -694,11 +1365,71 @@ packages:
694 resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} 1365 resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
695 engines: {node: '>=0.10.0'} 1366 engines: {node: '>=0.10.0'}
696 1367
1368+ react-dom@19.2.8:
1369+ resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
1370+ peerDependencies:
1371+ react: ^19.2.8
1372+
1373+ react-is@19.3.0:
1374+ resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==}
1375+
1376+ react-redux@9.3.0:
1377+ resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
1378+ peerDependencies:
1379+ '@types/react': ^18.2.25 || ^19
1380+ react: ^18.0 || ^19
1381+ redux: ^5.0.0
1382+ peerDependenciesMeta:
1383+ '@types/react':
1384+ optional: true
1385+ redux:
1386+ optional: true
1387+
1388+ react@19.2.8:
1389+ resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
1390+ engines: {node: '>=0.10.0'}
1391+
1392+ recharts@3.10.1:
1393+ resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==}
1394+ engines: {node: '>=18'}
1395+ peerDependencies:
1396+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1397+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1398+ react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1399+
1400+ redux-thunk@3.1.0:
1401+ resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
1402+ peerDependencies:
1403+ redux: ^5.0.0
1404+
1405+ redux@5.0.1:
1406+ resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
1407+
1408+ reselect@5.2.0:
1409+ resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
1410+
697 rollup@4.63.1: 1411 rollup@4.63.1:
698 resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} 1412 resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==}
699 engines: {node: '>=18.0.0', npm: '>=8.0.0'} 1413 engines: {node: '>=18.0.0', npm: '>=8.0.0'}
700 hasBin: true 1414 hasBin: true
701 1415
1416+ scheduler@0.27.0:
1417+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
1418+
1419+ semver@7.8.5:
1420+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
1421+ engines: {node: '>=10'}
1422+ hasBin: true
1423+
1424+ sharp@0.35.4:
1425+ resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
1426+ engines: {node: '>=20.9.0'}
1427+ peerDependencies:
1428+ '@types/node': '*'
1429+ peerDependenciesMeta:
1430+ '@types/node':
1431+ optional: true
1432+
702 siginfo@2.0.0: 1433 siginfo@2.0.0:
703 resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} 1434 resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
704 1435
@@ -722,6 +1453,29 @@ packages:
722 strip-literal@3.1.0: 1453 strip-literal@3.1.0:
723 resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} 1454 resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
724 1455
1456+ styled-jsx@5.1.6:
1457+ resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
1458+ engines: {node: '>= 12.0.0'}
1459+ peerDependencies:
1460+ '@babel/core': '*'
1461+ babel-plugin-macros: '*'
1462+ react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
1463+ peerDependenciesMeta:
1464+ '@babel/core':
1465+ optional: true
1466+ babel-plugin-macros:
1467+ optional: true
1468+
1469+ tailwindcss@4.3.3:
1470+ resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
1471+
1472+ tapable@2.3.3:
1473+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
1474+ engines: {node: '>=6'}
1475+
1476+ tiny-invariant@1.3.3:
1477+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
1478+
725 tinybench@2.9.0: 1479 tinybench@2.9.0:
726 resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} 1480 resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
727 1481
@@ -747,6 +1501,9 @@ packages:
747 ts-algebra@2.0.0: 1501 ts-algebra@2.0.0:
748 resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} 1502 resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
749 1503
1504+ tslib@2.8.1:
1505+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
1506+
750 tsx@4.23.13: 1507 tsx@4.23.13:
751 resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} 1508 resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
752 engines: {node: '>=18.0.0'} 1509 engines: {node: '>=18.0.0'}
@@ -760,6 +1517,14 @@ packages:
760 undici-types@7.18.2: 1517 undici-types@7.18.2:
761 resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} 1518 resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
762 1519
1520+ use-sync-external-store@1.7.0:
1521+ resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
1522+ peerDependencies:
1523+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
1524+
1525+ victory-vendor@37.3.6:
1526+ resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
1527+
763 vite-node@3.2.4: 1528 vite-node@3.2.4:
764 resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} 1529 resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
765 engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} 1530 engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -844,6 +1609,8 @@ packages:
844 1609
845 snapshots: 1610 snapshots:
846 1611
1612+ '@alloc/quick-lru@5.3.0': {}
1613+
847 '@anthropic-ai/sdk@0.125.0': 1614 '@anthropic-ai/sdk@0.125.0':
848 dependencies: 1615 dependencies:
849 json-schema-to-ts: 3.1.1 1616 json-schema-to-ts: 3.1.1
@@ -851,6 +1618,11 @@ snapshots:
851 1618
852 '@babel/runtime@7.29.7': {} 1619 '@babel/runtime@7.29.7': {}
853 1620
1621+ '@emnapi/runtime@1.11.3':
1622+ dependencies:
1623+ tslib: 2.8.1
1624+ optional: true
1625+
854 '@esbuild/aix-ppc64@0.28.2': 1626 '@esbuild/aix-ppc64@0.28.2':
855 optional: true 1627 optional: true
856 1628
@@ -929,11 +1701,173 @@ snapshots:
929 '@esbuild/win32-x64@0.28.2': 1701 '@esbuild/win32-x64@0.28.2':
930 optional: true 1702 optional: true
931 1703
1704+ '@img/colour@1.1.0':
1705+ optional: true
1706+
1707+ '@img/sharp-darwin-arm64@0.35.4':
1708+ optionalDependencies:
1709+ '@img/sharp-libvips-darwin-arm64': 1.3.3
1710+ optional: true
1711+
1712+ '@img/sharp-darwin-x64@0.35.4':
1713+ optionalDependencies:
1714+ '@img/sharp-libvips-darwin-x64': 1.3.3
1715+ optional: true
1716+
1717+ '@img/sharp-freebsd-wasm32@0.35.4':
1718+ dependencies:
1719+ '@img/sharp-wasm32': 0.35.4
1720+ optional: true
1721+
1722+ '@img/sharp-libvips-darwin-arm64@1.3.3':
1723+ optional: true
1724+
1725+ '@img/sharp-libvips-darwin-x64@1.3.3':
1726+ optional: true
1727+
1728+ '@img/sharp-libvips-linux-arm64@1.3.3':
1729+ optional: true
1730+
1731+ '@img/sharp-libvips-linux-arm@1.3.3':
1732+ optional: true
1733+
1734+ '@img/sharp-libvips-linux-ppc64@1.3.3':
1735+ optional: true
1736+
1737+ '@img/sharp-libvips-linux-riscv64@1.3.3':
1738+ optional: true
1739+
1740+ '@img/sharp-libvips-linux-s390x@1.3.3':
1741+ optional: true
1742+
1743+ '@img/sharp-libvips-linux-x64@1.3.3':
1744+ optional: true
1745+
1746+ '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
1747+ optional: true
1748+
1749+ '@img/sharp-libvips-linuxmusl-x64@1.3.3':
1750+ optional: true
1751+
1752+ '@img/sharp-linux-arm64@0.35.4':
1753+ optionalDependencies:
1754+ '@img/sharp-libvips-linux-arm64': 1.3.3
1755+ optional: true
1756+
1757+ '@img/sharp-linux-arm@0.35.4':
1758+ optionalDependencies:
1759+ '@img/sharp-libvips-linux-arm': 1.3.3
1760+ optional: true
1761+
1762+ '@img/sharp-linux-ppc64@0.35.4':
1763+ optionalDependencies:
1764+ '@img/sharp-libvips-linux-ppc64': 1.3.3
1765+ optional: true
1766+
1767+ '@img/sharp-linux-riscv64@0.35.4':
1768+ optionalDependencies:
1769+ '@img/sharp-libvips-linux-riscv64': 1.3.3
1770+ optional: true
1771+
1772+ '@img/sharp-linux-s390x@0.35.4':
1773+ optionalDependencies:
1774+ '@img/sharp-libvips-linux-s390x': 1.3.3
1775+ optional: true
1776+
1777+ '@img/sharp-linux-x64@0.35.4':
1778+ optionalDependencies:
1779+ '@img/sharp-libvips-linux-x64': 1.3.3
1780+ optional: true
1781+
1782+ '@img/sharp-linuxmusl-arm64@0.35.4':
1783+ optionalDependencies:
1784+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
1785+ optional: true
1786+
1787+ '@img/sharp-linuxmusl-x64@0.35.4':
1788+ optionalDependencies:
1789+ '@img/sharp-libvips-linuxmusl-x64': 1.3.3
1790+ optional: true
1791+
1792+ '@img/sharp-wasm32@0.35.4':
1793+ dependencies:
1794+ '@emnapi/runtime': 1.11.3
1795+ optional: true
1796+
1797+ '@img/sharp-webcontainers-wasm32@0.35.4':
1798+ dependencies:
1799+ '@img/sharp-wasm32': 0.35.4
1800+ optional: true
1801+
1802+ '@img/sharp-win32-arm64@0.35.4':
1803+ optional: true
1804+
1805+ '@img/sharp-win32-ia32@0.35.4':
1806+ optional: true
1807+
1808+ '@img/sharp-win32-x64@0.35.4':
1809+ optional: true
1810+
1811+ '@jridgewell/gen-mapping@0.3.13':
1812+ dependencies:
1813+ '@jridgewell/sourcemap-codec': 1.6.0
1814+ '@jridgewell/trace-mapping': 0.3.31
1815+
1816+ '@jridgewell/remapping@2.3.5':
1817+ dependencies:
1818+ '@jridgewell/gen-mapping': 0.3.13
1819+ '@jridgewell/trace-mapping': 0.3.31
1820+
1821+ '@jridgewell/resolve-uri@3.1.2': {}
1822+
932 '@jridgewell/sourcemap-codec@1.6.0': {} 1823 '@jridgewell/sourcemap-codec@1.6.0': {}
933 1824
1825+ '@jridgewell/trace-mapping@0.3.31':
1826+ dependencies:
1827+ '@jridgewell/resolve-uri': 3.1.2
1828+ '@jridgewell/sourcemap-codec': 1.6.0
1829+
934 '@napi-rs/lzma-linux-x64-gnu@1.5.1': 1830 '@napi-rs/lzma-linux-x64-gnu@1.5.1':
935 optional: true 1831 optional: true
936 1832
1833+ '@next/env@16.3.4': {}
1834+
1835+ '@next/swc-darwin-arm64@16.3.4':
1836+ optional: true
1837+
1838+ '@next/swc-darwin-x64@16.3.4':
1839+ optional: true
1840+
1841+ '@next/swc-linux-arm64-gnu@16.3.4':
1842+ optional: true
1843+
1844+ '@next/swc-linux-arm64-musl@16.3.4':
1845+ optional: true
1846+
1847+ '@next/swc-linux-x64-gnu@16.3.4':
1848+ optional: true
1849+
1850+ '@next/swc-linux-x64-musl@16.3.4':
1851+ optional: true
1852+
1853+ '@next/swc-win32-arm64-msvc@16.3.4':
1854+ optional: true
1855+
1856+ '@next/swc-win32-x64-msvc@16.3.4':
1857+ optional: true
1858+
1859+ '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.2.8)(redux@5.0.1))(react@19.2.8)':
1860+ dependencies:
1861+ '@standard-schema/spec': 1.1.0
1862+ '@standard-schema/utils': 0.3.0
1863+ immer: 11.1.18
1864+ redux: 5.0.1
1865+ redux-thunk: 3.1.0(redux@5.0.1)
1866+ reselect: 5.2.0
1867+ optionalDependencies:
1868+ react: 19.2.8
1869+ react-redux: 9.3.0(@types/react@19.3.0)(react@19.2.8)(redux@5.0.1)
1870+
937 '@rollup/rollup-android-arm-eabi@4.63.1': 1871 '@rollup/rollup-android-arm-eabi@4.63.1':
938 optional: true 1872 optional: true
939 1873
@@ -1011,11 +1945,114 @@ snapshots:
1011 1945
1012 '@stablelib/base64@1.0.1': {} 1946 '@stablelib/base64@1.0.1': {}
1013 1947
1948+ '@standard-schema/spec@1.1.0': {}
1949+
1950+ '@standard-schema/utils@0.3.0': {}
1951+
1952+ '@swc/helpers@0.5.23':
1953+ dependencies:
1954+ tslib: 2.8.1
1955+
1956+ '@tailwindcss/node@4.3.3':
1957+ dependencies:
1958+ '@jridgewell/remapping': 2.3.5
1959+ enhanced-resolve: 5.24.5
1960+ jiti: 2.7.0
1961+ lightningcss: 1.32.0
1962+ magic-string: 0.30.21
1963+ source-map-js: 1.2.1
1964+ tailwindcss: 4.3.3
1965+
1966+ '@tailwindcss/oxide-android-arm64@4.3.3':
1967+ optional: true
1968+
1969+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
1970+ optional: true
1971+
1972+ '@tailwindcss/oxide-darwin-x64@4.3.3':
1973+ optional: true
1974+
1975+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
1976+ optional: true
1977+
1978+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
1979+ optional: true
1980+
1981+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
1982+ optional: true
1983+
1984+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
1985+ optional: true
1986+
1987+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
1988+ optional: true
1989+
1990+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
1991+ optional: true
1992+
1993+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
1994+ optional: true
1995+
1996+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
1997+ optional: true
1998+
1999+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
2000+ optional: true
2001+
2002+ '@tailwindcss/oxide@4.3.3':
2003+ optionalDependencies:
2004+ '@tailwindcss/oxide-android-arm64': 4.3.3
2005+ '@tailwindcss/oxide-darwin-arm64': 4.3.3
2006+ '@tailwindcss/oxide-darwin-x64': 4.3.3
2007+ '@tailwindcss/oxide-freebsd-x64': 4.3.3
2008+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
2009+ '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
2010+ '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
2011+ '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
2012+ '@tailwindcss/oxide-linux-x64-musl': 4.3.3
2013+ '@tailwindcss/oxide-wasm32-wasi': 4.3.3
2014+ '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
2015+ '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
2016+
2017+ '@tailwindcss/postcss@4.3.3':
2018+ dependencies:
2019+ '@alloc/quick-lru': 5.3.0
2020+ '@tailwindcss/node': 4.3.3
2021+ '@tailwindcss/oxide': 4.3.3
2022+ postcss: 8.5.28
2023+ tailwindcss: 4.3.3
2024+
1014 '@types/chai@5.2.3': 2025 '@types/chai@5.2.3':
1015 dependencies: 2026 dependencies:
1016 '@types/deep-eql': 4.0.2 2027 '@types/deep-eql': 4.0.2
1017 assertion-error: 2.0.1 2028 assertion-error: 2.0.1
1018 2029
2030+ '@types/d3-array@3.2.2': {}
2031+
2032+ '@types/d3-color@3.1.3': {}
2033+
2034+ '@types/d3-ease@3.0.2': {}
2035+
2036+ '@types/d3-force@3.0.10': {}
2037+
2038+ '@types/d3-interpolate@3.0.4':
2039+ dependencies:
2040+ '@types/d3-color': 3.1.3
2041+
2042+ '@types/d3-path@3.1.1': {}
2043+
2044+ '@types/d3-scale@4.0.9':
2045+ dependencies:
2046+ '@types/d3-time': 3.0.4
2047+
2048+ '@types/d3-shape@3.2.0':
2049+ dependencies:
2050+ '@types/d3-path': 3.1.1
2051+
2052+ '@types/d3-time@3.0.4': {}
2053+
2054+ '@types/d3-timer@3.0.2': {}
2055+
1019 '@types/deep-eql@4.0.2': {} 2056 '@types/deep-eql@4.0.2': {}
1020 2057
1021 '@types/estree@1.0.9': {} 2058 '@types/estree@1.0.9': {}
@@ -1030,6 +2067,16 @@ snapshots:
1030 pg-protocol: 1.16.0 2067 pg-protocol: 1.16.0
1031 pg-types: 2.2.0 2068 pg-types: 2.2.0
1032 2069
2070+ '@types/react-dom@19.3.0(@types/react@19.3.0)':
2071+ dependencies:
2072+ '@types/react': 19.3.0
2073+
2074+ '@types/react@19.3.0':
2075+ dependencies:
2076+ csstype: 3.2.3
2077+
2078+ '@types/use-sync-external-store@0.0.6': {}
2079+
1033 '@vitest/expect@3.2.7': 2080 '@vitest/expect@3.2.7':
1034 dependencies: 2081 dependencies:
1035 '@types/chai': 5.2.3 2082 '@types/chai': 5.2.3
@@ -1038,13 +2085,13 @@ snapshots:
1038 chai: 5.3.3 2085 chai: 5.3.3
1039 tinyrainbow: 2.0.0 2086 tinyrainbow: 2.0.0
1040 2087
1041− '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13))': 2088+ '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13))':
1042 dependencies: 2089 dependencies:
1043 '@vitest/spy': 3.2.7 2090 '@vitest/spy': 3.2.7
1044 estree-walker: 3.0.3 2091 estree-walker: 3.0.3
1045 magic-string: 0.30.21 2092 magic-string: 0.30.21
1046 optionalDependencies: 2093 optionalDependencies:
1047− vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13) 2094+ vite: 7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
1048 2095
1049 '@vitest/pretty-format@3.2.7': 2096 '@vitest/pretty-format@3.2.7':
1050 dependencies: 2097 dependencies:
@@ -1074,8 +2121,12 @@ snapshots:
1074 2121
1075 assertion-error@2.0.1: {} 2122 assertion-error@2.0.1: {}
1076 2123
2124+ baseline-browser-mapping@2.11.22: {}
2125+
1077 cac@6.7.14: {} 2126 cac@6.7.14: {}
1078 2127
2128+ caniuse-lite@1.0.30001810: {}
2129+
1079 chai@5.3.3: 2130 chai@5.3.3:
1080 dependencies: 2131 dependencies:
1081 assertion-error: 2.0.1 2132 assertion-error: 2.0.1
@@ -1086,14 +2137,79 @@ snapshots:
1086 2137
1087 check-error@2.1.3: {} 2138 check-error@2.1.3: {}
1088 2139
2140+ client-only@0.0.1: {}
2141+
2142+ clsx@2.1.1: {}
2143+
2144+ csstype@3.2.3: {}
2145+
2146+ d3-array@3.2.4:
2147+ dependencies:
2148+ internmap: 2.0.3
2149+
2150+ d3-color@3.1.0: {}
2151+
2152+ d3-dispatch@3.0.1: {}
2153+
2154+ d3-ease@3.0.1: {}
2155+
2156+ d3-force@3.0.0:
2157+ dependencies:
2158+ d3-dispatch: 3.0.1
2159+ d3-quadtree: 3.0.1
2160+ d3-timer: 3.0.1
2161+
2162+ d3-format@3.1.2: {}
2163+
2164+ d3-interpolate@3.0.1:
2165+ dependencies:
2166+ d3-color: 3.1.0
2167+
2168+ d3-path@3.1.0: {}
2169+
2170+ d3-quadtree@3.0.1: {}
2171+
2172+ d3-scale@4.0.2:
2173+ dependencies:
2174+ d3-array: 3.2.4
2175+ d3-format: 3.1.2
2176+ d3-interpolate: 3.0.1
2177+ d3-time: 3.1.0
2178+ d3-time-format: 4.1.0
2179+
2180+ d3-shape@3.2.0:
2181+ dependencies:
2182+ d3-path: 3.1.0
2183+
2184+ d3-time-format@4.1.0:
2185+ dependencies:
2186+ d3-time: 3.1.0
2187+
2188+ d3-time@3.1.0:
2189+ dependencies:
2190+ d3-array: 3.2.4
2191+
2192+ d3-timer@3.0.1: {}
2193+
1089 debug@4.4.3: 2194 debug@4.4.3:
1090 dependencies: 2195 dependencies:
1091 ms: 2.1.3 2196 ms: 2.1.3
1092 2197
2198+ decimal.js-light@2.5.1: {}
2199+
1093 deep-eql@5.0.2: {} 2200 deep-eql@5.0.2: {}
1094 2201
2202+ detect-libc@2.1.2: {}
2203+
2204+ enhanced-resolve@5.24.5:
2205+ dependencies:
2206+ graceful-fs: 4.2.11
2207+ tapable: 2.3.3
2208+
1095 es-module-lexer@1.7.0: {} 2209 es-module-lexer@1.7.0: {}
1096 2210
2211+ es-toolkit@1.52.0: {}
2212+
1097 esbuild@0.28.2: 2213 esbuild@0.28.2:
1098 optionalDependencies: 2214 optionalDependencies:
1099 '@esbuild/aix-ppc64': 0.28.2 2215 '@esbuild/aix-ppc64': 0.28.2
@@ -1127,6 +2243,8 @@ snapshots:
1127 dependencies: 2243 dependencies:
1128 '@types/estree': 1.0.9 2244 '@types/estree': 1.0.9
1129 2245
2246+ eventemitter3@5.0.4: {}
2247+
1130 expect-type@1.4.0: {} 2248 expect-type@1.4.0: {}
1131 2249
1132 fast-sha256@1.3.0: {} 2250 fast-sha256@1.3.0: {}
@@ -1135,9 +2253,26 @@ snapshots:
1135 optionalDependencies: 2253 optionalDependencies:
1136 picomatch: 4.0.7 2254 picomatch: 4.0.7
1137 2255
2256+ framer-motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
2257+ dependencies:
2258+ motion-dom: 12.43.0
2259+ motion-utils: 12.39.0
2260+ tslib: 2.8.1
2261+ optionalDependencies:
2262+ react: 19.2.8
2263+ react-dom: 19.2.8(react@19.2.8)
2264+
1138 fsevents@2.3.3: 2265 fsevents@2.3.3:
1139 optional: true 2266 optional: true
1140 2267
2268+ graceful-fs@4.2.11: {}
2269+
2270+ immer@11.1.18: {}
2271+
2272+ internmap@2.0.3: {}
2273+
2274+ jiti@2.7.0: {}
2275+
1141 js-tokens@9.0.1: {} 2276 js-tokens@9.0.1: {}
1142 2277
1143 json-schema-to-ts@3.1.1: 2278 json-schema-to-ts@3.1.1:
@@ -1145,16 +2280,100 @@ snapshots:
1145 '@babel/runtime': 7.29.7 2280 '@babel/runtime': 7.29.7
1146 ts-algebra: 2.0.0 2281 ts-algebra: 2.0.0
1147 2282
2283+ lightningcss-android-arm64@1.32.0:
2284+ optional: true
2285+
2286+ lightningcss-darwin-arm64@1.32.0:
2287+ optional: true
2288+
2289+ lightningcss-darwin-x64@1.32.0:
2290+ optional: true
2291+
2292+ lightningcss-freebsd-x64@1.32.0:
2293+ optional: true
2294+
2295+ lightningcss-linux-arm-gnueabihf@1.32.0:
2296+ optional: true
2297+
2298+ lightningcss-linux-arm64-gnu@1.32.0:
2299+ optional: true
2300+
2301+ lightningcss-linux-arm64-musl@1.32.0:
2302+ optional: true
2303+
2304+ lightningcss-linux-x64-gnu@1.32.0:
2305+ optional: true
2306+
2307+ lightningcss-linux-x64-musl@1.32.0:
2308+ optional: true
2309+
2310+ lightningcss-win32-arm64-msvc@1.32.0:
2311+ optional: true
2312+
2313+ lightningcss-win32-x64-msvc@1.32.0:
2314+ optional: true
2315+
2316+ lightningcss@1.32.0:
2317+ dependencies:
2318+ detect-libc: 2.1.2
2319+ optionalDependencies:
2320+ lightningcss-android-arm64: 1.32.0
2321+ lightningcss-darwin-arm64: 1.32.0
2322+ lightningcss-darwin-x64: 1.32.0
2323+ lightningcss-freebsd-x64: 1.32.0
2324+ lightningcss-linux-arm-gnueabihf: 1.32.0
2325+ lightningcss-linux-arm64-gnu: 1.32.0
2326+ lightningcss-linux-arm64-musl: 1.32.0
2327+ lightningcss-linux-x64-gnu: 1.32.0
2328+ lightningcss-linux-x64-musl: 1.32.0
2329+ lightningcss-win32-arm64-msvc: 1.32.0
2330+ lightningcss-win32-x64-msvc: 1.32.0
2331+
1148 loupe@3.2.1: {} 2332 loupe@3.2.1: {}
1149 2333
2334+ lucide-react@1.44.0(react@19.2.8):
2335+ dependencies:
2336+ react: 19.2.8
2337+
1150 magic-string@0.30.21: 2338 magic-string@0.30.21:
1151 dependencies: 2339 dependencies:
1152 '@jridgewell/sourcemap-codec': 1.6.0 2340 '@jridgewell/sourcemap-codec': 1.6.0
1153 2341
2342+ motion-dom@12.43.0:
2343+ dependencies:
2344+ motion-utils: 12.39.0
2345+
2346+ motion-utils@12.39.0: {}
2347+
1154 ms@2.1.3: {} 2348 ms@2.1.3: {}
1155 2349
1156 nanoid@3.3.19: {} 2350 nanoid@3.3.19: {}
1157 2351
2352+ next@16.3.4(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
2353+ dependencies:
2354+ '@next/env': 16.3.4
2355+ '@swc/helpers': 0.5.23
2356+ baseline-browser-mapping: 2.11.22
2357+ caniuse-lite: 1.0.30001810
2358+ postcss: 8.5.23
2359+ react: 19.2.8
2360+ react-dom: 19.2.8(react@19.2.8)
2361+ styled-jsx: 5.1.6(react@19.2.8)
2362+ optionalDependencies:
2363+ '@next/swc-darwin-arm64': 16.3.4
2364+ '@next/swc-darwin-x64': 16.3.4
2365+ '@next/swc-linux-arm64-gnu': 16.3.4
2366+ '@next/swc-linux-arm64-musl': 16.3.4
2367+ '@next/swc-linux-x64-gnu': 16.3.4
2368+ '@next/swc-linux-x64-musl': 16.3.4
2369+ '@next/swc-win32-arm64-msvc': 16.3.4
2370+ '@next/swc-win32-x64-msvc': 16.3.4
2371+ sharp: 0.35.4(@types/node@24.13.4)
2372+ transitivePeerDependencies:
2373+ - '@babel/core'
2374+ - '@types/node'
2375+ - babel-plugin-macros
2376+
1158 pathe@2.0.3: {} 2377 pathe@2.0.3: {}
1159 2378
1160 pathval@2.0.1: {} 2379 pathval@2.0.1: {}
@@ -1204,6 +2423,12 @@ snapshots:
1204 dependencies: 2423 dependencies:
1205 playwright-core: 1.63.0 2424 playwright-core: 1.63.0
1206 2425
2426+ postcss@8.5.23:
2427+ dependencies:
2428+ nanoid: 3.3.19
2429+ picocolors: 1.1.1
2430+ source-map-js: 1.2.1
2431+
1207 postcss@8.5.28: 2432 postcss@8.5.28:
1208 dependencies: 2433 dependencies:
1209 nanoid: 3.3.19 2434 nanoid: 3.3.19
@@ -1220,6 +2445,52 @@ snapshots:
1220 dependencies: 2445 dependencies:
1221 xtend: 4.0.2 2446 xtend: 4.0.2
1222 2447
2448+ react-dom@19.2.8(react@19.2.8):
2449+ dependencies:
2450+ react: 19.2.8
2451+ scheduler: 0.27.0
2452+
2453+ react-is@19.3.0: {}
2454+
2455+ react-redux@9.3.0(@types/react@19.3.0)(react@19.2.8)(redux@5.0.1):
2456+ dependencies:
2457+ '@types/use-sync-external-store': 0.0.6
2458+ react: 19.2.8
2459+ use-sync-external-store: 1.7.0(react@19.2.8)
2460+ optionalDependencies:
2461+ '@types/react': 19.3.0
2462+ redux: 5.0.1
2463+
2464+ react@19.2.8: {}
2465+
2466+ recharts@3.10.1(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1):
2467+ dependencies:
2468+ '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.2.8)(redux@5.0.1))(react@19.2.8)
2469+ clsx: 2.1.1
2470+ decimal.js-light: 2.5.1
2471+ es-toolkit: 1.52.0
2472+ eventemitter3: 5.0.4
2473+ immer: 11.1.18
2474+ react: 19.2.8
2475+ react-dom: 19.2.8(react@19.2.8)
2476+ react-is: 19.3.0
2477+ react-redux: 9.3.0(@types/react@19.3.0)(react@19.2.8)(redux@5.0.1)
2478+ reselect: 5.2.0
2479+ tiny-invariant: 1.3.3
2480+ use-sync-external-store: 1.7.0(react@19.2.8)
2481+ victory-vendor: 37.3.6
2482+ transitivePeerDependencies:
2483+ - '@types/react'
2484+ - redux
2485+
2486+ redux-thunk@3.1.0(redux@5.0.1):
2487+ dependencies:
2488+ redux: 5.0.1
2489+
2490+ redux@5.0.1: {}
2491+
2492+ reselect@5.2.0: {}
2493+
1223 rollup@4.63.1: 2494 rollup@4.63.1:
1224 dependencies: 2495 dependencies:
1225 '@types/estree': 1.0.9 2496 '@types/estree': 1.0.9
@@ -1252,6 +2523,45 @@ snapshots:
1252 '@rollup/rollup-win32-x64-msvc': 4.63.1 2523 '@rollup/rollup-win32-x64-msvc': 4.63.1
1253 fsevents: 2.3.3 2524 fsevents: 2.3.3
1254 2525
2526+ scheduler@0.27.0: {}
2527+
2528+ semver@7.8.5:
2529+ optional: true
2530+
2531+ sharp@0.35.4(@types/node@24.13.4):
2532+ dependencies:
2533+ '@img/colour': 1.1.0
2534+ detect-libc: 2.1.2
2535+ semver: 7.8.5
2536+ optionalDependencies:
2537+ '@img/sharp-darwin-arm64': 0.35.4
2538+ '@img/sharp-darwin-x64': 0.35.4
2539+ '@img/sharp-freebsd-wasm32': 0.35.4
2540+ '@img/sharp-libvips-darwin-arm64': 1.3.3
2541+ '@img/sharp-libvips-darwin-x64': 1.3.3
2542+ '@img/sharp-libvips-linux-arm': 1.3.3
2543+ '@img/sharp-libvips-linux-arm64': 1.3.3
2544+ '@img/sharp-libvips-linux-ppc64': 1.3.3
2545+ '@img/sharp-libvips-linux-riscv64': 1.3.3
2546+ '@img/sharp-libvips-linux-s390x': 1.3.3
2547+ '@img/sharp-libvips-linux-x64': 1.3.3
2548+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
2549+ '@img/sharp-libvips-linuxmusl-x64': 1.3.3
2550+ '@img/sharp-linux-arm': 0.35.4
2551+ '@img/sharp-linux-arm64': 0.35.4
2552+ '@img/sharp-linux-ppc64': 0.35.4
2553+ '@img/sharp-linux-riscv64': 0.35.4
2554+ '@img/sharp-linux-s390x': 0.35.4
2555+ '@img/sharp-linux-x64': 0.35.4
2556+ '@img/sharp-linuxmusl-arm64': 0.35.4
2557+ '@img/sharp-linuxmusl-x64': 0.35.4
2558+ '@img/sharp-webcontainers-wasm32': 0.35.4
2559+ '@img/sharp-win32-arm64': 0.35.4
2560+ '@img/sharp-win32-ia32': 0.35.4
2561+ '@img/sharp-win32-x64': 0.35.4
2562+ '@types/node': 24.13.4
2563+ optional: true
2564+
1255 siginfo@2.0.0: {} 2565 siginfo@2.0.0: {}
1256 2566
1257 source-map-js@1.2.1: {} 2567 source-map-js@1.2.1: {}
@@ -1271,6 +2581,17 @@ snapshots:
1271 dependencies: 2581 dependencies:
1272 js-tokens: 9.0.1 2582 js-tokens: 9.0.1
1273 2583
2584+ styled-jsx@5.1.6(react@19.2.8):
2585+ dependencies:
2586+ client-only: 0.0.1
2587+ react: 19.2.8
2588+
2589+ tailwindcss@4.3.3: {}
2590+
2591+ tapable@2.3.3: {}
2592+
2593+ tiny-invariant@1.3.3: {}
2594+
1274 tinybench@2.9.0: {} 2595 tinybench@2.9.0: {}
1275 2596
1276 tinyexec@0.3.2: {} 2597 tinyexec@0.3.2: {}
@@ -1288,6 +2609,8 @@ snapshots:
1288 2609
1289 ts-algebra@2.0.0: {} 2610 ts-algebra@2.0.0: {}
1290 2611
2612+ tslib@2.8.1: {}
2613+
1291 tsx@4.23.13: 2614 tsx@4.23.13:
1292 dependencies: 2615 dependencies:
1293 esbuild: 0.28.2 2616 esbuild: 0.28.2
@@ -1298,13 +2621,34 @@ snapshots:
1298 2621
1299 undici-types@7.18.2: {} 2622 undici-types@7.18.2: {}
1300 2623
1301− vite-node@3.2.4(@types/node@24.13.4)(tsx@4.23.13): 2624+ use-sync-external-store@1.7.0(react@19.2.8):
2625+ dependencies:
2626+ react: 19.2.8
2627+
2628+ victory-vendor@37.3.6:
2629+ dependencies:
2630+ '@types/d3-array': 3.2.2
2631+ '@types/d3-ease': 3.0.2
2632+ '@types/d3-interpolate': 3.0.4
2633+ '@types/d3-scale': 4.0.9
2634+ '@types/d3-shape': 3.2.0
2635+ '@types/d3-time': 3.0.4
2636+ '@types/d3-timer': 3.0.2
2637+ d3-array: 3.2.4
2638+ d3-ease: 3.0.1
2639+ d3-interpolate: 3.0.1
2640+ d3-scale: 4.0.2
2641+ d3-shape: 3.2.0
2642+ d3-time: 3.1.0
2643+ d3-timer: 3.0.1
2644+
2645+ vite-node@3.2.4(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13):
1302 dependencies: 2646 dependencies:
1303 cac: 6.7.14 2647 cac: 6.7.14
1304 debug: 4.4.3 2648 debug: 4.4.3
1305 es-module-lexer: 1.7.0 2649 es-module-lexer: 1.7.0
1306 pathe: 2.0.3 2650 pathe: 2.0.3
1307− vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13) 2651+ vite: 7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
1308 transitivePeerDependencies: 2652 transitivePeerDependencies:
1309 - '@types/node' 2653 - '@types/node'
1310 - jiti 2654 - jiti
@@ -1319,7 +2663,7 @@ snapshots:
1319 - tsx 2663 - tsx
1320 - yaml 2664 - yaml
1321 2665
1322− vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13): 2666+ vite@7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13):
1323 dependencies: 2667 dependencies:
1324 esbuild: 0.28.2 2668 esbuild: 0.28.2
1325 fdir: 6.5.0(picomatch@4.0.7) 2669 fdir: 6.5.0(picomatch@4.0.7)
@@ -1330,13 +2674,15 @@ snapshots:
1330 optionalDependencies: 2674 optionalDependencies:
1331 '@types/node': 24.13.4 2675 '@types/node': 24.13.4
1332 fsevents: 2.3.3 2676 fsevents: 2.3.3
2677+ jiti: 2.7.0
2678+ lightningcss: 1.32.0
1333 tsx: 4.23.13 2679 tsx: 4.23.13
1334 2680
1335− vitest@3.2.7(@types/node@24.13.4)(tsx@4.23.13): 2681+ vitest@3.2.7(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13):
1336 dependencies: 2682 dependencies:
1337 '@types/chai': 5.2.3 2683 '@types/chai': 5.2.3
1338 '@vitest/expect': 3.2.7 2684 '@vitest/expect': 3.2.7
1339− '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13)) 2685+ '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13))
1340 '@vitest/pretty-format': 3.2.7 2686 '@vitest/pretty-format': 3.2.7
1341 '@vitest/runner': 3.2.7 2687 '@vitest/runner': 3.2.7
1342 '@vitest/snapshot': 3.2.7 2688 '@vitest/snapshot': 3.2.7
@@ -1354,8 +2700,8 @@ snapshots:
1354 tinyglobby: 0.2.17 2700 tinyglobby: 0.2.17
1355 tinypool: 1.1.1 2701 tinypool: 1.1.1
1356 tinyrainbow: 2.0.0 2702 tinyrainbow: 2.0.0
1357− vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13) 2703+ vite: 7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
1358− vite-node: 3.2.4(@types/node@24.13.4)(tsx@4.23.13) 2704+ vite-node: 3.2.4(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
1359 why-is-node-running: 2.3.0 2705 why-is-node-running: 2.3.0
1360 optionalDependencies: 2706 optionalDependencies:
1361 '@types/node': 24.13.4 2707 '@types/node': 24.13.4
modified tsconfig.json +1 −1
@@ -30,5 +30,5 @@
30 } 30 }
31 }, 31 },
32 "include": ["apps/**/*.ts", "packages/**/*.ts", "scripts/**/*.ts"], 32 "include": ["apps/**/*.ts", "packages/**/*.ts", "scripts/**/*.ts"],
33− "exclude": ["node_modules", "**/node_modules", "data", "connectors"] 33+ "exclude": ["node_modules", "**/node_modules", "data", "connectors", "apps/dashboard"]
34 } 34 }
35 35
Menu

Theme