fix(panel): format RCON replies and hide connection log noise
This commit is contained in:
7 files changed
+95
-21
No files matched your search
@@ -64,6 +64,37 @@ describe("LogConsole", () => {
|
||||
expect(container.textContent).not.toContain("§");
|
||||
});
|
||||
|
||||
it("hides only RCON connection lifecycle notices and can reveal them", async () => {
|
||||
render(<LogConsole url="/c" />);
|
||||
const started = "[15:42:03 INFO]: Thread RCON Client /10.42.0.80 started";
|
||||
const stopped = "[15:42:03] [RCON Client /10.42.0.80/INFO]: Thread RCON Client /10.42.0.80 shutting down";
|
||||
const warning = "[15:42:04 WARN]: Thread RCON Client /10.42.0.80 shutting down";
|
||||
const error = "[15:42:04 ERROR]: RCON authentication failed";
|
||||
const chat = "[15:42:04 INFO]: <Steve> Thread RCON Client /10.42.0.80 started";
|
||||
await send(started, stopped, warning, error, chat);
|
||||
expect(screen.queryByText(started)).toBeNull();
|
||||
expect(screen.queryByText(stopped)).toBeNull();
|
||||
expect(screen.getByText(warning)).toBeTruthy();
|
||||
expect(screen.getByText(error).className).toContain("text-red-400");
|
||||
expect(screen.getByText(chat)).toBeTruthy();
|
||||
const toggle = screen.getByRole("button", { name: /Show RCON connection logs/ });
|
||||
expect(toggle.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(toggle.textContent).toContain("(2)");
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.getByText(started)).toBeTruthy();
|
||||
expect(screen.getByText(stopped)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide RCON connection logs" }));
|
||||
expect(screen.queryByText(started)).toBeNull();
|
||||
});
|
||||
|
||||
it("explains when the buffer contains only filtered connection notices", async () => {
|
||||
render(<LogConsole url="/c" />);
|
||||
await send("[15:42:03 INFO]: Thread RCON Client /10.42.0.80 started");
|
||||
expect(screen.getByText(i18next.t("servers:log_rcon_only"))).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show RCON connection logs/ }));
|
||||
expect(screen.queryByText(i18next.t("servers:log_rcon_only"))).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps showing new lines in a chunk that was already on screen, and after a clear", async () => {
|
||||
const { container } = render(<LogConsole url="/c" />);
|
||||
await send(...range(0, 150));
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useLogStream } from "@/lib/useLogStream";
|
||||
import { chunkLines, sameChunk } from "@/lib/logchunks";
|
||||
import type { LogLevel, LogLine, StreamStatus } from "@/lib/logstream";
|
||||
import type { Segment } from "@/lib/mcformat";
|
||||
import type { Formatted, Segment } from "@/lib/mcformat";
|
||||
|
||||
// Per-level tint. Plain/info are the default readable foreground; warn/error draw
|
||||
// the eye. Debug is dimmed so it recedes. The console body is a fixed dark
|
||||
@@ -32,6 +32,15 @@ function segmentStyle(s: Segment): CSSProperties | undefined {
|
||||
};
|
||||
}
|
||||
|
||||
export function FormattedText({ formatted }: { formatted: Formatted }) {
|
||||
return formatted.segments
|
||||
? formatted.segments.map((segment, i) => <span key={i} style={segmentStyle(segment)}>{segment.text}</span>)
|
||||
: formatted.text;
|
||||
}
|
||||
|
||||
// Match only INFO lifecycle messages; RCON failures and command output remain visible.
|
||||
const RCON_LIFECYCLE = /^(?:\[\d{2}:\d{2}:\d{2} INFO\]|\[\d{2}:\d{2}:\d{2}(?:\.\d+)?\] \[[^\]]+\/INFO\]): Thread RCON Client \/\S+ (?:started|shutting down)$/;
|
||||
|
||||
// A chunk off screen is sized from its last layout, or before it has had one,
|
||||
// from 100 unwrapped lines of text-xs at leading-relaxed (19.5px each).
|
||||
const LogChunk = memo(function LogChunk({ lines }: { lines: LogLine[] }) {
|
||||
@@ -39,13 +48,7 @@ const LogChunk = memo(function LogChunk({ lines }: { lines: LogLine[] }) {
|
||||
<div className="[content-visibility:auto] [contain-intrinsic-size:auto_1950px]">
|
||||
{lines.map((line) => (
|
||||
<div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}>
|
||||
{line.segments
|
||||
? line.segments.map((s, i) => (
|
||||
<span key={i} style={segmentStyle(s)}>
|
||||
{s.text}
|
||||
</span>
|
||||
))
|
||||
: line.text || "\u00A0"}
|
||||
<FormattedText formatted={line.text ? line : { text: "\u00A0" }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -89,7 +92,10 @@ export function LogConsole({ url, className, starting = false }: { url: string;
|
||||
const timer = window.setTimeout(reconnect, 5000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [starting, retryable, status, reconnect]);
|
||||
const chunks = useMemo(() => chunkLines(lines), [lines]);
|
||||
const [showRcon, setShowRcon] = useState(false);
|
||||
const visibleLines = useMemo(() => showRcon ? lines : lines.filter((line) => !RCON_LIFECYCLE.test(line.text)), [lines, showRcon]);
|
||||
const hiddenCount = lines.length - visibleLines.length;
|
||||
const chunks = useMemo(() => chunkLines(visibleLines), [visibleLines]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [pinned, setPinned] = useState(true);
|
||||
|
||||
@@ -107,7 +113,7 @@ export function LogConsole({ url, className, starting = false }: { url: string;
|
||||
if (!pinned) return;
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines, pinned]);
|
||||
}, [visibleLines, pinned]);
|
||||
|
||||
const jumpToLatest = useCallback(() => setPinned(true), []);
|
||||
|
||||
@@ -117,6 +123,18 @@ export function LogConsole({ url, className, starting = false }: { url: string;
|
||||
<div className="flex items-center justify-between gap-2 border-b border-zinc-800 bg-zinc-900/60 px-3 py-2 shrink-0">
|
||||
<StatusIndicator status={starting && retryable && status === "ended" ? "reconnecting" : status} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(hiddenCount > 0 || showRcon) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={showRcon}
|
||||
onClick={() => setShowRcon((shown) => !shown)}
|
||||
className="text-zinc-300 hover:bg-zinc-800 hover:text-zinc-50"
|
||||
>
|
||||
{t(showRcon ? "log_hide_rcon" : "log_show_rcon")}
|
||||
{hiddenCount > 0 && <span className="tabular-nums">({hiddenCount})</span>}
|
||||
</Button>
|
||||
)}
|
||||
{status === "ended" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -146,11 +164,11 @@ export function LogConsole({ url, className, starting = false }: { url: string;
|
||||
onScroll={onScroll}
|
||||
className="h-full overflow-y-auto px-3 py-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
{visibleLines.length === 0 ? (
|
||||
<p className="select-none py-8 text-center text-zinc-600">
|
||||
{status === "ended" && (!starting || !retryable)
|
||||
? t("log_ended_empty")
|
||||
: t(starting ? "log_starting_wait" : "log_waiting")}
|
||||
: t(hiddenCount > 0 ? "log_rcon_only" : starting ? "log_starting_wait" : "log_waiting")}
|
||||
</p>
|
||||
) : (
|
||||
chunks.map((chunk) => <LogChunk key={chunk.key} lines={chunk.lines} />)
|
||||
|
||||
@@ -294,5 +294,8 @@
|
||||
"emergency_stop_unknown": "The result is unconfirmed. Stop intent may already be saved. Refresh status; if the control API is unreachable, use the host recovery steps.",
|
||||
"host_recovery": "Host emergency shutdown procedure",
|
||||
"host_recovery_hint": "When the control API is offline, the panel cannot perform cluster operations. Run these commands on the deployment host to save stop intent, set replicas to zero, and inspect game Pods. Replace minecraft if the deployment uses a different namespace.",
|
||||
"host_recovery_verify": "If a Pod is terminating or its node is unreachable, process termination cannot be confirmed. Inspect the node and container runtime. Force-deleting a Pod is not evidence of process termination."
|
||||
"host_recovery_verify": "If a Pod is terminating or its node is unreachable, process termination cannot be confirmed. Inspect the node and container runtime. Force-deleting a Pod is not evidence of process termination.",
|
||||
"log_show_rcon": "Show RCON connection logs",
|
||||
"log_hide_rcon": "Hide RCON connection logs",
|
||||
"log_rcon_only": "RCON connection lifecycle logs are hidden. Other logs will appear when available."
|
||||
}
|
||||
@@ -293,5 +293,8 @@
|
||||
"emergency_stop_unknown": "操作结果尚未确认。停止意图可能已经保存,请重新读取状态;若控制 API 不可达,请使用主机侧恢复步骤。",
|
||||
"host_recovery": "主机侧应急停止步骤",
|
||||
"host_recovery_hint": "控制 API 离线时,面板无法执行集群操作。请在部署主机依次执行以下命令:保存停止意图、将副本数设为零、检查游戏 Pod。命名空间不是 minecraft 时须替换命令中的命名空间。",
|
||||
"host_recovery_verify": "Pod 仍在终止或节点不可达时,无法确认进程已停止。请检查对应节点及容器运行时;强制删除 Pod 不能作为进程退出的依据。"
|
||||
"host_recovery_verify": "Pod 仍在终止或节点不可达时,无法确认进程已停止。请检查对应节点及容器运行时;强制删除 Pod 不能作为进程退出的依据。",
|
||||
"log_show_rcon": "显示 RCON 连接日志",
|
||||
"log_hide_rcon": "隐藏 RCON 连接日志",
|
||||
"log_rcon_only": "RCON 连接生命周期日志已隐藏。其他日志将在产生后显示。"
|
||||
}
|
||||
@@ -112,7 +112,7 @@ const DEFAULT_MAX_LINES = 2000;
|
||||
* "plain" and renders in the default colour.
|
||||
*/
|
||||
export function classifyLogLine(line: string): LogLevel {
|
||||
const m = line.match(/[/[](SEVERE|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\]/);
|
||||
const m = line.match(/(?:[/[]|^\[\d{2}:\d{2}:\d{2} )(SEVERE|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\]/);
|
||||
if (!m) return "plain";
|
||||
switch (m[1]) {
|
||||
case "SEVERE":
|
||||
|
||||
@@ -27,7 +27,10 @@ vi.mock("@/lib/api", async (importActual) => {
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
// The live log is a WebSocket; here it only matters whether the page shows it.
|
||||
vi.mock("@/components/LogConsole", () => ({ LogConsole: () => <div data-testid="log-stream" /> }));
|
||||
vi.mock("@/components/LogConsole", async (importActual) => ({
|
||||
...await importActual<typeof import("@/components/LogConsole")>(),
|
||||
LogConsole: () => <div data-testid="log-stream" />,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
tier.isAdmin = true;
|
||||
@@ -268,6 +271,21 @@ describe("ServerConsole command line", () => {
|
||||
expect(box.value).toBe("");
|
||||
});
|
||||
|
||||
it("renders Minecraft and ANSI formatting in command replies without interpreting markup", async () => {
|
||||
calls.sendCommand.mockResolvedValue({ output: "§eHelp: §fIndex §lBold§r\n§x§1§2§3§4§5§6Hex\n\x1b[31m<b>unsafe</b>\x1b[0m" });
|
||||
const box = await commandLine();
|
||||
await userEvent.type(box, "help{Enter}");
|
||||
const help = await screen.findByText("Help:");
|
||||
expect(help.style.color).toBe("rgb(255, 255, 85)");
|
||||
expect(screen.getByText("Index").style.color).toBe("rgb(255, 255, 255)");
|
||||
expect(screen.getByText("Bold").style.fontWeight).toBe("700");
|
||||
expect(screen.getByText("Hex").style.color).toBe("rgb(18, 52, 86)");
|
||||
const unsafe = screen.getByText("<b>unsafe</b>");
|
||||
expect(unsafe.style.color).toBe("rgb(205, 49, 49)");
|
||||
expect(unsafe.closest("pre")?.querySelector("b")).toBeNull();
|
||||
expect(unsafe.closest("pre")?.textContent).not.toMatch(/§|\x1b/);
|
||||
});
|
||||
|
||||
it("sends nothing for a blank line", async () => {
|
||||
const user = userEvent.setup();
|
||||
const box = await commandLine();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { BackLink } from "@/components/BackLink";
|
||||
import { MAX_AUTO_RESTARTS, PhaseBadge, pendingPower, shownPhase, startFailure, type StartFailure } from "@/components/PhaseBadge";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { LogConsole } from "@/components/LogConsole";
|
||||
import { FormattedText, LogConsole } from "@/components/LogConsole";
|
||||
import { Loading, ErrorState } from "@/components/States";
|
||||
import { api, consoleStreamURL, humanizeError } from "@/lib/api";
|
||||
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS, useAsync, useConfig, usePolling } from "@/lib/hooks";
|
||||
@@ -19,6 +19,7 @@ import type { Phase, AutostartPolicy } from "@/lib/types";
|
||||
import { EditServerDialog } from "@/components/EditServerDialog";
|
||||
import { PowerButton } from "@/components/PowerButton";
|
||||
import { RetireCard, RetireNotice } from "@/components/Retirement";
|
||||
import { parseFormatting, type Formatted } from "@/lib/mcformat";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { InlineError, MessageLine } from "@/components/MessageLine";
|
||||
|
||||
@@ -122,7 +123,7 @@ const MAX_HISTORY = 50;
|
||||
const HISTORY_KEY = (name: string) => `felis:cmd:history:${name}`;
|
||||
|
||||
/** CommandInput is the §8 write-side console input: a one-line text field that
|
||||
* sends an RCON command to the running server and displays its plain-text reply.
|
||||
* sends an RCON command to the running server and displays its formatted reply.
|
||||
* Enter sends; Up/Down cycle through persistent per-server command history.
|
||||
* Only available when the server is Running (RCON reachable). */
|
||||
function CommandInput({ name }: { name: string }) {
|
||||
@@ -133,7 +134,7 @@ function CommandInput({ name }: { name: string }) {
|
||||
// The last command's echo + the server's reply, rendered above the prompt the
|
||||
// way a terminal does. The reply IS the result of a write: RCON returns text
|
||||
// only, so dropping it would leave the user with no way to see what happened.
|
||||
const [last, setLast] = useState<{ cmd: string; out: string } | null>(null);
|
||||
const [last, setLast] = useState<{ cmd: string; out: Formatted } | null>(null);
|
||||
const historyRef = useRef<string[]>(loadHistory(name));
|
||||
const cursorRef = useRef(-1);
|
||||
// What was typed before stepping up into the history.
|
||||
@@ -155,7 +156,7 @@ function CommandInput({ name }: { name: string }) {
|
||||
cursorRef.current = h.length;
|
||||
try {
|
||||
const res = await api.sendCommand(name, text);
|
||||
setLast({ cmd: text, out: (res.output ?? "").trim() });
|
||||
setLast({ cmd: text, out: parseFormatting((res.output ?? "").trim()) });
|
||||
} catch (ex) {
|
||||
setErr(humanizeError(ex));
|
||||
} finally {
|
||||
@@ -203,7 +204,7 @@ function CommandInput({ name }: { name: string }) {
|
||||
{last && (
|
||||
<pre className="max-h-40 overflow-y-auto whitespace-pre-wrap break-words rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 font-mono text-xs text-zinc-300">
|
||||
<span className="text-zinc-500">{"> " + last.cmd + "\n"}</span>
|
||||
{last.out}
|
||||
<FormattedText formatted={last.out} />
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900 px-3 font-mono text-xs text-zinc-200">
|
||||
|
||||
Reference in new issue
Block a user