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

-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>