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

360 lines
15 KiB
TypeScript

import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react";
import { Link, useParams } from "react-router-dom";
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, ChevronRight, type LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { BackLink } from "@/components/BackLink";
import { PhaseBadge } from "@/components/PhaseBadge";
import { PageHeader } from "@/components/PageHeader";
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 { canManage } from "@/lib/ownership";
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);
// 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 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 {
const res = await api.sendCommand(name, text);
setLast({ cmd: text, out: (res.output ?? "").trim() });
} 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">
{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}
</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">
<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],
);
const { data: mine } = useAsync(
() => (isAdmin ? Promise.resolve([]) : api.myServers()),
[isAdmin, name],
);
const owned = canManage(isAdmin, mine, 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>
<BackLink to="/servers" label={t("my_servers_breadcrumb")} />
</div>
{loading && !data ? (
<Loading />
) : error ? (
<ErrorState error={error} onRetry={reload} />
) : data ? (
<>
<PageHeader
icon={Terminal}
title={data.displayName || data.name}
subtitle={cfg ? hostFor(data.subdomain, cfg) : undefined}
actions={
<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>
}
className="mb-6"
/>
<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="dark flex flex-col flex-1 lg:min-h-0 min-h-0 overflow-hidden bg-black text-zinc-50 border-zinc-800">
<CardContent className="p-0 flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
{!streamable ? (
<div className="flex-1 flex flex-col justify-center p-6 bg-black">
<NotStreaming phase={data.phase} />
</div>
) : cfg ? (
<div className="flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
<LogConsole
key={name}
url={consoleStreamURL(cfg.apiBase, name)}
className="border-0 rounded-none bg-transparent"
/>
{data.phase === "Running" && (
<div className="p-3 bg-zinc-900/40 border-t border-zinc-800">
<CommandInput name={name} />
</div>
)}
</div>
) : (
<div className="p-6 bg-black">
<Loading />
</div>
)}
</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}
currentIdleStopSeconds={data.idleStopSeconds}
playerCountUnknown={data.playerCountUnknown}
onUpdated={reload}
/>
)}
{owned && (
<Link
to={`/servers/${name}/luckperms`}
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"
>
<Shield className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("luckperms_link_title")}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t("luckperms_link_desc")}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
)}
{/* 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>
{/* File editor — its own subpage. Files are only reachable while the server
is stopped (the world volume is RWO), so the page itself owns that gate
and offers the stop action; the doorway shows at every phase. */}
<Link
to={`/servers/${name}/files`}
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"
>
<FolderOpen className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("files_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("files_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>
);
}