fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

-9
View File
@@ -2476,17 +2476,8 @@ export function AppShell({
ram: null,
lastAccess: new Date().toISOString(),
authType: "none",
enableTerminal: false,
enableCommandHistory: false,
enableTunnel: false,
enableFileManager: false,
enableProxmox: false,
enableProxmoxStats: false,
enableTmuxMonitor: false,
enableTerminalToolbar: false,
enableSsh: false,
sshPort: 22,
serverTunnels: [],
quickActions: [],
};
void invokeAction("terminal.open", host, {
+36 -3
View File
@@ -33,14 +33,24 @@ export interface PluginSettingsField {
group?: string;
component?: string;
hidden?: boolean;
defaultFrom?: string;
shareRead?: "connect" | "view" | "edit" | "manage";
ownerOnly?: boolean;
}
export interface PluginHostSettingsContribution {
enableKey?: string;
enableLabelKey?: string;
enableDefault?: boolean;
fields: PluginSettingsField[];
}
/** A plugin's Appearance defaults for each interface preset. */
export type PluginUiPresets = Record<
"simple" | "balanced" | "advanced",
Record<string, unknown>
>;
export interface PluginSettingsContribution {
admin?: PluginSettingsField[];
user?: PluginSettingsField[];
@@ -62,6 +72,7 @@ export interface PluginContributions {
guest?: boolean;
settings?: PluginSettingsContribution;
permissions?: { name: string; titleKey: string; descriptionKey: string }[];
uiPresets?: PluginUiPresets;
}
/** A secret as it arrives from the server. The value never leaves the server. */
@@ -127,11 +138,29 @@ export async function getPluginAdminSettings(
return response.data?.values ?? {};
}
/** Tells the plugin's own frontend, through app.onSettingsChanged. */
export const PLUGIN_SETTINGS_CHANGED_EVENT = "termix:plugin-settings-changed";
function announceSettingsChange(
pluginId: string,
scope: "admin" | "user" | "host",
hostId?: number,
): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(PLUGIN_SETTINGS_CHANGED_EVENT, {
detail: { pluginId, scope, hostId },
}),
);
}
export async function updatePluginAdminSettings(
pluginId: string,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
return putSettings(settingsPath(pluginId, "admin"), values);
const saved = await putSettings(settingsPath(pluginId, "admin"), values);
announceSettingsChange(pluginId, "admin");
return saved;
}
export async function getPluginUserSettings(
@@ -145,7 +174,9 @@ export async function updatePluginUserSettings(
pluginId: string,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
return putSettings(settingsPath(pluginId, "user"), values);
const saved = await putSettings(settingsPath(pluginId, "user"), values);
announceSettingsChange(pluginId, "user");
return saved;
}
export async function getPluginHostSettings(
@@ -163,10 +194,12 @@ export async function updatePluginHostSettings(
hostId: number,
values: PluginSettingsValues,
): Promise<PluginSettingsValues> {
return putSettings(
const saved = await putSettings(
settingsPath(pluginId, `host/${encodeURIComponent(String(hostId))}`),
values,
);
announceSettingsChange(pluginId, "host", hostId);
return saved;
}
/**
-2
View File
@@ -130,8 +130,6 @@ export type HostDefaults = {
theme?: string;
cursorStyle?: string;
cursorBlink?: boolean;
enableSessionLogging?: boolean;
enableCommandHistory?: boolean;
autoTmux?: boolean;
};
-48
View File
@@ -346,54 +346,6 @@ export async function exportAllSSHHosts(options?: {
}
}
// ============================================================================
// SSH AUTOSTART MANAGEMENT
// ============================================================================
export async function enableAutoStart(
sshConfigId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/autostart/enable", {
sshConfigId,
});
return response.data;
} catch (error) {
handleApiError(error, "enable autostart");
}
}
export async function disableAutoStart(
sshConfigId: number,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/autostart/disable", {
data: { sshConfigId },
});
return response.data;
} catch (error) {
handleApiError(error, "disable autostart");
}
}
export async function getAutoStartStatus(): Promise<{
autostart_configs: Array<{
sshConfigId: number;
host: string;
port: number;
username: string;
authType: string;
}>;
total_count: number;
}> {
try {
const response = await sshHostApi.get("/autostart/status");
return response.data;
} catch (error) {
handleApiError(error, "fetch autostart status");
}
}
// ============================================================================
// PROXY CONNECTIVITY TEST
// ============================================================================
+116
View File
@@ -0,0 +1,116 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { RefreshCw, AlertTriangle, Search } from "lucide-react";
import { Button } from "@/components/button";
import { MetricCard } from "@/components/metric-card";
export interface ManagerCardError {
message: string;
code?: string;
}
/** The frame a host manager card draws: title, refresh, error and empty states. */
export function ManagerCardShell({
title,
icon,
loading,
error,
onRefresh,
empty,
emptyMessage,
children,
headerExtra,
}: {
title: string;
icon: ReactNode;
loading: boolean;
error: ManagerCardError | null;
onRefresh: () => void;
empty?: boolean;
emptyMessage?: string;
children: ReactNode;
headerExtra?: ReactNode;
}) {
const { t } = useTranslation();
return (
<MetricCard
title={title}
icon={icon}
scroll
action={
<div className="flex items-center gap-1">
{headerExtra}
<button
onClick={onRefresh}
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title={t("managerCard.refresh")}
>
<RefreshCw className={`size-3 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
}
>
{error ? (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<AlertTriangle className="size-6 text-yellow-500" />
<span className="text-xs text-muted-foreground">{error.message}</span>
{error.code === "SUDO_REQUIRED" && (
<span className="text-[10px] text-muted-foreground">
{t("managerCard.sudoHint")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={onRefresh}
className="mt-1"
>
{t("managerCard.retry")}
</Button>
</div>
) : empty ? (
<div className="flex items-center justify-center py-8 text-xs text-muted-foreground/50">
{emptyMessage ?? t("managerCard.noData")}
</div>
) : (
children
)}
</MetricCard>
);
}
/** The filter box above a manager card's list. */
export function ManagerSearch({
value,
onChange,
placeholder,
count,
extra,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
count?: number;
extra?: ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="mb-2 flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/60" />
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? t("managerCard.filter")}
className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{count != null && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{count}
</span>
)}
{extra}
</div>
);
}
+16
View File
@@ -18,6 +18,8 @@ import {
defaultUiPreferences,
PRESETS,
resolveArea,
resolvePluginArea,
type UiPluginPresets,
sanitizeUiPreferences,
UI_ONBOARDING_VERSION,
type UiAreaKey,
@@ -62,6 +64,12 @@ interface UiPreferencesContextValue {
clearArea: (area: UiAreaKey) => void;
clearAllOverrides: () => void;
completeOnboarding: (skipped: boolean) => void;
/** A plugin's own area, from the presets it declared. */
resolvePlugin: (
pluginId: string,
presets: UiPluginPresets | undefined,
) => Record<string, unknown>;
setPluginOverride: (pluginId: string, key: string, value: unknown) => void;
}
const UiPreferencesContext = createContext<UiPreferencesContextValue | null>(
@@ -247,6 +255,14 @@ export function UiPreferencesProvider({ children }: { children: ReactNode }) {
clearArea,
clearAllOverrides,
completeOnboarding,
resolvePlugin: (pluginId, presets) =>
resolvePluginArea(preferences, pluginId, presets),
setPluginOverride: (pluginId, key, value) =>
setOverride(
`plugin:${pluginId}` as never,
key as never,
value as never,
),
}),
[
preferences,
+1 -14
View File
@@ -23,20 +23,7 @@ export function hostToSSHHost(h: Host): SSHHost {
credentialId: h.credentialId ? parseInt(h.credentialId, 10) : undefined,
terminalConfig: h.terminalConfig,
hasSudoPassword: h.hasSudoPassword,
enableTerminal: h.enableTerminal ?? false,
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
enableWebUi: h.enableWebUi ?? false,
webUiConfig: (h.pluginSettings?.["web-endpoint"]?.webUiConfig as
SSHHost["webUiConfig"] | undefined) ?? { endpoints: [] },
showTerminalInSidebar: true,
showFileManagerInSidebar: true,
showTunnelInSidebar: true,
showDockerInSidebar: true,
showServerStatsInSidebar: true,
defaultPath: h.defaultPath ?? "",
tunnelConnections: [],
pluginSettings: h.pluginSettings ?? {},
connectionType: "ssh",
connectionOrigin: h.connectionOrigin ?? null,
isShared: h.isShared ?? false,
+19 -59
View File
@@ -483,9 +483,6 @@
"environmentVariables": "Environment Variables",
"addVariable": "Add Variable",
"docker": "Docker",
"copyTerminalUrl": "Copy Terminal URL",
"copyFileManagerUrl": "Copy File Manager URL",
"copyRemoteDesktopUrl": "Copy Remote Desktop URL",
"failedToConnect": "Failed to connect to console",
"connect": "Connect",
"disconnect": "Disconnect",
@@ -671,9 +668,6 @@
"maxKeepaliveMisses": "Max Keepalive Misses",
"sameHost": "This host (direct tunnel)",
"bindHost": "Bind Host",
"connectionToolbar": "Connection Toolbar",
"showConnectionToolbar": "Show connection toolbar",
"showConnectionToolbarDesc": "Show the toolbar in this host's SSH, RDP, VNC, and Telnet sessions. You can also hide it temporarily from the toolbar itself.",
"proxmoxLastSyncStatus": {
"success": "Success"
},
@@ -683,7 +677,6 @@
"proxmoxImportButton_other": "Import {{count}} hosts",
"proxmoxResultSkippedNoIp": "{{count}} skipped (no IP found)",
"proxmoxStatsUrlCopied": "Proxmox Stats URL copied to clipboard",
"copyProxmoxStatsUrlAction": "Copy Proxmox Stats URL",
"statusChecksLabel": "Status Checks",
"enableStatusChecks": "Enable Status Checks",
"enableStatusChecksDesc": "Periodically ping this host to verify availability",
@@ -842,35 +835,18 @@
"openInNewTab": "Open in new tab",
"deletedHost": "Deleted {{name}}",
"copiedToClipboard": "Copied to clipboard",
"terminalUrlCopied": "Terminal URL copied",
"dockerUrlCopied": "Docker URL copied",
"hostMetricsUrlCopied": "Host Metrics URL copied",
"rdpUrlCopied": "RDP URL copied",
"vncUrlCopied": "VNC URL copied",
"telnetUrlCopied": "Telnet URL copied",
"remoteDesktopUrlCopied": "Remote Desktop URL copied",
"expandActions": "Expand actions",
"collapseActions": "Collapse actions",
"cloneHostAction": "Clone Host",
"copyAddress": "Copy Address",
"copyLink": "Copy Link",
"copyTerminalUrlAction": "Copy Terminal URL",
"copyDockerUrlAction": "Copy Docker URL",
"copyHostMetricsUrlAction": "Copy Host Metrics URL",
"copyRdpUrlAction": "Copy RDP URL",
"copyVncUrlAction": "Copy VNC URL",
"copyTelnetUrlAction": "Copy Telnet URL",
"copyRemoteDesktopUrlAction": "Copy Remote Desktop URL",
"deleteCredentialConfirm": "Delete credential \"{{name}}\"?",
"deletedCredential": "Deleted {{name}}",
"deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.",
"movedToRoot": "Moved to root",
"enableTerminalFeature": "Enable Terminal",
"disableTerminalFeature": "Disable Terminal",
"enableFilesFeature": "Enable Files",
"disableFilesFeature": "Disable Files",
"enableProxmoxFeature": "Enable Proxmox",
"disableProxmoxFeature": "Disable Proxmox",
"addTagsPlaceholder": "Add tags...",
"authDetails": "Authentication Details",
"credType": "Type",
@@ -987,13 +963,7 @@
"filterAuthStepca": "Step CA",
"filterProtocolGroup": "Protocol",
"filterProtocolSsh": "SSH",
"filterFeaturesGroup": "Features",
"filterFeatureTerminal": "Terminal",
"filterFeatureFileManager": "File Manager",
"filterTagsGroup": "Tags",
"connectRdp": "RDP",
"connectVnc": "VNC",
"connectTelnet": "Telnet",
"editHostAction": "Edit Host",
"moreOptions": "More options",
"shareHost": "Share Host",
@@ -1111,7 +1081,10 @@
"filterAuthTailscale": "Tailscale",
"authTypeNeedsPlugin": "This host uses {{type}}, which needs the {{plugin}} plugin. Turn it on to connect.",
"authTypeUnknown": "This host uses {{type}}, which no enabled plugin provides.",
"authFieldsAfterSave": "Save the host first, then fill in these fields."
"authFieldsAfterSave": "Save the host first, then fill in these fields.",
"enablePluginFeature": "Enable {{name}}",
"disablePluginFeature": "Disable {{name}}",
"filterFeaturesGroup": "Features"
},
"commandPalette": {
"searchPlaceholder": "Search hosts, commands, or settings...",
@@ -1605,13 +1578,13 @@
"allowRegistrationDesc": "Let new users self-register with a username and password",
"allowPasswordLogin": "Allow Password Login",
"allowPasswordLoginDesc": "Username/password login",
"oidcAutoProvision": "OIDC Auto-Provision",
"oidcAutoProvisionDesc": "Auto-create accounts for OIDC/SSO users on first login (independent of the registration toggle)",
"oidcAutoProvision": "Auto-create external accounts",
"oidcAutoProvisionDesc": "Create an account the first time someone signs in through an external login such as SSO or LDAP, even when registration is off",
"secondFactorAfterExternalLogin": "Ask for a second factor after external logins (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Off, an enrolled second factor is only asked for after password and other local logins, same as before. On, it is asked for after every login method, including SSO and LDAP.",
"oidcSilentLoginDefault": "Silent OIDC Login by Default",
"oidcSilentLoginDefaultDesc": "Automatically redirect to OIDC login on every visit, skipping the login form entirely",
"oidcSilentLoginDefaultLockedDesc": "This setting is locked by the OIDC_SILENT_LOGIN_DEFAULT environment variable and cannot be changed here.",
"oidcSilentLoginDefault": "Silent external login by default",
"oidcSilentLoginDefaultDesc": "Send visitors straight to the external login provider, skipping the login form",
"oidcSilentLoginDefaultLockedDesc": "This setting is set by the OIDC_SILENT_LOGIN_DEFAULT environment variable and cannot be changed here.",
"allowPasswordReset": "Allow Password Reset",
"allowPasswordResetDesc": "Reset code via Docker logs",
"analyticsEnabled": "Share Anonymous Usage Statistics",
@@ -1746,9 +1719,9 @@
"saving": "Saving...",
"updateRegistrationFailed": "Failed to update registration setting",
"updatePasswordLoginFailed": "Failed to update password login setting",
"updateOidcAutoProvisionFailed": "Failed to update OIDC auto-provision setting",
"updateOidcAutoProvisionFailed": "Failed to update the auto-create setting",
"updateSecondFactorAfterExternalLoginFailed": "Failed to update second factor setting",
"updateOidcSilentLoginDefaultFailed": "Failed to update silent OIDC login setting",
"updateOidcSilentLoginDefaultFailed": "Failed to update the silent login setting",
"updatePasswordResetFailed": "Failed to update password reset setting",
"sessionTimeoutRange2": "Session timeout must be between 1 and 720 hours",
"sessionTimeoutSaved": "Session timeout saved",
@@ -1854,10 +1827,6 @@
"hostDefaultsAutoTmux": "Auto-Tmux",
"hostDefaultsAutoTmuxDesc": "Run new hosts' terminals inside tmux so sessions survive disconnects (needs tmux on the host)",
"minutes": "minutes",
"hostDefaultsSessionLogging": "Session Logging",
"hostDefaultsSessionLoggingDesc": "Record terminal sessions on new hosts by default",
"hostDefaultsCommandHistory": "Command History",
"hostDefaultsCommandHistoryDesc": "Track command history on new hosts by default",
"hostDefaultsSaved": "Host defaults saved",
"hostDefaultsSaveFailed": "Failed to save host defaults",
"rolePermissions": {
@@ -2180,8 +2149,6 @@
"uiArea_dashboard": "Dashboard",
"uiArea_terminal": "Terminal",
"uiArea_fileManager": "File manager",
"uiArea_docker": "Docker",
"uiArea_hostMetrics": "Host metrics",
"uiArea_hostEditor": "Host editor",
"uiArea_homepage": "Homepage",
"sectionAppearance": "Appearance",
@@ -2343,21 +2310,7 @@
}
}
},
"terminalToolbar": {
"title": "Terminal Toolbar",
"showActions": "Show toolbar",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk",
"statsUnavailable": "Live stats unavailable",
"tmuxDetach": "Detach tmux session",
"openFiles": "Open Files",
"openDocker": "Open Docker",
"openTunnel": "Open Tunnel",
"openHostMetrics": "Open Host Metrics",
"openProxmoxStats": "Open Proxmox Stats",
"openTmuxMonitor": "Open Tmux Monitor"
},
"terminalToolbar": {},
"alerts": {
"addChannel": "Add Channel",
"editChannel": "Edit Channel",
@@ -2606,5 +2559,12 @@
"pluginSettingsLoadFailed": "Failed to load settings",
"pluginsGroupLabel": "Plugins",
"hostPluginNotRunning": "{{name}} is installed but not running. These settings are saved but will not take effect."
},
"managerCard": {
"refresh": "Refresh",
"retry": "Retry",
"sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.",
"noData": "No data",
"filter": "Filter..."
}
}
-12
View File
@@ -764,12 +764,6 @@ function initializeApiInstances() {
// RBAC API (port 30001)
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
// Tmux Monitor API (port 30010) --- tmux-monitor ---
tmuxMonitorApi = createApiInstance(
getApiUrl("/tmux_monitor", 30010),
"TMUX_MONITOR",
);
}
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
@@ -786,9 +780,6 @@ export let authApi: AxiosInstance;
// RBAC API (port 30001)
export let rbacApi: AxiosInstance;
// Tmux Monitor API (port 30010) --- tmux-monitor ---
export let tmuxMonitorApi: AxiosInstance;
// Pre-initialize with default values to avoid undefined errors during early mounting
initializeApiInstances();
@@ -1349,9 +1340,6 @@ export {
getSSHHostById,
exportSSHHostWithCredentials,
exportAllSSHHosts,
enableAutoStart,
disableAutoStart,
getAutoStartStatus,
testProxyConnection,
} from "@/api/ssh-host-management-api";
+20 -1
View File
@@ -4,7 +4,10 @@ import type {
PluginManifest,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import type { PluginContributions } from "@/api/plugins-api";
import {
PLUGIN_SETTINGS_CHANGED_EVENT,
type PluginContributions,
} from "@/api/plugins-api";
import { registerRailItem } from "@/sidebar/rail-items";
import { registerTabType, type TabRenderProps } from "@/shell/tab-registry";
import { registerPanel, type PanelRenderProps } from "@/shell/panel-registry";
@@ -415,6 +418,22 @@ export function createPluginApp(
onRemoteServerChange: (listener) => track(onRemoteServerChange(listener)),
},
onSettingsChanged: (listener) => {
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail as {
pluginId?: string;
scope: "admin" | "user" | "host";
hostId?: number;
};
if (detail?.pluginId !== pluginId) return;
listener({ scope: detail.scope, hostId: detail.hostId });
};
window.addEventListener(PLUGIN_SETTINGS_CHANGED_EVENT, handler);
return track(() =>
window.removeEventListener(PLUGIN_SETTINGS_CHANGED_EVENT, handler),
);
},
onDispose(dispose) {
if (disposed) {
dispose();
+47 -4
View File
@@ -19,7 +19,7 @@ import {
} from "@termix/plugin-sdk/frontend";
import { usePermissions } from "@/hooks/use-permissions";
import { useTheme } from "@/components/theme-provider";
import { createPluginApi } from "@/lib/plugin-transport";
import { createPluginApi, pluginApiFor } from "@/lib/plugin-transport";
import {
getPluginAdminSettings,
getPluginHostSettings,
@@ -28,9 +28,19 @@ import {
updatePluginHostSettings,
updatePluginUserSettings,
} from "@/api/plugins-api";
import { getUserInfo } from "@/main-axios";
import { getCookie, getUserInfo } from "@/main-axios";
import { logActivity } from "@/api/dashboard-api";
import { getHostPassword } from "@/api/credentials-api";
import {
getUserPreferences,
parseCustomKeybindings,
patchOpenTab,
} from "@/api/open-tabs-api";
import { setHostAutoTmux } from "@/api/host-terminal-config-api";
import { usePluginScope } from "./scope";
import { knownPluginIds } from "./plugin-store";
import { knownPluginIds, usePluginStore } from "./plugin-store";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import type { UiPluginPresets } from "@/types/ui-preferences";
import { tabsApi, useShellHosts } from "./shell-bridge";
import { invokeAction } from "@/shell/action-registry";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
@@ -262,6 +272,12 @@ export const pluginHostBridge: PluginHostBridge = {
},
getApi: (pluginId) => getApi(pluginId),
getApiFor: (pluginId, origin) =>
pluginApiFor(
pluginId,
origin as "local" | "remote" | undefined,
getApi(pluginId) as never,
) as never,
useTabs: () => tabsApi,
@@ -307,7 +323,7 @@ export const pluginHostBridge: PluginHostBridge = {
useActivityTypes: () => {
const defs = useTabTypes();
const types = new Set(["file_manager"]);
const types = new Set<string>();
for (const def of defs) {
for (const type of def.activityTypes ?? []) types.add(type);
}
@@ -325,6 +341,33 @@ export const pluginHostBridge: PluginHostBridge = {
homepageWidgetType: (id) =>
getHomepageWidgetType(id) as unknown as
HomepageWidgetContribution | undefined,
usePluginUiPreferences: (pluginId) => {
const ctx = useUiPreferencesContext();
const store = usePluginStore();
const presets = store.records.get(pluginId)?.summary.contributes
?.uiPresets as UiPluginPresets | undefined;
const values = ctx
? ctx.resolvePlugin(pluginId, presets)
: { ...(presets?.balanced ?? {}) };
return {
values,
set: (key, value) => ctx?.setPluginOverride(pluginId, key, value),
};
},
core: {
logActivity: async (type, hostId, hostName) => {
await logActivity(type, hostId, hostName);
},
getHostPassword: async (hostId, field) =>
(await getHostPassword(hostId, field)) ?? null,
patchOpenTab: (instanceId, updates) => patchOpenTab(instanceId, updates),
getCustomKeybindings: async () =>
parseCustomKeybindings((await getUserPreferences()).customKeybindings),
setHostAutoTmux: (hostId, autoTmux) => setHostAutoTmux(hostId, autoTmux),
getClientPreference: (name) => getCookie(name),
},
};
/** Installs the bridge. Idempotent. */
+9 -17
View File
@@ -83,13 +83,14 @@ export {
getPollingEnvironmentMultiplier,
} from "@/lib/adaptive-polling";
export * from "@/components/sheet";
// The frame and filter box every host manager card uses.
export {
ManagerCardShell,
ManagerSearch,
type ManagerCardError,
} from "@/components/manager-card";
export { useConfirmation } from "@/hooks/use-confirmation";
export { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
// Per-area display density and chart options the user picked in Appearance.
export {
useAreaPreferences,
useUiPreferencesContext,
} from "@/contexts/UiPreferencesContext";
// Homepage widget pieces, for plugins that register a widget.
export { WidgetTitle } from "@/lib/widget-title";
export { runVisibleInterval } from "@/lib/visible-interval";
@@ -134,12 +135,10 @@ export {
} from "@/lib/remote-server-api";
export { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
// Core APIs a terminal-like surface calls: recent activity, the host's stored
// password for sudo autofill, the open tab record, the user's keybindings and
// host terminal options.
export { getCookie } from "@/main-axios";
// Dashboard reads. The calls a plugin makes on the user's behalf (recent
// activity, sudo autofill, open tabs, keybindings, auto tmux) are typed in
// @termix/plugin-sdk/frontend instead.
export {
logActivity,
getRecentActivity,
getUptime,
type RecentActivityItem,
@@ -150,13 +149,6 @@ export {
getDatabaseHealth,
type VersionInfo,
} from "@/api/system-status-api";
export { getHostPassword } from "@/api/credentials-api";
export {
patchOpenTab,
getUserPreferences,
parseCustomKeybindings,
} from "@/api/open-tabs-api";
export { setHostAutoTmux } from "@/api/host-terminal-config-api";
// Notification channels are core (ctx.notify on the backend). A plugin that
// lets users pick or manage them shows core's own dialog.
+41 -6
View File
@@ -7,8 +7,7 @@
* shape the hardcoded feature tabs already have.
*
* Nothing is rendered when no enabled plugin declares host settings, so the
* group does not appear as an empty tab. Phase B fills it as each feature
* moves its columns out of ssh_data.
* group does not appear as an empty tab.
*/
import { useEffect, useMemo, useState } from "react";
@@ -51,6 +50,41 @@ export function usePluginHostSections(): PluginSummary[] {
);
}
/** What each field reads before the host saves it. */
function hostFieldDefaults(plugin: PluginSummary): Record<string, unknown> {
const host = plugin.contributes?.settings?.host;
if (!host) return {};
const defaults: Record<string, unknown> = {};
if (host.enableKey) defaults[host.enableKey] = host.enableDefault ?? false;
for (const field of host.fields) {
if (field.default !== undefined) defaults[field.key] = field.default;
}
return defaults;
}
/**
* A new host saves the fields whose default comes from an admin setting,
* since the server only knows the manifest default for a host with no value.
*/
export function withNewHostDefaults(
values: HostPluginSettings,
plugins: PluginSummary[],
): HostPluginSettings {
const next: HostPluginSettings = { ...values };
for (const plugin of plugins) {
const fields = plugin.contributes?.settings?.host?.fields ?? [];
for (const field of fields) {
if (!field.defaultFrom || field.default === undefined) continue;
if (next[plugin.id] && field.key in next[plugin.id]) continue;
next[plugin.id] = {
...(next[plugin.id] ?? {}),
[field.key]: field.default,
};
}
}
return next;
}
export interface HostPluginSectionsProps {
plugins: PluginSummary[];
values: HostPluginSettings;
@@ -90,12 +124,13 @@ function HostPluginSection({
const { t } = useTranslation();
const host = plugin.contributes?.settings?.host;
if (!host) return null;
const shown = { ...hostFieldDefaults(plugin), ...values };
const running = plugin.enabled && plugin.state !== "failed";
const enableKey = host.enableKey;
// With no enable switch the section is always on, which is what a plugin
// declaring only plain fields means.
const enabled = enableKey ? values[enableKey] === true : true;
const enabled = enableKey ? shown[enableKey] === true : true;
return (
<SectionCard
@@ -116,7 +151,7 @@ function HostPluginSection({
type: "boolean",
labelKey: host.enableLabelKey,
}}
values={values}
values={shown}
setValue={setValue}
running={running}
/>
@@ -124,13 +159,13 @@ function HostPluginSection({
{enabled &&
host.fields
.filter((field) => isFieldActive(field, values))
.filter((field) => isFieldActive(field, shown))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
values={shown}
setValue={setValue}
running={running}
/>
+21
View File
@@ -33,6 +33,7 @@ import { isSessionTabType } from "@/shell/tab-registry";
import { isElectron } from "@/lib/electron";
import type { Tab, TabType, SplitMode } from "@/types/ui-types";
import { SPLIT_MODES, PANE_COUNTS } from "@/lib/theme";
import { ActionSlot } from "@/shell/ActionSlot";
/**
* Tabs holding a live connection that can be refreshed: the registered
@@ -686,6 +687,26 @@ export function TabBar({
{t("nav.openFileManager")}
</button>
)}
{/* Plugins add entries here, invoked with the tab's surface handle. */}
<ActionSlot
slotId="tab.menu"
when={{ tab: ctxTab, handle: ctxTab.terminalRef?.current }}
context={() => [ctxTab.terminalRef?.current]}
renderItem={(contribution, invoke) => (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
invoke();
setContextTabId(null);
}}
>
{contribution.icon && (
<contribution.icon className="size-3" />
)}
{t(contribution.titleKey)}
</button>
)}
/>
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
+3 -3
View File
@@ -111,8 +111,8 @@ export function TabProvider({ children }: TabProviderProps) {
const computeUniqueTitle = useCallback(
(tabType: Tab["type"], desiredTitle: string | undefined): string => {
const defaultTitle =
tabType === "file_manager" ? t("nav.fileManager") : t("nav.terminal");
const titleKey = getTabType(tabType)?.titleKey;
const defaultTitle = titleKey ? t(titleKey) : t("nav.terminal");
const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
@@ -181,7 +181,7 @@ export function TabProvider({ children }: TabProviderProps) {
const instanceId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const isCommandTarget = !!getTabType(tabData.type)?.commandTarget;
const needsUniqueTitle =
isCommandTarget || tabData.type === "file_manager";
isCommandTarget || !!getTabType(tabData.type)?.multiInstance;
const singletonTitleKey = getTabType(tabData.type)?.singleton
? getTabType(tabData.type)?.titleKey
: undefined;
-28
View File
@@ -497,34 +497,6 @@ export function AdminHostDefaultsSection({
}
/>
</SettingRow>
<SettingRow
label={t("admin.hostDefaultsSessionLogging")}
description={t("admin.hostDefaultsSessionLoggingDesc")}
>
<AdminToggle
on={defaults.enableSessionLogging ?? true}
onToggle={() =>
setDefaults((p) => ({
...p,
enableSessionLogging: !(p.enableSessionLogging ?? true),
}))
}
/>
</SettingRow>
<SettingRow
label={t("admin.hostDefaultsCommandHistory")}
description={t("admin.hostDefaultsCommandHistoryDesc")}
>
<AdminToggle
on={defaults.enableCommandHistory ?? true}
onToggle={() =>
setDefaults((p) => ({
...p,
enableCommandHistory: !(p.enableCommandHistory ?? true),
}))
}
/>
</SettingRow>
</div>
<Button
+9 -1
View File
@@ -76,6 +76,10 @@ import {
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
import { withProtocolSettings } from "./host-protocols";
import {
usePluginHostSections,
withNewHostDefaults,
} from "@/settings/HostPluginSections";
import { canEditHost } from "./host-permissions";
import {
getHostEditorSection,
@@ -129,6 +133,7 @@ export function HostEditor({
onEditCredential?: (credentialId: string) => void;
}) {
const { t } = useTranslation();
const hostSettingPlugins = usePluginHostSections();
const { setPreviewTerminalTheme } = useTabsSafe();
const connectionDefaults = useConnectionDefaults();
const [form, setForm] = useState(() =>
@@ -315,9 +320,12 @@ export function HostEditor({
}
// After the host: a new one has no id to scope settings to until it
// exists. A failure here must not claim the host itself failed to save.
const pluginValues = withProtocolSettings(form.pluginSettings, protocols);
await savePluginHostSettings(
Number(saved.id),
withProtocolSettings(form.pluginSettings, protocols),
host
? pluginValues
: withNewHostDefaults(pluginValues ?? {}, hostSettingPlugins),
);
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
-42
View File
@@ -99,10 +99,6 @@ export function createHostEditorForm(
...(connectionDefaults?.terminal ?? {}),
...(host?.terminalConfig ?? {}),
};
const proxmoxSettings = (host?.pluginSettings?.proxmox ?? {}) as Record<
string,
unknown
>;
const rawTheme = terminalConfig.theme ?? d?.theme;
const normalizedTheme =
!rawTheme ||
@@ -152,27 +148,6 @@ export function createHostEditorForm(
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
connectionOrigin: (host?.connectionOrigin ?? null) as
"local" | "remote" | null,
enableTerminal: host?.enableTerminal ?? true,
enableSessionLogging:
host?.enableSessionLogging ?? d?.enableSessionLogging ?? true,
enableCommandHistory:
host?.enableCommandHistory ?? d?.enableCommandHistory ?? true,
enableFileManager: host?.enableFileManager ?? false,
scpLegacy: host?.scpLegacy ?? false,
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
enableTerminalToolbar: host?.enableTerminalToolbar ?? true,
enableProxmox: (proxmoxSettings.enableProxmox as boolean) ?? false,
// The proxmox tab fills in its own defaults when this is unset.
proxmoxConfig:
(proxmoxSettings.proxmoxConfig as Host["proxmoxConfig"]) ?? null,
enableProxmoxStats:
(proxmoxSettings.enableProxmoxStats as boolean) ?? false,
proxmoxStatsConfig:
((proxmoxSettings.proxmoxStatsConfig as Host["proxmoxStatsConfig"]) ?? {
pollInterval: 60,
nodeName: null,
}) as Host["proxmoxStatsConfig"],
defaultPath: host?.defaultPath ?? "/",
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
inheritTerminalAppearance:
!host ||
@@ -409,23 +384,6 @@ export function buildHostEditorPayload(
usesCredential && form.credentialId ? Number(form.credentialId) : null,
overrideCredentialUsername: form.overrideCredentialUsername,
notes: form.notes,
enableTerminal: form.enableTerminal,
enableSessionLogging: form.enableSessionLogging,
enableCommandHistory: form.enableCommandHistory,
enableFileManager: form.enableFileManager,
scpLegacy: form.scpLegacy,
enableTmuxMonitor: form.enableTmuxMonitor,
enableTerminalToolbar: form.enableTerminalToolbar,
enableProxmox: form.enableProxmox,
proxmoxConfig:
form.enableProxmox || form.proxmoxConfig?.source
? form.proxmoxConfig
: null,
enableProxmoxStats: form.enableProxmoxStats,
proxmoxStatsConfig: form.enableProxmoxStats
? form.proxmoxStatsConfig
: null,
defaultPath: form.defaultPath || "/",
useSocks5: form.useSocks5,
socks5Host:
form.socks5ProxyMode === "single" ? form.socks5Host || null : null,
+1 -27
View File
@@ -6,16 +6,7 @@ import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import type { Host } from "@/types/ui-types";
import {
Activity,
Globe,
LayoutGrid,
Plus,
Tag,
Terminal,
Trash2,
X,
} from "lucide-react";
import { Activity, Globe, Plus, Tag, Terminal, Trash2, X } from "lucide-react";
import { FolderPathPicker } from "./FolderPathPicker";
import { HostParentPicker } from "./HostParentPicker";
import { getSSHFolders, isElectron } from "@/main-axios";
@@ -851,23 +842,6 @@ export function HostEditorGeneralTab({
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.connectionToolbar")}
icon={<LayoutGrid className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.showConnectionToolbar")}
description={t("hosts.showConnectionToolbarDesc")}
>
<FakeSwitch
checked={form.enableTerminalToolbar}
onChange={(value) => setField("enableTerminalToolbar", value)}
/>
</SettingRow>
</div>
</SectionCard>
</>
);
}
-23
View File
@@ -46,10 +46,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
const isSshHost = h.connectionType === "ssh" || !h.connectionType;
const parsedTerminalConfig = parseJson(h.terminalConfig) as
(Host["terminalConfig"] & { sudoPassword?: string }) | undefined;
const proxmoxSettings = (h.pluginSettings?.proxmox ?? {}) as Record<
string,
unknown
>;
return {
id: String(h.id),
name: h.name,
@@ -83,23 +79,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
sortOrder: h.sortOrder ?? null,
connectionOrigin: h.connectionOrigin ?? null,
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
enableTerminal:
h.enableTerminal ?? (h.enableSsh != null ? h.enableSsh : isSshHost),
enableSessionLogging: h.enableSessionLogging ?? true,
enableCommandHistory: h.enableCommandHistory ?? true,
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? true,
enableWebUi: h.enableWebUi ?? false,
enableProxmox: (proxmoxSettings.enableProxmox as boolean) ?? false,
enableProxmoxStats:
(proxmoxSettings.enableProxmoxStats as boolean) ?? false,
enableTmuxMonitor: h.enableTmuxMonitor ?? false,
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
proxmoxConfig:
(proxmoxSettings.proxmoxConfig as Host["proxmoxConfig"]) ?? null,
proxmoxStatsConfig:
(proxmoxSettings.proxmoxStatsConfig as Host["proxmoxStatsConfig"]) ??
null,
sshPort:
h.sshPort ??
(h.connectionType === "ssh" || !h.connectionType ? h.port : 22),
@@ -132,12 +111,10 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
name: a.name,
snippetId: String(a.snippetId),
})),
serverTunnels: parseJson(h.tunnelConnections) ?? [],
jumpHosts: (parseJson<HostJumpHost[]>(h.jumpHosts) ?? []).map((j) => ({
hostId: String(j.hostId ?? j.hostid ?? j),
})),
portKnockSequence: parseJson(h.portKnockSequence) ?? [],
defaultPath: h.defaultPath,
terminalConfig: parsedTerminalConfig as Host["terminalConfig"],
hasSudoPassword:
!!host.hasSudoPassword || !!parsedTerminalConfig?.sudoPassword,
+44 -21
View File
@@ -30,6 +30,7 @@ import { HostShareModal } from "@/sidebar/HostShareModal";
import { HostExportDialog } from "@/sidebar/HostExportDialog";
import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel";
import { ComponentSlot } from "@/shell/ActionSlot";
import { usePluginHostSections } from "@/settings/HostPluginSections";
import { Button } from "@/components/button";
import {
DropdownMenu,
@@ -124,7 +125,14 @@ function groupHosts(
return { name: "root", children };
}
function hostPassesFilters(host: Host, filters: FilterState): boolean {
/** Plugin id to the host setting that switches it on. */
type HostSwitches = Record<string, string>;
function hostPassesFilters(
host: Host,
filters: FilterState,
switches: HostSwitches,
): boolean {
if (filters.status.length > 0) {
const ok =
(filters.status.includes("online") && host.online) ||
@@ -150,9 +158,12 @@ function hostPassesFilters(host: Host, filters: FilterState): boolean {
);
if (!ok) return false;
}
if (filters.features.length > 0) {
const ok =
filters.features.includes("fileManager") && host.enableFileManager;
// A saved filter for a plugin that is gone or off is ignored.
const features = filters.features.filter((id) => switches[id]);
if (features.length > 0) {
const ok = features.some(
(id) => host.pluginSettings?.[id]?.[switches[id]] === true,
);
if (!ok) return false;
}
if (filters.tags.length > 0) {
@@ -162,14 +173,18 @@ function hostPassesFilters(host: Host, filters: FilterState): boolean {
return true;
}
function applyFilters(folder: HostFolder, filters: FilterState): HostFolder {
function applyFilters(
folder: HostFolder,
filters: FilterState,
switches: HostSwitches,
): HostFolder {
const active = Object.values(filters).some((arr) => arr.length > 0);
if (!active) return folder;
const filteredChildren = folder.children
.map((child) => {
if (isFolder(child)) return applyFilters(child, filters);
return hostPassesFilters(child, filters) ? child : null;
if (isFolder(child)) return applyFilters(child, filters, switches);
return hostPassesFilters(child, filters, switches) ? child : null;
})
.filter((child): child is Host | HostFolder => {
if (child === null) return false;
@@ -203,6 +218,15 @@ export function HostsPanel({
active?: boolean;
}) {
const { t } = useTranslation();
const hostSwitchPlugins = usePluginHostSections().filter(
(plugin) => !!plugin.contributes?.settings?.host?.enableKey,
);
const hostSwitches: Record<string, string> = Object.fromEntries(
hostSwitchPlugins.map((plugin) => [
plugin.id,
plugin.contributes!.settings!.host!.enableKey!,
]),
);
const hostProtocols = useHostProtocols();
const sshAuthProviders = useSshAuthProviders();
const [hostSearch, setHostSearch] = useState("");
@@ -777,20 +801,18 @@ export function HostsPanel({
<DropdownMenuLabel>
{t("hosts.filterFeaturesGroup")}
</DropdownMenuLabel>
{([["fileManager", "FileManager"]] as const).map(
([val, key]) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.features.includes(val)}
onCheckedChange={() =>
handleFilterToggle("features", val)
}
onSelect={(e) => e.preventDefault()}
>
{t(`hosts.filterFeature${key}`)}
</DropdownMenuCheckboxItem>
),
)}
{hostSwitchPlugins.map((plugin) => (
<DropdownMenuCheckboxItem
key={plugin.id}
checked={filterState.features.includes(plugin.id)}
onCheckedChange={() =>
handleFilterToggle("features", plugin.id)
}
onSelect={(e) => e.preventDefault()}
>
{plugin.name}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
<>
<DropdownMenuSeparator />
@@ -999,6 +1021,7 @@ export function HostsPanel({
applyFilters(
sortHostTree(hostTree, sortKey, pinnedFirst),
filterState,
hostSwitches,
),
groupKey,
groupLabel,
+32 -8
View File
@@ -19,6 +19,7 @@ import {
} from "@/components/dialog";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
HOMEPAGE_AREA_KEY,
UI_AREA_KEYS,
@@ -58,14 +59,37 @@ export function InterfacePresetSettings({
// the customizations are listed separately below.
const activeLabel = ctx?.preferences.preset ?? "balanced";
const pluginStore = usePluginStore();
const customizedAreas = useMemo(() => {
if (!ctx) return [] as UiAreaKey[];
return UI_AREA_KEYS.filter(
if (!ctx) return [] as Array<{ key: string; label: string }>;
const overrides = ctx.preferences.overrides as Record<
string,
Record<string, unknown> | undefined
>;
const core = UI_AREA_KEYS.filter(
(area) =>
!HIDDEN_OVERRIDE_AREAS.has(area) &&
Object.keys(ctx.preferences.overrides[area] ?? {}).length > 0,
);
}, [ctx]);
Object.keys(overrides[area] ?? {}).length > 0,
).map((area) => ({
key: area as string,
label: t(`newUi.sidebar.userProfile.uiArea_${area}`),
}));
// A plugin's own area is labelled with the plugin's name.
const plugins = Object.keys(overrides)
.filter(
(area) =>
area.startsWith("plugin:") &&
Object.keys(overrides[area] ?? {}).length > 0,
)
.map((area) => {
const pluginId = area.slice("plugin:".length);
return {
key: area,
label: pluginStore.records.get(pluginId)?.summary.name ?? pluginId,
};
});
return [...core, ...plugins];
}, [ctx, pluginStore, t]);
if (!ctx) return null;
@@ -143,15 +167,15 @@ export function InterfacePresetSettings({
</span>
{customizedAreas.map((area) => (
<div
key={area}
key={area.key}
className="flex items-center justify-between gap-2 py-0.5"
>
<span className="min-w-0 flex-1 truncate text-xs">
{t(`newUi.sidebar.userProfile.uiArea_${area}`)}
{area.label}
</span>
<button
type="button"
onClick={() => ctx.clearArea(area)}
onClick={() => ctx.clearArea(area.key as UiAreaKey)}
className="shrink-0 text-[10px] text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
{t("newUi.sidebar.userProfile.presetRevertArea")}
+45 -33
View File
@@ -38,30 +38,9 @@ export const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
],
jumpHosts: ["jumpHosts"],
quickActions: ["quickActions"],
featureFlags: [
"enableTerminal",
"enableCommandHistory",
"enableTerminalToolbar",
"enableFileManager",
"enableWebUi",
"enableProxmox",
"enableTmuxMonitor",
"showTerminalInSidebar",
"showFileManagerInSidebar",
"showTunnelInSidebar",
"showDockerInSidebar",
"showServerStatsInSidebar",
"defaultPath",
"forceKeyboardInteractive",
],
advanced: [
"statusCheckEnabled",
"statusCheckInterval",
"webUiConfig",
"proxmoxConfig",
"terminalConfig",
"guacamoleConfig",
],
// Every plugin's host settings, enable switches included.
featureFlags: ["pluginSettings", "forceKeyboardInteractive"],
advanced: ["statusCheckEnabled", "statusCheckInterval", "terminalConfig"],
};
export const SECRET_KEYS = [
@@ -102,6 +81,25 @@ function nestedSecret(
return field in record ? record : null;
}
/** Plugin host settings can carry the same nested secrets as the host. */
function mapPluginSettings(
pluginSettings: unknown,
apply: (values: Record<string, unknown>) => void,
): unknown {
if (!pluginSettings || typeof pluginSettings !== "object") {
return pluginSettings;
}
const perPlugin: Record<string, unknown> = {};
for (const [pluginId, values] of Object.entries(
pluginSettings as Record<string, unknown>,
)) {
const copy = { ...(values as Record<string, unknown>) };
apply(copy);
perPlugin[pluginId] = copy;
}
return perPlugin;
}
export function hostKey(host: Record<string, unknown>): string {
return JSON.stringify(TUPLE_KEYS.map((k) => String(host[k] ?? "")));
}
@@ -129,10 +127,17 @@ export function buildExportPayload(
if (allowed.has(key)) shaped[key] = value;
}
if (!withCredentials) {
for (const { container, field } of NESTED_SECRETS) {
const record = nestedSecret(shaped, container, field);
if (record) shaped[container] = { ...record, [field]: null };
}
const clearNested = (target: Record<string, unknown>) => {
for (const { container, field } of NESTED_SECRETS) {
const record = nestedSecret(target, container, field);
if (record) target[container] = { ...record, [field]: null };
}
};
clearNested(shaped);
shaped.pluginSettings = mapPluginSettings(
shaped.pluginSettings,
clearNested,
);
for (const { container, field } of NESTED_SECRET_ARRAYS) {
const arr = shaped[container];
if (Array.isArray(arr)) {
@@ -175,12 +180,19 @@ export function maskSecrets(payload: ExportPayload): ExportPayload {
masked[key] = "<included>";
}
}
for (const { container, field } of NESTED_SECRETS) {
const record = nestedSecret(masked, container, field);
if (record && record[field]) {
masked[container] = { ...record, [field]: "<included>" };
const maskNested = (target: Record<string, unknown>) => {
for (const { container, field } of NESTED_SECRETS) {
const record = nestedSecret(target, container, field);
if (record && record[field]) {
target[container] = { ...record, [field]: "<included>" };
}
}
}
};
maskNested(masked);
masked.pluginSettings = mapPluginSettings(
masked.pluginSettings,
maskNested,
);
for (const { container, field } of NESTED_SECRET_ARRAYS) {
const arr = masked[container];
if (Array.isArray(arr)) {
-29
View File
@@ -30,11 +30,6 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
...createQuickConnectHost({ ...input, protocol: undefined, port: 22 }),
...login,
port: input.port,
enableTerminal: false,
enableCommandHistory: false,
enableFileManager: false,
enableTunnel: false,
enableTerminalToolbar: false,
enableSsh: false,
domain: input.domain,
pluginSettings: {
@@ -62,17 +57,7 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
ram: null,
lastAccess: new Date().toISOString(),
pin: false,
defaultPath: "",
serverTunnels: [],
quickActions: [],
enableTerminal: true,
enableCommandHistory: true,
enableFileManager: true,
enableTunnel: true,
enableProxmox: false,
enableProxmoxStats: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
enableSsh: true,
sshPort: input.port,
};
@@ -92,20 +77,6 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
: null,
folder: host.folder,
pin: host.pin,
defaultPath: host.defaultPath,
enableTerminal: true,
enableSessionLogging: true,
enableCommandHistory: host.enableCommandHistory,
enableFileManager: host.enableFileManager,
enableTunnel: host.enableTunnel,
enableProxmox: host.enableProxmox,
enableTmuxMonitor: host.enableTmuxMonitor,
enableTerminalToolbar: host.enableTerminalToolbar,
showTerminalInSidebar: true,
showFileManagerInSidebar: true,
showTunnelInSidebar: true,
showDockerInSidebar: true,
showServerStatsInSidebar: true,
connectionType: "ssh",
enableSsh: true,
sshPort: host.sshPort,
+39 -66
View File
@@ -7,15 +7,15 @@ import {
useState,
} from "react";
import { updatePluginHostSettings } from "@/api/plugins-api";
import { PluginIcon } from "@/lib/plugin-icon";
import { usePluginHostSections } from "@/settings/HostPluginSections";
import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import {
Boxes,
ChevronDown,
Download,
FolderOpen,
FolderSearch,
Loader2,
Plus,
Server,
@@ -117,6 +117,9 @@ export function SidebarTree({
focusExistingTab?: boolean;
}) {
const { t } = useTranslation();
const hostSwitchPlugins = usePluginHostSections().filter(
(plugin) => !!plugin.contributes?.settings?.host?.enableKey,
);
// Knobs with no other owner come straight from the interface preset; the
// ones above still come from the host sidebar preferences blob.
const { showResourceBars, showStatusStripes, rowActions } =
@@ -607,9 +610,6 @@ export function SidebarTree({
credentialId: host.credentialId ? Number(host.credentialId) : null,
overrideCredentialUsername: host.overrideCredentialUsername ?? false,
enableSsh: host.enableSsh,
enableTerminal: host.enableTerminal,
enableTunnel: host.enableTunnel,
enableFileManager: host.enableFileManager,
sshPort: host.sshPort,
rdpUser: host.rdpUser ?? null,
rdpPassword: host.rdpPassword ?? null,
@@ -622,7 +622,6 @@ export function SidebarTree({
vncUser: host.vncUser ?? null,
telnetUser: host.telnetUser ?? null,
telnetPassword: host.telnetPassword ?? null,
defaultPath: host.defaultPath ?? "/",
forceKeyboardInteractive: host.forceKeyboardInteractive ?? false,
useSocks5: host.useSocks5,
socks5Host: host.socks5Host ?? null,
@@ -634,7 +633,6 @@ export function SidebarTree({
hostId: Number(j.hostId),
})),
portKnockSequence: host.portKnockSequence ?? [],
tunnelConnections: host.serverTunnels ?? [],
quickActions: (host.quickActions ?? []).map((a) => ({
name: a.name,
snippetId: Number(a.snippetId),
@@ -1145,65 +1143,40 @@ export function SidebarTree({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="text-xs">
{[
{
labelKey: "hosts.enableTerminalFeature",
field: "enableTerminal",
value: true,
icon: Terminal,
},
{
labelKey: "hosts.disableTerminalFeature",
field: "enableTerminal",
value: false,
icon: Terminal,
},
{
labelKey: "hosts.enableFilesFeature",
field: "enableFileManager",
value: true,
icon: FolderSearch,
},
{
labelKey: "hosts.disableFilesFeature",
field: "enableFileManager",
value: false,
icon: FolderSearch,
},
{
labelKey: "hosts.enableProxmoxFeature",
field: "enableProxmox",
value: true,
icon: Boxes,
},
{
labelKey: "hosts.disableProxmoxFeature",
field: "enableProxmox",
value: false,
icon: Boxes,
},
].map(({ labelKey, field, value, icon: Icon }) => (
<DropdownMenuItem
key={labelKey}
onClick={async () => {
const ids = Array.from(selectedHostIds).map(Number);
try {
await bulkUpdateSSHHosts(ids, { [field]: value });
window.dispatchEvent(
new CustomEvent("termix:hosts-changed"),
);
toast.success(
t("hosts.updatedCount", { count: ids.length }),
);
} catch {
toast.error(t("hosts.bulkUpdateFailed"));
}
}}
>
<Icon className="size-3.5 mr-2" />
{t(labelKey)}
</DropdownMenuItem>
))}
{hostSwitchPlugins.flatMap((plugin) =>
[true, false].map((value) => (
<DropdownMenuItem
key={`${plugin.id}:${value}`}
onClick={async () => {
const ids = Array.from(selectedHostIds).map(Number);
try {
await bulkUpdateSSHHosts(ids, {
pluginEnable: { [plugin.id]: value },
});
window.dispatchEvent(
new CustomEvent("termix:hosts-changed"),
);
toast.success(
t("hosts.updatedCount", { count: ids.length }),
);
} catch {
toast.error(t("hosts.bulkUpdateFailed"));
}
}}
>
<PluginIcon
name={plugin.icon}
className="size-3.5 mr-2"
/>
{t(
value
? "hosts.enablePluginFeature"
: "hosts.disablePluginFeature",
{ name: plugin.name },
)}
</DropdownMenuItem>
)),
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
@@ -0,0 +1,140 @@
/**
* Every built plugin frontend in dist/plugins imports through the real loader
* and runs activate(app) without an error. Needs `npm run build` first.
*/
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { FrontendModule } from "@termix/plugin-sdk/frontend";
import type { PluginSummary } from "@/api/plugins-api";
import {
configurePluginLoader,
resetPluginLoader,
syncPlugins,
} from "@/plugin-host/loader";
import { getPluginRecord } from "@/plugin-host/plugin-store";
const REPO_ROOT = path.resolve(__dirname, "../../../..");
const BUILT = path.join(REPO_ROOT, "dist", "plugins");
function pluginIds(): string[] {
const root = path.join(REPO_ROOT, "plugins");
return fs
.readdirSync(root, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(root, entry.name, "manifest.json")),
)
.map((entry) => entry.name)
.sort();
}
function manifest(id: string) {
return JSON.parse(
fs.readFileSync(path.join(BUILT, id, "manifest.json"), "utf8"),
) as {
id: string;
name: string;
version: string;
icon?: string;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
contributes?: PluginSummary["contributes"];
};
}
function frontendFile(id: string): string {
return path.join(BUILT, id, "dist", "frontend.js");
}
/** What GET /plugins would answer for a running, enabled plugin. */
function summaryFor(id: string): PluginSummary {
const m = manifest(id);
return {
id,
name: m.name,
version: m.version,
enabled: true,
state: "active",
contributes: m.contributes ?? null,
icon: m.icon,
dependencies: m.dependencies ?? {},
optionalDependencies: m.optionalDependencies ?? {},
frontend: fs.existsSync(frontendFile(id)),
assetVersion: "test",
locales: ["en"],
};
}
const errors: unknown[][] = [];
beforeAll(async () => {
const missing = pluginIds().filter(
(id) => !fs.existsSync(path.join(BUILT, id, "manifest.json")),
);
if (missing.length > 0) {
throw new Error(
`dist/plugins is missing ${missing.join(", ")}. Run npm run build first.`,
);
}
// Nothing here talks to a server; a plugin that fetches on activate gets
// an empty answer rather than a network error.
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("{}", { status: 200 })),
);
vi.spyOn(console, "error").mockImplementation((...args) => {
errors.push(args);
});
configurePluginLoader({
fetchPlugins: async () => pluginIds().map(summaryFor),
importFrontend: async (summary) =>
(await import(
/* @vite-ignore */ pathToFileURL(frontendFile(summary.id)).href
)) as FrontendModule,
loadLocale: async (summary, file) => {
if (file !== "en") return null;
const locale = path.join(BUILT, summary.id, "locales", "en.json");
return fs.existsSync(locale)
? JSON.parse(fs.readFileSync(locale, "utf8"))
: null;
},
injectCss: () => null,
});
await syncPlugins();
}, 180_000);
afterAll(async () => {
await resetPluginLoader();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("built plugin frontends", () => {
it("activates every plugin that ships a frontend", () => {
const notActive = pluginIds()
.filter((id) => fs.existsSync(frontendFile(id)))
.map((id) => [
id,
getPluginRecord(id)?.frontend,
getPluginRecord(id)?.error,
])
.filter(([, state]) => state !== "active");
expect(notActive).toEqual([]);
});
it("logs no error while importing and activating them", () => {
// Axios has no server to reach here; a failed request is the test
// environment, not the plugin.
const real = errors
.map((args) => args.map(String).join(" "))
.filter((line) => !/Network Error/.test(line));
expect(real).toEqual([]);
});
});
@@ -29,9 +29,6 @@ vi.mock("@/sidebar/UserProfilePanel", () => ({
vi.mock("@/sidebar/AdminSettingsPanel", () => ({
AdminSettingsPanel: () => <div>admin-panel</div>,
}));
vi.mock("@/settings/legacy-settings-components", () => ({
registerLegacySettingsComponents: vi.fn(),
}));
const { SettingsScreen } = await import("@/settings/SettingsScreen");
@@ -42,15 +42,6 @@ function makeHost(overrides: Partial<Host>): Host {
ram: null,
lastAccess: "",
authType: "credential",
enableTerminal: true,
enableTerminalToolbar: true,
enableCommandHistory: true,
enableTunnel: false,
serverTunnels: [],
enableFileManager: true,
enableProxmox: false,
enableProxmoxStats: false,
enableTmuxMonitor: false,
quickActions: [],
enableSsh: true,
sshPort: 22,
+6 -123
View File
@@ -334,135 +334,18 @@ describe("sudo password persistence indicator", () => {
});
});
describe("Proxmox / Proxmox Stats independent toggles", () => {
it("defaults enableProxmoxStats to false with sane pollInterval/nodeName defaults", () => {
const form = createHostEditorForm(null);
expect(form.enableProxmoxStats).toBe(false);
expect(form.proxmoxStatsConfig).toEqual({
pollInterval: 60,
nodeName: null,
});
expect(form.enableProxmox).toBe(false);
});
it("seeds both configs independently from an existing host", () => {
describe("plugin host settings", () => {
it("keeps them on the form and out of the host payload", () => {
const host = {
pluginSettings: {
proxmox: {
enableProxmox: true,
proxmoxConfig: { windowsPatterns: "win", dockerPatterns: "docker" },
enableProxmoxStats: true,
proxmoxStatsConfig: { pollInterval: 30, nodeName: "pve-custom" },
},
},
pluginSettings: { proxmox: { enableProxmox: true } },
} as unknown as Host;
const form = createHostEditorForm(host);
expect(form.enableProxmox).toBe(true);
expect(form.enableProxmoxStats).toBe(true);
expect(form.proxmoxStatsConfig).toEqual({
pollInterval: 30,
nodeName: "pve-custom",
});
});
it("nulls proxmoxStatsConfig in the payload when enableProxmoxStats is off, regardless of enableProxmox", () => {
const form = {
...createHostEditorForm(null),
enableProxmox: true,
proxmoxConfig: {
defaultCredentialId: null,
defaultAuthType: "password",
windowsPatterns: "win",
dockerPatterns: "docker",
preferredPrefixes: "",
},
enableProxmoxStats: false,
proxmoxStatsConfig: { pollInterval: 45, nodeName: "leftover" },
};
const payload = buildHostEditorPayload(form, sshOnly);
expect(payload.enableProxmox).toBe(true);
expect(payload.proxmoxConfig).not.toBeNull();
expect(payload.enableProxmoxStats).toBe(false);
expect(payload.proxmoxStatsConfig).toBeNull();
});
it("keeps proxmoxConfig null in the payload when enableProxmox is off, even though enableProxmoxStats is on", () => {
const form = {
...createHostEditorForm(null),
enableProxmox: false,
enableProxmoxStats: true,
proxmoxStatsConfig: { pollInterval: 90, nodeName: "pve1" },
};
const payload = buildHostEditorPayload(form, sshOnly);
expect(payload.enableProxmox).toBe(false);
expect(payload.proxmoxConfig).toBeNull();
expect(payload.enableProxmoxStats).toBe(true);
expect(payload.proxmoxStatsConfig).toEqual({
pollInterval: 90,
nodeName: "pve1",
});
});
it("preserves the source identity when editing an imported Proxmox guest", () => {
const source = {
source: "proxmox" as const,
sourceHostId: 7,
node: "pve1",
vmid: 101,
type: "qemu" as const,
lastSeenAt: "2026-08-14T00:00:00.000Z",
lastStatus: "running",
missingSince: null,
};
const form = {
...createHostEditorForm({
pluginSettings: {
proxmox: {
enableProxmox: false,
proxmoxConfig: { source },
},
},
} as unknown as Host),
name: "Edited guest",
};
const payload = buildHostEditorPayload(form, sshOnly);
expect(payload.enableProxmox).toBe(false);
expect(payload.proxmoxConfig).toEqual({ source });
});
it("sends both configs when both toggles are on", () => {
const form = {
...createHostEditorForm(null),
enableProxmox: true,
proxmoxConfig: {
defaultCredentialId: null,
defaultAuthType: "password",
windowsPatterns: "win",
dockerPatterns: "docker",
preferredPrefixes: "",
},
enableProxmoxStats: true,
proxmoxStatsConfig: { pollInterval: 60, nodeName: null },
};
const payload = buildHostEditorPayload(form, sshOnly);
expect(payload.enableProxmox).toBe(true);
expect(payload.proxmoxConfig).not.toBeNull();
expect(payload.enableProxmoxStats).toBe(true);
expect(payload.proxmoxStatsConfig).toEqual({
pollInterval: 60,
nodeName: null,
});
expect(form.pluginSettings).toEqual({ proxmox: { enableProxmox: true } });
expect(payload).not.toHaveProperty("enableProxmox");
expect(payload).not.toHaveProperty("pluginSettings");
});
});
+12 -18
View File
@@ -45,20 +45,6 @@ function sshHost(
pin: false,
authType: "password",
connectionType: "ssh",
enableTerminal: true,
enableSessionLogging: false,
enableCommandHistory: false,
enableTunnel: false,
enableFileManager: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
showTerminalInSidebar: true,
showFileManagerInSidebar: false,
showTunnelInSidebar: false,
showDockerInSidebar: false,
showServerStatsInSidebar: false,
defaultPath: "",
tunnelConnections: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
status: "online",
@@ -76,9 +62,13 @@ function rawPayload() {
port: 22,
username: "deploy",
password: "hunter2",
guacamoleConfig: {
"gateway-hostname": "gw.example.com",
"gateway-password": "gw-secret",
pluginSettings: {
"remote-desktop": {
guacamoleConfig: {
"gateway-hostname": "gw.example.com",
"gateway-password": "gw-secret",
},
},
},
},
],
@@ -207,6 +197,10 @@ describe("HostExportDialog - credential handling", () => {
expect(text).toContain("gw.example.com");
const parsed = JSON.parse(text);
expect(parsed.hosts[0].guacamoleConfig["gateway-password"]).toBeNull();
expect(
parsed.hosts[0].pluginSettings["remote-desktop"].guacamoleConfig[
"gateway-password"
],
).toBeNull();
});
});
@@ -62,6 +62,14 @@ function shareRaw(): ExportPayload {
};
}
function gatewayConfig(host: Record<string, unknown>): Record<string, unknown> {
const plugins = host.pluginSettings as Record<
string,
Record<string, unknown>
>;
return plugins["remote-desktop"].guacamoleConfig as Record<string, unknown>;
}
function rdpRaw(): ExportPayload {
return {
hosts: [
@@ -72,9 +80,13 @@ function rdpRaw(): ExportPayload {
port: 3389,
username: "admin",
password: null,
guacamoleConfig: {
"gateway-hostname": "gw.example.com",
"gateway-password": "gw-secret",
pluginSettings: {
"remote-desktop": {
guacamoleConfig: {
"gateway-hostname": "gw.example.com",
"gateway-password": "gw-secret",
},
},
},
},
],
@@ -198,21 +210,21 @@ describe("buildExportPayload", () => {
it("nulls the guacamole gateway password when credentials are excluded", () => {
const out = buildExportPayload(rdpRaw(), null, ALL_GROUPS, false);
const config = out.hosts[0].guacamoleConfig as Record<string, unknown>;
const config = gatewayConfig(out.hosts[0]);
expect(config["gateway-password"]).toBeNull();
expect(config["gateway-hostname"]).toBe("gw.example.com");
});
it("keeps the guacamole gateway password when credentials are included", () => {
const out = buildExportPayload(rdpRaw(), null, ALL_GROUPS, true);
const config = out.hosts[0].guacamoleConfig as Record<string, unknown>;
const config = gatewayConfig(out.hosts[0]);
expect(config["gateway-password"]).toBe("gw-secret");
});
it("does not mutate the source payload when nulling nested secrets", () => {
const raw = rdpRaw();
buildExportPayload(raw, null, ALL_GROUPS, false);
const config = raw.hosts[0].guacamoleConfig as Record<string, unknown>;
const config = gatewayConfig(raw.hosts[0]);
expect(config["gateway-password"]).toBe("gw-secret");
});
@@ -260,7 +272,7 @@ describe("maskSecrets", () => {
it("masks the guacamole gateway password", () => {
const out = maskSecrets(rdpRaw());
const config = out.hosts[0].guacamoleConfig as Record<string, unknown>;
const config = gatewayConfig(out.hosts[0]);
expect(config["gateway-password"]).toBe("<included>");
expect(config["gateway-hostname"]).toBe("gw.example.com");
expect(JSON.stringify(out)).not.toContain("gw-secret");
@@ -25,9 +25,6 @@ function host(id: string, name: string): Host {
authType: "password",
pin: false,
enableSsh: true,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
quickActions: [],
} as Host;
}
+46 -9
View File
@@ -4,6 +4,7 @@ import {
hasUiOverrides,
PRESETS,
resolveArea,
resolvePluginArea,
sanitizeUiOverrides,
sanitizeUiPreferences,
UI_PREFERENCES_VERSION,
@@ -100,14 +101,6 @@ describe("sanitizeUiOverrides", () => {
expect(result.rail?.hiddenTabs).toEqual(["serial", "history"]);
});
it("clamps and rounds integer knobs, dropping out-of-range values", () => {
expect(sanitizeUiOverrides({ hostMetrics: { columns: 2.4 } })).toEqual({
hostMetrics: { columns: 2 },
});
expect(sanitizeUiOverrides({ hostMetrics: { columns: 9 } })).toEqual({});
expect(sanitizeUiOverrides({ hostMetrics: { columns: 0 } })).toEqual({});
});
it("keeps an explicit null for nullable array knobs", () => {
expect(sanitizeUiOverrides({ homepage: { enabledWidgets: null } })).toEqual(
{
@@ -161,7 +154,6 @@ describe("PRESETS.balanced", () => {
expect(PRESETS.balanced.rail.hiddenTabs).toEqual([]);
expect(PRESETS.balanced.terminal.toolbarDensity).toBe("labeled");
expect(PRESETS.balanced.fileManager.viewMode).toBe("grid");
expect(PRESETS.balanced.docker.viewMode).toBe("list");
expect(PRESETS.balanced.hostEditor.mode).toBe("full");
expect(PRESETS.balanced.homepage.enabledWidgets).toBeNull();
});
@@ -184,3 +176,48 @@ describe("PRESETS.balanced", () => {
}
});
});
describe("plugin areas", () => {
it("moves version 1 docker and host metrics overrides to their plugins", () => {
const upgraded = sanitizeUiPreferences({
version: 1,
preset: "balanced",
overrides: {
docker: { containerLayout: "table" },
hostMetrics: { columns: 2 },
},
});
expect(upgraded.overrides).toEqual({
"plugin:docker": { containerLayout: "table" },
"plugin:host-metrics": { columns: 2 },
});
});
it("resolves a plugin area from its presets and the user's overrides", () => {
const presets = {
simple: { columns: 1 },
balanced: { columns: 3 },
advanced: { columns: 4 },
};
const preferences = sanitizeUiPreferences({
preset: "advanced",
overrides: { "plugin:host-metrics": { columns: 2 } },
});
expect(resolvePluginArea(preferences, "host-metrics", presets)).toEqual({
columns: 2,
});
expect(resolvePluginArea(preferences, "docker", presets)).toEqual({
columns: 4,
});
});
it("keeps only plain values in a plugin area", () => {
const { overrides } = sanitizeUiPreferences({
overrides: {
"plugin:docker": { ok: "card", nested: { no: 1 }, list: ["a", 2] },
"plugin:Bad_Id": { x: 1 },
},
});
expect(overrides).toEqual({ "plugin:docker": { ok: "card" } });
});
});