Phase3
This commit is contained in:
86
apps/web/src/lib/api/console.ts
Normal file
86
apps/web/src/lib/api/console.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { getApiUrl } from "../env";
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export interface ConsoleTokenResponse {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ConsoleWebSocketHandlers {
|
||||
onOpen?: () => void;
|
||||
onClose?: (event: CloseEvent) => void;
|
||||
onError?: (event: Event) => void;
|
||||
onLine?: (line: string, stream?: string) => void;
|
||||
}
|
||||
|
||||
interface ConsoleLogMessage {
|
||||
type?: string;
|
||||
line?: string;
|
||||
stream?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function getConsoleWebSocketUrl(serverId: string, token: string): string {
|
||||
const apiUrl = new URL(getApiUrl());
|
||||
apiUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
const basePath = apiUrl.pathname.replace(/\/$/, "");
|
||||
apiUrl.pathname = `${basePath}/servers/${serverId}/console/ws`;
|
||||
apiUrl.search = new URLSearchParams({ token }).toString();
|
||||
return apiUrl.toString();
|
||||
}
|
||||
|
||||
export async function getConsoleToken(serverId: string): Promise<ConsoleTokenResponse> {
|
||||
return apiFetch<ConsoleTokenResponse>(`/servers/${serverId}/console/token`);
|
||||
}
|
||||
|
||||
export function createConsoleWebSocket(
|
||||
serverId: string,
|
||||
token: string,
|
||||
handlers: ConsoleWebSocketHandlers,
|
||||
): WebSocket {
|
||||
const socket = new WebSocket(getConsoleWebSocketUrl(serverId, token));
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
handlers.onOpen?.();
|
||||
});
|
||||
|
||||
socket.addEventListener("close", (event) => {
|
||||
handlers.onClose?.(event);
|
||||
});
|
||||
|
||||
socket.addEventListener("error", (event) => {
|
||||
handlers.onError?.(event);
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const raw = typeof event.data === "string" ? event.data : "";
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as ConsoleLogMessage;
|
||||
if (parsed.type === "log" && parsed.line) {
|
||||
handlers.onLine?.(parsed.line, parsed.stream);
|
||||
return;
|
||||
}
|
||||
if (parsed.line) {
|
||||
handlers.onLine?.(parsed.line, parsed.stream);
|
||||
return;
|
||||
}
|
||||
if (parsed.message) {
|
||||
handlers.onLine?.(parsed.message, parsed.stream);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Plain text log line
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
handlers.onLine?.(raw);
|
||||
}
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function sendConsoleCommand(socket: WebSocket, command: string): void {
|
||||
socket.send(JSON.stringify({ command }));
|
||||
}
|
||||
61
apps/web/src/lib/api/files.ts
Normal file
61
apps/web/src/lib/api/files.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListFilesResponse {
|
||||
path: string;
|
||||
entries: FileEntry[];
|
||||
}
|
||||
|
||||
export interface FileContentResponse {
|
||||
path: string;
|
||||
content: string;
|
||||
truncated?: boolean;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface WriteFilePayload {
|
||||
path: string;
|
||||
content: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export async function listFiles(
|
||||
serverId: string,
|
||||
path = "",
|
||||
): Promise<ListFilesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (path) {
|
||||
params.set("path", path);
|
||||
}
|
||||
const query = params.toString();
|
||||
return apiFetch<ListFilesResponse>(
|
||||
`/servers/${serverId}/files${query ? `?${query}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function readFile(
|
||||
serverId: string,
|
||||
path: string,
|
||||
): Promise<FileContentResponse> {
|
||||
const params = new URLSearchParams({ path });
|
||||
return apiFetch<FileContentResponse>(
|
||||
`/servers/${serverId}/files/content?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeFile(
|
||||
serverId: string,
|
||||
payload: WriteFilePayload,
|
||||
): Promise<FileContentResponse> {
|
||||
return apiFetch<FileContentResponse>(`/servers/${serverId}/files/content`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
17
apps/web/src/lib/api/players.ts
Normal file
17
apps/web/src/lib/api/players.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export interface OnlinePlayer {
|
||||
name: string;
|
||||
uuid?: string;
|
||||
ping?: number;
|
||||
}
|
||||
|
||||
export interface PlayerListResponse {
|
||||
online: OnlinePlayer[];
|
||||
maxPlayers: number;
|
||||
whitelistCount: number;
|
||||
}
|
||||
|
||||
export async function getPlayers(serverId: string): Promise<PlayerListResponse> {
|
||||
return apiFetch<PlayerListResponse>(`/servers/${serverId}/players`);
|
||||
}
|
||||
36
apps/web/src/lib/api/properties.ts
Normal file
36
apps/web/src/lib/api/properties.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export type GameMode = "survival" | "creative" | "adventure" | "spectator";
|
||||
export type Difficulty = "peaceful" | "easy" | "normal" | "hard";
|
||||
|
||||
export interface ServerProperties {
|
||||
motd: string;
|
||||
gamemode: GameMode;
|
||||
difficulty: Difficulty;
|
||||
maxPlayers: number;
|
||||
pvp: boolean;
|
||||
whitelist: boolean;
|
||||
hardcore?: boolean;
|
||||
onlineMode?: boolean;
|
||||
enableCommandBlock?: boolean;
|
||||
viewDistance?: number;
|
||||
simulationDistance?: number;
|
||||
spawnProtection?: number;
|
||||
allowFlight?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateServerPropertiesPayload = Partial<ServerProperties>;
|
||||
|
||||
export async function getProperties(serverId: string): Promise<ServerProperties> {
|
||||
return apiFetch<ServerProperties>(`/servers/${serverId}/properties`);
|
||||
}
|
||||
|
||||
export async function updateProperties(
|
||||
serverId: string,
|
||||
payload: UpdateServerPropertiesPayload,
|
||||
): Promise<ServerProperties> {
|
||||
return apiFetch<ServerProperties>(`/servers/${serverId}/properties`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
69
apps/web/src/lib/utils/ansi.ts
Normal file
69
apps/web/src/lib/utils/ansi.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
const ANSI_PATTERN =
|
||||
/\u001b\[[0-9;]*m|\u001b\]8;;.*?\u001b\\|\u001b\][0-9];.*?\u0007/g;
|
||||
|
||||
const ANSI_COLOR_MAP: Record<string, string> = {
|
||||
"30": "text-zinc-400",
|
||||
"31": "text-red-400",
|
||||
"32": "text-green-400",
|
||||
"33": "text-yellow-400",
|
||||
"34": "text-blue-400",
|
||||
"35": "text-purple-400",
|
||||
"36": "text-cyan-400",
|
||||
"37": "text-zinc-200",
|
||||
"90": "text-zinc-500",
|
||||
"91": "text-red-300",
|
||||
"92": "text-green-300",
|
||||
"93": "text-yellow-300",
|
||||
"94": "text-blue-300",
|
||||
"95": "text-purple-300",
|
||||
"96": "text-cyan-300",
|
||||
"97": "text-zinc-100",
|
||||
};
|
||||
|
||||
export function stripAnsi(text: string): string {
|
||||
return text.replace(ANSI_PATTERN, "");
|
||||
}
|
||||
|
||||
export interface AnsiSegment {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function parseAnsiLine(line: string): AnsiSegment[] {
|
||||
const segments: AnsiSegment[] = [];
|
||||
let currentClass: string | undefined;
|
||||
let buffer = "";
|
||||
const regex = /\u001b\[([0-9;]*)m/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(line)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
const text = line.slice(lastIndex, match.index);
|
||||
if (text) {
|
||||
segments.push({ text, className: currentClass });
|
||||
}
|
||||
}
|
||||
|
||||
const codes = match[1].split(";").filter(Boolean);
|
||||
if (codes.length === 0 || codes.includes("0")) {
|
||||
currentClass = undefined;
|
||||
} else {
|
||||
const colorCode = codes.find((code) => ANSI_COLOR_MAP[code]);
|
||||
currentClass = colorCode ? ANSI_COLOR_MAP[colorCode] : currentClass;
|
||||
}
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
buffer = line.slice(lastIndex).replace(ANSI_PATTERN, "");
|
||||
if (buffer) {
|
||||
segments.push({ text: buffer, className: currentClass });
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
return [{ text: stripAnsi(line) }];
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
Reference in New Issue
Block a user