Files
minecraft-account-manager/apps/web/src/components/rcon-console.tsx
T
dmg 65e3af7c78
CI / validate (push) Successful in 7m15s
Release / release (push) Successful in 12m13s
feat(rcon): add vanilla-safe custom gear builder
2026-09-17 23:11:01 -04:00

366 lines
19 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import {
createRconServer,
deleteRconServer,
setRconServerEnabled,
testSavedRconServer,
updateRconServer,
} from "@/app/admin/(console)/rcon/actions";
import { AdminModalForm } from "@/components/admin-modal-form";
import { rconSuggestions, type RconSuggestion } from "@/lib/rcon-help";
import { useRconSession } from "./use-rcon-session";
import { GearBuilder } from "./gear-builder";
const MAX_COMMAND_HISTORY = 50;
const MAX_TRANSCRIPT_EXCHANGES = 50;
type TranscriptExchange = {
id: number;
serverName: string;
command: string;
status: "pending" | "success" | "error";
message: string;
};
export type RconServerOption = {
id: string;
name: string;
host: string;
port: number;
enabled: boolean;
};
export type RconTerminalNotice = {
status: "success" | "error";
message: string;
};
export function RconConsole({
notice,
servers,
}: {
notice?: RconTerminalNotice;
servers: RconServerOption[];
}) {
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
const [command, setCommand] = useState("");
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
const [activeSuggestion, setActiveSuggestion] = useState(0);
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
const draftRef = useRef("");
const inputRef = useRef<HTMLInputElement>(null);
const nextExchangeIdRef = useRef(0);
const pendingExchangeIdRef = useRef<number | null>(null);
const transcriptRef = useRef<HTMLDivElement>(null);
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
const help = useRconSession(selected, command);
const { state, action, pending } = help;
const suggestions = selected?.enabled && !pending && suggestionsOpen
? rconSuggestions(command, help.catalog?.commands ?? [], help.usage?.text) : [];
const suggestionIndex = Math.min(activeSuggestion, Math.max(0, suggestions.length - 1));
function acceptSuggestion(suggestion: RconSuggestion) {
setCommand(suggestion.value);
setSuggestionsOpen(false);
setHistoryIndex(null);
inputRef.current?.focus();
}
useEffect(() => {
inputRef.current?.focus();
}, [selectedId]);
useEffect(() => {
if (suggestions.length) document.getElementById(`rcon-suggestion-${suggestionIndex}`)?.scrollIntoView?.({ block: "nearest" });
}, [suggestionIndex, suggestions.length]);
useEffect(() => {
if (!pending && state.status !== "idle") inputRef.current?.focus();
}, [pending, state.status]);
useEffect(() => {
const exchangeId = pendingExchangeIdRef.current;
if (exchangeId === null || state.status === "idle") return;
const resultStatus: TranscriptExchange["status"] = state.status === "error" ? "error" : "success";
setTranscript((current) => current.map((exchange) => exchange.id === exchangeId
? { ...exchange, status: resultStatus, message: state.message }
: exchange));
pendingExchangeIdRef.current = null;
}, [state]);
useEffect(() => {
if (transcriptRef.current) transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
}, [transcript]);
function navigateHistory(direction: "older" | "newer") {
if (!history.length) return;
if (direction === "older") {
const nextIndex = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
if (historyIndex === null) draftRef.current = command;
setHistoryIndex(nextIndex);
setCommand(history[nextIndex]!);
return;
}
if (historyIndex === null) return;
if (historyIndex < history.length - 1) {
const nextIndex = historyIndex + 1;
setHistoryIndex(nextIndex);
setCommand(history[nextIndex]!);
} else {
setHistoryIndex(null);
setCommand(draftRef.current);
}
}
function rememberSubmittedCommand() {
help.stopHelp();
setSuggestionsOpen(false);
const submitted = command.trim();
if (!submitted || !selected) return;
const exchangeId = ++nextExchangeIdRef.current;
pendingExchangeIdRef.current = exchangeId;
const exchange: TranscriptExchange = {
id: exchangeId,
serverName: selected.name,
command: submitted,
status: "pending",
message: "Command in progress…",
};
setTranscript((current) => [...current, exchange].slice(-MAX_TRANSCRIPT_EXCHANGES));
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
setHistoryIndex(null);
draftRef.current = "";
setCommand("");
}
return (
<section aria-label="RCON terminal" className="mt-8 w-full overflow-hidden border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<div className="flex flex-col gap-4 border-b-2 border-ink bg-canvas px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<div className="flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-wider text-muted">
<span aria-hidden="true" className={`size-2 rounded-full shadow-[0_0_0_1px_var(--color-ink)] ${selected?.enabled ? "bg-signal" : "bg-line"}`} />
<span>server://</span>
</div>
{servers.length ? (
<label className="flex min-w-0 items-center gap-2 font-mono text-[9px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
<span className="sr-only">Server</span>
<select
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
id="rcon-console-server"
onChange={(event) => {
help.stopHelp();
setSuggestionsOpen(false);
setSelectedId(event.target.value);
}}
value={selected?.id}
>
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
</select>
</label>
) : (
<span className="font-mono text-xs font-bold text-muted">no-target</span>
)}
{selected && <span className="font-mono text-[9px] text-muted">{selected.host}:{selected.port}</span>}
</div>
<div className="flex flex-wrap items-center gap-2">
<Link className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" href="/admin/rcon/history">Command history</Link>
<ConnectionModal mode="add" />
{selected && (
<>
<form action={testSavedRconServer}>
<input name="serverId" type="hidden" value={selected.id} />
<HeaderButton label="Test" />
</form>
<form action={setRconServerEnabled}>
<input name="serverId" type="hidden" value={selected.id} />
<input name="enabled" type="hidden" value={selected.enabled ? "no" : "yes"} />
<HeaderButton label={selected.enabled ? "Disable" : "Enable"} />
</form>
<ConnectionModal mode="edit" server={selected} />
<AdminModalForm
action={deleteRconServer}
description={`Delete ${selected.name} and its encrypted credential. This cannot be undone.`}
intent="danger"
submitLabel="Delete connection"
title={`Delete ${selected.name}?`}
triggerClassName="border border-accent px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-accent"
triggerLabel="Delete"
>
<input name="serverId" type="hidden" value={selected.id} />
<input name="confirmation" type="hidden" value={selected.id} />
</AdminModalForm>
</>
)}
</div>
</div>
<div aria-label="Terminal transcript" aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" ref={transcriptRef} role="status">
{notice && (
<div className={`mb-5 border-l-2 pl-3 ${notice.status === "error" ? "border-accent" : "border-signal"}`} role={notice.status === "error" ? "alert" : "status"}>
<p className={`text-[9px] font-bold uppercase tracking-wider ${notice.status === "error" ? "text-accent" : "text-muted"}`}>{notice.status === "error" ? "Connection error" : "Connection update"}</p>
<p className="mt-2">{notice.message}</p>
</div>
)}
{!transcript.length && <TerminalIdle selected={selected} />}
<div className="space-y-6">
{transcript.map((exchange) => (
<article className="border-l-2 border-line pl-3" key={exchange.id}>
<p className="break-words">
<span className="mr-2 text-[9px] font-bold uppercase tracking-wider text-muted">server://{exchange.serverName}</span>
<span className="text-accent">$</span> {exchange.command}
</p>
<div className={`mt-2 ${exchange.status === "error" ? "text-accent" : "text-ink"}`} role={exchange.status === "error" ? "alert" : undefined}>
{exchange.status === "pending" ? <p className="text-muted">Command in progress…</p> : <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-5">{exchange.message}</pre>}
</div>
</article>
))}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-line bg-canvas px-4 py-2 font-mono text-[10px] text-muted">
<p aria-live="polite" role="status">{help.loading ? "Reading server help…" : help.message}</p>
<button className="border border-line px-3 py-2 font-bold uppercase tracking-wider hover:border-ink disabled:opacity-50" disabled={!selected?.enabled || pending || help.loading} onClick={() => void help.refresh()} type="button">Refresh commands</button>
</div>
{help.usage && (
<div aria-label="Command usage" className="border-t border-line bg-panel px-4 py-3 font-mono text-xs" id="rcon-usage">
<p className="mb-1 text-[9px] uppercase tracking-wider text-muted">Server usage hint</p>
<pre className="whitespace-pre-wrap break-words font-mono text-xs">{help.usage.text ?? (help.usage.failed ? "Usage unavailable. Refresh commands to retry." : "No usage supplied by server help.")}</pre>
</div>
)}
{suggestions.length > 0 && (
<div className="border-t border-line bg-panel px-4 py-3 font-mono text-xs">
<p className="mb-2 text-[9px] uppercase tracking-wider text-muted" id="rcon-suggestion-hint">↑↓ Select · Tab / Enter Insert · Esc Dismiss</p>
<ul aria-label="Command suggestions" className="max-h-48 overflow-y-auto" id="rcon-suggestions" role="listbox">
{suggestions.map((suggestion, index) => (
<li aria-selected={index === suggestionIndex} className={`flex cursor-pointer flex-wrap gap-x-4 gap-y-1 border-l-2 px-3 py-2 ${index === suggestionIndex ? "border-accent bg-canvas text-ink" : "border-transparent text-muted"}`} id={`rcon-suggestion-${index}`} key={suggestion.value} onClick={() => acceptSuggestion(suggestion)} onMouseDown={(event) => event.preventDefault()} role="option">
<span className="font-bold">{suggestion.label}</span>{" "}<span className="break-words">{suggestion.description}</span>
</li>
))}
</ul>
</div>
)}
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
<label className="sr-only" htmlFor="rcon-command">Command</label>
<input
aria-activedescendant={suggestions.length ? `rcon-suggestion-${suggestionIndex}` : undefined}
aria-autocomplete="list"
aria-controls={suggestions.length ? "rcon-suggestions" : undefined}
aria-describedby={[suggestions.length ? "rcon-suggestion-hint" : "", help.usage ? "rcon-usage" : ""].filter(Boolean).join(" ") || undefined}
aria-expanded={suggestions.length > 0}
role="combobox"
autoComplete="off"
autoFocus
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={!selected?.enabled || pending}
id="rcon-command"
key={selected?.id ?? "no-server"}
maxLength={1024}
name="command"
onSelect={(event) => {
const input = event.currentTarget;
if (input.selectionStart !== input.value.length || input.selectionEnd !== input.value.length) setSuggestionsOpen(false);
}}
onBlur={() => setSuggestionsOpen(false)}
onChange={(event) => {
setCommand(event.target.value);
setSuggestionsOpen(true);
setActiveSuggestion(0);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Escape") { setSuggestionsOpen(false); return; }
if (suggestions.length) {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
setActiveSuggestion((suggestionIndex + (event.key === "ArrowUp" ? -1 : 1) + suggestions.length) % suggestions.length);
return;
}
if ((event.key === "Tab" && !event.shiftKey) || event.key === "Enter") {
event.preventDefault();
acceptSuggestion(suggestions[suggestionIndex]!);
return;
}
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
}
}}
placeholder={selected ? (selected.enabled ? "list" : "Enable this connection to run commands") : "Add a connection to begin"}
ref={inputRef}
required
spellCheck={false}
value={command}
/>
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
</form>
<GearBuilder server={selected} />
</section>
);
}
function TerminalIdle({ selected }: { selected?: RconServerOption }) {
if (!selected) return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready</p><p className="mt-3">No connections configured. Use Add to create a server connection.</p></>;
if (!selected.enabled) return <><p className="text-[9px] font-bold uppercase tracking-wider text-accent">Disabled — {selected.name}</p><p className="mt-3">Enable this connection before testing commands.</p></>;
return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready — {selected.name}</p><p className="mt-3">Awaiting command</p></>;
}
function HeaderButton({ label }: { label: string }) {
return <button className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" type="submit">{label}</button>;
}
function ConnectionModal({
mode,
server,
}: {
mode: "add" | "edit";
server?: RconServerOption;
}) {
const editing = mode === "edit" ? server : undefined;
return (
<AdminModalForm
action={editing ? updateRconServer : createRconServer}
description={editing ? `Update ${editing.name}. Leave the password blank to preserve its encrypted credential.` : "Add an internal or external RCON server address. The password is encrypted before storage."}
submitLabel={editing ? "Save connection" : "Add connection"}
title={editing ? `Edit ${editing.name}` : "Add RCON connection"}
triggerClassName={editing ? "border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" : "border border-ink bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas"}
triggerLabel={editing ? "Edit" : "Add"}
>
<div className="space-y-4">
{editing && <input name="serverId" type="hidden" value={editing.id} />}
<ConnectionFields defaults={editing} prefix={editing?.id ?? "new"} />
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase">
<input className="size-4" defaultChecked={editing?.enabled ?? false} name="enabled" type="checkbox" value="yes" />
{editing ? "Enabled" : "Enable immediately"}
</label>
</div>
</AdminModalForm>
);
}
function ConnectionFields({
defaults,
prefix,
}: {
defaults?: { name: string; host: string; port: number };
prefix: string;
}) {
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
return (
<>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Server address<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="minecraft.example.com" required spellCheck={false} /></label>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
</>
);
}