mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
* 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
606 lines
20 KiB
TypeScript
606 lines
20 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Bell,
|
|
Check,
|
|
LogOut,
|
|
PanelRight,
|
|
Settings,
|
|
SlidersHorizontal,
|
|
SquareArrowOutUpRight,
|
|
User,
|
|
} from "lucide-react";
|
|
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
|
import { getAlertFirings } from "@/api/alerts-api";
|
|
import { isElectron } from "@/lib/electron";
|
|
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
|
import { visibleRailItems } from "./rail-items";
|
|
import { useAiAvailability } from "@/hooks/use-ai-availability";
|
|
|
|
export type RailView =
|
|
| "hosts"
|
|
| "credentials"
|
|
| "termix-id"
|
|
| "quick-connect"
|
|
| "serial"
|
|
| ToolsTab
|
|
| "connections"
|
|
| "session-logs"
|
|
| "user-profile"
|
|
| "admin-settings"
|
|
| "alerts"
|
|
| "automations"
|
|
| "ai"
|
|
| "fleets"
|
|
| "workspaces"
|
|
| "collab";
|
|
|
|
export type HideableRailView =
|
|
| Exclude<RailView, "user-profile" | "admin-settings">
|
|
| "network_graph"
|
|
| "homepage";
|
|
|
|
type RailItem =
|
|
| {
|
|
kind?: undefined;
|
|
view: RailView;
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
dot?: boolean;
|
|
promotable?: boolean;
|
|
rightDockable?: boolean;
|
|
}
|
|
| { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string }
|
|
| { kind: "separator" };
|
|
|
|
function buildRailButtons(
|
|
splitMode: SplitMode,
|
|
t: (key: string) => string,
|
|
hidden: Set<string>,
|
|
): RailItem[] {
|
|
const all: RailItem[] = [];
|
|
for (const item of visibleRailItems()) {
|
|
const Icon = item.icon;
|
|
if (item.kind === "tab") {
|
|
all.push({
|
|
kind: "tab",
|
|
tabType: item.id as TabType,
|
|
icon: <Icon size={16} />,
|
|
title: t(item.labelKey),
|
|
});
|
|
} else {
|
|
all.push({
|
|
view: item.id as RailView,
|
|
icon: <Icon size={16} />,
|
|
title: t(item.labelKey),
|
|
dot: item.id === "split-screen" ? splitMode !== "none" : undefined,
|
|
promotable: item.promotable,
|
|
rightDockable: item.rightDockable,
|
|
});
|
|
}
|
|
if (item.separatorAfter) all.push({ kind: "separator" });
|
|
}
|
|
|
|
// Filter out hidden items, then collapse consecutive/leading/trailing separators
|
|
const filtered = all.filter((item) => {
|
|
if (item.kind === "separator") return true;
|
|
if ("tabType" in item) return !hidden.has(item.tabType);
|
|
return !hidden.has(item.view);
|
|
});
|
|
|
|
const result: RailItem[] = [];
|
|
for (const item of filtered) {
|
|
if (item.kind === "separator") {
|
|
if (result.length === 0 || result[result.length - 1].kind === "separator")
|
|
continue;
|
|
result.push(item);
|
|
} else {
|
|
result.push(item);
|
|
}
|
|
}
|
|
if (result[result.length - 1]?.kind === "separator") result.pop();
|
|
return result;
|
|
}
|
|
|
|
const btnBase =
|
|
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
|
|
const btnStyle = { margin: "0 4px", padding: "0 8px" };
|
|
|
|
export function AppRail({
|
|
railView,
|
|
sidebarOpen,
|
|
splitMode,
|
|
username,
|
|
isAdmin,
|
|
onRailClick,
|
|
onOpenTab,
|
|
onOpenInRightDock,
|
|
onLogout,
|
|
}: {
|
|
railView: RailView;
|
|
sidebarOpen: boolean;
|
|
splitMode: SplitMode;
|
|
username: string;
|
|
isAdmin: boolean;
|
|
onRailClick: (view: RailView) => void;
|
|
onOpenTab?: (type: TabType) => void;
|
|
onOpenInRightDock?: (view: RailView) => void;
|
|
onLogout: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [hovered, setHovered] = useState(false);
|
|
const [pinned, setPinned] = useState(() => readRailPreference("pinAppRail"));
|
|
const [expandOnHover, setExpandOnHover] = useState(() =>
|
|
readRailPreference("expandAppRailOnHover"),
|
|
);
|
|
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
|
// Which promotable item was right-clicked, so the menu can offer to open it
|
|
// as a tab. Null when the right-click landed on empty rail space.
|
|
const [menuTarget, setMenuTarget] = useState<{
|
|
view: RailView;
|
|
title: string;
|
|
promotable?: boolean;
|
|
rightDockable?: boolean;
|
|
} | null>(null);
|
|
const [unreadAlerts, setUnreadAlerts] = useState(0);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const poll = () => {
|
|
if (document.visibilityState === "hidden") return;
|
|
getAlertFirings({ acknowledged: false, limit: 50 })
|
|
.then((firings) => {
|
|
if (!cancelled) setUnreadAlerts(firings.length);
|
|
})
|
|
.catch(() => {});
|
|
};
|
|
|
|
const start = () => {
|
|
if (intervalId !== null) return;
|
|
intervalId = setInterval(poll, 30000);
|
|
};
|
|
const stop = () => {
|
|
if (intervalId === null) return;
|
|
clearInterval(intervalId);
|
|
intervalId = null;
|
|
};
|
|
const onVisibility = () => {
|
|
if (document.visibilityState === "hidden") {
|
|
stop();
|
|
return;
|
|
}
|
|
poll();
|
|
start();
|
|
};
|
|
|
|
poll();
|
|
if (document.visibilityState !== "hidden") start();
|
|
document.addEventListener("visibilitychange", onVisibility);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
stop();
|
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
};
|
|
}, []);
|
|
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
|
|
try {
|
|
const stored = localStorage.getItem("hiddenRailTabs");
|
|
return stored ? new Set(JSON.parse(stored)) : new Set();
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
const pinHandler = () => setPinned(readRailPreference("pinAppRail"));
|
|
const hoverHandler = () =>
|
|
setExpandOnHover(readRailPreference("expandAppRailOnHover"));
|
|
window.addEventListener("pinAppRailChanged", pinHandler);
|
|
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
|
return () => {
|
|
window.removeEventListener("pinAppRailChanged", pinHandler);
|
|
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!menuPos) return;
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!(e.target as HTMLElement).closest("[data-rail-context-menu]")) {
|
|
setMenuPos(null);
|
|
}
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setMenuPos(null);
|
|
};
|
|
window.addEventListener("mousedown", onDown);
|
|
window.addEventListener("keydown", onKey);
|
|
return () => {
|
|
window.removeEventListener("mousedown", onDown);
|
|
window.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [menuPos]);
|
|
|
|
const { userEnabled: aiEnabled } = useAiAvailability();
|
|
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
try {
|
|
const stored = localStorage.getItem("hiddenRailTabs");
|
|
setHiddenTabs(stored ? new Set(JSON.parse(stored)) : new Set());
|
|
} catch {
|
|
setHiddenTabs(new Set());
|
|
}
|
|
};
|
|
window.addEventListener("hiddenRailTabsChanged", handler);
|
|
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
|
|
}, []);
|
|
|
|
// Termix ID publishes SSH public keys under a claimed public handle for
|
|
// other servers to fetch -- meaningless for a standalone desktop install
|
|
// with no synced multi-device account, so it stays hidden until a remote
|
|
// server is actually connected.
|
|
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState(
|
|
() => !isElectron(),
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isElectron()) return;
|
|
let cancelled = false;
|
|
const refreshSyncStatus = () => {
|
|
window.electronAPI
|
|
?.invoke?.("get-remote-sync-config")
|
|
.then((config) => {
|
|
if (!cancelled) {
|
|
setIsRemoteSyncConnected(
|
|
!!(config as { serverUrl?: string } | null)?.serverUrl,
|
|
);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
};
|
|
refreshSyncStatus();
|
|
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
|
|
refreshSyncStatus(),
|
|
);
|
|
return () => {
|
|
cancelled = true;
|
|
unsubscribe?.();
|
|
};
|
|
}, []);
|
|
|
|
const railExpanded = pinned || (expandOnHover && hovered);
|
|
const effectiveHiddenTabs = new Set([
|
|
...hiddenTabs,
|
|
...(isRemoteSyncConnected ? [] : ["termix-id"]),
|
|
// The assistant is hidden until an admin has enabled it instance-wide and
|
|
// the user has said yes, so someone who declined never sees the entry.
|
|
...(aiEnabled ? [] : ["ai"]),
|
|
]);
|
|
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
|
|
|
|
const togglePinned = () => {
|
|
setRailPreference("pinAppRail", !pinned);
|
|
setMenuPos(null);
|
|
};
|
|
|
|
const toggleExpandOnHover = () => {
|
|
setRailPreference("expandAppRailOnHover", !expandOnHover);
|
|
setMenuPos(null);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
|
|
style={{ width: railExpanded ? 160 : 40 }}
|
|
onMouseEnter={() => setHovered(true)}
|
|
onMouseLeave={() => setHovered(false)}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
// Keep the menu on screen when right-clicking near the viewport edges
|
|
const MENU_W = 190;
|
|
const MENU_H = 96;
|
|
setMenuPos({
|
|
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
|
|
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
|
|
});
|
|
if (!(e.target as HTMLElement).closest("[data-rail-promotable]")) {
|
|
setMenuTarget(null);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
|
|
{railButtons.map((item, i) =>
|
|
item.kind === "separator" ? (
|
|
<div
|
|
key={`sep-${i}`}
|
|
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
|
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
|
|
/>
|
|
) : "tabType" in item ? (
|
|
<button
|
|
key={item.tabType}
|
|
onClick={() => onOpenTab?.(item.tabType)}
|
|
style={btnStyle}
|
|
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
|
|
>
|
|
<span
|
|
className="shrink-0 flex items-center justify-center"
|
|
style={{ width: 16, height: 16 }}
|
|
>
|
|
{item.icon}
|
|
</span>
|
|
<span
|
|
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
|
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
|
}`}
|
|
>
|
|
{item.title}
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
key={item.view}
|
|
onClick={(e) => {
|
|
if (item.promotable && (e.ctrlKey || e.metaKey)) {
|
|
onOpenTab?.(item.view as TabType);
|
|
return;
|
|
}
|
|
onRailClick(item.view);
|
|
}}
|
|
onAuxClick={(e) => {
|
|
if (e.button !== 1 || !item.promotable) return;
|
|
e.preventDefault();
|
|
onOpenTab?.(item.view as TabType);
|
|
}}
|
|
onContextMenu={() => {
|
|
if (item.promotable || item.rightDockable)
|
|
setMenuTarget({
|
|
view: item.view,
|
|
title: item.title,
|
|
promotable: item.promotable,
|
|
rightDockable: item.rightDockable,
|
|
});
|
|
}}
|
|
data-rail-promotable={
|
|
item.promotable || item.rightDockable ? "" : undefined
|
|
}
|
|
title={
|
|
item.promotable
|
|
? `${item.title}\n${t("nav.openAsTabHint")}`
|
|
: item.title
|
|
}
|
|
style={btnStyle}
|
|
className={`${btnBase} ${
|
|
sidebarOpen && railView === item.view
|
|
? "text-accent-brand bg-accent-brand/10"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
|
}`}
|
|
>
|
|
<span
|
|
className="shrink-0 flex items-center justify-center"
|
|
style={{ width: 16, height: 16 }}
|
|
>
|
|
{item.icon}
|
|
</span>
|
|
<span
|
|
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
|
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
|
}`}
|
|
>
|
|
{item.title}
|
|
</span>
|
|
{item.dot && (
|
|
<span className="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-accent-brand" />
|
|
)}
|
|
</button>
|
|
),
|
|
)}
|
|
</div>
|
|
|
|
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
|
{[
|
|
{
|
|
view: "alerts" as RailView,
|
|
icon: <Bell size={16} />,
|
|
title: t("nav.alerts"),
|
|
promotable: true,
|
|
},
|
|
{
|
|
view: "user-profile" as RailView,
|
|
icon: <User size={16} />,
|
|
title: t("nav.userProfile"),
|
|
},
|
|
...(isAdmin
|
|
? [
|
|
{
|
|
view: "admin-settings" as RailView,
|
|
icon: <Settings size={16} />,
|
|
title: t("nav.admin"),
|
|
},
|
|
]
|
|
: []),
|
|
].map((item) => (
|
|
<button
|
|
key={item.view}
|
|
onClick={(e) => {
|
|
if (item.promotable && (e.ctrlKey || e.metaKey)) {
|
|
onOpenTab?.(item.view as TabType);
|
|
return;
|
|
}
|
|
onRailClick(item.view);
|
|
}}
|
|
onAuxClick={(e) => {
|
|
if (e.button !== 1 || !item.promotable) return;
|
|
e.preventDefault();
|
|
onOpenTab?.(item.view as TabType);
|
|
}}
|
|
onContextMenu={() => {
|
|
if (item.promotable)
|
|
setMenuTarget({
|
|
view: item.view,
|
|
title: item.title,
|
|
promotable: item.promotable,
|
|
rightDockable: true,
|
|
});
|
|
}}
|
|
data-rail-promotable={item.promotable ? "" : undefined}
|
|
title={
|
|
item.promotable
|
|
? `${item.title}\n${t("nav.openAsTabHint")}`
|
|
: item.title
|
|
}
|
|
style={btnStyle}
|
|
className={`${btnBase} ${
|
|
sidebarOpen && railView === item.view
|
|
? "text-accent-brand bg-accent-brand/10"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
|
}`}
|
|
>
|
|
<span
|
|
className="relative shrink-0 flex items-center justify-center"
|
|
style={{ width: 16, height: 16 }}
|
|
>
|
|
{item.icon}
|
|
{item.view === "alerts" && unreadAlerts > 0 && (
|
|
<span className="absolute -top-1 -right-1 flex size-3 items-center justify-center rounded-full bg-destructive text-[8px] font-bold text-white leading-none">
|
|
{unreadAlerts > 9 ? "9+" : unreadAlerts}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span
|
|
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
|
>
|
|
{item.title}
|
|
</span>
|
|
</button>
|
|
))}
|
|
<div className="mx-2 my-1 border-t border-border" />
|
|
<button
|
|
onClick={onLogout}
|
|
style={btnStyle}
|
|
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
|
>
|
|
<span
|
|
className="shrink-0 flex items-center justify-center"
|
|
style={{ width: 16, height: 16 }}
|
|
>
|
|
<LogOut size={16} />
|
|
</span>
|
|
<span
|
|
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
|
>
|
|
{t("common.logout")}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="shrink-0 border-t border-border">
|
|
<button
|
|
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
|
style={{ padding: "0 8px" }}
|
|
>
|
|
<div
|
|
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
|
style={{ width: 24, height: 24, fontSize: 11 }}
|
|
>
|
|
{username.charAt(0).toUpperCase() || "U"}
|
|
</div>
|
|
<div
|
|
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
|
|
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
|
}`}
|
|
>
|
|
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
|
|
{username || "User"}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
|
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{menuPos && (
|
|
<div
|
|
data-rail-context-menu
|
|
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
|
|
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[190px]"
|
|
>
|
|
{menuTarget && (
|
|
<>
|
|
{menuTarget.promotable && (
|
|
<button
|
|
onClick={() => {
|
|
onOpenTab?.(menuTarget.view as TabType);
|
|
setMenuPos(null);
|
|
}}
|
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
|
>
|
|
<span className="shrink-0 w-3 flex items-center justify-center">
|
|
<SquareArrowOutUpRight className="size-3" />
|
|
</span>
|
|
{t("nav.openAsTab")}
|
|
</button>
|
|
)}
|
|
{menuTarget.rightDockable && (
|
|
<button
|
|
onClick={() => {
|
|
onOpenInRightDock?.(menuTarget.view);
|
|
setMenuPos(null);
|
|
}}
|
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
|
>
|
|
<span className="shrink-0 w-3 flex items-center justify-center">
|
|
<PanelRight className="size-3" />
|
|
</span>
|
|
{t("nav.openInRightDock")}
|
|
</button>
|
|
)}
|
|
<div className="h-px bg-border my-1" />
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={togglePinned}
|
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
|
role="menuitemcheckbox"
|
|
aria-checked={pinned}
|
|
>
|
|
<span className="shrink-0 w-3 flex items-center justify-center">
|
|
{pinned && <Check className="size-3" />}
|
|
</span>
|
|
{t("newUi.sidebar.userProfile.pinAppRail")}
|
|
</button>
|
|
<button
|
|
onClick={toggleExpandOnHover}
|
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
|
role="menuitemcheckbox"
|
|
aria-checked={expandOnHover}
|
|
>
|
|
<span className="shrink-0 w-3 flex items-center justify-center">
|
|
{expandOnHover && <Check className="size-3" />}
|
|
</span>
|
|
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
|
|
</button>
|
|
<div className="h-px bg-border my-1" />
|
|
<button
|
|
onClick={() => {
|
|
onRailClick("user-profile");
|
|
setMenuPos(null);
|
|
}}
|
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
|
>
|
|
<span className="shrink-0 w-3 flex items-center justify-center">
|
|
<SlidersHorizontal className="size-3" />
|
|
</span>
|
|
{t("nav.sidebarSettings")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|