mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
fix: finish 2.9.0 plugin moves and get every check green
This commit is contained in:
1 parent
8c1e02af89
commit
6489b62ece
335 files changed
+29878
-17749
No files matched your search
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -130,8 +130,6 @@ export type HostDefaults = {
|
||||
theme?: string;
|
||||
cursorStyle?: string;
|
||||
cursorBlink?: boolean;
|
||||
enableSessionLogging?: boolean;
|
||||
enableCommandHistory?: boolean;
|
||||
autoTmux?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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..."
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user