Files
Termix/src/ui/shell/tabUtils.tsx
T
ZacharyZcR 81d79cc89b feat: collaboration rooms with switchable presenter (#1328)
* feat: add collaboration rooms with switchable presenter

Rooms are a group of members watching one stage - the live SSH/RDP/VNC
session the current presenter shares. Any member can take over the
stage; the host can invite, force-stop and end the meeting. Stages
reuse session_shares (new room share type), so gating, recording,
expiry and the global sharing toggle all apply unchanged.

* feat: add stage control handoff to collaboration rooms

The presenter or host can grant any member write access to the live
stage and take it back; members can raise a hand to ask. SSH flips the
participant's permission on the live gate; RDP/VNC re-mint the viewer's
join token. Control clears on every stage switch.

* feat: guest links, role invites and invite awareness for collab rooms

- Anonymous guest link per room (host toggles/rotates), followed by
  polling the public resolve endpoint; SSH guests join over the terminal
  WS with roomGuestToken, guac guests get read-only join tokens
- Invite by role (expands to current members, snapshot semantics)
- Toast when a room you were invited to appears
- Stale stages are cleared lazily when the presenter is gone
- Telnet presenting, expired-tab fallback, documented single-instance
  and guac-kick limits
- Tests for the collab routes, room hub, share access and control flip

* fix: keep remote desktop collaboration read-only
2026-08-25 00:56:04 +08:00

717 lines
20 KiB
TypeScript

/* eslint-disable react-refresh/only-export-components */
import {
Box,
Boxes,
Braces,
FolderSearch,
HardDrive,
LayoutDashboard,
LayoutGrid,
LayoutPanelLeft,
MessagesSquare,
Monitor,
MousePointerClick,
Network,
Server,
Settings,
Terminal,
Usb,
User,
Activity,
TerminalSquare,
Layers, // --- tmux-monitor ---
Clock,
Fingerprint,
Hammer,
Play,
Plug,
ScrollText,
Sparkles,
Presentation,
Workflow,
} from "lucide-react";
import { lazy, Suspense } from "react";
import { useTranslation } from "react-i18next";
import type { SerialHandle } from "@/features/serial/serial-types";
import type {
TerminalHandle,
TerminalHostConfig,
} from "@/features/terminal/Terminal";
import type { GuacamoleAppHandle } from "@/features/guacamole/GuacamoleApp";
import { useIsMobile } from "@/hooks/use-mobile";
import type { Tab, TabType, Host } from "@/types/ui-types";
import type { SSHHost } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
// Heavy tab surfaces — keep out of the AppShell critical path.
const CommandHistoryProvider = lazy(() =>
import("@/features/terminal/command-history/CommandHistoryContext").then(
(m) => ({ default: m.CommandHistoryProvider }),
),
);
const loadTerminalFeature = () =>
import("@/features/terminal/Terminal").then((m) => ({
default: m.Terminal,
}));
const TerminalFeature = lazy(loadTerminalFeature);
const CollabRoomTab = lazy(() =>
import("@/features/collab/CollabRoomTab").then((m) => ({
default: m.CollabRoomTab,
})),
);
const LocalTerminal = lazy(() =>
import("@/features/local-terminal/LocalTerminal").then((m) => ({
default: m.LocalTerminal,
})),
);
const MobileTerminalKeyboard = lazy(() =>
import("@/features/terminal/MobileTerminalKeyboard").then((m) => ({
default: m.MobileTerminalKeyboard,
})),
);
const loadFileManager = () =>
import("@/features/file-manager/FileManager").then((m) => ({
default: m.FileManager,
}));
const FileManager = lazy(loadFileManager);
const loadDockerManager = () =>
import("@/features/docker/DockerManager").then((m) => ({
default: m.DockerManager,
}));
const DockerManager = lazy(loadDockerManager);
const loadHostMetricsTab = () =>
import("@/features/host-metrics/HostMetricsTab").then((m) => ({
default: m.HostMetricsTab,
}));
const HostMetricsTab = lazy(loadHostMetricsTab);
const loadProxmoxStatsTab = () =>
import("@/features/proxmox-stats/ProxmoxStatsTab").then((m) => ({
default: m.ProxmoxStatsTab,
}));
const ProxmoxStatsTab = lazy(loadProxmoxStatsTab);
const loadTmuxMonitor = () =>
import("@/features/tmux-monitor/TmuxMonitor").then((m) => ({
default: m.TmuxMonitor,
}));
const TmuxMonitor = lazy(loadTmuxMonitor);
const loadGuacamoleApp = () =>
import("@/features/guacamole/GuacamoleApp").then((m) => ({
default: m.default,
}));
const GuacamoleApp = lazy(loadGuacamoleApp);
const DashboardTab = lazy(() =>
import("@/dashboard/DashboardTab").then((m) => ({
default: m.DashboardTab,
})),
);
const HomepageCanvas = lazy(() =>
import("@/features/homepage/HomepageCanvas").then((m) => ({
default: m.HomepageCanvas,
})),
);
const loadTunnelTab = () =>
import("@/features/tunnel/TunnelTab").then((m) => ({
default: m.TunnelTab,
}));
const TunnelTab = lazy(loadTunnelTab);
const NetworkGraphCard = lazy(() =>
import("@/dashboard/cards/NetworkGraphCard").then((m) => ({
default: m.NetworkGraphCard,
})),
);
const Serial = lazy(() =>
import("@/features/serial/Serial").then((m) => ({
default: m.Serial,
})),
);
const FleetInventoryTab = lazy(() =>
import("@/sidebar/FleetInventoryTab").then((m) => ({
default: m.FleetInventoryTab,
})),
);
// Rail panels promoted to full tabs.
const TermixIdPanel = lazy(() =>
import("@/sidebar/TermixIdPanel").then((m) => ({ default: m.TermixIdPanel })),
);
const AlertsPanel = lazy(() =>
import("@/sidebar/AlertsPanel").then((m) => ({ default: m.AlertsPanel })),
);
const SessionLogsPanel = lazy(() =>
import("@/sidebar/SessionLogsPanel").then((m) => ({
default: m.SessionLogsPanel,
})),
);
const SnippetsPanel = lazy(() =>
import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })),
);
const MacrosPanel = lazy(() =>
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
);
const HistoryPanel = lazy(() =>
import("@/sidebar/HistoryPanel").then((m) => ({ default: m.HistoryPanel })),
);
const SshToolsPanel = lazy(() =>
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
);
const AutomationsPanel = lazy(() =>
import("@/sidebar/AutomationsPanel").then((m) => ({
default: m.AutomationsPanel,
})),
);
const AiPanel = lazy(() =>
import("@/features/ai/AiPanel").then((m) => ({
default: m.AiPanel,
})),
);
const tabSurfaceLoaders: Partial<Record<TabType, () => Promise<unknown>>> = {
terminal: loadTerminalFeature,
files: loadFileManager,
docker: loadDockerManager,
"host-metrics": loadHostMetricsTab,
"proxmox-stats": loadProxmoxStatsTab,
tmux_monitor: loadTmuxMonitor,
tunnel: loadTunnelTab,
rdp: loadGuacamoleApp,
vnc: loadGuacamoleApp,
telnet: loadGuacamoleApp,
};
/** Download a likely next tab without starting a connection or mounting UI. */
export function preloadTabSurface(type: TabType): void {
const loader = tabSurfaceLoaders[type];
if (loader) runAdaptiveBackgroundTask("module", `tab:${type}`, loader);
}
export function markTabSurfaceUsed(type: TabType): void {
markAdaptiveResourceUsed("module", `tab:${type}`);
}
function hostToSSHHost(h: Host): SSHHost {
return {
id: parseInt(h.id, 10),
name: h.name,
ip: h.ip,
port: h.port,
username: h.username,
folder: h.folder ?? "",
tags: h.tags ?? [],
pin: h.pin ?? false,
authType: h.authType,
password: h.password,
hasPassword: h.hasPassword,
key: h.key,
keyPassword: h.keyPassword,
hasKey: h.hasKey,
hasKeyPassword: h.hasKeyPassword,
keyType: h.keyType,
credentialId: h.credentialId ? parseInt(h.credentialId, 10) : undefined,
terminalConfig: h.terminalConfig,
hasSudoPassword: h.hasSudoPassword,
enableTerminal: h.enableTerminal ?? false,
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableDocker: h.enableDocker ?? false,
dockerConfig: h.dockerConfig ?? null,
showTerminalInSidebar: true,
showFileManagerInSidebar: true,
showTunnelInSidebar: true,
showDockerInSidebar: true,
showServerStatsInSidebar: true,
defaultPath: h.defaultPath ?? "",
tunnelConnections: [],
connectionType: "ssh",
connectionOrigin: h.connectionOrigin ?? null,
// Carries the host's identity to a delegated backend. Without it the
// remote side resolves our local row id against its own table.
syncId: h.syncId ?? null,
createdAt: "",
updatedAt: "",
} as unknown as SSHHost;
}
function EmptyState({
icon: Icon,
messageKey,
}: {
icon: React.ElementType;
messageKey: string;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Icon className="size-5 text-muted-foreground/30" />
</div>
<span className="text-sm font-semibold text-muted-foreground/60">
{t(messageKey)}
</span>
</div>
);
}
function TabChunkFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-background">
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
function withTabSuspense(node: React.ReactNode) {
return <Suspense fallback={<TabChunkFallback />}>{node}</Suspense>;
}
/**
* Host frame for rail panels opened as tabs. Panels expect a full-height flex
* column like the sidebar gives them. The max width keeps forms readable on a
* wide monitor instead of stretching them edge to edge.
*/
function PanelTabFrame({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full w-full justify-center overflow-y-auto bg-background">
<div className="flex flex-col flex-1 min-h-0 w-full max-w-5xl">
{children}
</div>
</div>
);
}
export function tabIcon(type: TabType) {
switch (type) {
case "dashboard":
return <LayoutDashboard className="size-3.5" />;
case "terminal":
return <Terminal className="size-3.5" />;
case "local-terminal":
return <TerminalSquare className="size-3.5" />;
case "rdp":
return <Monitor className="size-3.5" />;
case "vnc":
return <MousePointerClick className="size-3.5" />;
case "telnet":
return <MessagesSquare className="size-3.5" />;
case "host-metrics":
return <Server className="size-3.5" />;
case "proxmox-stats":
return <HardDrive className="size-3.5" />;
case "files":
return <FolderSearch className="size-3.5" />;
case "host-manager":
return <Server className="size-3.5" />;
case "user-profile":
return <User className="size-3.5" />;
case "admin-settings":
return <Settings className="size-3.5" />;
case "docker":
return <Box className="size-3.5" />;
case "tunnel":
return <Network className="size-3.5" />;
case "network_graph":
return <Network className="size-3.5" />;
// --- tmux-monitor ---
case "tmux_monitor":
return <Layers className="size-3.5" />;
case "serial":
return <Usb className="size-3.5" />;
case "homepage":
return <LayoutGrid className="size-3.5" />;
case "fleet-inventory":
return <Boxes className="size-3.5" />;
case "collab":
return <Presentation className="size-3.5" />;
case "termix-id":
return <Fingerprint className="size-3.5" />;
case "alerts":
return <Plug className="size-3.5" />;
case "session-logs":
return <ScrollText className="size-3.5" />;
case "snippets":
return <Play className="size-3.5" />;
case "macros":
return <Braces className="size-3.5" />;
case "history":
return <Clock className="size-3.5" />;
case "ssh-tools":
return <Hammer className="size-3.5" />;
case "automations":
return <Workflow className="size-3.5" />;
case "ai":
return <Sparkles className="size-3.5" />;
case "split-screen":
return <LayoutPanelLeft className="size-3.5" />;
}
}
function TerminalTabContent({
tab,
host,
label,
isVisible,
isFocusedPane,
onCloseTab,
onRenameTab,
onOpenFileInEditor,
onOpenFileManager,
onOpenTab,
onSaveQuickConnect,
}: {
tab: Tab;
host: Host;
label: string;
isVisible: boolean;
isFocusedPane: boolean;
onCloseTab?: (id: string) => void;
onRenameTab?: (tabId: string, newLabel: string) => void;
onOpenFileInEditor?: (filePath: string) => void;
onOpenFileManager?: (path?: string) => void;
onOpenTab?: (type: TabType) => void;
onSaveQuickConnect?: () => Promise<void>;
}) {
const { previewTerminalTheme } = useTabsSafe();
const isMobile = useIsMobile();
return withTabSuspense(
<CommandHistoryProvider>
<div className="flex flex-col h-full w-full">
<div className="flex-1 min-h-0">
<TerminalFeature
ref={tab.terminalRef as React.Ref<TerminalHandle>}
hostConfig={
{
...hostToSSHHost(host),
sshPort: host.sshPort ?? host.port,
instanceId: tab.instanceId ?? tab.id,
restoredSessionId: tab.restoredSessionId ?? null,
joinSharedSessionId: tab.joinSharedSessionId ?? null,
joinShareId: tab.joinShareId ?? null,
} as TerminalHostConfig
}
isVisible={isVisible}
initialPath={tab.initialFilePath}
title={label}
showTitle={false}
splitScreen={false}
onClose={() => onCloseTab?.(tab.id)}
onTitleChange={
onRenameTab && host.terminalConfig?.useSSHTitle
? (title) => onRenameTab(tab.id, title)
: undefined
}
previewTheme={previewTerminalTheme}
onOpenFileInEditor={onOpenFileInEditor}
onOpenFileManager={onOpenFileManager}
isQuickConnect={host.id.startsWith("quick-connect-")}
onSaveQuickConnect={onSaveQuickConnect}
host={host}
onOpenTab={onOpenTab}
isFocusedPane={isFocusedPane}
/>
</div>
{isMobile && (
<MobileTerminalKeyboard
terminalRef={
tab.terminalRef as React.RefObject<TerminalHandle | null>
}
/>
)}
</div>
</CommandHistoryProvider>,
);
}
/**
* Everything the promoted rail panels need from AppShell. Passed as one bag
* rather than more positional params, which renderTabContent already has too
* many of.
*/
export type PromotedPanelProps = {
terminalTabs?: Tab[];
targetTerminalTabId?: string;
storageMode?: "local" | "cloud";
};
export function renderTabContent(
tab: Tab,
onOpenSingletonTab?: (type: TabType) => void,
onOpenTab?: (host: Host, type: TabType) => void,
onCloseTab?: (id: string) => void,
isVisible = true,
onOpenFileInEditor?: (host: Host, filePath: string) => void,
onOpenFileManager?: (host: Host, path?: string) => void,
onOpenTerminalTab?: (host: Host, path?: string) => void,
onRenameTab?: (tabId: string, newLabel: string) => void,
onSaveQuickConnect?: (tab: Tab, host: Host) => Promise<void>,
isFocusedPane = true,
panelProps?: PromotedPanelProps,
) {
const { host, label } = tab;
switch (tab.type) {
case "dashboard":
return withTabSuspense(
<DashboardTab
onOpenSingletonTab={onOpenSingletonTab!}
onOpenTab={onOpenTab!}
isVisible={isVisible}
/>,
);
case "terminal":
if (!host)
return (
<EmptyState
icon={TerminalSquare}
messageKey="terminal.noHostSelected"
/>
);
return (
<TerminalTabContent
tab={tab}
host={host}
label={label}
isVisible={isVisible}
isFocusedPane={isFocusedPane}
onCloseTab={onCloseTab}
onRenameTab={onRenameTab}
onOpenFileInEditor={
onOpenFileInEditor
? (fp) => onOpenFileInEditor(host, fp)
: undefined
}
onOpenFileManager={
onOpenFileManager ? (p) => onOpenFileManager(host, p) : undefined
}
onOpenTab={onOpenTab ? (type) => onOpenTab(host, type) : undefined}
onSaveQuickConnect={
onSaveQuickConnect ? () => onSaveQuickConnect(tab, host) : undefined
}
/>
);
case "local-terminal":
return withTabSuspense(
<LocalTerminal instanceId={tab.instanceId} isVisible={isVisible} />,
);
case "files":
if (!host)
return (
<EmptyState
icon={FolderSearch}
messageKey="fileManager.noHostSelected"
/>
);
return withTabSuspense(
<FileManager
initialHost={hostToSSHHost(host)}
initialFilePath={tab.initialFilePath}
initialPath={tab.initialPath}
isVisible={isVisible}
onOpenTerminalTab={
onOpenTerminalTab
? (path) => onOpenTerminalTab(host, path)
: undefined
}
/>,
);
case "docker":
if (!host)
return <EmptyState icon={Box} messageKey="docker.noHostSelected" />;
return withTabSuspense(
<DockerManager
hostConfig={hostToSSHHost(host)}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
/>,
);
case "host-metrics":
if (!host)
return (
<EmptyState icon={Activity} messageKey="hostMetrics.noHostSelected" />
);
return withTabSuspense(
<HostMetricsTab
hostConfig={hostToSSHHost(host)}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
/>,
);
case "proxmox-stats":
if (!host)
return (
<EmptyState
icon={HardDrive}
messageKey="proxmoxStats.noHostSelected"
/>
);
return withTabSuspense(
<ProxmoxStatsTab
hostConfig={hostToSSHHost(host)}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
/>,
);
case "tunnel":
return withTabSuspense(
<TunnelTab label={label} host={host} isVisible={isVisible} />,
);
case "rdp":
case "vnc":
case "telnet":
if (!host)
return (
<EmptyState icon={Monitor} messageKey="guacamole.noHostSelected" />
);
return withTabSuspense(
<GuacamoleApp
ref={tab.terminalRef as React.Ref<GuacamoleAppHandle>}
hostId={host.id}
tabId={tab.id}
protocol={tab.type as "rdp" | "vnc" | "telnet"}
isVisible={isVisible}
/>,
);
case "network_graph":
return withTabSuspense(
<NetworkGraphCard embedded={false} isVisible={isVisible} />,
);
// --- tmux-monitor ---
case "tmux_monitor":
return withTabSuspense(
<TmuxMonitor
initialHostId={host ? parseInt(host.id, 10) : undefined}
isVisible={isVisible}
/>,
);
case "serial":
if (!tab.serialConfig)
return <EmptyState icon={Usb} messageKey="serial.notSupportedTitle" />;
return withTabSuspense(
<Serial
ref={tab.terminalRef as React.Ref<SerialHandle>}
config={tab.serialConfig}
isVisible={isVisible}
instanceId={tab.instanceId}
/>,
);
case "homepage":
return withTabSuspense(<HomepageCanvas />);
case "fleet-inventory":
return withTabSuspense(
<FleetInventoryTab fleetId={tab.fleetId} isVisible={isVisible} />,
);
case "collab":
return withTabSuspense(
<CollabRoomTab roomId={tab.collabRoomId} isVisible={isVisible} />,
);
case "termix-id":
return withTabSuspense(
<PanelTabFrame>
<TermixIdPanel />
</PanelTabFrame>,
);
case "alerts":
return withTabSuspense(
<PanelTabFrame>
<AlertsPanel />
</PanelTabFrame>,
);
case "session-logs":
return withTabSuspense(
<PanelTabFrame>
<SessionLogsPanel />
</PanelTabFrame>,
);
case "automations":
return withTabSuspense(
<PanelTabFrame>
<AutomationsPanel active={isVisible} />
</PanelTabFrame>,
);
case "ai":
return withTabSuspense(
<PanelTabFrame>
<AiPanel />
</PanelTabFrame>,
);
case "split-screen":
return null;
case "snippets":
return withTabSuspense(
<PanelTabFrame>
<SnippetsPanel
terminalTabs={panelProps?.terminalTabs ?? []}
activeTabId={panelProps?.targetTerminalTabId ?? ""}
storageMode={panelProps?.storageMode ?? "local"}
/>
</PanelTabFrame>,
);
case "macros":
return withTabSuspense(
<PanelTabFrame>
<MacrosPanel
terminalTabs={panelProps?.terminalTabs ?? []}
activeTabId={panelProps?.targetTerminalTabId ?? ""}
storageMode={panelProps?.storageMode ?? "local"}
/>
</PanelTabFrame>,
);
case "history":
return withTabSuspense(
<PanelTabFrame>
<HistoryPanel
terminalTabs={panelProps?.terminalTabs ?? []}
activeTabId={panelProps?.targetTerminalTabId ?? ""}
/>
</PanelTabFrame>,
);
case "ssh-tools":
return withTabSuspense(
<PanelTabFrame>
<SshToolsPanel
terminalTabs={panelProps?.terminalTabs ?? []}
activeTabId={panelProps?.targetTerminalTabId ?? ""}
/>
</PanelTabFrame>,
);
case "host-manager":
case "user-profile":
case "admin-settings":
return null;
}
}