Files
Felis/panel/src/pages/ServerConsole.tsx
T

321 lines
13 KiB
TypeScript

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 (
<div className="flex items-start gap-3 rounded-md border border-dashed border-border bg-muted/30 p-6 text-sm text-muted-foreground">
<Icon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70" />
<div className="space-y-1">
<p className="font-medium text-foreground">{title}</p>
<p>{body}</p>
</div>
</div>
);
}
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<string | null>(null);
const historyRef = useRef<string[]>(loadHistory(name));
const cursorRef = useRef(-1);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="space-y-1.5">
<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">
<span className="shrink-0 select-none text-zinc-500">{">"}</span>
<input
ref={inputRef}
value={command}
onChange={(e) => { 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 && <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0 text-zinc-500" />}
</div>
{err && <p className="text-xs text-destructive">{err}</p>}
</div>
);
}
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 (
<div className="flex flex-col lg:h-[calc(100vh-3.5rem)] lg:min-h-[35rem] gap-4 min-h-0">
<div>
<Link
to="/servers"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" /> {t("my_servers_breadcrumb")}
</Link>
</div>
{loading && !data ? (
<Loading />
) : error ? (
<ErrorState error={error} onRetry={reload} />
) : data ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3 shrink-0">
<div className="flex items-center gap-3">
<Terminal className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{data.displayName || data.name}
</h1>
{cfg && (
<p className="text-sm text-muted-foreground">
{hostFor(data.subdomain, cfg)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<PhaseBadge phase={data.phase} />
{streamable ? (
<Button
variant="destructive"
size="sm"
onClick={() => api.stop(name).then(reload)}
>
{t("stop")}
</Button>
) : (
<Button variant="default" size="sm" onClick={() => api.wake(name).then(reload)}>
{t("wake")}
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 flex-1 lg:min-h-0 min-h-0">
{/* Left/Main column: Console */}
<div className="lg:col-span-3 flex flex-col lg:min-h-0 min-h-0 h-full">
<Card className="flex flex-col flex-1 lg:min-h-0 min-h-0">
<CardHeader className="py-3.5 shrink-0">
<CardTitle className="flex items-center gap-2 text-base">
<Terminal className="h-4 w-4" /> {t("console_card_title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 flex-1 flex flex-col lg:min-h-0 min-h-0 pb-4">
{!streamable ? (
<div className="flex-1 flex flex-col justify-center">
<NotStreaming phase={data.phase} />
</div>
) : cfg ? (
<div className="flex-1 flex flex-col lg:min-h-0 min-h-0 gap-3">
{/* 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. */}
<LogConsole key={name} url={consoleStreamURL(cfg.apiBase, name)} />
{data.phase === "Running" && (
<CommandInput name={name} />
)}
</div>
) : (
<Loading />
)}
</CardContent>
</Card>
</div>
{/* Right/Sidebar column: Navigation */}
<div className="flex flex-col gap-4 lg:col-span-1 shrink-0">
{isAdmin && (
<EditServerDialog
serverName={name}
currentDisplayName={data.displayName}
currentPolicy={data.autostartPolicy as AutostartPolicy}
currentImage={data.image}
currentMemory={data.javaMemory}
currentStorage={data.storageSize}
currentCpu={data.cpu}
onUpdated={reload}
/>
)}
{/* 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. */}
<Link
to={`/servers/${name}/players`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
>
<Users className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("players_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("players_link_desc")}</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
{/* 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). */}
<Link
to={`/servers/${name}/backups`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
>
<Archive className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("backups_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("backups_link_desc")}</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
</div>
</div>
</>
) : null}
</div>
);
}