mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -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 })
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user