Refine management terminal logs and command UX
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
|
import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
|
||||||
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type { LogEntryBody, LogStreamResponse } from "../api/types";
|
import type { LogEntryBody, LogStreamResponse } from "../api/types";
|
||||||
@@ -9,16 +9,24 @@ import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews"
|
|||||||
|
|
||||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||||
type LiveLogEntry = LogEntryBody & { source: string; streamId: string; streamKey: string };
|
type LiveLogEntry = LogEntryBody & { source: string; streamId: string; streamKey: string };
|
||||||
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "error"; text: string; at: string };
|
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
|
||||||
|
type TerminalQuickCommand = { label: string; command: string; hint: string };
|
||||||
|
|
||||||
const liveLogPollMs = 2000;
|
const liveLogPollMs = 1000;
|
||||||
|
const terminalLogPollMs = 1000;
|
||||||
|
const logStreamPollMs = 5000;
|
||||||
|
const liveLogHistoryWindow = 100;
|
||||||
|
const terminalLogQueryLimit = 150;
|
||||||
|
const terminalHistoryWindow = 150;
|
||||||
const maxLogEntries = 500;
|
const maxLogEntries = 500;
|
||||||
const terminalQuickCommands = [
|
const maxTerminalLines = 600;
|
||||||
{ label: "查询玩家", command: "ListPlayers" },
|
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||||
{ label: "服务器状态", command: "ServerInfo" },
|
"game.scum": [
|
||||||
{ label: "设为中午", command: "SetTime 12" },
|
{ label: "查询玩家", command: "ListPlayers", hint: "SCUM RCON" },
|
||||||
{ label: "保存世界", command: "SaveWorld" }
|
{ label: "服务器信息", command: "ServerInfo", hint: "SCUM RCON" },
|
||||||
];
|
{ label: "设为中午", command: "SetTime 12", hint: "SCUM RCON" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
interface LiveOperationDrawerProps {
|
interface LiveOperationDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -29,9 +37,10 @@ interface LiveOperationDrawerProps {
|
|||||||
backdropClassName?: string;
|
backdropClassName?: string;
|
||||||
panelClassName?: string;
|
panelClassName?: string;
|
||||||
bodyClassName?: string;
|
bodyClassName?: string;
|
||||||
|
hideHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LiveOperationDrawer({ open, title, description, onClose, children, backdropClassName, panelClassName, bodyClassName }: LiveOperationDrawerProps) {
|
function LiveOperationDrawer({ open, title, description, onClose, children, backdropClassName, panelClassName, bodyClassName, hideHeader }: LiveOperationDrawerProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return undefined;
|
if (!open) return undefined;
|
||||||
const previous = document.body.style.overflow;
|
const previous = document.body.style.overflow;
|
||||||
@@ -50,6 +59,7 @@ function LiveOperationDrawer({ open, title, description, onClose, children, back
|
|||||||
return (
|
return (
|
||||||
<div className={cx("drawer-backdrop", backdropClassName)} role="presentation" onClick={onClose}>
|
<div className={cx("drawer-backdrop", backdropClassName)} role="presentation" onClick={onClose}>
|
||||||
<aside className={cx("drawer-panel live-operation-drawer", panelClassName)} role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
|
<aside className={cx("drawer-panel live-operation-drawer", panelClassName)} role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
|
||||||
|
{!hideHeader && (
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
@@ -57,6 +67,7 @@ function LiveOperationDrawer({ open, title, description, onClose, children, back
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onClose}><X size={14} /><span>关闭</span></button>
|
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onClose}><X size={14} /><span>关闭</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className={cx("live-operation-content", bodyClassName)}>{children}</div>
|
<div className={cx("live-operation-content", bodyClassName)}>{children}</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,9 +90,9 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
|
|||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [lastRefreshAt, setLastRefreshAt] = useState("");
|
const [lastRefreshAt, setLastRefreshAt] = useState("");
|
||||||
|
|
||||||
const loadStreams = useCallback(async () => {
|
const loadStreams = useCallback(async (showLoading = true) => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setStreams({ status: "loading" });
|
if (showLoading) setStreams({ status: "loading" });
|
||||||
try {
|
try {
|
||||||
const response = await platformApiClient.listServerLiveLogs(serverId);
|
const response = await platformApiClient.listServerLiveLogs(serverId);
|
||||||
setStreams({ status: "ready", data: response.items });
|
setStreams({ status: "ready", data: response.items });
|
||||||
@@ -99,13 +110,20 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
|
|||||||
void loadStreams();
|
void loadStreams();
|
||||||
}, [loadStreams, open]);
|
}, [loadStreams, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [loadStreams, open]);
|
||||||
|
|
||||||
const selectedStream = streams.status === "ready" ? streams.data.find((stream) => stream.id === selectedStreamId) : undefined;
|
const selectedStream = streams.status === "ready" ? streams.data.find((stream) => stream.id === selectedStreamId) : undefined;
|
||||||
|
|
||||||
const tailSelectedStream = useCallback(async () => {
|
const tailSelectedStream = useCallback(async () => {
|
||||||
if (!open || !selectedStream) return;
|
if (!open || !selectedStream) return;
|
||||||
const afterSeq = cursorByStream[selectedStream.id] ?? 0;
|
const afterSeq = cursorByStream[selectedStream.id] ?? initialLogCursor(selectedStream, liveLogHistoryWindow);
|
||||||
const cursor = await platformApiClient.queryLogStream({ logStreamId: selectedStream.id, afterSeq, limit: 100 });
|
const cursor = await platformApiClient.queryLogStream({ logStreamId: selectedStream.id, afterSeq, limit: 100 });
|
||||||
setCursorByStream((current) => ({ ...current, [selectedStream.id]: Math.max(current[selectedStream.id] ?? 0, cursor.nextSeq, cursor.latestSeq) }));
|
const nextSeq = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq);
|
||||||
|
setCursorByStream((current) => updateCursor(current, selectedStream.id, nextSeq));
|
||||||
setLastRefreshAt(new Date().toLocaleTimeString());
|
setLastRefreshAt(new Date().toLocaleTimeString());
|
||||||
if (cursor.entries.length === 0) return;
|
if (cursor.entries.length === 0) return;
|
||||||
setEntries((current) => [
|
setEntries((current) => [
|
||||||
@@ -131,10 +149,20 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
|
|||||||
if (selectedStream) setCursorByStream((current) => ({ ...current, [selectedStream.id]: selectedStream.latestSeq }));
|
if (selectedStream) setCursorByStream((current) => ({ ...current, [selectedStream.id]: selectedStream.latestSeq }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectLogStream(nextStreamId: string) {
|
||||||
|
setSelectedStreamId(nextStreamId);
|
||||||
|
setEntries([]);
|
||||||
|
setCursorByStream((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[nextStreamId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LiveOperationDrawer open={open} title="实时日志" description={`${serverName} · 每 ${liveLogPollMs / 1000} 秒刷新一次平台日志游标`} onClose={onClose}>
|
<LiveOperationDrawer open={open} title="实时日志" description={`${serverName} · 每 ${liveLogPollMs / 1000} 秒刷新一次平台日志游标`} onClose={onClose}>
|
||||||
<div className="log-filter-bar live-operation-toolbar">
|
<div className="log-filter-bar live-operation-toolbar">
|
||||||
<select value={selectedStreamId} aria-label="选择日志源" onChange={(event) => { setSelectedStreamId(event.target.value); setEntries([]); setCursorByStream((current) => ({ ...current, [event.target.value]: 0 })); }}>
|
<select value={selectedStreamId} aria-label="选择日志源" onChange={(event) => selectLogStream(event.target.value)}>
|
||||||
{streams.status === "ready" && streams.data.map((stream) => <option key={stream.id} value={stream.id}>{stream.streamKey || stream.source} · seq {stream.latestSeq}</option>)}
|
{streams.status === "ready" && streams.data.map((stream) => <option key={stream.id} value={stream.id}>{stream.streamKey || stream.source} · seq {stream.latestSeq}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input type="search" value={keyword} placeholder="过滤可见日志" aria-label="过滤可见日志" onChange={(event) => setKeyword(event.target.value)} />
|
<input type="search" value={keyword} placeholder="过滤可见日志" aria-label="过滤可见日志" onChange={(event) => setKeyword(event.target.value)} />
|
||||||
@@ -175,60 +203,173 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
const [command, setCommand] = useState("");
|
const [command, setCommand] = useState("");
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [lines, setLines] = useState<TerminalLine[]>([]);
|
const [lines, setLines] = useState<TerminalLine[]>([]);
|
||||||
|
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||||
|
const [cursorByStream, setCursorByStream] = useState<Record<string, number>>({});
|
||||||
|
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||||
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||||
const supportsCommands = pluginId === "game.scum";
|
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
|
||||||
|
const supportsCommands = quickCommands.length > 0;
|
||||||
|
const terminalStreams = useMemo(() => streams.status === "ready" ? terminalRelevantStreams(streams.data) : [], [streams]);
|
||||||
|
|
||||||
|
const appendLines = useCallback((incoming: TerminalLine[]) => {
|
||||||
|
if (incoming.length === 0) return;
|
||||||
|
setLines((current) => mergeTerminalLines(current, incoming));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadStreams = useCallback(async (showLoading = true) => {
|
||||||
|
if (!open) return;
|
||||||
|
if (showLoading) setStreams({ status: "loading" });
|
||||||
|
try {
|
||||||
|
const response = await platformApiClient.listServerLiveLogs(serverId);
|
||||||
|
setStreams({ status: "ready", data: response.items });
|
||||||
|
} catch (error) {
|
||||||
|
setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" });
|
||||||
|
}
|
||||||
|
}, [open, serverId]);
|
||||||
|
|
||||||
|
const tailTerminalLogs = useCallback(async (targetStreams = terminalStreams) => {
|
||||||
|
if (!open || targetStreams.length === 0) return;
|
||||||
|
const cursorUpdates: Record<string, number> = {};
|
||||||
|
const batches = await Promise.all(targetStreams.map(async (stream) => {
|
||||||
|
const afterSeq = cursorByStream[stream.id] ?? initialLogCursor(stream, terminalHistoryWindow);
|
||||||
|
const cursor = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq, limit: terminalLogQueryLimit });
|
||||||
|
cursorUpdates[stream.id] = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq);
|
||||||
|
return cursor.entries.map((entry) => terminalLineFromLog(stream, entry));
|
||||||
|
}));
|
||||||
|
setCursorByStream((current) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = { ...current };
|
||||||
|
for (const [streamId, cursor] of Object.entries(cursorUpdates)) {
|
||||||
|
const nextCursor = Math.max(next[streamId] ?? 0, cursor);
|
||||||
|
if (nextCursor !== next[streamId]) {
|
||||||
|
next[streamId] = nextCursor;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : current;
|
||||||
|
});
|
||||||
|
appendLines(batches.flat().sort(compareTerminalLines));
|
||||||
|
}, [appendLines, cursorByStream, open, terminalStreams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setCommand("");
|
setCommand("");
|
||||||
setPending(false);
|
setPending(false);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setLines([{ id: `open-${Date.now()}`, tone: "info", text: supportsCommands ? "SCUM 管理终端已连接到平台 Source RCON 调度通道。" : "该插件暂未声明可用的管理终端命令通道。", at: new Date().toLocaleTimeString() }]);
|
setCursorByStream({});
|
||||||
}, [open, supportsCommands]);
|
setHistoryIndex(null);
|
||||||
|
setLines([terminalSystemLine("info", supportsCommands ? "读取平台历史日志,后续按游标实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
|
||||||
|
void loadStreams();
|
||||||
|
}, [loadStreams, open, supportsCommands]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [loadStreams, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || streams.status !== "ready") return;
|
||||||
|
void tailTerminalLogs(terminalRelevantStreams(streams.data)).catch((error) => appendLines([terminalSystemLine("error", error instanceof Error ? error.message : "历史日志读取失败", "LOGS")]));
|
||||||
|
}, [appendLines, open, streams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || terminalStreams.length === 0) return undefined;
|
||||||
|
const timer = window.setInterval(() => void tailTerminalLogs().catch(() => undefined), terminalLogPollMs);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [open, tailTerminalLogs, terminalStreams]);
|
||||||
|
|
||||||
|
function selectQuickCommand(item: TerminalQuickCommand) {
|
||||||
|
setCommand(item.command);
|
||||||
|
setHistoryIndex(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommandKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
||||||
|
if (commandHistory.length === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const lastIndex = commandHistory.length - 1;
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
const nextIndex = historyIndex === null ? lastIndex : Math.max(0, historyIndex - 1);
|
||||||
|
setHistoryIndex(nextIndex);
|
||||||
|
setCommand(commandHistory[nextIndex] ?? "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (historyIndex === null) return;
|
||||||
|
const nextIndex = historyIndex + 1;
|
||||||
|
if (nextIndex > lastIndex) {
|
||||||
|
setHistoryIndex(null);
|
||||||
|
setCommand("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHistoryIndex(nextIndex);
|
||||||
|
setCommand(commandHistory[nextIndex] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTerminalBuffer() {
|
||||||
|
setLines([]);
|
||||||
|
}
|
||||||
|
|
||||||
async function submitCommand(event: FormEvent<HTMLFormElement>) {
|
async function submitCommand(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!supportsCommands || !canManage || pending || !command.trim()) return;
|
if (!supportsCommands || !canManage || pending || !command.trim()) return;
|
||||||
const submitted = command.trim();
|
const submitted = command.trim();
|
||||||
setCommand("");
|
setCommand("");
|
||||||
|
setHistoryIndex(null);
|
||||||
|
setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50));
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setResult({ status: "pending", label: "正在提交命令" });
|
setResult({ status: "pending", label: "正在提交命令" });
|
||||||
setLines((current) => [...current, { id: `input-${Date.now()}`, tone: "input", text: `> ${submitted}`, at: new Date().toLocaleTimeString() }]);
|
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
|
||||||
try {
|
try {
|
||||||
const response = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, submitted));
|
const response = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, submitted));
|
||||||
const label = `已排队 · 任务 ${response.jobId}`;
|
const label = `已排队 · 任务 ${response.jobId}`;
|
||||||
setResult({ status: "succeeded", label });
|
setResult({ status: "succeeded", label });
|
||||||
setLines((current) => [...current, { id: `ok-${response.jobId}`, tone: "success", text: `${label} · ${response.message || response.status}`, at: new Date().toLocaleTimeString() }]);
|
appendLines([terminalSystemLine("success", `${label} · ${response.message || response.status}`, "PLATFORM", `ok-${response.jobId}`)]);
|
||||||
|
void tailTerminalLogs().catch(() => undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const label = error instanceof Error ? error.message : "命令提交失败";
|
const label = error instanceof Error ? error.message : "命令提交失败";
|
||||||
setResult({ status: "failed", label });
|
setResult({ status: "failed", label });
|
||||||
setLines((current) => [...current, { id: `err-${Date.now()}`, tone: "error", text: label, at: new Date().toLocaleTimeString() }]);
|
appendLines([terminalSystemLine("error", label, "ERROR")]);
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LiveOperationDrawer open={open} title="管理终端" description={`${serverName} · 平台授权的一次性命令调度`} onClose={onClose} backdropClassName="terminal-drawer-backdrop" panelClassName="management-terminal-drawer" bodyClassName="management-terminal-body">
|
<LiveOperationDrawer open={open} title="管理终端" onClose={onClose} backdropClassName="terminal-drawer-backdrop" panelClassName="management-terminal-drawer" bodyClassName="management-terminal-body" hideHeader>
|
||||||
<section className="terminal-output-panel" aria-label="terminal output">
|
<section className="terminal-output-panel" aria-label="terminal output">
|
||||||
|
<div className="terminal-output-topbar">
|
||||||
|
<div>
|
||||||
|
<strong>{serverName}</strong>
|
||||||
|
<span>最近历史 + {terminalLogPollMs / 1000}s 实时刷新 · 日志源 {logStreamPollMs / 1000}s 探测 · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
||||||
|
<button type="button" className="terminal-output-action" aria-label="关闭管理终端" onClick={onClose}><X size={14} /><span>关闭</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="terminal-output" role="log" aria-live="polite">
|
<div className="terminal-output" role="log" aria-live="polite">
|
||||||
{lines.map((line) => <div key={line.id} className={`terminal-line terminal-line-${line.tone}`}><time>{line.at}</time><span>{line.text}</span></div>)}
|
{streams.status === "error" && <div className="terminal-line terminal-line-error"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">{streams.reason}</span></div>}
|
||||||
|
{streams.status === "ready" && streams.data.length === 0 && <div className="terminal-line terminal-line-warn"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">暂无日志源。需要 Run 上报或历史日志回填后,这里才会持续追加。</span></div>}
|
||||||
|
{lines.map((line) => <div key={line.id} className={`terminal-line terminal-line-${line.tone}`}><time>{line.at}</time><span className="terminal-stream">{line.streamKey || line.level || "LOG"}</span><span className="terminal-text">{line.text}</span></div>)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="terminal-command-dock" aria-label="terminal command controls">
|
<section className="terminal-command-dock" aria-label="terminal command controls">
|
||||||
<div className="terminal-command-dock-header">
|
<div className="terminal-command-dock-header">
|
||||||
<span><ListChecks size={14} />快捷指令</span>
|
<span><ListChecks size={14} />{quickCommands.length > 0 ? "插件快捷指令" : "命令输入"}</span>
|
||||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="terminal-quick-command-list">
|
{quickCommands.length > 0 && <div className="terminal-quick-command-list">
|
||||||
{terminalQuickCommands.map((item) => <button key={item.command} type="button" className="terminal-quick-command" disabled={!supportsCommands || !canManage || pending} onClick={() => setCommand(item.command)}><strong>{item.label}</strong><span>{item.command}</span></button>)}
|
{quickCommands.map((item) => <button key={item.command} type="button" className="terminal-quick-command" disabled={!canManage || pending} onClick={() => selectQuickCommand(item)}><strong>{item.label}</strong><span>{item.command}</span><small>{item.hint}</small></button>)}
|
||||||
</div>
|
</div>}
|
||||||
{!supportsCommands && <EmptyState icon={<Terminal size={24} />} title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />}
|
{!supportsCommands && <EmptyState icon={<Terminal size={24} />} title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />}
|
||||||
{supportsCommands && (
|
{supportsCommands && (
|
||||||
<form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
|
<form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
|
||||||
<label>
|
<label>
|
||||||
SCUM 管理命令
|
<span>SCUM 管理命令</span>
|
||||||
<input value={command} disabled={!canManage || pending} placeholder={canManage ? "输入单行命令,回车提交" : "当前账号没有运行操作权限"} onChange={(event) => setCommand(event.target.value)} />
|
<small>Enter 发送,↑/↓ 调出历史;命令结果以 Run 日志追加为准。</small>
|
||||||
|
<input value={command} disabled={!canManage || pending} placeholder={canManage ? "输入单行命令" : "当前账号没有运行操作权限"} onKeyDown={handleCommandKeyDown} onChange={(event) => { setCommand(event.target.value); setHistoryIndex(null); }} />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" className="primary-command" disabled={!canManage || pending || !command.trim()}>{pending ? <Sparkles size={15} /> : <Send size={15} />}<span>{pending ? "提交中…" : "发送"}</span></button>
|
<button type="submit" className="primary-command" disabled={!canManage || pending || !command.trim()}>{pending ? <Sparkles size={15} /> : <Send size={15} />}<span>{pending ? "提交中…" : "发送"}</span></button>
|
||||||
</form>
|
</form>
|
||||||
@@ -244,3 +385,79 @@ function levelClass(level?: string): string {
|
|||||||
if (normalized === "warn" || normalized === "warning") return "log-level-warn";
|
if (normalized === "warn" || normalized === "warning") return "log-level-warn";
|
||||||
return "log-level-info";
|
return "log-level-info";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initialLogCursor(stream: LogStreamResponse, historyWindow: number): number {
|
||||||
|
return Math.max(0, stream.latestSeq - historyWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextCursorSeq(afterSeq: number, entries: LogEntryBody[], nextSeq: number): number {
|
||||||
|
if (entries.length === 0) return Math.max(afterSeq, nextSeq);
|
||||||
|
return Math.max(afterSeq, nextSeq, entries[entries.length - 1]?.seq ?? afterSeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCursor(current: Record<string, number>, streamId: string, cursor: number): Record<string, number> {
|
||||||
|
const nextCursor = Math.max(current[streamId] ?? 0, cursor);
|
||||||
|
if (nextCursor === current[streamId]) return current;
|
||||||
|
return { ...current, [streamId]: nextCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
|
||||||
|
return terminalQuickCommandCatalog[pluginId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalRelevantStreams(streams: LogStreamResponse[]): LogStreamResponse[] {
|
||||||
|
return [...streams].sort(compareTerminalStreams).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTerminalStreams(a: LogStreamResponse, b: LogStreamResponse): number {
|
||||||
|
return terminalStreamRank(a) - terminalStreamRank(b) || a.streamKey.localeCompare(b.streamKey) || a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalStreamRank(stream: LogStreamResponse): number {
|
||||||
|
const key = `${stream.source}:${stream.streamKey}`.toLowerCase();
|
||||||
|
if (key.includes("management-program")) return 0;
|
||||||
|
if (key.includes("stderr")) return 1;
|
||||||
|
if (key.includes("stdout")) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
|
||||||
|
return {
|
||||||
|
id: `log-${stream.id}-${entry.seq}`,
|
||||||
|
tone: terminalTone(entry),
|
||||||
|
text: entry.line,
|
||||||
|
at: new Date(entry.timestamp).toLocaleTimeString(),
|
||||||
|
sortKey: Date.parse(entry.timestamp) || Date.now(),
|
||||||
|
streamKey: stream.streamKey || stream.source,
|
||||||
|
level: entry.level,
|
||||||
|
seq: entry.seq
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`): TerminalLine {
|
||||||
|
const now = Date.now();
|
||||||
|
return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalTone(entry: LogEntryBody): TerminalLine["tone"] {
|
||||||
|
const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase();
|
||||||
|
if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error";
|
||||||
|
if (/\b(warn|warning|timeout|retry)\b/.test(value)) return "warn";
|
||||||
|
if (/\b(success|succeeded|ready|started|online|listening|accepted)\b/.test(value)) return "success";
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTerminalLines(current: TerminalLine[], incoming: TerminalLine[]): TerminalLine[] {
|
||||||
|
const seen = new Set(current.map((line) => line.id));
|
||||||
|
const merged = [...current];
|
||||||
|
for (const line of incoming) {
|
||||||
|
if (seen.has(line.id)) continue;
|
||||||
|
seen.add(line.id);
|
||||||
|
merged.push(line);
|
||||||
|
}
|
||||||
|
return merged.sort(compareTerminalLines).slice(-maxTerminalLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTerminalLines(a: TerminalLine, b: TerminalLine): number {
|
||||||
|
return a.sortKey - b.sortKey || (a.seq ?? 0) - (b.seq ?? 0) || a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|||||||
@@ -292,9 +292,23 @@ describe("first-party console pages", () => {
|
|||||||
|
|
||||||
it("renders management terminal as a large command console with quick commands", () => {
|
it("renders management terminal as a large command console with quick commands", () => {
|
||||||
expect(serverLiveOperationsSource).toContain("management-terminal-drawer");
|
expect(serverLiveOperationsSource).toContain("management-terminal-drawer");
|
||||||
|
expect(serverLiveOperationsSource).toContain("terminal-output-topbar");
|
||||||
expect(serverLiveOperationsSource).toContain("terminal-command-dock");
|
expect(serverLiveOperationsSource).toContain("terminal-command-dock");
|
||||||
expect(serverLiveOperationsSource).toContain("terminalQuickCommands");
|
expect(serverLiveOperationsSource).toContain("terminalQuickCommandCatalog");
|
||||||
expect(serverLiveOperationsSource).toContain("setCommand(item.command)");
|
expect(serverLiveOperationsSource).toContain("terminalQuickCommandsForPlugin");
|
||||||
|
expect(serverLiveOperationsSource).toContain("selectQuickCommand(item)");
|
||||||
|
expect(serverLiveOperationsSource).toContain("hideHeader");
|
||||||
|
expect(serverLiveOperationsSource).toContain("handleCommandKeyDown");
|
||||||
|
expect(serverLiveOperationsSource).toContain("commandHistory");
|
||||||
|
expect(serverLiveOperationsSource).toContain("ArrowUp");
|
||||||
|
expect(serverLiveOperationsSource).toContain("listServerLiveLogs");
|
||||||
|
expect(serverLiveOperationsSource).toContain("queryLogStream");
|
||||||
|
expect(serverLiveOperationsSource).toContain("terminalLogPollMs = 1000");
|
||||||
|
expect(serverLiveOperationsSource).toContain("logStreamPollMs = 5000");
|
||||||
|
expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
|
||||||
|
expect(serverLiveOperationsSource).toContain("nextCursorSeq");
|
||||||
|
expect(serverLiveOperationsSource).toContain("initialLogCursor");
|
||||||
|
expect(serverLiveOperationsSource).not.toContain("SaveWorld");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders plugin catalog bridge readiness", () => {
|
it("renders plugin catalog bridge readiness", () => {
|
||||||
|
|||||||
@@ -168,9 +168,16 @@ describe("platform web shared theme CSS", () => {
|
|||||||
it("keeps the management terminal opaque and large", () => {
|
it("keeps the management terminal opaque and large", () => {
|
||||||
const css = compact(readThemeCss());
|
const css = compact(readThemeCss());
|
||||||
|
|
||||||
|
expect(css).toContain(".terminal-drawer-backdrop{align-items:flex-end;justify-content:center;padding:10dvh00;background:#000;backdrop-filter:none}");
|
||||||
expect(css).toContain(".management-terminal-drawer{width:100%;max-width:none;height:90dvh;max-height:90dvh");
|
expect(css).toContain(".management-terminal-drawer{width:100%;max-width:none;height:90dvh;max-height:90dvh");
|
||||||
expect(css).toContain("background:#05090f;backdrop-filter:none");
|
expect(css).toContain("background:#000;backdrop-filter:none;box-shadow:none");
|
||||||
expect(css).toContain(".management-terminal-body{height:100%;grid-template-rows:minmax(0,1fr)auto");
|
expect(css).toContain(".management-terminal-body{height:100%;grid-template-rows:minmax(0,1fr)auto");
|
||||||
|
expect(css).toContain(".terminal-output-panel{display:grid;grid-template-rows:autominmax(0,1fr);min-height:0;border:1pxsolid#222;border-radius:8px;background:#000");
|
||||||
|
expect(css).toContain(".terminal-output-topbar{display:flex;align-items:center;justify-content:space-between");
|
||||||
|
expect(css).toContain(".terminal-line{display:grid;grid-template-columns:76px148pxminmax(0,1fr)");
|
||||||
|
expect(css).toContain(".terminal-line-success.terminal-stream,.terminal-line-success.terminal-text{color:#7ee787}");
|
||||||
|
expect(css).toContain(".terminal-line-warn.terminal-stream,.terminal-line-warn.terminal-text{color:#d29922}");
|
||||||
|
expect(css).toContain(".terminal-line-error.terminal-stream,.terminal-line-error.terminal-text{color:#ff7b72}");
|
||||||
expect(css).toContain(".terminal-quick-command-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr))");
|
expect(css).toContain(".terminal-quick-command-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr))");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+19
-20
@@ -490,31 +490,29 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
|||||||
.live-operation-toolbar .icon-command{min-height:36px}
|
.live-operation-toolbar .icon-command{min-height:36px}
|
||||||
.live-log-list{max-height:min(58dvh,560px)}
|
.live-log-list{max-height:min(58dvh,560px)}
|
||||||
.live-log-line{cursor:default}
|
.live-log-line{cursor:default}
|
||||||
.terminal-drawer-backdrop{align-items:flex-end;justify-content:center;padding:5dvh 0 0;background:rgba(2,6,10,.76);backdrop-filter:none}
|
.terminal-drawer-backdrop{align-items:flex-end;justify-content:center;padding:10dvh 0 0;background:#000;backdrop-filter:none}
|
||||||
.management-terminal-drawer{width:100%;max-width:none;height:90dvh;max-height:90dvh;align-content:stretch;grid-template-rows:auto minmax(0,1fr);padding:16px 18px;border:1px solid var(--line-strong);border-bottom:0;border-radius:8px 8px 0 0;background:#05090f;backdrop-filter:none;box-shadow:0 -18px 48px rgba(0,0,0,.56),inset 0 1px 0 color-mix(in srgb,var(--rim-light) 24%,transparent)}
|
.management-terminal-drawer{width:100%;max-width:none;height:90dvh;max-height:90dvh;align-content:stretch;grid-template-rows:minmax(0,1fr);padding:12px 14px;border:1px solid #242424;border-bottom:0;border-radius:8px 8px 0 0;background:#000;backdrop-filter:none;box-shadow:none}
|
||||||
.management-terminal-drawer>.panel-header{top:-16px;margin:-16px -18px 0;padding:14px 18px 12px;background:#07101a;-webkit-backdrop-filter:none;backdrop-filter:none}
|
.management-terminal-body{height:100%;grid-template-rows:minmax(0,1fr) auto;gap:10px}
|
||||||
.management-terminal-body{height:100%;grid-template-rows:minmax(0,1fr) auto;gap:12px}
|
.terminal-output-panel{display:grid;grid-template-rows:auto minmax(0,1fr);min-height:0;border:1px solid #222;border-radius:8px;background:#000;overflow:hidden}
|
||||||
.terminal-output-panel{display:grid;min-height:0}
|
.terminal-output-topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:44px;padding:8px 10px;border-bottom:1px solid #1f2933;background:#000;color:#dbeafe}
|
||||||
.terminal-output{display:grid;gap:6px;min-height:260px;max-height:min(54dvh,520px);overflow:auto;padding:12px;border-radius:8px;background:radial-gradient(circle at 92% 0,rgba(255,255,255,.1),transparent 36%),var(--code-surface);font-family:var(--font-mono);font-size:12.5px;color:var(--code-ink);box-shadow:inset 0 1px 0 rgba(255,255,255,.16),0 14px 32px rgba(255,255,255,.12)}
|
.terminal-output-topbar>div{display:flex;align-items:center;gap:8px;min-width:0}.terminal-output-topbar>div:first-child{display:grid;gap:2px}.terminal-output-topbar strong{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.terminal-output-topbar span{font-size:11px;color:#8b949e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.terminal-output-action{min-height:30px;display:inline-flex;align-items:center;gap:5px;padding:0 9px;border:1px solid #30363d;border-radius:6px;background:#0d1117;color:#c9d1d9;cursor:pointer;font:inherit;font-size:12px}.terminal-output-action:hover,.terminal-output-action:focus-visible{border-color:#58a6ff;color:#fff;outline:0}
|
||||||
|
.terminal-output{display:grid;align-content:start;gap:2px;min-height:260px;max-height:min(54dvh,520px);overflow:auto;padding:10px;border-radius:0;background:#000;font-family:var(--font-mono);font-size:12.5px;color:#c9d1d9;box-shadow:none}
|
||||||
.management-terminal-body .terminal-output{height:100%;min-height:0;max-height:none}
|
.management-terminal-body .terminal-output{height:100%;min-height:0;max-height:none}
|
||||||
.terminal-line{display:grid;grid-template-columns:72px minmax(0,1fr);gap:10px;align-items:start}
|
.terminal-line{display:grid;grid-template-columns:76px 148px minmax(0,1fr);gap:10px;align-items:start;min-height:20px;padding:1px 0}
|
||||||
.terminal-line time{color:var(--code-muted)}
|
.terminal-line time{color:#6e7681}.terminal-stream{color:#8b949e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.terminal-text{overflow-wrap:anywhere;color:#c9d1d9}
|
||||||
.terminal-line span{overflow-wrap:anywhere}
|
.terminal-line-input .terminal-stream,.terminal-line-input .terminal-text{color:#79c0ff}.terminal-line-success .terminal-stream,.terminal-line-success .terminal-text{color:#7ee787}.terminal-line-warn .terminal-stream,.terminal-line-warn .terminal-text{color:#d29922}.terminal-line-error .terminal-stream,.terminal-line-error .terminal-text{color:#ff7b72}
|
||||||
.terminal-line-input span{color:var(--accent)}
|
.terminal-command-dock{display:grid;gap:10px;padding:10px;border:1px solid #222;border-radius:8px;background:#000;box-shadow:none}
|
||||||
.terminal-line-success span{color:var(--teal)}
|
|
||||||
.terminal-line-error span{color:var(--danger)}
|
|
||||||
.terminal-command-dock{display:grid;gap:10px;padding:12px;border:1px solid var(--line-strong);border-radius:8px;background:#08111b;box-shadow:inset 0 1px 0 rgba(255,255,255,.12),0 14px 32px rgba(0,0,0,.28)}
|
|
||||||
.terminal-command-dock-header{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
.terminal-command-dock-header{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
||||||
.terminal-command-dock-header>span{display:inline-flex;align-items:center;gap:6px;color:var(--ink);font-weight:800}
|
.terminal-command-dock-header>span{display:inline-flex;align-items:center;gap:6px;color:#f0f6fc;font-weight:800}
|
||||||
.terminal-quick-command-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
|
.terminal-quick-command-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
|
||||||
.terminal-quick-command{display:grid;gap:4px;min-height:52px;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:#0b1722;color:var(--ink-soft);cursor:pointer;text-align:left}
|
.terminal-quick-command{display:grid;gap:3px;min-height:56px;padding:8px 10px;border:1px solid #30363d;border-radius:6px;background:#0d1117;color:#8b949e;cursor:pointer;text-align:left}
|
||||||
.terminal-quick-command:hover,.terminal-quick-command:focus-visible{border-color:var(--accent);outline:0;color:var(--ink)}
|
.terminal-quick-command:hover,.terminal-quick-command:focus-visible{border-color:#58a6ff;outline:0;color:#f0f6fc}
|
||||||
.terminal-quick-command:disabled{cursor:not-allowed;opacity:.56}
|
.terminal-quick-command:disabled{cursor:not-allowed;opacity:.56}
|
||||||
.terminal-quick-command strong{font-size:12px;color:var(--ink)}
|
.terminal-quick-command strong{font-size:12px;color:#f0f6fc}.terminal-quick-command span,.terminal-quick-command small{font-family:var(--font-mono);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.terminal-quick-command small{color:#6e7681}
|
||||||
.terminal-quick-command span{font-family:var(--font-mono);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
||||||
.terminal-command-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:end}
|
.terminal-command-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:end}
|
||||||
.terminal-command-form label{display:grid;gap:6px;color:var(--ink-soft);font-size:12px}
|
.terminal-command-form label{display:grid;gap:4px;color:#c9d1d9;font-size:12px}.terminal-command-form label small{color:#6e7681}
|
||||||
.terminal-command-form input{min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit}
|
.terminal-command-form input{min-height:38px;border:1px solid #30363d;border-radius:6px;padding:0 10px;background:#050505;color:#f0f6fc;font:inherit}.terminal-command-form input:focus{border-color:#58a6ff;outline:0}.terminal-command-form .primary-command{border-color:#30363d;background:#161b22;color:#f0f6fc;box-shadow:none}.terminal-command-form .primary-command:hover,.terminal-command-form .primary-command:focus-visible{border-color:#58a6ff;color:#fff}
|
||||||
.plugin-detail-panel{width:100%;height:auto;overflow:visible;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--panel-shadow)}
|
.plugin-detail-panel{width:100%;height:auto;overflow:visible;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--panel-shadow)}
|
||||||
.management-dialog-panel{width:min(720px,calc(100vw - 32px));height:auto;max-height:min(760px,calc(100dvh - 32px));overflow-x:hidden;overflow-y:auto;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 48px var(--glass-shadow),0 0 34px var(--moonbeam)}
|
.management-dialog-panel{width:min(720px,calc(100vw - 32px));height:auto;max-height:min(760px,calc(100dvh - 32px));overflow-x:hidden;overflow-y:auto;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 48px var(--glass-shadow),0 0 34px var(--moonbeam)}
|
||||||
.management-dialog-wide{width:min(920px,calc(100vw - 32px))}
|
.management-dialog-wide{width:min(920px,calc(100vw - 32px))}
|
||||||
@@ -712,6 +710,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
|||||||
.provider-table,.resource-table{min-width:680px}
|
.provider-table,.resource-table{min-width:680px}
|
||||||
.log-line{grid-template-columns:minmax(0,1fr);gap:2px}
|
.log-line{grid-template-columns:minmax(0,1fr);gap:2px}
|
||||||
.terminal-quick-command-list{grid-template-columns:repeat(2,minmax(0,1fr))}
|
.terminal-quick-command-list{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
|
.terminal-line{grid-template-columns:64px minmax(0,1fr);gap:5px 8px}.terminal-stream,.terminal-text{grid-column:2}.terminal-output-topbar{align-items:stretch}.terminal-output-topbar,.terminal-output-topbar>div{display:grid}.terminal-output-topbar>div:last-child{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
.management-terminal-drawer{height:92dvh;max-height:92dvh}
|
.management-terminal-drawer{height:92dvh;max-height:92dvh}
|
||||||
.ai-provider-toolbar{align-items:stretch}
|
.ai-provider-toolbar{align-items:stretch}
|
||||||
.icon-command,.segmented-button{flex:1 1 auto}
|
.icon-command,.segmented-button{flex:1 1 auto}
|
||||||
|
|||||||
Reference in New Issue
Block a user