366 lines
19 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|