import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react"; import { Link, useParams } from "react-router-dom"; import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, Loader2, Users, Archive, ChevronRight, type LucideIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { PhaseBadge } from "@/components/PhaseBadge"; import { LogConsole } from "@/components/LogConsole"; import { Loading, ErrorState } from "@/components/States"; import { api, consoleStreamURL, humanizeError } from "@/lib/api"; import { useAsync, useConfig } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; import { hostFor } from "@/lib/config"; import type { Phase, AutostartPolicy } from "@/lib/types"; import { EditServerDialog } from "@/components/EditServerDialog"; // notStreamingCopy explains why there is no live feed for a phase that has no // streamable pod. The read path only has something to relay once a pod is up, so // Stopped/Failed/Stopping each get their own honest line rather than an empty // console. `default` covers Unknown plus any future phase the backend may emit // that the panel hasn't modelled yet — the screen stays informative regardless. function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string } { const { t } = useTranslation("servers"); switch (phase) { case "Stopped": return { icon: Moon, title: t("server_asleep_title"), body: t("server_asleep_body"), }; case "Stopping": return { icon: Moon, title: t("server_shutting_down_title"), body: t("server_shutting_down_body"), }; case "Failed": return { icon: ShieldAlert, title: t("server_failed_title"), body: t("server_failed_body"), }; default: return { icon: HelpCircle, title: t("console_offline_title"), body: t("console_offline_body"), }; } } function NotStreaming({ phase }: { phase: Phase }) { const { icon: Icon, title, body } = useNotStreamingCopy(phase); return (

{title}

{body}

); } function loadHistory(name: string): string[] { try { const raw = localStorage.getItem(HISTORY_KEY(name)); return raw ? JSON.parse(raw) : []; } catch { return []; } } function persistHistory(name: string, h: string[]): void { try { localStorage.setItem(HISTORY_KEY(name), JSON.stringify(h)); } catch { /* storage full — silently drop */ } } 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. * 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 }) { const { t } = useTranslation("servers"); const [command, setCommand] = useState(""); const [sending, setSending] = useState(false); const [err, setErr] = useState(null); const historyRef = useRef(loadHistory(name)); const cursorRef = useRef(-1); const inputRef = useRef(null); const composingRef = useRef(false); const send = useCallback(async (text: string) => { if (!text || sending) return; setSending(true); setErr(null); setCommand(""); const h = historyRef.current; if (h.length === 0 || h[h.length - 1] !== text) { if (h.length >= MAX_HISTORY) h.shift(); h.push(text); persistHistory(name, h); } cursorRef.current = h.length; try { await api.sendCommand(name, text); } catch (ex) { setErr(humanizeError(ex)); } finally { setSending(false); } }, [name, sending]); useLayoutEffect(() => { if (!sending) inputRef.current?.focus(); }, [sending]); const onKeyDown = useCallback((e: KeyboardEvent) => { if (composingRef.current) return; const h = historyRef.current; if (e.key === "Enter") { e.preventDefault(); send(command.trim()); return; } if (h.length === 0) return; if (e.key === "ArrowUp") { e.preventDefault(); const idx = Math.max(0, cursorRef.current === -1 ? h.length - 1 : cursorRef.current - 1); cursorRef.current = idx; setCommand(h[idx]); setTimeout(() => { inputRef.current?.setSelectionRange(h[idx].length, h[idx].length); }, 0); } if (e.key === "ArrowDown") { e.preventDefault(); const idx = Math.min(h.length, cursorRef.current + 1); cursorRef.current = idx; setCommand(idx < h.length ? h[idx] : ""); if (idx < h.length) { setTimeout(() => { inputRef.current?.setSelectionRange(h[idx].length, h[idx].length); }, 0); } } }, [command, send]); return (
{">"} { setCommand(e.target.value); cursorRef.current = -1; }} onKeyDown={onKeyDown} onCompositionStart={() => { composingRef.current = true; }} onCompositionEnd={() => { composingRef.current = false; }} placeholder={t("command_placeholder")} autoComplete="off" spellCheck={false} className="flex-1 bg-transparent py-1.5 text-zinc-200 placeholder:text-zinc-500 focus:outline-none" /> {sending && }
{err &&

{err}

}
); } export function ServerConsole() { const { name = "" } = useParams(); const cfg = useConfig(); const { isAdmin } = useTier(); const { t } = useTranslation("servers"); const { data, error, loading, reload } = useAsync( () => api.status(name), [name], ); // The read-side stream (spec §8) follows the running pod's log. A pod exists // during BOTH Starting and Running — the operator marks Starting once the pod // is up but RCON is not yet reachable (it only flips to Running after RCON // readiness). Boot logs flow precisely in that Starting window, which is when a // read most wants them, so the gate streams for both, not Running alone. const streamable = data?.phase === "Running" || data?.phase === "Starting"; return (
{t("my_servers_breadcrumb")}
{loading && !data ? ( ) : error ? ( ) : data ? ( <>

{data.displayName || data.name}

{cfg && (

{hostFor(data.subdomain, cfg)}

)}
{streamable ? ( ) : ( )}
{/* Left/Main column: Console */}
{t("console_card_title")} {!streamable ? (
) : cfg ? (
{/* key on name so navigating between servers remounts the viewport (fresh stream + scroll state). useLogStream also resets its controller when the url changes, so this is belt-and-suspenders. */} {data.phase === "Running" && ( )}
) : ( )}
{/* Right/Sidebar column: Navigation */}
{isAdmin && ( )} {/* Player management lives on its own subpage (whitelist / online / bans), not crammed under the console. This is the doorway to it; the page itself owns the ownership + readiness gating. */}

{t("players_link_title")}

{t("players_link_desc")}

{/* Backups & restore — its own subpage, doorway mirrors the players card. The page owns its ownership gating and works while the server sleeps (a restore in fact needs it stopped, so this doorway shows at every phase). */}

{t("backups_link_title")}

{t("backups_link_desc")}

) : null}
); }