From e7328bd9b7d702c7e84fc415d4079da7d2745e5a Mon Sep 17 00:00:00 2001 From: Brooke Date: Sat, 01 Aug 2026 19:12:20 +0000 Subject: [PATCH] add crawl for specific relay --- web/vite.config.ts | 2 +- web/src/RequestCrawl.tsx | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- web/src/api.ts | 6 +++++- web/src/style.css | 46 +++++++++++++++++++++++++++++++++++++++++++++- server/src/routes/status.ts | 24 ++++++++++++++++++++---- 5 file(s) changed, 167 insertion(s)(+), 16 deletion(s)(-) diff --git a/web/vite.config.ts b/web/vite.config.ts --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -5,7 +5,7 @@ plugins: [react()], server: { proxy: { - "/api": "http://localhost:8788", + "/api": `http://localhost:${process.env.API_PORT ?? 8788}`, }, }, }); diff --git a/web/src/RequestCrawl.tsx b/web/src/RequestCrawl.tsx --- a/web/src/RequestCrawl.tsx +++ b/web/src/RequestCrawl.tsx @@ -1,10 +1,10 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { api } from "./api.js"; type CrawlState = | { state: "idle" } | { state: "requesting" } - | { state: "done" } + | { state: "done"; relay?: string } | { state: "failed"; message: string }; export function RequestCrawl({ @@ -15,12 +15,35 @@ onRequested?: () => void; }) { const [crawl, setCrawl] = useState({ state: "idle" }); + const [menuOpen, setMenuOpen] = useState(false); + const [showInput, setShowInput] = useState(false); + const [relayInput, setRelayInput] = useState(""); + const inputRef = useRef(null); - const handleRequestCrawl = async () => { + useEffect(() => { + if (!menuOpen) return; + const close = (e: MouseEvent | KeyboardEvent) => { + if (e instanceof KeyboardEvent && e.key !== "Escape") return; + setMenuOpen(false); + }; + document.addEventListener("click", close); + document.addEventListener("keydown", close); + return () => { + document.removeEventListener("click", close); + document.removeEventListener("keydown", close); + }; + }, [menuOpen]); + + useEffect(() => { + if (showInput) inputRef.current?.focus(); + }, [showInput]); + + const handleRequestCrawl = async (relay?: string) => { + setMenuOpen(false); setCrawl({ state: "requesting" }); try { - await api.requestCrawl(); - setCrawl({ state: "done" }); + await api.requestCrawl(relay); + setCrawl({ state: "done", relay }); onRequested?.(); setTimeout(() => setCrawl({ state: "idle" }), 8000); } catch (e) { @@ -30,12 +53,76 @@ return (
- +
e.stopPropagation()}> + + + {menuOpen && ( +
+ {showInput ? ( +
{ + e.preventDefault(); + const relay = relayInput.trim(); + if (relay) handleRequestCrawl(relay); + }} + > + setRelayInput(e.target.value)} + placeholder="relay.example.com" + aria-label="relay hostname" + spellCheck={false} + autoCapitalize="none" + /> + +
+ ) : ( + + )} +
+ )} +
{crawl.state === "done" && ( - crawl requested. the relay status will refresh shortly. + {crawl.relay ? `crawl requested from ${crawl.relay}.` : "crawl requested."} the relay + status will refresh shortly. )} {crawl.state === "failed" && ( diff --git a/web/src/api.ts b/web/src/api.ts --- a/web/src/api.ts +++ b/web/src/api.ts @@ -79,7 +79,11 @@ enable: (did: string) => req<{ ok: true }>(`/api/accounts/${encodeURIComponent(did)}/enable`, { method: "POST" }), resetPassword: (did: string) => req<{ ok: true; password: string }>(`/api/accounts/${encodeURIComponent(did)}/reset-password`, { method: "POST" }), cursorStatus: () => req("/api/status/cursor"), - requestCrawl: () => req<{ ok: true }>("/api/status/request-crawl", { method: "POST" }), + requestCrawl: (relay?: string) => + req<{ ok: true }>("/api/status/request-crawl", { + method: "POST", + body: relay ? JSON.stringify({ relay }) : undefined, + }), passkeyRegisterOptions: (enrollToken?: string) => req("/api/passkeys/options", { method: "POST", body: JSON.stringify({ enrollToken }) }), passkeyRegister: (name: string, response: unknown, enrollToken?: string) => diff --git a/web/src/style.css b/web/src/style.css --- a/web/src/style.css +++ b/web/src/style.css @@ -209,6 +209,45 @@ margin-top: 12px; } +/* request crawl is a split button: main action + chevron sharing one outline. + .menu-wrap sets inline-block later in the file, so match its specificity */ +.menu-wrap.crawl-split { + display: inline-flex; +} + +.crawl-split > button:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: none; +} + +.crawl-split > button.chevron { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + padding: 0; + width: 35px; /* button height: 21px line + 12px padding + 2px border */ + display: inline-flex; + align-items: center; + justify-content: center; +} + +.crawl-split .menu .relay-form { + display: flex; + gap: 6px; + padding: 4px; +} + +.crawl-split .menu .relay-form input { + width: 170px; + min-width: 0; + padding: 4px 8px; +} + +.crawl-split .menu .relay-form button { + border: 1px solid var(--border); + text-align: center; +} + .stat { display: flex; flex-direction: column; @@ -796,8 +835,13 @@ font-size: 14px; } - .crawl-row button { + .crawl-row .crawl-split { + display: flex; width: 100%; + } + + .crawl-row .crawl-split > button:first-child { + flex: 1; } .accounts-toolbar { diff --git a/server/src/routes/status.ts b/server/src/routes/status.ts --- a/server/src/routes/status.ts +++ b/server/src/routes/status.ts @@ -1,9 +1,11 @@ import type { FastifyInstance } from "fastify"; import type { PdsClient } from "../pdsClient.js"; -import type { RelayClient } from "../relayClient.js"; +import { RelayClient } from "../relayClient.js"; import { getSyncState, type Db } from "../db.js"; import { requireAuth } from "../auth.js"; import { recordAction } from "../auditLog.js"; + +const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i; export function registerStatusRoutes( app: FastifyInstance, @@ -26,9 +28,23 @@ }; }); - app.post("/api/status/request-crawl", { preHandler: requireAuth }, async (req) => { - await relay.requestCrawl(pdsHostname); - await recordAction({ operator: req.session.operator!, action: "request-crawl", target: pdsHostname }); + app.post("/api/status/request-crawl", { preHandler: requireAuth }, async (req, reply) => { + const raw = (req.body as { relay?: string } | null)?.relay?.trim(); + let target = relay; + let relayHostname: string | undefined; + if (raw) { + relayHostname = raw.replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + if (!HOSTNAME_RE.test(relayHostname)) { + return reply.code(400).send({ error: "invalid relay hostname" }); + } + target = new RelayClient(relayHostname); + } + await target.requestCrawl(pdsHostname); + await recordAction({ + operator: req.session.operator!, + action: "request-crawl", + target: relayHostname ? `${pdsHostname} via ${relayHostname}` : pdsHostname, + }); return { ok: true }; }); } -- tangled.sh