From 0c07ce073d79f83a77f728d5b77cc88f2884b167 Mon Sep 17 00:00:00 2001 From: Andraxion Date: Sat, 27 Jun 2026 00:38:21 -0400 Subject: [PATCH] Add request board admin panel --- server.js | 21 ++ src/WorldshaperLauncher.tsx | 484 ++++++++++++++++++++++++++++++++---- src/index.css | 239 +++++++++++++++++- 3 files changed, 695 insertions(+), 49 deletions(-) diff --git a/server.js b/server.js index 83c114e..048f59e 100644 --- a/server.js +++ b/server.js @@ -682,6 +682,13 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") { stdio: ["ignore", "pipe", "pipe"], }); requestAnalysisRunState.child = child; + recordSaveEvent({ + type: "launcher-request-analysis-launch", + provider, + reason, + queuedPendingCount, + pid: child.pid, + }); child.stdout?.on("data", (chunk) => { const text = String(chunk || "").trim(); if (text) { @@ -697,6 +704,14 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") { child.on("exit", (code, signal) => { requestAnalysisRunState.child = null; console.log(`[request-analysis] worker finished code=${String(code)} signal=${String(signal || "")} reason=${reason}`); + recordSaveEvent({ + type: "launcher-request-analysis-finish", + provider, + reason, + queuedPendingCount: getQueuedPendingLauncherRequestCount(), + code: Number.isFinite(Number(code)) ? Number(code) : null, + signal: signal || "", + }); if (getQueuedPendingLauncherRequestCount() > 0) { scheduleQueuedRequestAnalysis("drain-pending-requests", 1200); } @@ -704,6 +719,12 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") { child.on("error", (error) => { requestAnalysisRunState.child = null; console.error(`[request-analysis] worker launch failed: ${String(error)}`); + recordSaveEvent({ + type: "launcher-request-analysis-launch-error", + provider, + reason, + error: String(error), + }); }); console.log(`[request-analysis] launched provider=${provider} queuedPending=${queuedPendingCount} reason=${reason}`); return { diff --git a/src/WorldshaperLauncher.tsx b/src/WorldshaperLauncher.tsx index da5c96e..3fd7b57 100644 --- a/src/WorldshaperLauncher.tsx +++ b/src/WorldshaperLauncher.tsx @@ -31,6 +31,31 @@ type LauncherRequest = { sourceText: string; summary: string; implementationNotes: string; + analysis?: { + state?: "unprocessed" | "processing" | "processed" | "needs_review" | "error"; + confidence?: number | null; + model?: string; + createdAt?: string; + updatedAt?: string; + error?: string; + submissionId?: string; + sourceTextSnapshot?: string; + itemCount?: number; + items?: Array<{ + title?: string; + primaryCategory?: string; + tags?: string[]; + statusRecommendation?: string; + parsedInterpretation?: string; + implementationApproach?: string; + affectedSystems?: string[]; + affectedFiles?: string[]; + problemType?: string; + rawExcerpt?: string; + confidence?: number | null; + notes?: string; + }>; + }; createdAt: string; updatedAt: string; }; @@ -39,6 +64,37 @@ type LauncherRequestsPayload = { requests?: LauncherRequest[]; }; +type RecentSaveEvent = { + at?: string; + type?: string; + requestId?: string; + textPreview?: string; + status?: string; + category?: string; + itemCount?: number; + model?: string; + reason?: string; + provider?: string; + pid?: number; + code?: number | null; + signal?: string; + error?: string; +}; + +type RecentSaveEventsPayload = { + saves?: RecentSaveEvent[]; +}; + +type ProcessPendingPayload = { + ok?: boolean; + launched?: boolean; + reason?: string; + autorunEnabled?: boolean; + configured?: boolean; + queuedPendingCount?: number; + pid?: number; +}; + const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld"; async function resolveDefaultWorldId(): Promise { @@ -83,6 +139,123 @@ function formatRequestStatusLabel(status: "pending" | "active"): string { return status === "active" ? "Active" : "Pending"; } +function normalizeAnalysisState(value: string | undefined): string { + return String(value || "").trim().toLowerCase(); +} + +function formatAnalysisStateLabel(value: string | undefined): string { + const normalized = normalizeAnalysisState(value); + if (normalized === "processing") { + return "Processing"; + } + if (normalized === "processed") { + return "Processed"; + } + if (normalized === "needs_review") { + return "Needs Review"; + } + if (normalized === "error") { + return "Error"; + } + return "Unprocessed"; +} + +function getRequestDisplayStateLabel(requestEntry: LauncherRequest): string { + if (requestEntry.status === "active") { + return "Active"; + } + const analysisState = normalizeAnalysisState(requestEntry.analysis?.state); + if (!analysisState || analysisState === "unprocessed") { + return "Queued"; + } + if (analysisState === "needs_review") { + return "Needs Review"; + } + if (analysisState === "error") { + return "Analysis Error"; + } + if (analysisState === "processed") { + return "Reviewed"; + } + return formatAnalysisStateLabel(analysisState); +} + +function getRequestDisplayStateClassName(requestEntry: LauncherRequest): string { + if (requestEntry.status === "active") { + return "active"; + } + const analysisState = normalizeAnalysisState(requestEntry.analysis?.state); + if (!analysisState || analysisState === "unprocessed") { + return "queued"; + } + if (analysisState === "needs_review") { + return "needs-review"; + } + if (analysisState === "error") { + return "error"; + } + if (analysisState === "processed") { + return "processed"; + } + if (analysisState === "processing") { + return "processing"; + } + return "pending"; +} + +function isQueuedPendingRequest(requestEntry: LauncherRequest): boolean { + const analysisState = normalizeAnalysisState(requestEntry.analysis?.state); + return requestEntry.status === "pending" && (!analysisState || analysisState === "unprocessed" || analysisState === "processing"); +} + +function isNeedsReviewRequest(requestEntry: LauncherRequest): boolean { + const analysisState = normalizeAnalysisState(requestEntry.analysis?.state); + return requestEntry.status === "pending" && (analysisState === "needs_review" || analysisState === "error"); +} + +function formatEventLabel(event: RecentSaveEvent): string { + switch (String(event.type || "").trim()) { + case "launcher-request-add": + return "Request submitted"; + case "launcher-request-delete": + return "Request deleted"; + case "launcher-request-update": + return "Request updated"; + case "launcher-request-review": + return "Analysis saved for review"; + case "launcher-request-promote": + return "Pending request promoted"; + case "launcher-request-analysis-error": + return "Analysis failed"; + case "launcher-request-analysis-launch": + return "Queue worker launched"; + case "launcher-request-analysis-finish": + return "Queue worker finished"; + case "launcher-request-analysis-launch-error": + return "Queue worker launch error"; + default: + return String(event.type || "Event"); + } +} + +function formatEventDetail(event: RecentSaveEvent): string { + const parts = [ + event.requestId ? `Request ${event.requestId}` : "", + event.category ? `Category ${event.category}` : "", + event.status ? `Status ${event.status}` : "", + event.itemCount ? `${event.itemCount} item${event.itemCount === 1 ? "" : "s"}` : "", + event.provider ? `Provider ${event.provider}` : "", + event.model ? `Model ${event.model}` : "", + event.reason ? `Reason ${event.reason}` : "", + event.pid ? `PID ${event.pid}` : "", + Number.isFinite(Number(event.code)) ? `Exit ${event.code}` : "", + event.signal ? `Signal ${event.signal}` : "", + event.error ? String(event.error) : "", + event.textPreview ? `Preview: ${event.textPreview}` : "", + ].filter(Boolean); + return parts.join(" • "); +} + function openStudioPopup(worldId: string): boolean { const popup = openWorldshaperStudioWindow(worldId, window, { worldId }); return Boolean(popup); @@ -107,6 +280,12 @@ function WorldshaperLauncher() { const [requestMutatingId, setRequestMutatingId] = useState(""); const [requestFilter, setRequestFilter] = useState("all"); const [expandedRequestIds, setExpandedRequestIds] = useState([]); + const [adminPanelOpen, setAdminPanelOpen] = useState(false); + const [recentSaveEvents, setRecentSaveEvents] = useState([]); + const [logsLoading, setLogsLoading] = useState(false); + const [logsError, setLogsError] = useState(""); + const [queueTriggering, setQueueTriggering] = useState(false); + const [adminNotice, setAdminNotice] = useState(""); useEffect(() => { let cancelled = false; @@ -129,36 +308,89 @@ function WorldshaperLauncher() { }; }, []); - useEffect(() => { - let cancelled = false; - - async function loadRequests() { + async function loadRequests(options?: { silent?: boolean }): Promise { + const silent = options?.silent === true; + if (!silent) { setRequestsLoading(true); - try { - const payload = await fetchJsonOrThrow("/api/launcher-requests"); - if (cancelled) { - return; - } - setRequests(Array.isArray(payload.requests) ? payload.requests : []); - setRequestsError(""); - } catch (nextError: unknown) { - if (cancelled) { - return; - } - setRequestsError(String(nextError || "Failed to load requests.")); - } finally { - if (!cancelled) { - setRequestsLoading(false); - } + } + try { + const payload = await fetchJsonOrThrow("/api/launcher-requests"); + setRequests(Array.isArray(payload.requests) ? payload.requests : []); + setRequestsError(""); + } catch (nextError: unknown) { + setRequestsError(String(nextError || "Failed to load requests.")); + } finally { + if (!silent) { + setRequestsLoading(false); } } + } + async function loadRecentSaveEvents(): Promise { + setLogsLoading(true); + try { + const payload = await fetchJsonOrThrow("/api/debug/recent-saves"); + setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []); + setLogsError(""); + } catch (nextError: unknown) { + setLogsError(String(nextError || "Failed to load admin logs.")); + } finally { + setLogsLoading(false); + } + } + + async function refreshAdminData(options?: { includeLogs?: boolean; silentRequests?: boolean }): Promise { + await loadRequests({ silent: options?.silentRequests === true }); + if (options?.includeLogs) { + await loadRecentSaveEvents(); + } + } + + useEffect(() => { void loadRequests(); + }, []); + useEffect(() => { + if (!adminPanelOpen) { + return; + } + void loadRecentSaveEvents(); + }, [adminPanelOpen]); + + useEffect(() => { + if (activeBoardTab !== "requests") { + return; + } + let cancelled = false; + const refreshBoard = async (): Promise => { + try { + const payload = await fetchJsonOrThrow("/api/launcher-requests"); + if (!cancelled) { + setRequests(Array.isArray(payload.requests) ? payload.requests : []); + } + } catch { + // Keep the current list visible during background refresh failures. + } + if (!adminPanelOpen) { + return; + } + try { + const payload = await fetchJsonOrThrow("/api/debug/recent-saves"); + if (!cancelled) { + setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []); + } + } catch { + // Avoid surfacing noisy polling failures in the admin panel. + } + }; + const intervalId = window.setInterval(() => { + void refreshBoard(); + }, 15000); return () => { cancelled = true; + window.clearInterval(intervalId); }; - }, []); + }, [activeBoardTab, adminPanelOpen]); async function handleLaunch(): Promise { setError(""); @@ -203,6 +435,13 @@ function WorldshaperLauncher() { setRequestDraft(""); setRequestDraftOpen(false); setRequestsError(""); + setAdminNotice("Request saved. The VPS queue worker will pick it up if analysis autorun is enabled."); + if (adminPanelOpen) { + void loadRecentSaveEvents(); + } + window.setTimeout(() => { + void refreshAdminData({ includeLogs: adminPanelOpen, silentRequests: true }); + }, 3500); } catch (nextError: unknown) { setRequestsError(String(nextError || "Failed to save request.")); } finally { @@ -231,6 +470,10 @@ function WorldshaperLauncher() { setRequests(Array.isArray(payload.requests) ? payload.requests : []); setRequestsError(""); setExpandedRequestIds((current) => current.filter((entry) => entry !== requestEntry.id)); + setAdminNotice(`Deleted request "${requestEntry.title}".`); + if (adminPanelOpen) { + void loadRecentSaveEvents(); + } } catch (nextError: unknown) { setRequestsError(String(nextError || "Failed to delete request.")); } finally { @@ -238,10 +481,45 @@ function WorldshaperLauncher() { } } + async function handleProcessPendingQueue(): Promise { + setQueueTriggering(true); + try { + const payload = await fetchJsonOrThrow("/api/launcher-requests/process-pending", { + method: "POST", + }); + if (payload.launched) { + setAdminNotice(`Queue worker launched for ${payload.queuedPendingCount ?? 0} pending request${payload.queuedPendingCount === 1 ? "" : "s"}.`); + } else { + const reason = String(payload.reason || "no-op"); + if (reason === "no-pending-requests") { + setAdminNotice("No unprocessed pending requests are waiting in the queue."); + } else if (reason === "request-analysis-already-running") { + setAdminNotice("The request analysis worker is already running on the VPS."); + } else if (reason === "request-analysis-not-configured") { + setAdminNotice("Request analysis is not configured on the server."); + } else { + setAdminNotice(`Queue trigger returned: ${reason}.`); + } + } + await refreshAdminData({ includeLogs: true, silentRequests: true }); + if (payload.launched) { + window.setTimeout(() => { + void refreshAdminData({ includeLogs: true, silentRequests: true }); + }, 4200); + } + } catch (nextError: unknown) { + setLogsError(String(nextError || "Failed to trigger the queue worker.")); + } finally { + setQueueTriggering(false); + } + } + const isBusy = launchState === "opening"; const requestCount = requests.length; const pendingRequestCount = requests.filter((entry) => entry.status === "pending").length; const activeRequestCount = requests.filter((entry) => entry.status === "active").length; + const queuedPendingRequestCount = requests.filter(isQueuedPendingRequest).length; + const needsReviewRequestCount = requests.filter(isNeedsReviewRequest).length; const requestTags = Array.from(new Set( requests .flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : []) @@ -252,6 +530,12 @@ function WorldshaperLauncher() { if (requestFilter === "status:pending") { return entry.status === "pending"; } + if (requestFilter === "status:queued") { + return isQueuedPendingRequest(entry); + } + if (requestFilter === "status:review") { + return isNeedsReviewRequest(entry); + } if (requestFilter === "status:active") { return entry.status === "active"; } @@ -263,7 +547,7 @@ function WorldshaperLauncher() { }); const boardHint = activeBoardTab === "news" ? "Latest announcements" - : `${pendingRequestCount} pending, ${activeRequestCount} active`; + : `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active`; return (
Shared Request Board
Requests
- {requestCount} saved request{requestCount === 1 ? "" : "s"}: {pendingRequestCount} pending and {activeRequestCount} active. + {requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, and {activeRequestCount} active.
- +
+ + +
+ {adminPanelOpen ? ( +
+
+
+
Moderation Tools
+

Admin Panel

+

+ Run the VPS queue worker, review the latest request-analysis events, and manage deletions from one place. +

+
+
+ {queuedPendingRequestCount} queued + {needsReviewRequestCount} review + {pendingRequestCount} pending + {activeRequestCount} active +
+
+
+ + +
+ {adminNotice ?

{adminNotice}

: null} + {logsError ?

{logsError}

: null} +
+
+
+

Request Management

+
Delete actions live here now.
+
+
+ {requests.map((requestEntry) => { + const isMutating = requestMutatingId === requestEntry.id; + const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state); + return ( +
+
+
{requestEntry.title}
+
+ {formatRequestStatusLabel(requestEntry.status)} + {requestEntry.category} + {analysisState} + {formatRequestTimestamp(requestEntry.updatedAt)} +
+
+ +
+ ); + })} +
+
+
+
+

Recent Logs

+
Newest events first.
+
+
+ {logsLoading && recentSaveEvents.length === 0 ? ( +
Loading admin logs...
+ ) : null} + {!logsLoading && recentSaveEvents.length === 0 ? ( +
No admin logs have been recorded yet.
+ ) : null} + {recentSaveEvents.map((eventEntry, index) => ( +
+
+
{formatEventLabel(eventEntry)}
+
{formatRequestTimestamp(String(eventEntry.at || ""))}
+
+
{formatEventDetail(eventEntry) || "No extra details recorded."}
+
+ ))} +
+
+
+
+ ) : null} {requestDraftOpen ? (
) : null} {!requestsLoading ? filteredRequests.map((requestEntry) => { - const isMutating = requestMutatingId === requestEntry.id; const isExpanded = expandedRequestIds.includes(requestEntry.id); const isActiveRequest = requestEntry.status === "active"; + const requestDisplayState = getRequestDisplayStateLabel(requestEntry); + const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry); + const analysisStateLabel = requestEntry.status === "pending" + ? formatAnalysisStateLabel(requestEntry.analysis?.state) + : ""; return (
-
- {formatRequestStatusLabel(requestEntry.status)} +
+ {requestDisplayState}

{requestEntry.title}

{requestEntry.category}
-
{requestEntry.tags.length > 0 ? (
@@ -498,7 +883,10 @@ function WorldshaperLauncher() {
{requestEntry.status === "active" ? requestEntry.summary : requestEntry.sourceText}
-
{formatRequestTimestamp(requestEntry.createdAt)}
+
+ {requestEntry.status === "pending" ? `${analysisStateLabel} | ` : ""} + {formatRequestTimestamp(requestEntry.updatedAt || requestEntry.createdAt)} +
{isActiveRequest && isExpanded ? (
@@ -523,7 +911,7 @@ function WorldshaperLauncher() {
- Requests are saved and shared from this launcher. Use the checkbox to mark progress and the X to remove an entry. + Requests are saved and shared from this launcher. Public rows stay focused on the request itself, while moderation tools and logs live in the admin panel.
diff --git a/src/index.css b/src/index.css index ffff6eb..67216fb 100644 --- a/src/index.css +++ b/src/index.css @@ -278,6 +278,12 @@ body { justify-content: flex-start; } +.launcher-request-toolbar-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + .launcher-request-filter { display: grid; gap: 4px; @@ -309,6 +315,195 @@ body { box-shadow: inset 0 0 0 1px rgba(10, 16, 32, 0.14); } +.launcher-request-admin-panel { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid #476d9d; + border-radius: 12px; + background: + radial-gradient(circle at top right, rgba(100, 170, 248, 0.18) 0%, transparent 48%), + linear-gradient(180deg, rgba(18, 33, 63, 0.9) 0%, rgba(10, 19, 38, 0.94) 100%); + box-shadow: + 0 12px 28px rgba(3, 8, 18, 0.24), + inset 0 0 0 1px rgba(10, 16, 32, 0.14); +} + +.launcher-request-admin-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.launcher-request-admin-kicker { + color: #ffd166; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 6px; +} + +.launcher-request-admin-title { + margin: 0 0 6px; + color: #eef6ff; + font-size: 18px; + line-height: 1.1; +} + +.launcher-request-admin-copy { + margin: 0; + color: #b8cfee; + font-size: 12px; + line-height: 1.5; + max-width: 58ch; +} + +.launcher-request-admin-stats { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.launcher-request-admin-stats span { + padding: 4px 8px; + border: 1px solid #365782; + border-radius: 999px; + background: rgba(8, 16, 31, 0.76); + color: #d7e7ff; + font-size: 11px; + line-height: 1; +} + +.launcher-request-admin-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.launcher-request-admin-notice { + margin: 0; + color: #a8e6c2; + font-size: 12px; + line-height: 1.5; +} + +.launcher-request-admin-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + min-height: 0; +} + +.launcher-request-admin-card { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 10px; + min-height: 0; + padding: 12px; + border: 1px solid #365782; + border-radius: 12px; + background: rgba(8, 16, 31, 0.74); +} + +.launcher-request-admin-card-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} + +.launcher-request-admin-card-title { + margin: 0; + color: #eef6ff; + font-size: 13px; + font-weight: 800; +} + +.launcher-request-admin-card-hint { + color: #9fb8e5; + font-size: 11px; + line-height: 1.4; +} + +.launcher-request-admin-request-list, +.launcher-request-admin-log-list { + min-height: 0; + max-height: 320px; + overflow: auto; + display: grid; + gap: 8px; + padding-right: 4px; +} + +.launcher-request-admin-request-row, +.launcher-request-admin-log-row { + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid #365782; + border-radius: 10px; + background: rgba(17, 32, 63, 0.82); +} + +.launcher-request-admin-request-row { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 10px; +} + +.launcher-request-admin-request-copy { + min-width: 0; + display: grid; + gap: 5px; +} + +.launcher-request-admin-request-title, +.launcher-request-admin-log-title { + color: #eef6ff; + font-size: 13px; + font-weight: 700; + line-height: 1.35; +} + +.launcher-request-admin-request-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.launcher-request-admin-request-meta span { + padding: 3px 7px; + border: 1px solid #365782; + border-radius: 999px; + background: rgba(25, 48, 87, 0.72); + color: #d7e7ff; + font-size: 10px; + line-height: 1; +} + +.launcher-request-admin-log-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} + +.launcher-request-admin-log-time { + color: #9fb8e5; + font-size: 11px; + white-space: nowrap; +} + +.launcher-request-admin-log-detail { + color: #d7e7ff; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; +} + .launcher-request-composer-label { color: #d7e7ff; font-size: 12px; @@ -408,6 +603,31 @@ body { color: #ffe7a9; } +.launcher-request-status-pill.is-queued, +.launcher-request-status-pill.is-processing { + border-color: #7d6d3b; + background: rgba(80, 62, 18, 0.82); + color: #ffe7a9; +} + +.launcher-request-status-pill.is-needs-review { + border-color: #b96a32; + background: rgba(101, 42, 13, 0.84); + color: #ffd5b0; +} + +.launcher-request-status-pill.is-error { + border-color: #9b4053; + background: rgba(87, 24, 36, 0.82); + color: #ffd4dc; +} + +.launcher-request-status-pill.is-processed { + border-color: #466e99; + background: rgba(26, 53, 87, 0.82); + color: #d5e8ff; +} + .launcher-request-entry-title-block { min-width: 0; display: grid; @@ -1692,7 +1912,9 @@ button.danger:not(:disabled):hover { } .launcher-board-tabs, - .launcher-request-composer-actions { + .launcher-request-composer-actions, + .launcher-request-admin-actions, + .launcher-request-toolbar-buttons { flex-direction: column; } @@ -1700,6 +1922,21 @@ button.danger:not(:disabled):hover { grid-template-columns: 1fr; } + .launcher-request-admin-head, + .launcher-request-admin-card-head, + .launcher-request-admin-log-head { + grid-template-columns: 1fr; + display: grid; + } + + .launcher-request-admin-grid { + grid-template-columns: 1fr; + } + + .launcher-request-admin-request-row { + grid-template-columns: 1fr; + } + .launcher-request-filter { min-width: 0; }