Initial push
This commit is contained in:
254
app/(dashboard)/console/page.tsx
Normal file
254
app/(dashboard)/console/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Terminal, Send, Trash2, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface LogLine {
|
||||
text: string;
|
||||
timestamp: number;
|
||||
type: "info" | "warn" | "error" | "raw";
|
||||
}
|
||||
|
||||
function classifyLine(line: string): LogLine["type"] {
|
||||
if (/\[WARN\]|WARNING/i.test(line)) return "warn";
|
||||
if (/\[ERROR\]|SEVERE|Exception|Error/i.test(line)) return "error";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function LineColor({ type }: { type: LogLine["type"] }) {
|
||||
const colors = {
|
||||
info: "text-zinc-300",
|
||||
warn: "text-amber-400",
|
||||
error: "text-red-400",
|
||||
raw: "text-zinc-500",
|
||||
};
|
||||
return colors[type];
|
||||
}
|
||||
|
||||
const MAX_LINES = 1000;
|
||||
|
||||
export default function ConsolePage() {
|
||||
const [lines, setLines] = useState<LogLine[]>([]);
|
||||
const [command, setCommand] = useState("");
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io("/console", {
|
||||
transports: ["websocket"],
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect", () => setConnected(true));
|
||||
socket.on("disconnect", () => setConnected(false));
|
||||
socket.on("connect_error", () => {
|
||||
toast.error("Failed to connect to server console");
|
||||
});
|
||||
|
||||
socket.on("output", (data: { line: string; timestamp: number }) => {
|
||||
setLines((prev) => {
|
||||
const newLine: LogLine = {
|
||||
text: data.line,
|
||||
timestamp: data.timestamp,
|
||||
type: classifyLine(data.line),
|
||||
};
|
||||
const updated = [...prev, newLine];
|
||||
return updated.length > MAX_LINES ? updated.slice(-MAX_LINES) : updated;
|
||||
});
|
||||
});
|
||||
|
||||
// Receive buffered history on connect
|
||||
socket.on("history", (data: { lines: string[] }) => {
|
||||
const historicalLines = data.lines.map((line) => ({
|
||||
text: line,
|
||||
timestamp: Date.now(),
|
||||
type: classifyLine(line) as LogLine["type"],
|
||||
}));
|
||||
setLines(historicalLines);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [lines]);
|
||||
|
||||
const sendCommand = useCallback(() => {
|
||||
const cmd = command.trim();
|
||||
if (!cmd || !socketRef.current) return;
|
||||
|
||||
// Add to local history
|
||||
setHistory((prev) => {
|
||||
const updated = [cmd, ...prev.filter((h) => h !== cmd)].slice(0, 50);
|
||||
return updated;
|
||||
});
|
||||
setHistoryIndex(-1);
|
||||
|
||||
// Echo to console
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{ text: `> ${cmd}`, timestamp: Date.now(), type: "raw" },
|
||||
]);
|
||||
|
||||
socketRef.current.emit("command", { command: cmd });
|
||||
setCommand("");
|
||||
}, [command]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
sendCommand();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const newIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||
setHistoryIndex(newIndex);
|
||||
if (history[newIndex]) setCommand(history[newIndex]);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const newIndex = Math.max(historyIndex - 1, -1);
|
||||
setHistoryIndex(newIndex);
|
||||
setCommand(newIndex === -1 ? "" : history[newIndex] ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (ts: number) =>
|
||||
new Date(ts).toLocaleTimeString("en", { hour12: false });
|
||||
|
||||
return (
|
||||
<div className="p-6 h-full flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Server Console</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Real-time server output and command input
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
className={
|
||||
connected
|
||||
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
|
||||
: "bg-red-500/20 text-red-400 border-red-500/30"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
|
||||
connected ? "bg-emerald-500" : "bg-red-500"
|
||||
} animate-pulse`}
|
||||
/>
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
onClick={() => setLines([])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800 flex-1 min-h-0 flex flex-col">
|
||||
<CardHeader className="py-3 px-4 border-b border-zinc-800 flex-row items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-emerald-500" />
|
||||
<CardTitle className="text-sm font-medium text-zinc-400">
|
||||
Console Output
|
||||
</CardTitle>
|
||||
<span className="ml-auto text-xs text-zinc-600">
|
||||
{lines.length} lines
|
||||
</span>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0 flex-1 min-h-0">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
autoScrollRef.current =
|
||||
el.scrollTop + el.clientHeight >= el.scrollHeight - 20;
|
||||
}}
|
||||
className="h-full overflow-y-auto font-mono text-xs p-4 space-y-0.5"
|
||||
style={{ maxHeight: "calc(100vh - 280px)" }}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-40 text-zinc-600">
|
||||
<Terminal className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p>Waiting for server output...</p>
|
||||
</div>
|
||||
) : (
|
||||
lines.map((line, i) => (
|
||||
<div key={i} className="flex gap-3 leading-5">
|
||||
<span className="text-zinc-700 shrink-0 select-none">
|
||||
{formatTime(line.timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className={`break-all ${
|
||||
{
|
||||
info: "text-zinc-300",
|
||||
warn: "text-amber-400",
|
||||
error: "text-red-400",
|
||||
raw: "text-emerald-400",
|
||||
}[line.type]
|
||||
}`}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Command input */}
|
||||
<div className="p-3 border-t border-zinc-800 flex gap-2">
|
||||
<div className="flex-1 flex items-center gap-2 bg-zinc-950 border border-zinc-700 rounded-md px-3 focus-within:border-emerald-500/50 transition-colors">
|
||||
<span className="text-emerald-500 font-mono text-sm select-none">
|
||||
/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter server command... (↑↓ for history)"
|
||||
disabled={!connected}
|
||||
className="flex-1 bg-transparent py-2 text-sm text-white placeholder:text-zinc-600 outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={sendCommand}
|
||||
disabled={!connected || !command.trim()}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white shrink-0"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!connected && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
Console disconnected. The server may be offline or restarting.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user