feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

+2 -2
View File
@@ -9,7 +9,7 @@ import {
setPluginEnabled,
type PluginSummary,
} from "@/api/plugins-api";
import { refreshPluginState } from "@/shell/pluginLoader";
import { syncPlugins } from "@/plugin-host/loader";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import { PluginPermissionsDialog } from "./PluginPermissionsDialog";
@@ -50,7 +50,7 @@ export function AdminPluginsSection({
await setPluginEnabled(plugin.id, !plugin.enabled);
await load();
// Re-read state so the shell picks the change up without a reload.
await refreshPluginState();
await syncPlugins();
toast.success(
plugin.enabled
? t("admin.pluginDisabled", { name: plugin.name })
+5 -71
View File
@@ -1,19 +1,14 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { notifyAiStatusChanged } from "@/hooks/use-ai-availability";
import { useBranding } from "@/contexts/BrandingContext";
import {
getAiGloballyEnabled,
getAiPrivateEndpoints,
getNotificationPrivateEndpoints,
getStepCaPrivateEndpoints,
setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi,
getSecretSourcePrivateEndpoints,
setSecretSourcePrivateEndpoints as setSecretSourcePrivateEndpointsApi,
setAiGloballyEnabled as setAiGloballyEnabledApi,
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
} from "@/api/ai-api";
} from "@/api/private-endpoints-api";
import {
getUserList,
getSessions,
@@ -80,10 +75,6 @@ import {
updateSSOProvider,
deleteSSOProvider,
} from "@/api/sso-provider-api";
import {
getMetricsHistoryRetention,
saveMetricsHistoryRetention,
} from "@/api/host-metrics-api";
import type { SSOProvider } from "@/types/index";
import {
type ApiKey,
@@ -176,7 +167,6 @@ export function AdminSettingsPanel({
}, []);
const [statusInterval, setStatusInterval] = useState("60");
const [metricsInterval, setMetricsInterval] = useState("30");
const [metricsHistoryRetention, setMetricsHistoryRetention] = useState("7");
const [guacEnabled, setGuacEnabled] = useState(false);
const [guacUrl, setGuacUrl] = useState("guacd:4822");
const [logLevel, setLogLevel] = useState("info");
@@ -187,8 +177,6 @@ export function AdminSettingsPanel({
useState(false);
const [sessionSharingGloballyEnabled, setSessionSharingGloballyEnabled] =
useState(true);
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState<string[]>([]);
const [stepCaPrivateEndpoints, setStepCaPrivateEndpoints] = useState<
string[]
>([]);
@@ -410,8 +398,6 @@ export function AdminSettingsPanel({
analytics,
sessionSharingEnabled,
touchInput,
aiEnabled,
aiEndpoints,
notificationEndpoints,
stepCaEndpoints,
secretSourceEndpoints,
@@ -431,8 +417,6 @@ export function AdminSettingsPanel({
getAnalyticsEnabled(),
getSessionSharingGloballyEnabled(),
getTouchInputSettings(),
getAiGloballyEnabled(),
getAiPrivateEndpoints(),
getNotificationPrivateEndpoints(),
getStepCaPrivateEndpoints(),
getSecretSourcePrivateEndpoints(),
@@ -457,9 +441,6 @@ export function AdminSettingsPanel({
setMetricsInterval(String(monitoring.value.metricsInterval));
}
getMetricsHistoryRetention()
.then((days) => setMetricsHistoryRetention(String(days)))
.catch(() => {});
if (level.status === "fulfilled") setLogLevel(level.value.level);
if (guac.status === "fulfilled") {
setGuacEnabled(guac.value.enabled);
@@ -479,12 +460,6 @@ export function AdminSettingsPanel({
setTouchInputSettings(touchInput.value);
cacheTouchInputSettings(touchInput.value);
}
if (aiEnabled.status === "fulfilled") {
setAiGloballyEnabled(aiEnabled.value);
}
if (aiEndpoints.status === "fulfilled") {
setAiPrivateEndpoints(aiEndpoints.value);
}
if (stepCaEndpoints.status === "fulfilled") {
setStepCaPrivateEndpoints(stepCaEndpoints.value);
}
@@ -631,31 +606,6 @@ export function AdminSettingsPanel({
}
}
async function handleToggleAiGloballyEnabled() {
const newVal = !aiGloballyEnabled;
setAiGloballyEnabled(newVal);
try {
await setAiGloballyEnabledApi(newVal);
// Every AI surface listens for this, so the admin sees the entry appear
// or disappear right away instead of after a reload.
notifyAiStatusChanged();
} catch {
setAiGloballyEnabled(!newVal);
toast.error(t("admin.updateAiEnabledFailed"));
}
}
async function handleSaveAiPrivateEndpoints(hosts: string[]) {
const previous = aiPrivateEndpoints;
setAiPrivateEndpoints(hosts);
try {
setAiPrivateEndpoints(await setAiPrivateEndpointsApi(hosts));
} catch {
setAiPrivateEndpoints(previous);
toast.error(t("admin.updateAiEndpointsFailed"));
}
}
async function handleSaveStepCaSettings() {
try {
await updateStepCaSettings(stepCaSettings);
@@ -828,25 +778,15 @@ export function AdminSettingsPanel({
async function handleSaveMonitoring() {
const status = parseInt(statusInterval, 10);
const metrics = parseInt(metricsInterval, 10);
const retention = parseInt(metricsHistoryRetention, 10);
if (isNaN(status) || isNaN(metrics)) {
toast.error(t("admin.monitoringIntervalInvalid"));
return;
}
if (!isNaN(retention) && (retention < 1 || retention > 90)) {
toast.error(t("admin.metricsHistoryRetentionRange"));
return;
}
try {
await Promise.all([
updateGlobalMonitoringSettings({
statusCheckInterval: status,
metricsInterval: metrics,
}),
!isNaN(retention)
? saveMetricsHistoryRetention(retention)
: Promise.resolve(),
]);
await updateGlobalMonitoringSettings({
statusCheckInterval: status,
metricsInterval: metrics,
});
toast.success(t("admin.monitoringSaved"));
} catch {
toast.error(t("admin.monitoringSaveFailed"));
@@ -1264,10 +1204,6 @@ export function AdminSettingsPanel({
analyticsLocked={analyticsLocked}
handleToggleAnalytics={handleToggleAnalytics}
sessionSharingGloballyEnabled={sessionSharingGloballyEnabled}
aiGloballyEnabled={aiGloballyEnabled}
onToggleAiGloballyEnabled={handleToggleAiGloballyEnabled}
aiPrivateEndpoints={aiPrivateEndpoints}
onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints}
notificationPrivateEndpoints={notificationPrivateEndpoints}
stepCaPrivateEndpoints={stepCaPrivateEndpoints}
onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints}
@@ -1307,8 +1243,6 @@ export function AdminSettingsPanel({
setStatusInterval={setStatusInterval}
metricsInterval={metricsInterval}
setMetricsInterval={setMetricsInterval}
metricsHistoryRetention={metricsHistoryRetention}
setMetricsHistoryRetention={setMetricsHistoryRetention}
handleSaveMonitoring={handleSaveMonitoring}
guacEnabled={guacEnabled}
handleToggleGuacamole={handleToggleGuacamole}
-75
View File
@@ -28,10 +28,6 @@ type GeneralSettingsSectionProps = {
analyticsLocked: boolean;
handleToggleAnalytics: () => void;
sessionSharingGloballyEnabled: boolean;
aiGloballyEnabled: boolean;
onToggleAiGloballyEnabled: () => void;
aiPrivateEndpoints: string[];
onSaveAiPrivateEndpoints: (hosts: string[]) => void;
notificationPrivateEndpoints: string[];
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
stepCaPrivateEndpoints: string[];
@@ -67,8 +63,6 @@ type GeneralSettingsSectionProps = {
setStatusInterval: Dispatch<SetStateAction<string>>;
metricsInterval: string;
setMetricsInterval: Dispatch<SetStateAction<string>>;
metricsHistoryRetention: string;
setMetricsHistoryRetention: Dispatch<SetStateAction<string>>;
handleSaveMonitoring: () => void;
guacEnabled: boolean;
handleToggleGuacamole: () => void;
@@ -86,10 +80,6 @@ export function AdminGeneralSettingsSection({
analyticsLocked,
handleToggleAnalytics,
sessionSharingGloballyEnabled,
aiGloballyEnabled,
onToggleAiGloballyEnabled,
aiPrivateEndpoints,
onSaveAiPrivateEndpoints,
notificationPrivateEndpoints,
onSaveNotificationPrivateEndpoints,
stepCaPrivateEndpoints,
@@ -123,8 +113,6 @@ export function AdminGeneralSettingsSection({
setStatusInterval,
metricsInterval,
setMetricsInterval,
metricsHistoryRetention,
setMetricsHistoryRetention,
handleSaveMonitoring,
guacEnabled,
handleToggleGuacamole,
@@ -167,40 +155,6 @@ export function AdminGeneralSettingsSection({
onToggle={handleToggleSessionSharingGloballyEnabled}
/>
</SettingRow>
<SettingRow
label={t("admin.aiGloballyEnabled")}
description={t("admin.aiGloballyEnabledDesc")}
>
<AdminToggle
on={aiGloballyEnabled}
onToggle={onToggleAiGloballyEnabled}
/>
</SettingRow>
{aiGloballyEnabled && (
// Full-width rather than a SettingRow: the panel is narrow, and an
// inline field here squeezes the label down to a word per line.
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.aiPrivateEndpoints")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("admin.aiPrivateEndpointsDesc")}
</span>
<Input
className="rounded-none"
defaultValue={aiPrivateEndpoints.join(", ")}
placeholder="localhost, 127.0.0.1"
onBlur={(event) =>
onSaveAiPrivateEndpoints(
event.target.value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</div>
)}
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.notificationPrivateEndpoints")}
@@ -477,35 +431,6 @@ export function AdminGeneralSettingsSection({
</Button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.metricsHistoryRetention")}
</label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={90}
value={metricsHistoryRetention}
onChange={(e) => setMetricsHistoryRetention(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t("admin.days")}
</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveMonitoring}
>
{t("common.save")}
</Button>
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.metricsHistoryRetentionRange")}
</span>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
+4 -8
View File
@@ -405,14 +405,10 @@ export function AdminUserManagePanel({
if (editor) {
const isHost = editor.kind === "host";
const tabs = isHost
? makeHostTabs(t).filter((tab) => {
if (tab.id === "general") return true;
if (tab.id === "ssh") return editorProtocols.enableSsh;
if (tab.id === "rdp") return editorProtocols.enableRdp;
if (tab.id === "vnc") return editorProtocols.enableVnc;
if (tab.id === "telnet") return editorProtocols.enableTelnet;
return false;
})
? makeHostTabs(
t,
editorProtocols as unknown as Record<string, boolean>,
).filter((tab) => tab.id !== "ssh" || editorProtocols.enableSsh)
: makeCredentialTabs(t);
return (
+10 -15
View File
@@ -13,10 +13,10 @@ import {
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
import { isElectron } from "@/lib/electron";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { visibleRailItems } from "./rail-items";
import { useAiAvailability } from "@/hooks/use-ai-availability";
import { useRailItems, visibleRailItems } from "./rail-items";
export type RailView =
/** Core rail views; plugins add their own ids at runtime. */
export type CoreRailView =
| "hosts"
| "credentials"
| "port-forwarding"
@@ -29,17 +29,14 @@ export type RailView =
| "session-logs"
| "user-profile"
| "admin-settings"
| "automations"
| "ai"
| "fleets"
| "tailscale"
| "workspaces"
| "collab";
export type RailView = CoreRailView | (string & {});
export type HideableRailView =
| Exclude<RailView, "user-profile" | "admin-settings">
| "network_graph"
| "homepage";
| Exclude<CoreRailView, "user-profile" | "admin-settings">
| "homepage"
| (string & {});
type RailItem =
| {
@@ -195,7 +192,8 @@ export function AppRail({
};
}, [menuPos]);
const { userEnabled: aiEnabled } = useAiAvailability();
// Plugins add and remove rail items at runtime.
useRailItems();
useEffect(() => {
const handler = () => {
@@ -247,9 +245,6 @@ export function AppRail({
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 setRailPinned = (nextPinned: boolean) => {
+14 -24
View File
@@ -1,3 +1,4 @@
import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react";
@@ -21,28 +22,17 @@ import {
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
const CONNECTION_TAB_TYPES: TabType[] = [
"terminal",
"rdp",
"vnc",
"telnet",
"files",
"docker",
"host-metrics",
"tunnel",
];
const TYPE_LABELS: Record<string, string> = {
terminal: "SSH",
rdp: "RDP",
vnc: "VNC",
telnet: "Telnet",
/** Core badge labels; plugin tabs are labelled by their registered title. */
const CORE_TYPE_LABELS: Record<string, string> = {
files: "Files",
docker: "Docker",
"host-metrics": "Host Metrics",
tunnel: "Tunnel",
};
/** Saved connection tabs: the ones reopened after login. */
function isConnectionTabType(type: TabType): boolean {
return isPersistentTabType(type);
}
function formatDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return `${s}s`;
@@ -195,7 +185,10 @@ function ConnectionRow({
variant="outline"
className="text-[9px] px-1 py-0 h-4 font-mono shrink-0 text-muted-foreground/60 border-border/60"
>
{TYPE_LABELS[tabType] ?? tabType}
{CORE_TYPE_LABELS[tabType] ??
(getTabType(tabType)?.titleKey
? t(getTabType(tabType)!.titleKey!)
: tabType)}
</Badge>
</div>
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
@@ -334,9 +327,7 @@ export function ConnectionsPanel({
const dragStartY = useRef<number>(0);
const didDragRef = useRef(false);
const openTabs = tabs.filter((tab) =>
CONNECTION_TAB_TYPES.includes(tab.type),
);
const openTabs = tabs.filter((tab) => isConnectionTabType(tab.type));
const openInstanceIds = new Set(
tabs.map((t) => t.instanceId).filter(Boolean),
@@ -437,9 +428,8 @@ export function ConnectionsPanel({
if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
const reordered = [...openTabs];
reordered.splice(toIdx, 0, reordered.splice(fromIdx, 1)[0]);
const connectionSet = new Set(CONNECTION_TAB_TYPES as string[]);
const nonConnectionTabs = tabs.filter(
(t) => !connectionSet.has(t.type),
(t) => !isConnectionTabType(t.type),
);
onReorderTabs([...nonConnectionTabs, ...reordered]);
}
+82 -230
View File
@@ -30,7 +30,6 @@ import {
Palette,
Pencil,
Plus,
Settings,
Shield,
Upload,
X,
@@ -57,7 +56,6 @@ import {
adminCreateUserCredential,
} from "@/main-axios";
import { getHostDefaults } from "@/api/settings-api";
import { getTailscaleDevices } from "../../../plugins/tailscale/src/frontend/tailscale-api";
import {
getUserPreferences,
saveUserPreferences,
@@ -82,23 +80,16 @@ import {
type HostProtocols,
} from "./HostEditorData";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import {
HostDockerTab,
HostWebUiTab,
HostProxmoxTab,
HostProxmoxStatsTab,
HostFilesTab,
} from "./HostEditorFeatureTabs";
import { HostFilesTab } from "./HostEditorFilesTab";
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
import { canEditHost } from "./host-permissions";
import {
HostEditorRdpTab,
HostEditorTelnetTab,
HostEditorVncTab,
} from "./HostEditorGuacamoleTabs";
import { HostStatsTab } from "./HostEditorStatsTab";
import { VaultProfileManager } from "./VaultProfileManager";
import { getRegisteredHostEditorTab } from "./HostManagerTabs";
import {
getHostEditorSection,
isSshGroupTab,
makeHostTabs,
} from "./HostManagerTabs";
import { useSshAuthEditors } from "@/plugin-host/auth-registry";
import {
SecretReferenceHint,
SecretSourceManager,
@@ -111,6 +102,18 @@ import {
const CUSTOM_FONT_OPTION = "__custom__";
/** The SSH auth types core itself handles. Plugins add the rest. */
const CORE_AUTH_METHODS = [
"password",
"key",
"credential",
"vault",
"none",
"opkssh",
"stepca",
"agent",
];
export function HostEditor({
host,
activeTab,
@@ -168,12 +171,14 @@ export function HostEditor({
}));
};
const setGuacField = (key: string, value: unknown) => {
/** Sets several fields at once, from the latest form. For plugin sections. */
const updateForm = (
patch: (current: Record<string, unknown>) => Record<string, unknown>,
) => {
onDirtyChange?.(true);
setForm((current) => ({
...current,
inheritRemoteDesktopDefaults: false,
guacamoleConfig: { ...current.guacamoleConfig, [key]: value },
...patch(current as unknown as Record<string, unknown>),
}));
};
@@ -182,19 +187,6 @@ export function HostEditor({
const [tunnelStatuses, setTunnelStatuses] = useState<
Record<string, TunnelStatus>
>({});
const [tailscaleDevices, setTailscaleDevices] = useState<
Array<{
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
}>
>([]);
const [tailscaleHasApiKey, setTailscaleHasApiKey] = useState(false);
const [tailscaleLoading, setTailscaleLoading] = useState(false);
const [tailscaleDeviceError, setTailscaleDeviceError] = useState(false);
const [connectingTunnel, setConnectingTunnel] = useState<number | null>(null);
const [isOidcUser, setIsOidcUser] = useState(false);
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
@@ -320,20 +312,6 @@ export function HostEditor({
return unsub;
}, [activeTab]);
useEffect(() => {
if (form.authType !== "tailscale") return;
setTailscaleLoading(true);
setTailscaleDeviceError(false);
getTailscaleDevices()
.then((res) => {
setTailscaleDevices(res?.devices ?? []);
setTailscaleHasApiKey(res?.hasApiKey ?? false);
setTailscaleDeviceError(!!res?.error);
})
.catch(() => setTailscaleDeviceError(true))
.finally(() => setTailscaleLoading(false));
}, [form.authType]);
/**
* Writes each plugin's host-scope values through its own route.
*
@@ -389,6 +367,16 @@ export function HostEditor({
};
const authMethod = form.authType;
// Auth types a plugin adds (Tailscale) bring their own editor and label.
const sshAuthEditors = useSshAuthEditors();
const activeAuthEditor = sshAuthEditors.find(
(editor) => editor.id === authMethod,
);
const ActiveAuthEditor = activeAuthEditor?.component;
const authEditorLabel = (method: string) => {
const editor = sshAuthEditors.find((item) => item.id === method);
return editor ? t(editor.titleKey) : method;
};
const availableCredentials =
quickCreatedCredential &&
!credentials.some(
@@ -497,28 +485,31 @@ export function HostEditor({
) => {
onDirtyChange?.(true);
onProtocolChange({ [proto]: value });
const tabForProto: Record<string, string> = {
enableSsh: "ssh",
enableRdp: "rdp",
enableVnc: "vnc",
enableTelnet: "telnet",
};
const sshGroupTabs = [
"ssh",
"terminal",
"tunnels",
"docker",
"files",
"host-metrics",
];
// Tabs are shown per protocol: SSH owns its group, plugins decide their
// own visibility. Jump to a tab the change reveals, and away from one it
// hides.
const before = protocols as unknown as Record<string, boolean>;
const after = { ...before, [proto]: value };
const visibleBefore = new Set(
makeHostTabs((k) => k, before).map((tab) => tab.id),
);
const visibleAfter = makeHostTabs((k) => k, after).map((tab) => tab.id);
if (!value) {
if (proto === "enableSsh" && sshGroupTabs.includes(activeTab)) {
if (proto === "enableSsh" && isSshGroupTab(activeTab)) {
onTabChange("general");
} else if (activeTab === tabForProto[proto]) {
} else if (!visibleAfter.includes(activeTab)) {
onTabChange("general");
}
return;
}
if (value && tabForProto[proto]) onTabChange(tabForProto[proto]);
if (proto === "enableSsh") {
onTabChange("ssh");
return;
}
const revealed = visibleAfter.find(
(id) => id !== "ssh" && !visibleBefore.has(id),
);
if (revealed) onTabChange(revealed);
};
return (
@@ -621,15 +612,8 @@ export function HostEditor({
aria-label={t("hosts.authenticationMethod")}
>
{[
"password",
"key",
"credential",
"vault",
"none",
"opkssh",
"stepca",
"tailscale",
"agent",
...CORE_AUTH_METHODS,
...sshAuthEditors.map((editor) => editor.id),
].map((m) => (
<button
key={m}
@@ -647,7 +631,7 @@ export function HostEditor({
}}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${authMethod === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m}
{authEditorLabel(m)}
</button>
))}
</div>
@@ -705,9 +689,9 @@ export function HostEditor({
</p>
</div>
)}
{authMethod === "tailscale" && (
{activeAuthEditor?.hintKey && (
<p className="text-[10px] text-muted-foreground/60">
{t("hosts.tailscaleUsernameHint")}
{t(activeAuthEditor.hintKey)}
</p>
)}
</div>
@@ -1092,82 +1076,16 @@ export function HostEditor({
</p>
</div>
)}
{authMethod === "tailscale" && (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.tailscaleDeviceSelect")}
</label>
<a
href="https://docs.termix.site/features/networking/tailscale"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.tailscaleDocsLink")}
</a>
</div>
{tailscaleLoading ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleLoadingDevices")}
</p>
) : tailscaleDeviceError ? (
<p className="text-[10px] text-destructive">
{t("hosts.tailscaleDeviceLoadFailed")}
</p>
) : !tailscaleHasApiKey ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleNoApiKey")}
</p>
) : tailscaleDevices.length === 0 ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleNoDevices")}
</p>
) : (
<>
<Select2
className="w-full border border-border bg-background text-foreground text-xs px-2 py-1.5 focus:outline-none focus:border-accent-brand/50"
value={
tailscaleDevices.find((d) =>
d.addresses.includes(form.ip),
)?.id ?? ""
}
onChange={(e) => {
const device = tailscaleDevices.find(
(d) => d.id === e.target.value,
);
if (device) {
const tailscaleIp =
device.addresses.find((a) =>
a.startsWith("100."),
) ??
device.addresses[0] ??
"";
setField("ip", tailscaleIp);
}
}}
>
<option value="" disabled>
{t("hosts.tailscaleDeviceSelectPlaceholder")}
</option>
{tailscaleDevices.map((d) => (
<option key={d.id} value={d.id}>
{d.hostname} (
{d.addresses.find((a) =>
a.startsWith("100."),
) ??
d.addresses[0] ??
""}
)
</option>
))}
</Select2>
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleDeviceAutoFill")}
</p>
</>
)}
</div>
{ActiveAuthEditor && (
<ActiveAuthEditor
form={form}
setField={
setField as unknown as (
key: string,
value: unknown,
) => void
}
/>
)}
{authMethod === "agent" && (
<div className="flex flex-col gap-2 border-t border-border pt-3">
@@ -2614,93 +2532,27 @@ export function HostEditor({
</>
)}
{activeTab === "docker" && (
<HostDockerTab form={form} setField={setField} />
)}
{activeTab === "web-ui" && (
<HostWebUiTab
form={form}
setField={setField}
protocols={protocols}
/>
)}
{activeTab === "proxmox" && (
<>
<HostProxmoxTab form={form} setField={setField} />
<HostProxmoxStatsTab form={form} setField={setField} />
</>
)}
{activeTab === "files" && (
<HostFilesTab form={form} setField={setField} />
)}
{activeTab === "host-metrics" && (
<HostStatsTab form={form} setField={setField} snippets={snippets} />
)}
{activeTab === "rdp" && (
<>
<SectionCard
title={t("hosts.rdpDefaults", {
defaultValue: "RDP defaults",
})}
icon={<Settings className="size-3.5" />}
>
<SettingRow
label={t("hosts.useUserDefaults", {
defaultValue: "Use user defaults",
})}
description={t("hosts.useUserRdpDefaultsDesc", {
defaultValue:
"Inherit performance, redirection, and clipboard settings from User Profile.",
})}
>
<FakeSwitch
checked={form.inheritRemoteDesktopDefaults}
onChange={(value) =>
setField("inheritRemoteDesktopDefaults", value)
}
/>
</SettingRow>
</SectionCard>
<HostEditorRdpTab
{(() => {
const section = getHostEditorSection(activeTab);
if (!section) return null;
const Section = section.component;
return (
<Section
form={form}
setField={setField}
setGuacField={setGuacField}
setField={
setField as unknown as (key: string, value: unknown) => void
}
updateForm={updateForm}
host={host}
credentials={availableCredentials}
snippets={snippets}
protocols={protocols as unknown as Record<string, boolean>}
/>
</>
)}
{activeTab === "vnc" && (
<HostEditorVncTab
form={form}
setField={setField}
setGuacField={setGuacField}
host={host}
credentials={availableCredentials}
/>
)}
{activeTab === "telnet" && (
<HostEditorTelnetTab
form={form}
setField={setField}
setGuacField={setGuacField}
host={host}
credentials={availableCredentials}
/>
)}
{(() => {
const registeredTab = getRegisteredHostEditorTab(activeTab);
if (!registeredTab) return null;
const RegisteredTabComponent = registeredTab.component;
return <RegisteredTabComponent form={form} setField={setField} />;
);
})()}
</div>
</fieldset>
-500
View File
@@ -1,500 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, FolderSearch, Server } from "lucide-react";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import { getCredentials } from "@/main-axios";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
import { Select2 } from "@/components/select2";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import { HostEditorWebUiSection } from "./HostEditorWebUiSection";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostDockerTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
const dockerConfig = form.dockerConfig ?? { runtime: "docker" as const };
const runtime =
dockerConfig.runtime === "podman"
? ("podman" as const)
: ("docker" as const);
return (
<SectionCard
title={t("hosts.dockerIntegration")}
icon={<Box className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableDockerMonitor")}
description={t("hosts.enableDockerMonitorDesc")}
>
<FakeSwitch
checked={form.enableDocker}
onChange={(v) => setField("enableDocker", v)}
/>
</SettingRow>
{form.enableDocker && (
<SettingRow
label={t("hosts.containerRuntime")}
description={t("hosts.containerRuntimeDesc")}
>
<Select2
value={runtime}
onChange={(e) =>
setField("dockerConfig", {
...dockerConfig,
runtime: e.target.value as "docker" | "podman",
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="docker">
{t("hosts.containerRuntimeDocker")}
</option>
<option value="podman">
{t("hosts.containerRuntimePodman")}
</option>
</Select2>
</SettingRow>
)}
</div>
</SectionCard>
);
}
export function HostProxmoxTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<
{ id: number; name: string; username: string | null }[]
>([]);
useEffect(() => {
getCredentials()
.then((res: unknown) => {
const raw =
(res as { credentials?: unknown })?.credentials ?? res ?? [];
const list = (Array.isArray(raw) ? raw : []).map(
(c: Record<string, unknown>) => ({
id: c.id as number,
name: c.name as string,
username: (c.username as string | null) ?? null,
}),
);
setCredentials(list);
})
.catch(() => {});
}, []);
const cfg = form.proxmoxConfig ?? {
defaultCredentialId: null,
defaultAuthType: "password",
windowsPatterns: "win, windows",
dockerPatterns: "docker",
preferredPrefixes: "10., 192.168.",
autoSyncEnabled: false,
syncIntervalMinutes: 15,
markMissingGuests: true,
};
const lastSyncResult = cfg.lastSyncResult;
const lastSyncSummary = lastSyncResult
? t("hosts.proxmoxLastSyncSummary", {
created: lastSyncResult.created,
updated: lastSyncResult.updated,
markedMissing: lastSyncResult.markedMissing,
skipped: lastSyncResult.skipped,
})
: t("hosts.proxmoxLastSyncNoResult");
const lastSyncDescription = cfg.lastSyncAt
? `${new Date(cfg.lastSyncAt).toLocaleString()} · ${lastSyncSummary}`
: t("hosts.proxmoxLastSyncNever");
return (
<SectionCard
title={t("hosts.proxmoxIntegration")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableProxmox")}
description={
<>
{t("hosts.enableProxmoxDesc")}{" "}
<a
href="https://docs.termix.site/features/files-and-hosts/proxmox-import"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch
checked={form.enableProxmox}
onChange={(v) => setField("enableProxmox", v)}
/>
</SettingRow>
{form.enableProxmox && (
<>
<SettingRow
label={t("hosts.proxmoxDefaultAuthType")}
description={t("hosts.proxmoxDefaultAuthTypeDesc")}
>
<Select2
value={cfg.defaultAuthType ?? "password"}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
defaultAuthType: e.target.value,
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="password">{t("hosts.authTypePassword")}</option>
<option value="key">{t("hosts.authTypeKey")}</option>
<option value="credential">
{t("hosts.authTypeCredential")}
</option>
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
<option value="stepca">{t("hosts.authTypeStepca")}</option>
<option value="none">{t("hosts.authTypeNone")}</option>
</Select2>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxDefaultCredential")}
description={t("hosts.proxmoxDefaultCredentialDesc")}
>
<Select2
value={cfg.defaultCredentialId ?? ""}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
defaultCredentialId: e.target.value
? Number(e.target.value)
: null,
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.none")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</Select2>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxWindowsDetection")}
description={t("hosts.proxmoxWindowsDetectionDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.windowsPatterns}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
windowsPatterns: e.target.value,
})
}
placeholder="win, windows"
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxDockerDetection")}
description={t("hosts.proxmoxDockerDetectionDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.dockerPatterns}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
dockerPatterns: e.target.value,
})
}
placeholder="docker"
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxPreferredRanges")}
description={t("hosts.proxmoxPreferredRangesDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={cfg.preferredPrefixes}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
preferredPrefixes: e.target.value,
})
}
placeholder="10., 192.168."
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxAutoSync")}
description={t("hosts.proxmoxAutoSyncDesc")}
>
<FakeSwitch
checked={cfg.autoSyncEnabled === true}
onChange={(v) =>
setField("proxmoxConfig", {
...cfg,
autoSyncEnabled: v,
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxSyncInterval")}
description={t("hosts.proxmoxSyncIntervalDesc")}
>
<Input
className="w-24 h-7 text-xs"
type="number"
min={5}
value={cfg.syncIntervalMinutes ?? 15}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
syncIntervalMinutes: Math.max(
5,
Number.parseInt(e.target.value || "15", 10),
),
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxMarkMissing")}
description={t("hosts.proxmoxMarkMissingDesc")}
>
<FakeSwitch
checked={cfg.markMissingGuests !== false}
onChange={(v) =>
setField("proxmoxConfig", {
...cfg,
markMissingGuests: v,
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxLastSync")}
description={lastSyncDescription}
>
<span
className={`text-xs ${
cfg.lastSyncStatus === "error"
? "text-destructive"
: "text-muted-foreground"
}`}
title={cfg.lastSyncError ?? undefined}
>
{cfg.lastSyncStatus
? t(`hosts.proxmoxLastSyncStatus.${cfg.lastSyncStatus}`)
: t("hosts.proxmoxLastSyncStatus.pending")}
</span>
</SettingRow>
</>
)}
</div>
</SectionCard>
);
}
export function HostProxmoxStatsTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.enableProxmoxStats")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableProxmoxStats")}
description={t("hosts.enableProxmoxStatsDesc")}
>
<FakeSwitch
checked={form.enableProxmoxStats}
onChange={(v) => setField("enableProxmoxStats", v)}
/>
</SettingRow>
{form.enableProxmoxStats && (
<>
<SettingRow
label={t("hosts.proxmoxStatsPollInterval")}
description={t("hosts.proxmoxStatsPollIntervalDesc")}
>
<Input
className="w-24 h-7 text-xs"
type="number"
min={15}
value={form.proxmoxStatsConfig?.pollInterval ?? 60}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
pollInterval: Math.max(
15,
Number.parseInt(e.target.value || "60", 10),
),
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxStatsNodeOverride")}
description={t("hosts.proxmoxStatsNodeOverrideDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={form.proxmoxStatsConfig?.nodeName ?? ""}
placeholder={t("hosts.proxmoxStatsNodeOverridePlaceholder")}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
nodeName: e.target.value || null,
})
}
/>
</SettingRow>
</>
)}
</div>
</SectionCard>
);
}
export function HostFilesTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.fileManager")}
icon={<FolderSearch className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableFileManagerMonitor")}
description={t("hosts.enableFileManagerMonitorDesc")}
>
<FakeSwitch
checked={form.enableFileManager}
onChange={(v) => setField("enableFileManager", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.scpLegacyLabel")}
description={t("hosts.scpLegacyDesc")}
>
<FakeSwitch
checked={form.scpLegacy}
onChange={(v) => setField("scpLegacy", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.defaultPathLabel")}
</label>
<Input
placeholder="/"
value={form.defaultPath}
onChange={(e) => setField("defaultPath", e.target.value)}
/>
<span className="text-[10px] text-muted-foreground">
{t("hosts.fileManagerPathHint")}
</span>
</div>
</div>
</SectionCard>
);
}
/**
* Owns the async connection-origin resolution for the Web UI tab's tunnel
* gate, then hands the resolved boolean to the presentational section.
*
* tunnelAvailable = enableSsh && originIsLocal. Deliberately NOT gated on
* isElectron(): the forward binds wherever the backend runs, exactly as the
* server tunnels feature does, and a web deployment reaches it at the host
* serving Termix provided the endpoint opted out of a loopback bind. Gating
* this on the desktop would be stricter than the tunnels feature it mirrors.
*
* connectionType is passed as "ssh" deliberately: a tunnel endpoint always
* rides an SSH connection, and "ssh" keeps resolveConnectionOrigin out of its
* RDP/VNC/Telnet guacamole special case, which always resolves to "remote".
*
* The origin state lives here rather than in HostEditor.tsx, which is already
* ~2600 lines; this follows HostEditorGeneralTab's precedent of taking
* `protocols` as a prop and gating its own connection-origin control.
*
* Note the deliberate asymmetry with the sidebar: the sidebar entry appears on
* enableWebUi alone, because a direct endpoint needs no SSH -- but Web UI is
* an SSH sub-tab, so a host with SSH disabled cannot configure endpoints at
* all. That is the accepted behaviour, not an oversight.
*/
export function HostWebUiTab({
form,
setField,
protocols,
}: {
form: HostEditorForm;
setField: SetHostField;
protocols: HostProtocols;
}) {
const [originIsLocal, setOriginIsLocal] = useState(false);
useEffect(() => {
let cancelled = false;
void resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: form.connectionOrigin,
}).then((origin) => {
if (!cancelled) setOriginIsLocal(origin === "local");
});
return () => {
cancelled = true;
};
}, [form.connectionOrigin]);
return (
<HostEditorWebUiSection
enableWebUi={form.enableWebUi}
webUiConfig={form.webUiConfig}
tunnelAvailable={protocols.enableSsh && originIsLocal}
setField={setField as (field: string, value: unknown) => void}
/>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useTranslation } from "react-i18next";
import { FolderSearch } from "lucide-react";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type { HostEditorForm } from "./HostEditorData";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostFilesTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.fileManager")}
icon={<FolderSearch className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableFileManagerMonitor")}
description={t("hosts.enableFileManagerMonitorDesc")}
>
<FakeSwitch
checked={form.enableFileManager}
onChange={(v) => setField("enableFileManager", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.scpLegacyLabel")}
description={t("hosts.scpLegacyDesc")}
>
<FakeSwitch
checked={form.scpLegacy}
onChange={(v) => setField("scpLegacy", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.defaultPathLabel")}
</label>
<Input
placeholder="/"
value={form.defaultPath}
onChange={(e) => setField("defaultPath", e.target.value)}
/>
<span className="text-[10px] text-muted-foreground">
{t("hosts.fileManagerPathHint")}
</span>
</div>
</div>
</SectionCard>
);
}
File diff suppressed because it is too large. Load diff
-402
View File
@@ -1,402 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Activity,
HardDrive,
LayoutDashboard,
Plus,
Server,
Trash2,
Zap,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type { HostEditorForm } from "./HostEditorData";
import { Select2 } from "@/components/select2";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostStatsTab({
form,
setField,
snippets,
}: {
form: HostEditorForm;
setField: SetHostField;
snippets: { id: number; name: string }[];
}) {
const { t } = useTranslation();
const [newMount, setNewMount] = useState("");
const [newMonitoredPath, setNewMonitoredPath] = useState("");
const [newMonitoredLabel, setNewMonitoredLabel] = useState("");
const excludedMounts = form.statsConfig.excludedMounts ?? [];
const monitoredMounts = form.statsConfig.monitoredMounts ?? [];
const addExcludedMount = () => {
const value = newMount.trim();
if (!value || excludedMounts.includes(value)) {
setNewMount("");
return;
}
setField("statsConfig", {
...form.statsConfig,
excludedMounts: [...excludedMounts, value],
});
setNewMount("");
};
const addMonitoredMount = () => {
const path = newMonitoredPath.trim();
if (!path || monitoredMounts.some((entry) => entry.path === path)) return;
setField("statsConfig", {
...form.statsConfig,
monitoredMounts: [
...monitoredMounts,
{ path, label: newMonitoredLabel.trim() || undefined },
],
});
setNewMonitoredPath("");
setNewMonitoredLabel("");
};
return (
<>
<SectionCard
title={t("hosts.statusChecksLabel")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
>
<FakeSwitch
checked={form.statsConfig.statusCheckEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckEnabled: v,
})
}
/>
</SettingRow>
{form.statsConfig.statusCheckEnabled && (
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalStatusInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalStatusInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.statusCheckEnabled &&
!form.statsConfig.useGlobalStatusInterval && (
<SettingRow
label={t("hosts.checkIntervalS")}
description={t("hosts.checkIntervalDesc")}
>
<Input
type="number"
value={form.statsConfig.statusCheckInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.metricsCollectionLabel")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableMetricsLabel")}
description={t("hosts.enableMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.metricsEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
metricsEnabled: v,
})
}
/>
</SettingRow>
{form.statsConfig.metricsEnabled && (
<SettingRow
label={t("hosts.useGlobalMetrics")}
description={t("hosts.useGlobalMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalMetricsInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalMetricsInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.metricsEnabled &&
!form.statsConfig.useGlobalMetricsInterval && (
<SettingRow
label={t("hosts.metricsIntervalS")}
description={t("hosts.metricsIntervalDesc2")}
>
<Input
type="number"
value={form.statsConfig.metricsInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
metricsInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.monitoredMountsLabel")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.monitoredMountsDesc")}
</p>
<div className="grid grid-cols-[1fr_0.7fr_auto] gap-2">
<Input
className="h-7 text-xs"
placeholder={t("hosts.monitoredMountPathPlaceholder")}
value={newMonitoredPath}
onChange={(e) => setNewMonitoredPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addMonitoredMount();
}
}}
/>
<Input
className="h-7 text-xs"
placeholder={t("hosts.monitoredMountLabelPlaceholder")}
value={newMonitoredLabel}
onChange={(e) => setNewMonitoredLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addMonitoredMount();
}
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-[10px]"
onClick={addMonitoredMount}
>
<Plus className="mr-1 size-3" /> {t("hosts.addMonitoredMount")}
</Button>
</div>
{monitoredMounts.map((entry) => (
<div
key={entry.path}
className="flex items-center gap-2 border border-border bg-muted/20 p-2 group"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{entry.path}
</span>
{entry.label && (
<span className="truncate text-xs text-muted-foreground">
{entry.label}
</span>
)}
<button
className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
monitoredMounts: monitoredMounts.filter(
(mount) => mount.path !== entry.path,
),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
<SectionCard
title={t("hosts.excludedMountsLabel")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.excludedMountsDesc")}
</p>
<div className="flex items-center gap-2">
<Input
className="h-7 text-xs flex-1"
placeholder={t("hosts.excludedMountsPlaceholder")}
value={newMount}
onChange={(e) => setNewMount(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addExcludedMount();
}
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={addExcludedMount}
>
<Plus className="size-3 mr-1" /> {t("hosts.addExcludedMount")}
</Button>
</div>
{excludedMounts.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<HardDrive className="size-6" />
<span className="text-xs">{t("hosts.excludedMountsEmpty")}</span>
</div>
)}
{excludedMounts.map((mount, i) => (
<div
key={`${mount}-${i}`}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<span className="text-xs flex-1 font-mono truncate">{mount}</span>
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
excludedMounts: excludedMounts.filter(
(_, idx) => idx !== i,
),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
<SectionCard
title={t("hosts.visibleWidgets")}
icon={<LayoutDashboard className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.widgetsMovedToHostMetrics")}
</p>
</div>
</SectionCard>
<SectionCard
title={t("hosts.quickActionsLabel")}
icon={<Zap className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("quickActions", [
...form.quickActions,
{ name: "", snippetId: "" },
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addActionBtn")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.quickActionsToolbar")}
</p>
{form.quickActions.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<Zap className="size-6" />
<span className="text-xs">{t("hosts.noQuickActions")}</span>
</div>
)}
{form.quickActions.map((a, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<Input
className="h-7 text-xs flex-1"
placeholder={t("hosts.buttonLabel")}
value={a.name}
onChange={(e) => {
const updated = [...form.quickActions];
updated[i] = { ...updated[i], name: e.target.value };
setField("quickActions", updated);
}}
/>
<Select2
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={a.snippetId}
onChange={(e) => {
const updated = [...form.quickActions];
updated[i] = {
...updated[i],
snippetId: e.target.value,
};
setField("quickActions", updated);
}}
>
<option value="">{t("hosts.selectSnippetPlaceholder")}</option>
{snippets.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
))}
</Select2>
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setField(
"quickActions",
form.quickActions.filter((_, idx) => idx !== i),
)
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
</>
);
}
-457
View File
@@ -1,457 +0,0 @@
import { Globe } from "lucide-react";
import { useTranslation } from "react-i18next";
import { isElectron } from "@/lib/electron";
import {
webEndpointRefusalReason,
type WebEndpointRefusalReason,
} from "../../../plugins/web-endpoint/src/frontend/web-endpoint-url";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Checkbox } from "@/components/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import {
MAX_WEB_ENDPOINTS,
MAX_WEB_ENDPOINT_LABEL_LENGTH,
type WebEndpoint,
type WebUiConfig,
} from "@/types/index";
import {
MAX_WEB_ENDPOINT_PORT,
MIN_WEB_ENDPOINT_PORT,
isWebEndpointPortValid,
webEndpointErrorKey,
webEndpointRowError,
} from "../../../plugins/web-endpoint/src/frontend/web-endpoint-validation";
/**
* crypto.randomUUID is undefined outside a secure context, so a plain-http web
* deployment -- a first-class target for the direct+external path -- would
* throw on "Add endpoint". Matches the guarded form already used in
* MacrosPanel, PanePreview, KeybindingsDialog and AppShell.
*/
function endpointId(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
// Only the tunnel-bind reasons render here; this preview sits inside the
// tunnel-access block. The direct cookie refusal is enforced at open time in
// WebEndpointTab / openWebEndpointExternally, where the target host is known.
const BIND_HOST_REFUSAL_MESSAGES: Record<
Exclude<WebEndpointRefusalReason, "direct-shares-session-cookie">,
string
> = {
"loopback-bind-on-remote-backend": "hosts.webUiBindHostUnreachable",
"shares-session-cookie-with-termix": "hosts.webUiBindHostSharesSessionCookie",
};
/**
* True when the bind address reaches beyond the machine running the backend. A
* forward bound there serves the target's web UI to anyone who can reach the
* port, with no authentication in front of it, so the editor says so plainly
* rather than leaving it to be discovered.
*/
function isExposedBindHost(bindHost: string | undefined): boolean {
const value = (bindHost ?? "").trim();
if (!value) return false;
return !["127.0.0.1", "localhost", "::1", "[::1]"].includes(
value.toLowerCase(),
);
}
function newEndpoint(label: string): WebEndpoint {
return {
id: endpointId(),
label,
scheme: "https",
port: 443,
path: "/",
access: "direct",
render: "embedded",
};
}
export function HostEditorWebUiSection({
enableWebUi,
webUiConfig,
tunnelAvailable,
setField,
}: {
enableWebUi: boolean;
webUiConfig: WebUiConfig;
tunnelAvailable: boolean;
setField: (field: string, value: unknown) => void;
}) {
const { t } = useTranslation();
const endpoints = webUiConfig?.endpoints ?? [];
const commit = (next: WebEndpoint[]) =>
setField("webUiConfig", { endpoints: next });
const update = (index: number, patch: Partial<WebEndpoint>) =>
commit(
endpoints.map((endpoint, i) =>
i === index ? { ...endpoint, ...patch } : endpoint,
),
);
// Labels identify an endpoint in the sidebar picker, so a fresh row must not
// silently collide with one already there.
const uniqueLabel = (base: string) => {
const taken = new Set(endpoints.map((e) => e.label.trim()));
if (!taken.has(base)) return base;
for (let n = 2; ; n += 1) {
const candidate = `${base} ${n}`;
if (!taken.has(candidate)) return candidate;
}
};
const addEndpoint = () =>
commit([
...endpoints,
newEndpoint(uniqueLabel(t("hosts.webUiNewEndpointLabel"))),
]);
return (
// Two cards, as the tunnels tab does: settings first, then the list it
// governs, each with its own header.
<>
<SectionCard
title={t("hosts.webUiIntegration")}
icon={<Globe className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableWebUi")}
description={t("hosts.enableWebUiDesc")}
>
<FakeSwitch
checked={enableWebUi}
onChange={(v: boolean) => setField("enableWebUi", v)}
/>
</SettingRow>
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
<p>{t("hosts.webUiRequirementsText")}</p>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.webUiEndpointsSection")}
icon={<Globe className="size-3.5" />}
action={
enableWebUi ? (
// Same header control, and the same classes, as the tunnels list.
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
disabled={endpoints.length >= MAX_WEB_ENDPOINTS}
onClick={addEndpoint}
>
{t("hosts.webUiAddEndpoint")}
</Button>
) : undefined
}
>
<div className="flex flex-col gap-4 py-3">
{enableWebUi && (
<div className="flex flex-col gap-3">
{endpoints.map((endpoint, index) => {
const rowError = webEndpointRowError(endpoint);
return (
<div
key={endpoint.id}
className="flex flex-col gap-3 p-3 border border-border bg-muted/20 relative group"
>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-bold text-muted-foreground">
{t("hosts.webUiEndpointLabel", { number: index + 1 })}
</span>
<Button
variant="ghost"
size="sm"
aria-label={t("hosts.webUiRemoveEndpoint")}
className="h-6 text-[10px] px-2 text-destructive"
onClick={() =>
commit(endpoints.filter((_, i) => i !== index))
}
>
{t("common.delete")}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiLabel")}
</label>
<Input
aria-label={t("hosts.webUiLabel")}
value={endpoint.label}
onChange={(e) =>
update(index, { label: e.target.value })
}
maxLength={MAX_WEB_ENDPOINT_LABEL_LENGTH}
className="h-7 text-xs"
/>
</div>
{rowError && (
<p className="text-[11px] text-destructive">
{t(webEndpointErrorKey(rowError), { row: index + 1 })}
</p>
)}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiAddress")}
</label>
<div className="flex items-center gap-2">
<Select
value={endpoint.scheme}
onValueChange={(value) =>
update(index, {
scheme: value as WebEndpoint["scheme"],
})
}
>
<SelectTrigger
aria-label={t("hosts.webUiScheme")}
size="sm"
className="h-7 w-24 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="https" className="text-xs">
https
</SelectItem>
<SelectItem value="http" className="text-xs">
http
</SelectItem>
</SelectContent>
</Select>
<Input
aria-label={t("hosts.webUiPort")}
type="number"
min={MIN_WEB_ENDPOINT_PORT}
max={MAX_WEB_ENDPOINT_PORT}
value={endpoint.port}
onChange={(e) => {
// Number("") is 0, which the normalizer rejects --
// so clearing the field would otherwise write an
// endpoint that is silently dropped on save.
// Commit only a value the normalizer would keep;
// anything else leaves the last good port.
const parsed = Number(e.target.value);
if (isWebEndpointPortValid(parsed)) {
update(index, { port: parsed });
}
}}
className="h-7 w-20 text-xs"
/>
<Input
aria-label={t("hosts.webUiPath")}
value={endpoint.path ?? "/"}
onChange={(e) =>
update(index, { path: e.target.value })
}
className="h-7 flex-1 text-xs"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiOpening")}
</label>
<div className="flex items-center gap-2">
<Select
value={endpoint.access}
onValueChange={(value) =>
update(index, {
access: value as WebEndpoint["access"],
// Meaningless for a tunnel, whose host component
// is loopback and already exempt.
ignoreCert:
value === "direct"
? endpoint.ignoreCert
: false,
})
}
>
<SelectTrigger
aria-label={t("hosts.webUiAccess")}
size="sm"
className="h-7 flex-1 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct" className="text-xs">
{t("hosts.webUiAccessDirect")}
</SelectItem>
<SelectItem
value="tunnel"
disabled={!tunnelAvailable}
>
{t("hosts.webUiAccessTunnel")}
</SelectItem>
</SelectContent>
</Select>
<Select
value={endpoint.render}
onValueChange={(value) =>
update(index, {
render: value as WebEndpoint["render"],
})
}
>
<SelectTrigger
aria-label={t("hosts.webUiRender")}
size="sm"
className="h-7 flex-1 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
value="external"
className="text-xs"
disabled={!isElectron()}
>
{t("hosts.webUiRenderExternal")}
</SelectItem>
<SelectItem value="embedded" className="text-xs">
{t("hosts.webUiRenderEmbedded")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{endpoint.access === "tunnel" && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.bindHost")}
</label>
<Input
aria-label={t("hosts.bindHost")}
placeholder="127.0.0.1"
value={endpoint.bindHost ?? ""}
onChange={(e) =>
update(index, { bindHost: e.target.value })
}
className="h-7 text-xs"
/>
<p className="text-[11px] opacity-70">
{t("hosts.webUiBindHostDesc")}
</p>
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiLocalPort")}
</label>
<Input
aria-label={t("hosts.webUiLocalPort")}
type="number"
min={MIN_WEB_ENDPOINT_PORT}
max={MAX_WEB_ENDPOINT_PORT}
placeholder={t("hosts.webUiLocalPortAuto")}
value={endpoint.localPort ?? ""}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
update(index, { localPort: undefined });
return;
}
const parsed = Number(raw);
if (isWebEndpointPortValid(parsed)) {
update(index, { localPort: parsed });
}
}}
className="h-7 text-xs"
/>
<p className="text-[11px] opacity-70">
{t("hosts.webUiLocalPortDesc")}
</p>
{isExposedBindHost(endpoint.bindHost) && (
<p className="text-[11px] text-destructive">
{t("hosts.webUiBindHostExposed")}
</p>
)}
{(() => {
// Said while configuring, not only when the tab fails
// to load: a loopback bind on a backend that is not
// this machine opens fine and then leaves the browser
// with nothing to connect to, and a tunnel reached at
// Termix's own hostname would hand the tunnelled
// service this session.
const refusal = webEndpointRefusalReason(
endpoint,
isElectron(),
undefined,
);
if (!refusal) return null;
// Unreachable from this tunnel-only block; narrows
// the union to the reasons this preview renders.
if (refusal === "direct-shares-session-cookie") {
return null;
}
return (
<p className="text-[11px] text-destructive">
{t(BIND_HOST_REFUSAL_MESSAGES[refusal])}
</p>
);
})()}
</div>
)}
{endpoint.render === "embedded" && (
<p className="text-[11px] opacity-70">
{t("hosts.webUiRenderEmbeddedDesc")}
</p>
)}
{endpoint.access === "direct" && (
<div className="flex flex-col gap-1">
<label className="flex items-center gap-2 text-xs">
<Checkbox
checked={endpoint.ignoreCert === true}
onCheckedChange={(value) =>
update(index, { ignoreCert: value === true })
}
/>
{t("hosts.webUiIgnoreCert")}
</label>
<p className="text-[11px] opacity-70">
{t("hosts.webUiIgnoreCertDesc")}
</p>
</div>
)}
</div>
);
})}
{!tunnelAvailable && endpoints.length > 0 && (
// Shown whenever tunnelling is unavailable, not only once a row
// already asks for it: the Tunnel option renders disabled, and
// a disabled control with no stated reason reads as the
// dropdown being broken.
<p className="text-[11px] opacity-70">
{t("hosts.webUiAccessTunnelUnavailable")}
</p>
)}
</div>
)}
</div>
</SectionCard>
</>
);
}
+16 -13
View File
@@ -39,7 +39,8 @@ import {
makeCredentialTabs,
makeHostTabs,
makeHostSshSubTabs,
SSH_GROUP_TABS,
isSshGroupTab,
useHostEditorSections,
TabStrip,
} from "./HostManagerTabs";
import { Select2 } from "@/components/select2";
@@ -73,6 +74,8 @@ export function HostManager({
onTagsChange?: (tags: string[]) => void;
active?: boolean;
} = {}) {
// Re-render when a plugin adds or removes a host editor tab.
useHostEditorSections();
const { t } = useTranslation();
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
const [shareCredential, setShareCredential] = useState<Credential | null>(
@@ -422,15 +425,13 @@ export function HostManager({
// buildHostEditorPayload still serializes all of them, so hiding a tab
// hides its inputs, not its values.
const collapseAdvanced = isHost && simpleEditor && !showAdvancedEditor;
const protocols = editingProtocols as unknown as Record<string, boolean>;
// Plugin tabs decide their own visibility from the host's protocols.
const tabs = isHost
? makeHostTabs(t).filter((tab) => {
? makeHostTabs(t, protocols).filter((tab) => {
if (tab.id === "general") return true;
if (tab.id === "ssh") return editingProtocols.enableSsh;
if (collapseAdvanced) return false;
if (tab.id === "rdp") return editingProtocols.enableRdp;
if (tab.id === "vnc") return editingProtocols.enableVnc;
if (tab.id === "telnet") return editingProtocols.enableTelnet;
return false;
return !collapseAdvanced;
})
: makeCredentialTabs(t);
// Collapsing while on a now-hidden tab would leave nothing selected. The
@@ -438,14 +439,17 @@ export function HostManager({
// The top-level strip only lists general/ssh/rdp/vnc/telnet -- the SSH
// sub-tabs live in the secondary strip, so they count as visible whenever
// the SSH group is expanded.
const sshSubTabs = makeHostSshSubTabs(t, protocols);
// Registered SSH-group tabs count too: checking a fixed list here is what
// left plugin tabs unreachable.
const hostTabVisible =
tabs.some((tab) => tab.id === activeHostTab) ||
(!collapseAdvanced &&
editingProtocols.enableSsh &&
SSH_GROUP_TABS.has(activeHostTab as never));
sshSubTabs.some((tab) => tab.id === activeHostTab));
const effectiveHostTab = hostTabVisible
? activeHostTab
: collapseAdvanced && SSH_GROUP_TABS.has(activeHostTab as never)
: collapseAdvanced && isSshGroupTab(activeHostTab)
? "ssh"
: "general";
const activeTab = isHost ? effectiveHostTab : activeCredentialTab;
@@ -454,8 +458,7 @@ export function HostManager({
isHost &&
!collapseAdvanced &&
editingProtocols.enableSsh &&
SSH_GROUP_TABS.has(activeHostTab as never);
const sshSubTabs = makeHostSshSubTabs(t);
isSshGroupTab(activeHostTab);
return (
<div className="flex flex-col flex-1 min-h-0">
@@ -499,7 +502,7 @@ export function HostManager({
activeTab={activeTab}
onTabChange={(id) => {
if (isHost && id === "ssh") {
if (!SSH_GROUP_TABS.has(activeHostTab as never)) {
if (!isSshGroupTab(activeHostTab)) {
setActiveHostTab("ssh");
}
} else {
@@ -510,7 +513,7 @@ export function HostManager({
isHost
? (id) =>
id === "ssh"
? SSH_GROUP_TABS.has(activeHostTab as never)
? isSshGroupTab(activeHostTab)
: activeHostTab === id
: undefined
}
+141 -137
View File
@@ -1,33 +1,19 @@
/* eslint-disable react-refresh/only-export-components */
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
import {
Activity,
Box,
Folder,
KeyRound,
Monitor,
MousePointerClick,
Network,
Server,
Settings,
SquareTerminal,
Terminal,
Globe,
} from "lucide-react";
import { byOrderThenId, createRegistry } from "@/lib/registry";
export type HostTabId =
| "general"
| "ssh"
| "terminal"
| "tunnels"
| "docker"
| "web-ui"
| "proxmox"
| "files"
| "host-metrics"
| "rdp"
| "vnc"
| "telnet";
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
export type CoreHostTabId =
"general" | "ssh" | "terminal" | "tunnels" | "files";
export type HostTabId = CoreHostTabId | (string & {});
export type CredentialTabId = "general" | "auth";
type HostTab = {
@@ -41,139 +27,157 @@ type CredentialTab = {
icon: ReactNode;
};
export const SSH_GROUP_TABS = new Set<HostTabId>([
"ssh",
"terminal",
"tunnels",
"docker",
"web-ui",
"proxmox",
"files",
"host-metrics",
]);
/**
* A plugin-contributed host editor tab. `component` receives the same
* {form, setField} pair every built-in tab body gets (see HostDockerTab in
* HostEditorFeatureTabs.tsx for the shape a plugin's own tab component
* should match).
*/
export type HostEditorTabComponent = ComponentType<{
form: unknown;
export interface HostEditorSectionRenderProps {
// The editor form is a plain object. Sections read the fields they own.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: any;
setField: (key: string, value: unknown) => void;
}>;
interface RegisteredHostEditorTab {
id: string;
labelKey: string;
icon: ReactNode;
component: HostEditorTabComponent;
/** Applies several fields at once, against the latest form. */
updateForm: (
patch: (form: Record<string, unknown>) => Record<string, unknown>,
) => void;
host?: unknown;
credentials?: unknown[];
snippets?: unknown[];
protocols: Record<string, boolean>;
}
/**
* Runtime registry for host editor tabs that don't exist in the built-in
* HostTabId union. A plugin's frontend registers into this from its own
* register() (see plugins/*\/frontend/index.mjs), the same pattern
* rail-items.ts and tabUtils.tsx already use for their seams.
* A host editor tab contributed at runtime. "top" tabs sit in the main strip
* beside General and SSH; "ssh" tabs sit in the SSH group's second strip.
*/
const registeredHostEditorTabs = new Map<string, RegisteredHostEditorTab>();
export function registerHostEditorTab(def: RegisteredHostEditorTab): void {
registeredHostEditorTabs.set(def.id, def);
export interface HostEditorSectionDef {
id: string;
pluginId?: string;
group: "top" | "ssh";
labelKey: string;
icon?: ComponentType<{ className?: string }>;
/** Position among the group's tabs, core ones included. */
order?: number;
/** Whether to offer the tab for these protocols. Defaults to always. */
visible?: (protocols: Record<string, boolean>) => boolean;
component: ComponentType<HostEditorSectionRenderProps>;
}
export function unregisterHostEditorTab(id: string): void {
registeredHostEditorTabs.delete(id);
const sections = createRegistry<HostEditorSectionDef>(byOrderThenId);
export const registerHostEditorSection = sections.register;
export const unregisterHostEditorSection = sections.unregister;
export const getHostEditorSection = sections.get;
export const hostEditorSectionList = sections.list;
export const useHostEditorSections = sections.useList;
export const resetHostEditorSections = sections.reset;
const CORE_SSH_GROUP = new Set<string>(["ssh", "terminal", "tunnels", "files"]);
/** Whether a tab lives in the SSH group's second strip. */
export function isSshGroupTab(id: string): boolean {
return CORE_SSH_GROUP.has(id) || sections.get(id)?.group === "ssh";
}
export function getRegisteredHostEditorTab(
id: string,
): RegisteredHostEditorTab | undefined {
return registeredHostEditorTabs.get(id);
function sectionVisible(
section: HostEditorSectionDef,
protocols: Record<string, boolean>,
): boolean {
if (!section.visible) return true;
try {
return section.visible(protocols);
} catch {
return false;
}
}
export function registeredHostEditorTabList(): RegisteredHostEditorTab[] {
return [...registeredHostEditorTabs.values()];
function mergeByOrder(
core: (HostTab & { order: number })[],
group: "top" | "ssh",
t: (key: string) => string,
protocols: Record<string, boolean> | undefined,
): HostTab[] {
const registered = sections
.list()
.filter(
(section) =>
section.group === group &&
(!protocols || sectionVisible(section, protocols)),
)
.map((section) => {
const Icon = section.icon;
return {
id: section.id,
label: t(section.labelKey),
icon: Icon ? <Icon className="size-3" /> : null,
order: section.order ?? 1000,
};
});
return [...core, ...registered]
.sort((a, b) => a.order - b.order)
.map(({ order: _order, ...tab }) => tab);
}
export function makeHostTabs(t: (key: string) => string): HostTab[] {
return [
{
id: "general",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
},
{
id: "ssh",
label: t("hosts.tabSsh"),
icon: <Terminal className="size-3" />,
},
{
id: "rdp",
label: t("hosts.tabRdp"),
icon: <Monitor className="size-3" />,
},
{
id: "vnc",
label: t("hosts.tabVnc"),
icon: <MousePointerClick className="size-3" />,
},
{
id: "telnet",
label: t("hosts.tabTelnet"),
icon: <Terminal className="size-3" />,
},
];
/**
* The main strip. With `protocols`, registered tabs are filtered by their
* `visible`; without, every registered top tab is listed.
*/
export function makeHostTabs(
t: (key: string) => string,
protocols?: Record<string, boolean>,
): HostTab[] {
return mergeByOrder(
[
{
id: "general",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
order: 0,
},
{
id: "ssh",
label: t("hosts.tabSsh"),
icon: <Terminal className="size-3" />,
order: 10,
},
],
"top",
t,
protocols,
);
}
export function makeHostSshSubTabs(t: (key: string) => string): HostTab[] {
return [
{
id: "ssh",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
},
{
id: "terminal",
label: t("hosts.tabTerminal"),
icon: <SquareTerminal className="size-3" />,
},
{
id: "tunnels",
label: t("hosts.tabTunnels"),
icon: <Network className="size-3" />,
},
{
id: "docker",
label: t("hosts.tabDocker"),
icon: <Box className="size-3" />,
},
{
id: "web-ui",
label: t("hosts.tabWebUi"),
icon: <Globe className="size-3" />,
},
{
id: "proxmox",
label: t("hosts.tabProxmox"),
icon: <Server className="size-3" />,
},
{
id: "files",
label: t("hosts.tabFiles"),
icon: <Folder className="size-3" />,
},
{
id: "host-metrics",
label: t("hosts.tabHostMetrics"),
icon: <Activity className="size-3" />,
},
...registeredHostEditorTabList().map((tab) => ({
id: tab.id as HostTabId,
label: t(tab.labelKey),
icon: tab.icon,
})),
];
export function makeHostSshSubTabs(
t: (key: string) => string,
protocols?: Record<string, boolean>,
): HostTab[] {
return mergeByOrder(
[
{
id: "ssh",
label: t("hosts.tabGeneral"),
icon: <Settings className="size-3" />,
order: 0,
},
{
id: "terminal",
label: t("hosts.tabTerminal"),
icon: <SquareTerminal className="size-3" />,
order: 10,
},
{
id: "tunnels",
label: t("hosts.tabTunnels"),
icon: <Network className="size-3" />,
order: 20,
},
{
id: "files",
label: t("hosts.tabFiles"),
icon: <Folder className="size-3" />,
order: 60,
},
],
"ssh",
t,
protocols,
);
}
export function makeCredentialTabs(
+7 -46
View File
@@ -17,7 +17,6 @@ import {
Plus,
RefreshCw,
Search,
Server,
SlidersHorizontal,
Upload,
X,
@@ -28,7 +27,7 @@ import { HostManager } from "@/sidebar/HostManager";
import { HostShareModal } from "@/sidebar/HostShareModal";
import { HostExportDialog } from "@/sidebar/HostExportDialog";
import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel";
import { ProxmoxDiscoverDialog } from "../../../plugins/proxmox/src/frontend/ProxmoxDiscoverDialog";
import { ComponentSlot } from "@/shell/ActionSlot";
import { Button } from "@/components/button";
import {
DropdownMenu,
@@ -193,7 +192,7 @@ export function HostsPanel({
host: Host,
type: TabType,
options?: {
endpointId?: string;
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
@@ -216,19 +215,6 @@ export function HostsPanel({
const [exportPreselection, setExportPreselection] = useState<Set<string>>(
new Set(),
);
const [proxmoxDialogOpen, setProxmoxDialogOpen] = useState(false);
const [proxmoxHostId, setProxmoxHostId] = useState<number | undefined>(
undefined,
);
const [proxmoxDefaultCredentialId, setProxmoxDefaultCredentialId] = useState<
number | null
>(null);
const [proxmoxDefaultAuthType, setProxmoxDefaultAuthType] = useState<
string | undefined
>(undefined);
const [proxmoxDefaultUsername, setProxmoxDefaultUsername] = useState<
string | undefined
>(undefined);
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useHostSidebarPreferences();
const sortKey = sidebarPrefs.sort.key;
@@ -579,16 +565,7 @@ export function HostsPanel({
<Upload className="size-3.5 mr-2" />
{t("hosts.importSSHConfig")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setProxmoxHostId(undefined);
setProxmoxDialogOpen(true);
}}
disabled={!rawHosts.some((h) => h.enableProxmox)}
>
<Server className="size-3.5 mr-2" />
{t("hosts.proxmoxImportTitle")}
</DropdownMenuItem>
<ComponentSlot slotId="hosts.importMenu" />
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
@@ -1059,14 +1036,6 @@ export function HostsPanel({
onOpenTab={onOpenTab}
onEditHost={onEditHost}
onShareHost={(host) => setShareModalHost(host)}
onProxmoxDiscover={(host) => {
const cfg = host.proxmoxConfig;
setProxmoxHostId(Number(host.id));
setProxmoxDefaultCredentialId(cfg?.defaultCredentialId ?? null);
setProxmoxDefaultAuthType(cfg?.defaultAuthType ?? undefined);
setProxmoxDefaultUsername(undefined);
setProxmoxDialogOpen(true);
}}
query={hostSearch.trim().toLowerCase()}
selectionMode={selectionMode}
onToggleSelectionMode={toggleSelectionMode}
@@ -1105,18 +1074,10 @@ export function HostsPanel({
preselectedHostIds={exportPreselection}
/>
<ProxmoxDiscoverDialog
open={proxmoxDialogOpen}
onClose={() => {
setProxmoxDialogOpen(false);
setProxmoxHostId(undefined);
}}
hosts={rawHosts}
onHostsChanged={setRawHosts}
preselectedHostId={proxmoxHostId}
defaultCredentialId={proxmoxDefaultCredentialId}
defaultAuthType={proxmoxDefaultAuthType}
defaultUsername={proxmoxDefaultUsername}
{/* Plugins mount their own host-list dialogs here. */}
<ComponentSlot
slotId="hosts.panel"
props={{ hosts: rawHosts, onHostsChanged: setRawHosts }}
/>
</div>
);
@@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next";
import { FakeSwitch } from "@/components/section-card";
import { useRailItems } from "./rail-items";
/**
* Appearance > Sidebar > Navigation: one switch per rail destination,
* including the ones plugins register, which appear and disappear here as
* their plugin is turned on and off.
*/
export function NavigationVisibilityToggles({
hidden,
onChange,
}: {
hidden: Set<string>;
onChange: (next: Set<string>) => void;
}) {
const { t } = useTranslation();
const railItems = useRailItems();
return (
<>
{railItems
.filter((item) => item.hideable !== false)
.map((item) => (
<div
key={item.id}
className="flex items-center justify-between py-1.5"
data-testid={`nav-toggle-${item.id}`}
>
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">
<item.icon size={12} />
</span>
{t(item.labelKey)}
</span>
<FakeSwitch
checked={!hidden.has(item.id)}
onChange={(visible) => {
const next = new Set(hidden);
if (visible) next.delete(item.id);
else next.add(item.id);
onChange(next);
}}
/>
</div>
))}
</>
);
}
+6 -4
View File
@@ -23,9 +23,10 @@ const DEFAULT_PORTS: Record<QuickConnectProtocol, string> = {
vnc: "5900",
};
import { Select2 } from "@/components/select2";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
interface QuickConnectPanelProps {
onConnect: (host: Host, type: "terminal" | "files" | "rdp" | "vnc") => void;
onConnect: (host: Host, type: string) => void;
}
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
@@ -60,7 +61,8 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
setProtocol(next);
};
const connect = (type: "terminal" | "files" | "rdp" | "vnc") => {
// The tab comes from whichever plugin connects hosts of this protocol.
const connect = (type?: string) => {
if (!host) return;
if (!isDesktop && !username) return;
const hostConfig = createQuickConnectHost({
@@ -74,10 +76,10 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
protocol,
domain: domain || undefined,
});
onConnect(hostConfig, type);
onConnect(hostConfig, type ?? resolveHostTabType(hostConfig));
};
const connectDefault = () => connect(isDesktop ? protocol : "terminal");
const connectDefault = () => connect();
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
+12 -110
View File
@@ -74,9 +74,8 @@ import {
User,
X,
} from "lucide-react";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
import { SettingRow, FakeSwitch } from "@/components/section-card";
import { visibleRailItems } from "./rail-items";
import { NavigationVisibilityToggles } from "./NavigationVisibilityToggles";
import { InterfacePresetSettings } from "./InterfacePresetSettings";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { KeybindingsDialog } from "./KeybindingsDialog";
@@ -693,57 +692,12 @@ export function UserProfilePanel({
return v !== null ? v === "true" : true;
});
const [keybindingsDialogOpen, setKeybindingsDialogOpen] = useState(false);
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(false);
const [aiReadOnlyCommands, setAiReadOnlyCommands] = useState(false);
// Null until the status call answers; the whole section stays hidden while
// unknown so a user who cannot have the feature never sees it mentioned.
const [aiGloballyEnabled, setAiGloballyEnabled] = useState<boolean | null>(
null,
);
// Sidebar display customization (density, tags, tray trigger, status
// colors) now lives in the dedicated Customize Sidebar panel opened from
// the Hosts toolbar, not here -- resetToDefaults still needs write access.
const { update: updateSidebarPrefs } = useHostSidebarPreferences();
const uiPrefs = useUiPreferencesContext();
useEffect(() => {
let cancelled = false;
import("@/api/ai-api")
.then(({ getAiStatus }) => getAiStatus())
.then((status) => {
if (cancelled) return;
setAiGloballyEnabled(status.globallyEnabled);
setAiAssistantEnabled(status.enabled);
setAiReadOnlyCommands(status.allowReadOnlyCommands);
})
.catch(() => {
if (!cancelled) setAiGloballyEnabled(false);
});
return () => {
cancelled = true;
};
}, []);
/**
* Turning the assistant off also hides its rail tab, so declining removes
* the feature from view entirely rather than leaving an inert entry behind.
*/
const applyAiEnabled = (enabled: boolean) => {
setAiAssistantEnabled(enabled);
const hidden = readHiddenRailTabs();
if (enabled) hidden.delete("ai");
else hidden.add("ai");
const serialized = JSON.stringify([...hidden]);
localStorage.setItem("hiddenRailTabs", serialized);
setHiddenRailTabs(hidden);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
if (storageMode === "cloud") {
saveToCloud({ aiAssistantEnabled: enabled, hiddenRailTabs: serialized });
}
};
const [pinAppRail, setPinAppRail] = useState(() =>
readRailPreference("pinAppRail"),
);
@@ -947,12 +901,6 @@ export function UserProfilePanel({
String(prefs.commandPaletteEnabled),
);
}
if (prefs.aiAssistantEnabled != null) {
setAiAssistantEnabled(prefs.aiAssistantEnabled);
}
if (prefs.aiReadOnlyCommands != null) {
setAiReadOnlyCommands(prefs.aiReadOnlyCommands);
}
// showHostTags/hostTrayOnClick/compactHostView/statusColorScheme are
// no longer restored here -- useHostSidebarPreferences independently
// fetches its own authoritative copy from /host-sidebar/preferences.
@@ -2082,34 +2030,6 @@ export function UserProfilePanel({
}}
/>
</SettingRow>
{aiGloballyEnabled && (
<>
<SettingRow
label={t("ai.enableTitle")}
description={t("ai.enableDescription")}
>
<FakeSwitch
checked={aiAssistantEnabled}
onChange={applyAiEnabled}
/>
</SettingRow>
{aiAssistantEnabled && (
<SettingRow
label={t("ai.readOnlyCommands")}
description={t("ai.readOnlyCommandsDescription")}
>
<FakeSwitch
checked={aiReadOnlyCommands}
onChange={(v) => {
setAiReadOnlyCommands(v);
if (storageMode === "cloud")
saveToCloud({ aiReadOnlyCommands: v });
}}
/>
</SettingRow>
)}
</>
)}
<SettingRow
label={t("newUi.sidebar.userProfile.reopenTabsOnLogin")}
description={t("newUi.sidebar.userProfile.reopenTabsOnLoginDesc")}
@@ -2211,35 +2131,17 @@ export function UserProfilePanel({
<p className="text-[10px] text-muted-foreground mb-2">
{t("newUi.sidebar.userProfile.navigationTabsDesc")}
</p>
{visibleRailItems()
.filter((tab) => tab.id !== "ai" || aiGloballyEnabled)
.map((tab) => (
<div
key={tab.id}
className="flex items-center justify-between py-1.5"
>
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">
<tab.icon size={12} />
</span>
{t(tab.labelKey)}
</span>
<FakeSwitch
checked={!hiddenRailTabs.has(tab.id)}
onChange={(visible) => {
const next = new Set(hiddenRailTabs);
if (visible) next.delete(tab.id);
else next.add(tab.id);
setHiddenRailTabs(next);
const serialized = JSON.stringify([...next]);
localStorage.setItem("hiddenRailTabs", serialized);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
if (storageMode === "cloud")
saveToCloud({ hiddenRailTabs: serialized });
}}
/>
</div>
))}
<NavigationVisibilityToggles
hidden={hiddenRailTabs}
onChange={(next) => {
setHiddenRailTabs(next);
const serialized = JSON.stringify([...next]);
localStorage.setItem("hiddenRailTabs", serialized);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
if (storageMode === "cloud")
saveToCloud({ hiddenRailTabs: serialized });
}}
/>
</div>
<div className="flex flex-col gap-1 border-t border-border pt-3">
+122
View File
@@ -0,0 +1,122 @@
import type { ComponentType } from "react";
import type { Host } from "@/types/ui-types";
import { byOrderThenId, createRegistry } from "@/lib/registry";
import type { TabShellCallbacks } from "@/shell/tab-registry";
type Icon = ComponentType<{ className?: string; size?: number | string }>;
/**
* What a plugin adds to a host: ways to connect or open a tool, badges on its
* row, and entries in its context menu. The host list, quick connect and the
* command palette all read these instead of knowing about each protocol.
*/
export interface HostActionDef {
id: string;
pluginId?: string;
titleKey: string;
icon: Icon;
kind: "connect" | "open";
priority?: number;
tabType?: string;
when: (host: Host) => boolean;
run?: (host: Host, shell: TabShellCallbacks) => void;
tray?: boolean;
copyUrlView?: string;
/** Where the dashboard's host status list sends a click. */
overview?: boolean;
order?: number;
/** A label worked out per host, e.g. a single endpoint's name. */
label?: (host: Host) => string | undefined;
/** Several targets: two or more become a picker, one runs directly. */
items?: (host: Host) => {
id: string;
label: string;
run: (host: Host, shell: TabShellCallbacks) => void;
}[];
}
export interface HostBadgeDef {
id: string;
pluginId?: string;
when: (host: Host) => boolean;
component: ComponentType<{ host: Host }>;
order?: number;
}
export interface HostContextMenuItemDef {
id: string;
pluginId?: string;
titleKey: string;
icon?: Icon;
when: (host: Host) => boolean;
run: (host: Host, shell: TabShellCallbacks) => void;
order?: number;
}
const actions = createRegistry<HostActionDef>(byOrderThenId);
const badges = createRegistry<HostBadgeDef>(byOrderThenId);
const menuItems = createRegistry<HostContextMenuItemDef>(byOrderThenId);
export const registerHostAction = actions.register;
export const useHostActions = actions.useList;
export const listHostActions = actions.list;
export const registerHostBadge = badges.register;
export const useHostBadges = badges.useList;
export const registerHostContextMenuItem = menuItems.register;
export const useHostContextMenuItems = menuItems.useList;
/** A predicate from a plugin must never take the host list down with it. */
function safeWhen(predicate: (host: Host) => boolean, host: Host): boolean {
try {
return predicate(host);
} catch {
return false;
}
}
export function hostActionsFor(
all: HostActionDef[],
host: Host,
kind?: HostActionDef["kind"],
): HostActionDef[] {
return all.filter(
(action) => (!kind || action.kind === kind) && safeWhen(action.when, host),
);
}
/** The connect action a click on the host should run, by priority. */
export function defaultConnectAction(
all: HostActionDef[],
host: Host,
): HostActionDef | undefined {
return hostActionsFor(all, host, "connect").sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
)[0];
}
export function hostBadgesFor(all: HostBadgeDef[], host: Host): HostBadgeDef[] {
return all.filter((badge) => safeWhen(badge.when, host));
}
export function hostMenuItemsFor(
all: HostContextMenuItemDef[],
host: Host,
): HostContextMenuItemDef[] {
return all.filter((item) => safeWhen(item.when, host));
}
/** Runs an action, falling back to opening its tab type. */
export function runHostAction(
action: HostActionDef,
host: Host,
shell: TabShellCallbacks,
): void {
if (action.run) action.run(host, shell);
else if (action.tabType) shell.openTab(host, action.tabType);
}
export function resetHostContributions(): void {
actions.reset();
badges.reset();
menuItems.reset();
}
-18
View File
@@ -1,6 +1,5 @@
import type { SSHHostData } from "@/types";
import type { Host } from "@/types/ui-types";
import type { GuacamoleQuickHost } from "../../../plugins/remote-desktop/src/frontend/GuacamoleApp";
export type QuickConnectProtocol = "ssh" | "rdp" | "vnc";
@@ -123,20 +122,3 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
telnetPort: host.telnetPort,
};
}
/** The slice of a quick-connect host that GuacamoleApp mints a token from. */
export function quickConnectGuacHost(host: Host): GuacamoleQuickHost {
return {
name: host.name,
ip: host.ip,
connectionType: host.enableVnc ? "vnc" : "rdp",
domain: host.domain,
rdpPort: host.rdpPort,
vncPort: host.vncPort,
rdpAuthType: host.rdpAuthType,
rdpUser: host.rdpUser,
rdpPassword: host.rdpPassword,
vncUser: host.vncUser,
vncPassword: host.vncPassword,
};
}
+106 -73
View File
@@ -1,30 +1,27 @@
import {
ArrowLeftRight,
Boxes,
Braces,
Clock,
Fingerprint,
Hammer,
KeyRound,
LayoutPanelLeft,
LayoutTemplate,
Network,
Play,
Plug,
Radar,
ScrollText,
Server,
Settings,
Sparkles,
TerminalSquare,
Usb,
User,
Workflow,
Zap,
Presentation,
type LucideIcon,
} from "lucide-react";
import { useSyncExternalStore } from "react";
import { isElectron } from "@/lib/electron";
import { createRegistry } from "@/lib/registry";
/**
* The one list of navigation destinations.
@@ -42,7 +39,7 @@ export interface RailItemDef {
icon: LucideIcon;
/** i18n key; every label goes through t() so nothing is hardcoded English. */
labelKey: string;
/** Tab-opening entries (network_graph) rather than sidebar panels. */
/** Tab-opening entries rather than sidebar panels. */
kind?: "tab";
/** Always-available destinations that users cannot hide. */
alwaysVisible?: boolean;
@@ -62,6 +59,16 @@ export interface RailItemDef {
rightDockable?: boolean;
/** Desktop app only. Hidden in the browser build, including its toggle. */
electronOnly?: boolean;
/** False keeps it out of the Navigation visibility toggles. */
hideable?: boolean;
/** Registered but not shown, e.g. while its feature is switched off. */
hidden?: boolean;
/** Places a registered item after this id instead of at the end. */
after?: string;
/** Tie-break among registered items. */
order?: number;
/** Set for items a plugin registered. */
pluginId?: string;
}
export const RAIL_ITEMS: RailItemDef[] = [
@@ -139,28 +146,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
promotable: true,
rightDockable: true,
},
{ id: "fleets", icon: Boxes, labelKey: "nav.fleets", separatorAfter: true },
{
id: "tailscale",
icon: Radar,
labelKey: "nav.tailscale",
separatorAfter: true,
},
{
id: "automations",
icon: Workflow,
labelKey: "nav.automations",
separatorAfter: true,
promotable: true,
},
{
id: "ai",
icon: Sparkles,
labelKey: "nav.ai",
separatorAfter: true,
promotable: true,
rightDockable: true,
},
{
id: "history",
icon: Clock,
@@ -183,12 +168,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.splitScreen",
separatorAfter: true,
},
{
id: "workspaces",
icon: LayoutTemplate,
labelKey: "nav.workspaces",
separatorAfter: true,
},
{
id: "local-terminal",
icon: TerminalSquare,
@@ -197,44 +176,87 @@ export const RAIL_ITEMS: RailItemDef[] = [
separatorAfter: true,
electronOnly: true,
},
{
id: "network_graph",
icon: Network,
labelKey: "nav.networkGraph",
kind: "tab",
separatorAfter: true,
},
];
/**
* Runtime-registered rail items, for a future plugin loader to add
* destinations that don't exist at build time. Keyed by id so a plugin can
* cleanly unregister its own entries without touching anyone else's.
* Rail items registered by plugins at runtime. Reactive, because a plugin can
* be enabled or disabled while the app is open and the rail, the mobile bar
* and the Navigation toggles all have to follow.
*/
const registeredRailItems = new Map<string, RailItemDef>();
const registeredRailItems = createRegistry<RailItemDef>();
export function registerRailItem(def: RailItemDef): void {
registeredRailItems.set(def.id, def);
export function registerRailItem(def: RailItemDef): () => void {
return registeredRailItems.register(def);
}
export function unregisterRailItem(id: string): void {
registeredRailItems.delete(id);
registeredRailItems.unregister(id);
}
export const subscribeToRailItems = registeredRailItems.subscribe;
/** Registered items, hidden ones included. */
export const listRegisteredRailItems = registeredRailItems.list;
/**
* Core items in their fixed order, with each registered item placed after
* the item its `after` names, or at the end. Hidden items are left out.
*/
function mergedRailItems(): RailItemDef[] {
const merged = [...RAIL_ITEMS];
const plugins = [...registeredRailItems.list()]
.filter((item) => !item.hidden)
.sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id),
);
for (const item of plugins) {
let at = item.after
? merged.findIndex((existing) => existing.id === item.after)
: -1;
if (at < 0) {
merged.push(item);
continue;
}
// Items sharing an anchor keep their own order after it.
while (
at + 1 < merged.length &&
merged[at + 1].pluginId &&
merged[at + 1].after === item.after
) {
at++;
}
merged.splice(at + 1, 0, item);
}
return merged;
}
/**
* Rail items available in the current build. Electron-only destinations are
* dropped in the browser build so they never reach the rail, the mobile bar,
* or the visibility toggles. Runtime-registered items are appended after the
* built-in list.
* or the visibility toggles.
*/
export function visibleRailItems(): RailItemDef[] {
const electron = isElectron();
return [
...RAIL_ITEMS.filter((item) => !item.electronOnly || electron),
...[...registeredRailItems.values()].filter(
(item) => !item.electronOnly || electron,
),
];
return mergedRailItems().filter((item) => !item.electronOnly || electron);
}
let railSnapshot: RailItemDef[] | null = null;
registeredRailItems.subscribe(() => {
railSnapshot = null;
});
function railItemsSnapshot(): RailItemDef[] {
if (!railSnapshot) railSnapshot = visibleRailItems();
return railSnapshot;
}
/** visibleRailItems() as a hook, re-rendering when plugins change it. */
export function useRailItems(): RailItemDef[] {
return useSyncExternalStore(
registeredRailItems.subscribe,
railItemsSnapshot,
railItemsSnapshot,
);
}
/**
@@ -247,29 +269,40 @@ export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
];
/** Ids that may be opened in the right dock. */
export const RIGHT_DOCKABLE_IDS = [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
.filter((item) => item.rightDockable)
.map((item) => item.id);
export function rightDockableIds(): string[] {
return [...mergedRailItems(), ...RAIL_UTILITY_ITEMS]
.filter((item) => item.rightDockable)
.map((item) => item.id);
}
/** Ids that may be opened as a full-width tab. */
export const PROMOTABLE_IDS = [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
.filter((item) => item.promotable)
.map((item) => item.id);
export function promotableIds(): string[] {
return [...mergedRailItems(), ...RAIL_UTILITY_ITEMS]
.filter((item) => item.promotable)
.map((item) => item.id);
}
/** Ids a user is allowed to hide, mirroring HideableRailView. */
export const HIDEABLE_RAIL_IDS = RAIL_ITEMS.filter(
(item) => !item.alwaysVisible,
).map((item) => item.id);
/** Ids a user is allowed to hide from Appearance > Sidebar > Navigation. */
export function hideableRailIds(): string[] {
return visibleRailItems()
.filter((item) => !item.alwaysVisible && item.hideable !== false)
.map((item) => item.id);
}
const LABEL_KEYS: Record<string, string> = Object.fromEntries(
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].map((item) => [
item.id,
item.labelKey,
]),
);
/** Whether a rail view is one of core's own, rather than a plugin's. */
export function isCoreRailView(id: string): boolean {
return [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].some((item) => item.id === id);
}
/** Translated label for any rail destination, including registered ones. */
export function railItemLabel(id: string, t: (key: string) => string): string {
const key = LABEL_KEYS[id] ?? registeredRailItems.get(id)?.labelKey;
const key =
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].find((item) => item.id === id)
?.labelKey ?? registeredRailItems.get(id)?.labelKey;
return key ? t(key) : id;
}
/** Test seam. */
export function resetRegisteredRailItems(): void {
registeredRailItems.reset();
}
@@ -40,7 +40,6 @@ export function FolderItem({
onShareHost,
onDeleteHost,
onDuplicateHost,
onProxmoxDiscover,
query = "",
stripeMap,
openFolders,
@@ -85,7 +84,7 @@ export function FolderItem({
host: Host,
type: TabType,
options?: {
endpointId?: string;
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
@@ -94,7 +93,6 @@ export function FolderItem({
onShareHost?: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onProxmoxDiscover?: (host: Host) => void;
query?: string;
stripeMap?: Map<Host | HostFolder, number>;
openFolders: Set<string>;
@@ -323,7 +321,6 @@ export function FolderItem({
onShareHost={onShareHost}
onDeleteHost={onDeleteHost}
onDuplicateHost={onDuplicateHost}
onProxmoxDiscover={onProxmoxDiscover}
query={query}
stripeMap={stripeMap}
openFolders={openFolders}
@@ -362,9 +359,6 @@ export function FolderItem({
onOpenTab={(t, options) => onOpenTab(child, t, options)}
onEditHost={onEditHost ? () => onEditHost(child) : undefined}
onShareHost={onShareHost ? () => onShareHost(child) : undefined}
onProxmoxDiscover={
onProxmoxDiscover ? () => onProxmoxDiscover(child) : undefined
}
onDelete={() => onDeleteHost(child)}
onDuplicate={() => onDuplicateHost(child)}
query={query}
+178 -395
View File
@@ -3,8 +3,6 @@ import { useEffect, useRef, useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
Box,
Boxes,
Check,
ChevronRight,
Copy,
@@ -12,28 +10,21 @@ import {
Cpu,
FolderSearch,
GripVertical,
HardDrive,
Key,
KeyRound,
Layers, // --- tmux-monitor ---
Link,
MemoryStick,
MessagesSquare,
Monitor,
MonitorUp,
MoreHorizontal,
MousePointerClick,
Network,
Pencil,
Pin,
Server,
Share2,
SquarePlus,
Terminal,
Trash2,
Users,
Zap,
Globe,
} from "lucide-react";
import {
DropdownMenu,
@@ -86,9 +77,18 @@ import {
getPreferredHostAction,
recordHostActionPreference,
} from "@/lib/local-adaptive-preferences";
import type { WebEndpoint } from "@/types/index";
import { openWebEndpointExternally } from "../../../../../plugins/web-endpoint/src/frontend/web-endpoint-api";
import { isTabTypeAvailable } from "@/shell/pluginLoader";
import {
defaultConnectAction,
hostActionsFor,
hostBadgesFor,
hostMenuItemsFor,
useHostActions,
useHostBadges,
useHostContextMenuItems,
type HostActionDef,
} from "@/sidebar/host-contributions";
import { shell } from "@/plugin-host/shell-bridge";
import type { TabShellCallbacks } from "@/shell/tab-registry";
export function statusCheckEnabled(host: Host): boolean {
return host.statsConfig?.statusCheckEnabled !== false;
@@ -115,75 +115,31 @@ export function buildStatusTooltip(
return `${protocols.join(", ")}: ${statusLabel}`;
}
/**
* Core's own per-host tools. Ways to connect and plugin tools (terminal,
* remote desktop, Docker, metrics) are host actions plugins register.
*/
export function getSshActions(host: Host): {
type: TabType;
icon: typeof Terminal;
label: string;
endpointId?: string;
order: number;
}[] {
const metricsEnabled =
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
// Gated on enableWebUi ALONE -- unlike every entry below, which requires
// enableSsh. A "direct" endpoint needs no SSH at all; SSH matters only
// per-endpoint, for "tunnel" access, which the open route enforces.
//
// One entry, not one per endpoint: a host may declare up to 16, and a row of
// 16 identical globes is unusable. With a single endpoint the entry acts on
// it directly and wears its label; with several it carries no endpointId and
// the click surfaces a picker instead.
const webEndpoints = host.enableWebUi
? (host.webUiConfig?.endpoints ?? [])
: [];
const webEndpointActions =
webEndpoints.length > 0
? [
{
type: "web-endpoint" as TabType,
icon: Globe,
label: webEndpoints.length === 1 ? webEndpoints[0].label : "Web UI",
endpointId:
webEndpoints.length === 1 ? webEndpoints[0].id : undefined,
},
]
: [];
const connectionActions = [
host.enableSsh &&
host.enableTerminal &&
isTabTypeAvailable("terminal") && {
type: "terminal" as TabType,
icon: Terminal,
label: "Terminal",
},
return [
host.enableSsh &&
host.enableFileManager && {
type: "files" as TabType,
icon: FolderSearch,
label: "Files",
},
host.enableSsh &&
host.enableDocker && {
type: "docker" as TabType,
icon: Box,
label: "Docker",
order: 20,
},
host.enableSsh &&
host.enableTunnel && {
type: "tunnel" as TabType,
icon: Network,
label: "Tunnel",
order: 40,
},
metricsEnabled && {
type: "host-metrics" as TabType,
icon: Server,
label: "Host Metrics",
},
host.enableProxmoxStats === true && {
type: "proxmox-stats" as TabType,
icon: HardDrive,
label: "Proxmox Stats",
},
// --- tmux-monitor --- opt-in per host, off by default
host.enableSsh &&
host.enableTerminal &&
@@ -191,14 +147,19 @@ export function getSshActions(host: Host): {
type: "tmux_monitor" as TabType,
icon: Layers,
label: "Tmux Monitor",
order: 70,
},
].filter(Boolean) as {
type: TabType;
icon: typeof Terminal;
label: string;
order: number;
}[];
}
return [...connectionActions, ...webEndpointActions];
/** Plugin actions sort by order; connect actions default to the end. */
function actionOrder(action: HostActionDef): number {
return action.order ?? (action.kind === "connect" ? 100 : 50);
}
export async function writeClipboardText(value: string): Promise<void> {
@@ -250,7 +211,6 @@ export function HostItem({
onOpenTab,
onEditHost: onEditHostProp,
onShareHost: onShareHostProp,
onProxmoxDiscover,
onDelete,
onDuplicate,
query = "",
@@ -289,7 +249,11 @@ export function HostItem({
host: Host;
onOpenTab: (
type: TabType,
options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
options?: {
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
) => void;
onEditHost?: () => void;
onShareHost?: () => void;
@@ -306,7 +270,6 @@ export function HostItem({
onTrayOpenChange?: (open: boolean) => void;
isHovered?: boolean;
onHoverChange?: (hovered: boolean) => void;
onProxmoxDiscover?: () => void;
onDragStart?: () => void;
onDragEnd?: () => void;
/** Nesting level when rendered in a flattened virtual list. */
@@ -353,8 +316,10 @@ export function HostItem({
const onEditHost = canEditHost(host) ? onEditHostProp : undefined;
const onShareHost = canShareHost(host) ? onShareHostProp : undefined;
const allowDelete = canDeleteHost(host);
const metricsEnabled =
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
const allHostActions = useHostActions();
const pluginActions = hostActionsFor(allHostActions, host);
const badges = hostBadgesFor(useHostBadges(), host);
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
const statusScheme = useStatusColorScheme();
const { initialLoadComplete } = useServerStatusMeta();
const statusCheckOn = statusCheckEnabled(host);
@@ -400,19 +365,11 @@ export function HostItem({
const [authOverrideProtocol, setAuthOverrideProtocol] =
useState<AuthOverrideProtocol | null>(null);
const [parentDragOver, setParentDragOver] = useState(false);
const [nativeRdpAvailable, setNativeRdpAvailable] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState<{
x: number;
y: number;
} | null>(null);
useEffect(() => {
if (!window.electronAPI?.isElectron) return;
window.electronAPI
.getPlatform()
.then((platform) => setNativeRdpAvailable(platform === "win32"))
.catch(() => setNativeRdpAvailable(false));
}, []);
// Density decides the base shape; the preset can only take rows away, never
// add them, so a row's real height stays <= the virtualizer's fixed estimate.
const densityTokens = HOST_ITEM_DENSITY_TOKENS[density];
@@ -453,25 +410,6 @@ export function HostItem({
}
}
async function handleNativeRdp(e: MouseEvent) {
e.stopPropagation();
try {
const result = await window.electronAPI.openNativeRdp({
host: host.ip,
port: host.rdpPort ?? 3389,
username: host.rdpUser,
domain: host.domain,
});
if (result.success) {
toast.success(t("hosts.nativeRdpOpened"));
} else {
toast.error(result.error || t("hosts.nativeRdpFailed"));
}
} catch {
toast.error(t("hosts.nativeRdpFailed"));
}
}
async function handleWakeOnLan(e: MouseEvent) {
e.stopPropagation();
try {
@@ -493,24 +431,19 @@ export function HostItem({
const sshActions = getSshActions(host);
const availableActions: TabType[] = [
...sshActions.map(({ type }) => type),
...(host.enableRdp && isTabTypeAvailable("rdp") ? (["rdp"] as const) : []),
...(host.enableVnc && isTabTypeAvailable("vnc") ? (["vnc"] as const) : []),
...(host.enableTelnet && isTabTypeAvailable("telnet")
? (["telnet"] as const)
: []),
...pluginActions.flatMap((action) =>
action.tabType ? [action.tabType] : [],
),
];
const defaultAction: TabType = host.enableSsh
? "terminal"
: host.enableRdp && isTabTypeAvailable("rdp")
? "rdp"
: host.enableVnc && isTabTypeAvailable("vnc")
? "vnc"
: host.enableTelnet && isTabTypeAvailable("telnet")
? "telnet"
: "terminal";
const defaultAction: TabType =
defaultConnectAction(allHostActions, host)?.tabType ?? "terminal";
const openHostTab = (
type: TabType,
options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
options?: {
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
) => {
markTabSurfaceUsed(type);
recordHostActionPreference(host.id, type);
@@ -520,50 +453,76 @@ export function HostItem({
});
};
// Mirrors getSshActions: the single Web UI entry carries an endpointId only
// when the host has exactly one endpoint. Without one, the click opens a
// picker instead of a tab.
const webEndpoints: WebEndpoint[] = host.enableWebUi
? (host.webUiConfig?.endpoints ?? [])
: [];
const openWebEndpoint = (endpoint: WebEndpoint) => {
if (endpoint.render === "external") {
// No tab at all: hand it to the real browser. On the desktop main's
// setWindowOpenHandler routes it to shell.openExternal.
openWebEndpointExternally(host, endpoint).catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : t("hosts.webUiOpenFailed"),
);
});
return;
}
openHostTab("web-endpoint", {
endpointId: endpoint.id,
label: endpoint.label,
});
// A plugin action opening a tab goes through this row, so it gets the same
// focus-existing and preference handling as a core one.
const rowShell: TabShellCallbacks = {
...shell,
openTab: (_host, type, options) => openHostTab(type, options),
};
const handleWebEndpointAction = (endpointId?: string) => {
const endpoint = endpointId
? webEndpoints.find((candidate) => candidate.id === endpointId)
: undefined;
if (endpoint) openWebEndpoint(endpoint);
const runPluginAction = (action: HostActionDef) => {
if (action.run) action.run(host, rowShell);
else if (action.tabType) openHostTab(action.tabType);
};
const isWebEndpointPicker = (action: {
type: TabType;
endpointId?: string;
}) => action.type === "web-endpoint" && !action.endpointId;
/** Core tools and plugin actions in one row, in order. */
const rowEntries = [
...sshActions.map((action) => ({
key: action.type,
order: action.order,
icon: action.icon,
label: action.label,
tabType: action.type as string | undefined,
tray: true,
items: undefined as
{ id: string; label: string; run: () => void }[] | undefined,
run: () => openHostTab(action.type),
})),
...pluginActions.map((action) => {
const items = action.items?.(host);
return {
key: action.id,
order: actionOrder(action),
icon: action.icon as typeof Terminal,
label: action.label?.(host) ?? t(action.titleKey),
tabType: action.tabType,
tray: action.tray !== false,
items:
items && items.length > 1
? items.map((item) => ({
id: item.id,
label: item.label,
run: () => item.run(host, rowShell),
}))
: undefined,
run: () =>
items && items.length === 1
? items[0].run(host, rowShell)
: runPluginAction(action),
};
}),
].sort((a, b) => a.order - b.order || a.key.localeCompare(b.key));
const trayEntries = rowEntries.filter((entry) => entry.tray);
const connectionButtons = (
<>
{sshActions.map(({ type, icon: Icon, label, endpointId }) =>
isWebEndpointPicker({ type, endpointId }) ? (
<DropdownMenu key={type}>
{trayEntries.map((entry, index) => {
const Icon = entry.icon;
// A thin rule between host tools and the other ways to connect.
const separator =
index > 0 &&
trayEntries[index - 1].order < 100 &&
entry.order >= 100 ? (
<div
key={`${entry.key}-separator`}
className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0"
/>
) : null;
const button = entry.items ? (
<DropdownMenu key={entry.key}>
<DropdownMenuTrigger asChild>
<button
title={label}
title={entry.label}
onClick={(e) => e.stopPropagation()}
className={trayButtonClass}
>
@@ -571,98 +530,39 @@ export function HostItem({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{webEndpoints.map((endpoint) => (
{entry.items.map((item) => (
<DropdownMenuItem
key={endpoint.id}
key={item.id}
onClick={(e) => {
e.stopPropagation();
openWebEndpoint(endpoint);
item.run();
}}
>
<Globe className="size-3.5 mr-2" />
{endpoint.label}
<Icon className="size-3.5 mr-2" />
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
key={type}
title={label}
onPointerEnter={() => preloadTabSurface(type)}
onFocus={() => preloadTabSurface(type)}
key={entry.key}
title={entry.label}
onPointerEnter={() =>
entry.tabType && preloadTabSurface(entry.tabType)
}
onFocus={() => entry.tabType && preloadTabSurface(entry.tabType)}
onClick={(e) => {
e.stopPropagation();
if (type === "web-endpoint") {
handleWebEndpointAction(endpointId);
return;
}
openHostTab(type);
entry.run();
}}
className={trayButtonClass}
>
<Icon className="size-3.5" />
</button>
),
)}
{host.enableSsh &&
((host.enableRdp && isTabTypeAvailable("rdp")) ||
(host.enableVnc && isTabTypeAvailable("vnc")) ||
(host.enableTelnet && isTabTypeAvailable("telnet"))) &&
sshActions.length > 0 && (
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
)}
{host.enableRdp && isTabTypeAvailable("rdp") && (
<button
title={t("hosts.connectRdp")}
onPointerEnter={() => preloadTabSurface("rdp")}
onFocus={() => preloadTabSurface("rdp")}
onClick={(e) => {
e.stopPropagation();
openHostTab("rdp");
}}
className={trayButtonClass}
>
<Monitor className="size-3.5" />
</button>
)}
{host.enableRdp && nativeRdpAvailable && (
<button
title={t("hosts.openNativeRdp")}
onClick={handleNativeRdp}
className={trayButtonClass}
>
<MonitorUp className="size-3.5" />
</button>
)}
{host.enableVnc && isTabTypeAvailable("vnc") && (
<button
title={t("hosts.connectVnc")}
onPointerEnter={() => preloadTabSurface("vnc")}
onFocus={() => preloadTabSurface("vnc")}
onClick={(e) => {
e.stopPropagation();
openHostTab("vnc");
}}
className={trayButtonClass}
>
<MousePointerClick className="size-3.5" />
</button>
)}
{host.enableTelnet && isTabTypeAvailable("telnet") && (
<button
title={t("hosts.connectTelnet")}
onPointerEnter={() => preloadTabSurface("telnet")}
onFocus={() => preloadTabSurface("telnet")}
onClick={(e) => {
e.stopPropagation();
openHostTab("telnet");
}}
className={trayButtonClass}
>
<MessagesSquare className="size-3.5" />
</button>
)}
);
return separator ? [separator, button] : button;
})}
{host.macAddress && (
<button
title={t("hosts.wakeOnLanAction")}
@@ -676,8 +576,7 @@ export function HostItem({
);
// "essential" only trims buttons that the overflow menu also offers, so no
// action becomes unreachable. Share and Proxmox discover have no menu entry,
// so they always stay on the row.
// action becomes unreachable.
const essentialActions = rowActions === "essential";
const managementButtons = (
@@ -724,18 +623,6 @@ export function HostItem({
<Share2 className="size-3.5" />
</button>
)}
{host.enableProxmox && onProxmoxDiscover && (
<button
title={t("hosts.proxmoxDiscoverAction")}
onClick={(e) => {
e.stopPropagation();
onProxmoxDiscover();
}}
className={trayButtonClass}
>
<Boxes className="size-3.5" />
</button>
)}
<DropdownMenu
open={isMenuOpen}
onOpenChange={(open) => {
@@ -787,78 +674,42 @@ export function HostItem({
{t("common.connect")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{sshActions.map(({ type, icon: Icon, label, endpointId }) =>
isWebEndpointPicker({ type, endpointId }) ? (
<DropdownMenuSub key={type}>
{rowEntries.map((entry) => {
const Icon = entry.icon;
return entry.items ? (
<DropdownMenuSub key={entry.key}>
<DropdownMenuSubTrigger>
<Icon className="size-3.5 mr-2" />
{label}
{entry.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{webEndpoints.map((endpoint) => (
{entry.items.map((item) => (
<DropdownMenuItem
key={endpoint.id}
key={item.id}
onClick={(e) => {
e.stopPropagation();
openWebEndpoint(endpoint);
item.run();
}}
>
<Globe className="size-3.5 mr-2" />
{endpoint.label}
<Icon className="size-3.5 mr-2" />
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<DropdownMenuItem
key={type}
key={entry.key}
onClick={(e) => {
e.stopPropagation();
if (type === "web-endpoint") {
handleWebEndpointAction(endpointId);
return;
}
openHostTab(type);
entry.run();
}}
>
<Icon className="size-3.5 mr-2" />
{label}
{entry.label}
</DropdownMenuItem>
),
)}
{host.enableRdp && isTabTypeAvailable("rdp") && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
openHostTab("rdp");
}}
>
<Monitor className="size-3.5 mr-2" />
{t("hosts.connectRdp")}
</DropdownMenuItem>
)}
{host.enableVnc && isTabTypeAvailable("vnc") && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
openHostTab("vnc");
}}
>
<MousePointerClick className="size-3.5 mr-2" />
{t("hosts.connectVnc")}
</DropdownMenuItem>
)}
{host.enableTelnet && isTabTypeAvailable("telnet") && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
openHostTab("telnet");
}}
>
<MessagesSquare className="size-3.5 mr-2" />
{t("hosts.connectTelnet")}
</DropdownMenuItem>
)}
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{focusExistingTab && (
@@ -895,17 +746,21 @@ export function HostItem({
{t("hosts.shareHost")}
</DropdownMenuItem>
)}
{host.enableProxmox && onProxmoxDiscover && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onProxmoxDiscover();
}}
>
<Boxes className="size-3.5 mr-2" />
{t("hosts.proxmoxDiscoverAction")}
</DropdownMenuItem>
)}
{pluginMenuItems.map((item) => {
const Icon = item.icon;
return (
<DropdownMenuItem
key={item.id}
onClick={(e) => {
e.stopPropagation();
item.run(host, rowShell);
}}
>
{Icon && <Icon className="size-3.5 mr-2" />}
{t(item.titleKey)}
</DropdownMenuItem>
);
})}
{host.macAddress && (
<DropdownMenuItem onClick={handleWakeOnLan}>
<Zap className="size-3.5 mr-2" />
@@ -959,20 +814,6 @@ export function HostItem({
{t("hosts.copyLink")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{host.enableSsh && host.enableTerminal && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
writeClipboardText(
`${window.location.origin}?view=terminal&hostId=${host.id}`,
);
toast.success(t("hosts.terminalUrlCopied"));
}}
>
<Terminal className="size-3.5 mr-2" />
{t("hosts.copyTerminalUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && host.enableFileManager && (
<DropdownMenuItem
onClick={(e) => {
@@ -1001,48 +842,6 @@ export function HostItem({
{t("hosts.copyTunnelUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && host.enableDocker && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
writeClipboardText(
`${window.location.origin}?view=docker&hostId=${host.id}`,
);
toast.success(t("hosts.dockerUrlCopied"));
}}
>
<Box className="size-3.5 mr-2" />
{t("hosts.copyDockerUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && metricsEnabled && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
writeClipboardText(
`${window.location.origin}?view=host-metrics&hostId=${host.id}`,
);
toast.success(t("hosts.hostMetricsUrlCopied"));
}}
>
<Server className="size-3.5 mr-2" />
{t("hosts.copyHostMetricsUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && host.enableProxmoxStats === true && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
writeClipboardText(
`${window.location.origin}?view=proxmox-stats&hostId=${host.id}`,
);
toast.success(t("hosts.proxmoxStatsUrlCopied"));
}}
>
<HardDrive className="size-3.5 mr-2" />
{t("hosts.copyProxmoxStatsUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh &&
host.enableTerminal &&
host.enableTmuxMonitor && (
@@ -1059,48 +858,28 @@ export function HostItem({
{t("hosts.copyTmuxMonitorUrlAction")}
</DropdownMenuItem>
)}
{host.enableRdp && isTabTypeAvailable("rdp") && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
writeClipboardText(
`${window.location.origin}?view=rdp&hostId=${host.id}`,
);
toast.success(t("hosts.rdpUrlCopied"));
}}
>
<Monitor className="size-3.5 mr-2" />
{t("hosts.copyRdpUrlAction")}
</DropdownMenuItem>
)}
{host.enableVnc && isTabTypeAvailable("vnc") && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
writeClipboardText(
`${window.location.origin}?view=vnc&hostId=${host.id}`,
);
toast.success(t("hosts.vncUrlCopied"));
}}
>
<MousePointerClick className="size-3.5 mr-2" />
{t("hosts.copyVncUrlAction")}
</DropdownMenuItem>
)}
{host.enableTelnet && isTabTypeAvailable("telnet") && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
writeClipboardText(
`${window.location.origin}?view=telnet&hostId=${host.id}`,
);
toast.success(t("hosts.telnetUrlCopied"));
}}
>
<MessagesSquare className="size-3.5 mr-2" />
{t("hosts.copyTelnetUrlAction")}
</DropdownMenuItem>
)}
{pluginActions
.filter((action) => action.copyUrlView)
.map((action) => {
const Icon = action.icon;
return (
<DropdownMenuItem
key={action.id}
onClick={(e) => {
e.stopPropagation();
writeClipboardText(
`${window.location.origin}?view=${encodeURIComponent(action.copyUrlView!)}&hostId=${host.id}`,
);
toast.success(t("hosts.copiedToClipboard"));
}}
>
<Icon className="size-3.5 mr-2" />
{t("hosts.copyViewUrlAction", {
name: t(action.titleKey),
})}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{allowDelete && (
@@ -1372,6 +1151,10 @@ export function HostItem({
{host.pin && (
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
)}
{badges.map((badge) => {
const Badge = badge.component;
return <Badge key={badge.id} host={host} />;
})}
{host.isShared && (
<TooltipProvider delayDuration={300}>
<Tooltip>
+2 -13
View File
@@ -76,7 +76,6 @@ export function SidebarTree({
onOpenTab,
onEditHost,
onShareHost,
onProxmoxDiscover,
query = "",
selectionMode,
onToggleSelectionMode,
@@ -95,14 +94,13 @@ export function SidebarTree({
host: Host,
type: TabType,
options?: {
endpointId?: string;
data?: Record<string, unknown>;
label?: string;
forceNewTab?: boolean;
},
) => void;
onEditHost: (host: Host) => void;
onShareHost?: (host: Host) => void;
onProxmoxDiscover?: (host: Host) => void;
query?: string;
selectionMode: boolean;
onToggleSelectionMode: () => void;
@@ -974,7 +972,6 @@ export function SidebarTree({
onShareHost={onShareHost}
onDeleteHost={handleDeleteHost}
onDuplicateHost={handleDuplicateHost}
onProxmoxDiscover={onProxmoxDiscover}
query={query}
openFolders={openFolders}
onToggleFolder={toggleFolder}
@@ -1046,11 +1043,6 @@ export function SidebarTree({
onShareHost={
onShareHost ? () => onShareHost(item) : undefined
}
onProxmoxDiscover={
onProxmoxDiscover
? () => onProxmoxDiscover(item)
: undefined
}
onDelete={() => handleDeleteHost(item)}
onDuplicate={() => handleDuplicateHost(item)}
query={query}
@@ -1306,10 +1298,7 @@ export function SidebarTree({
selectedHostIds.has(String(h.id)),
);
for (const host of selectedHosts) {
if (host.enableSsh) onOpenTab(host, "terminal");
else if (host.enableRdp) onOpenTab(host, "rdp");
else if (host.enableVnc) onOpenTab(host, "vnc");
else if (host.enableTelnet) onOpenTab(host, "telnet");
onOpenTab(host, resolveHostTabType(host));
}
setSelectedHostIds(new Set());
onToggleSelectionMode();