mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
This commit is contained in:
1 parent
44062e30d1
commit
ffa872ebd3
19 files changed
+270
-292
No files matched your search
@@ -0,0 +1,78 @@
|
|||||||
|
export type ProxmoxSource = {
|
||||||
|
source: "proxmox";
|
||||||
|
sourceHostId: number;
|
||||||
|
node: string;
|
||||||
|
vmid: number;
|
||||||
|
type: "qemu" | "lxc";
|
||||||
|
lastSeenAt?: string;
|
||||||
|
lastStatus?: string;
|
||||||
|
missingSince?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseJsonObject(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value) return {};
|
||||||
|
if (typeof value === "object") return value as Record<string, unknown>;
|
||||||
|
if (typeof value !== "string") return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === "object"
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The guest a host was imported from, read from its proxmoxConfig setting. */
|
||||||
|
export function getProxmoxSource(config: unknown): ProxmoxSource | null {
|
||||||
|
const source = parseJsonObject(config).source;
|
||||||
|
if (!source || typeof source !== "object") return null;
|
||||||
|
const src = source as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
src.source !== "proxmox" ||
|
||||||
|
typeof src.sourceHostId !== "number" ||
|
||||||
|
typeof src.node !== "string" ||
|
||||||
|
typeof src.vmid !== "number" ||
|
||||||
|
(src.type !== "qemu" && src.type !== "lxc")
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return src as ProxmoxSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxmoxSourceKey(source: {
|
||||||
|
sourceHostId: number;
|
||||||
|
node: string;
|
||||||
|
type: string;
|
||||||
|
vmid: number;
|
||||||
|
}): string {
|
||||||
|
return `${source.sourceHostId}:${source.node}:${source.type}:${source.vmid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportedGuest {
|
||||||
|
host: Record<string, unknown>;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
source: ProxmoxSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hosts already imported from one Proxmox node, keyed by guest. The
|
||||||
|
* source lives in each guest's proxmoxConfig host setting, not on the host
|
||||||
|
* row, so the caller passes both.
|
||||||
|
*/
|
||||||
|
export function indexImportedGuests(
|
||||||
|
hosts: Record<string, unknown>[],
|
||||||
|
configs: Array<{ hostId: number; value: unknown }>,
|
||||||
|
sourceHostId: number,
|
||||||
|
): Map<string, ImportedGuest> {
|
||||||
|
const configById = new Map(configs.map((c) => [c.hostId, c.value]));
|
||||||
|
const out = new Map<string, ImportedGuest>();
|
||||||
|
for (const host of hosts) {
|
||||||
|
const config = parseJsonObject(configById.get(Number(host.id)));
|
||||||
|
const source = getProxmoxSource(config);
|
||||||
|
if (source?.sourceHostId === sourceHostId) {
|
||||||
|
out.set(proxmoxSourceKey(source), { host, config, source });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
serializeProxmoxJumpHosts,
|
serializeProxmoxJumpHosts,
|
||||||
} from "./proxmox-jump-hosts.js";
|
} from "./proxmox-jump-hosts.js";
|
||||||
import { isSafeNodeName } from "./proxmox-shared.js";
|
import { isSafeNodeName } from "./proxmox-shared.js";
|
||||||
|
import {
|
||||||
|
indexImportedGuests,
|
||||||
|
parseJsonObject,
|
||||||
|
type ProxmoxSource,
|
||||||
|
} from "./guest-sync.js";
|
||||||
|
|
||||||
/** A thrown value's message, or the fallback when it is not an Error. */
|
/** A thrown value's message, or the fallback when it is not an Error. */
|
||||||
function getErrorMessage(error: unknown, fallback = "Unknown error"): string {
|
function getErrorMessage(error: unknown, fallback = "Unknown error"): string {
|
||||||
@@ -187,17 +192,6 @@ type ProxmoxGuest = {
|
|||||||
/** The resolved host ctx.ssh.connect hands back, plus the sudo secret proxmox needs for pvesh. */
|
/** The resolved host ctx.ssh.connect hands back, plus the sudo secret proxmox needs for pvesh. */
|
||||||
type PluginSshHostWithSudo = PluginHostRecord & { sudoPassword?: string };
|
type PluginSshHostWithSudo = PluginHostRecord & { sudoPassword?: string };
|
||||||
|
|
||||||
type ProxmoxSource = {
|
|
||||||
source: "proxmox";
|
|
||||||
sourceHostId: number;
|
|
||||||
node: string;
|
|
||||||
vmid: number;
|
|
||||||
type: "qemu" | "lxc";
|
|
||||||
lastSeenAt?: string;
|
|
||||||
lastStatus?: string;
|
|
||||||
missingSince?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProxmoxSyncResult = {
|
type ProxmoxSyncResult = {
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
@@ -206,41 +200,6 @@ type ProxmoxSyncResult = {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseJsonObject(value: unknown): Record<string, unknown> {
|
|
||||||
if (!value) return {};
|
|
||||||
if (typeof value === "object") return value as Record<string, unknown>;
|
|
||||||
if (typeof value !== "string") return {};
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return parsed && typeof parsed === "object"
|
|
||||||
? (parsed as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProxmoxSource(host: Record<string, unknown>): ProxmoxSource | null {
|
|
||||||
const config = parseJsonObject(host.proxmoxConfig);
|
|
||||||
const source = config.source;
|
|
||||||
if (!source || typeof source !== "object") return null;
|
|
||||||
const src = source as Record<string, unknown>;
|
|
||||||
if (
|
|
||||||
src.source !== "proxmox" ||
|
|
||||||
typeof src.sourceHostId !== "number" ||
|
|
||||||
typeof src.node !== "string" ||
|
|
||||||
typeof src.vmid !== "number" ||
|
|
||||||
(src.type !== "qemu" && src.type !== "lxc")
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return src as ProxmoxSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
function proxmoxSourceKey(source: ProxmoxSource): string {
|
|
||||||
return `${source.sourceHostId}:${source.node}:${source.type}:${source.vmid}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function guestSourceKey(sourceHostId: number, guest: ProxmoxGuest): string {
|
function guestSourceKey(sourceHostId: number, guest: ProxmoxGuest): string {
|
||||||
return `${sourceHostId}:${guest.node}:${guest.type}:${guest.vmid}`;
|
return `${sourceHostId}:${guest.node}:${guest.type}:${guest.vmid}`;
|
||||||
}
|
}
|
||||||
@@ -552,19 +511,18 @@ async function syncProxmoxHost(
|
|||||||
string,
|
string,
|
||||||
unknown
|
unknown
|
||||||
>[];
|
>[];
|
||||||
const existingBySource = new Map<string, Record<string, unknown>>();
|
const existingBySource = indexImportedGuests(
|
||||||
for (const host of existingHosts) {
|
existingHosts,
|
||||||
const source = getProxmoxSource(host);
|
await ctx.settings.listHostValues("proxmoxConfig"),
|
||||||
if (source?.sourceHostId === sourceHostId) {
|
sourceHostId,
|
||||||
existingBySource.set(proxmoxSourceKey(source), host);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const guest of discovery.guests) {
|
for (const guest of discovery.guests) {
|
||||||
const key = guestSourceKey(sourceHostId, guest);
|
const key = guestSourceKey(sourceHostId, guest);
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
const existing = existingBySource.get(key);
|
const imported = existingBySource.get(key);
|
||||||
|
const existing = imported?.host;
|
||||||
const source: ProxmoxSource = {
|
const source: ProxmoxSource = {
|
||||||
source: "proxmox",
|
source: "proxmox",
|
||||||
sourceHostId,
|
sourceHostId,
|
||||||
@@ -576,13 +534,7 @@ async function syncProxmoxHost(
|
|||||||
missingSince: null,
|
missingSince: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseConfig = existing
|
const proxmoxConfig = { ...(imported?.config ?? {}), source };
|
||||||
? parseJsonObject(existing.proxmoxConfig)
|
|
||||||
: {};
|
|
||||||
const proxmoxConfig = {
|
|
||||||
...baseConfig,
|
|
||||||
source,
|
|
||||||
};
|
|
||||||
const existingConnectionType =
|
const existingConnectionType =
|
||||||
existing?.connectionType === "ssh" || existing?.connectionType === "rdp"
|
existing?.connectionType === "ssh" || existing?.connectionType === "rdp"
|
||||||
? existing.connectionType
|
? existing.connectionType
|
||||||
@@ -616,7 +568,6 @@ async function syncProxmoxHost(
|
|||||||
connectionType,
|
connectionType,
|
||||||
folder: existing?.folder || sourceHostName,
|
folder: existing?.folder || sourceHostName,
|
||||||
tags: mergeTags(existing?.tags, guestTags(guest), ["proxmox-missing"]),
|
tags: mergeTags(existing?.tags, guestTags(guest), ["proxmox-missing"]),
|
||||||
proxmoxConfig: JSON.stringify(proxmoxConfig),
|
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -629,6 +580,11 @@ async function syncProxmoxHost(
|
|||||||
existing.id as number,
|
existing.id as number,
|
||||||
update as PluginHostUpdateInput,
|
update as PluginHostUpdateInput,
|
||||||
);
|
);
|
||||||
|
await ctx.settings.setHost(
|
||||||
|
existing.id as number,
|
||||||
|
"proxmoxConfig",
|
||||||
|
proxmoxConfig,
|
||||||
|
);
|
||||||
result.updated++;
|
result.updated++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -639,7 +595,7 @@ async function syncProxmoxHost(
|
|||||||
enableRdp: connectionType === "rdp",
|
enableRdp: connectionType === "rdp",
|
||||||
});
|
});
|
||||||
|
|
||||||
await ctx.hosts.create({
|
const created = await ctx.hosts.create({
|
||||||
...update,
|
...update,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
pin: false,
|
pin: false,
|
||||||
@@ -660,8 +616,6 @@ async function syncProxmoxHost(
|
|||||||
telnetPassword: null,
|
telnetPassword: null,
|
||||||
jumpHosts: serializeProxmoxJumpHosts(discovery.jumpHosts),
|
jumpHosts: serializeProxmoxJumpHosts(discovery.jumpHosts),
|
||||||
quickActions: null,
|
quickActions: null,
|
||||||
statsConfig: null,
|
|
||||||
dockerConfig: null,
|
|
||||||
terminalConfig: null,
|
terminalConfig: null,
|
||||||
forceKeyboardInteractive: "false",
|
forceKeyboardInteractive: "false",
|
||||||
useSocks5: 0,
|
useSocks5: 0,
|
||||||
@@ -672,30 +626,28 @@ async function syncProxmoxHost(
|
|||||||
socks5ProxyChain: null,
|
socks5ProxyChain: null,
|
||||||
portKnockSequence: null,
|
portKnockSequence: null,
|
||||||
} as unknown as PluginHostCreateInput);
|
} as unknown as PluginHostCreateInput);
|
||||||
|
await ctx.settings.setHost(
|
||||||
|
Number(created.id),
|
||||||
|
"proxmoxConfig",
|
||||||
|
proxmoxConfig,
|
||||||
|
);
|
||||||
result.created++;
|
result.created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (discovery.config.markMissingGuests) {
|
if (discovery.config.markMissingGuests) {
|
||||||
for (const [key, existing] of existingBySource.entries()) {
|
for (const [key, { host, config, source }] of existingBySource) {
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
const config = parseJsonObject(existing.proxmoxConfig);
|
|
||||||
const source = getProxmoxSource(existing);
|
|
||||||
if (!source) continue;
|
|
||||||
const missingSince = source.missingSince || now;
|
|
||||||
await ctx.hosts.update(
|
await ctx.hosts.update(
|
||||||
existing.id as number,
|
host.id as number,
|
||||||
{
|
{
|
||||||
tags: mergeTags(existing.tags, ["proxmox-missing"]),
|
tags: mergeTags(host.tags, ["proxmox-missing"]),
|
||||||
proxmoxConfig: JSON.stringify({
|
|
||||||
...config,
|
|
||||||
source: {
|
|
||||||
...source,
|
|
||||||
missingSince,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
} as PluginHostUpdateInput,
|
} as PluginHostUpdateInput,
|
||||||
);
|
);
|
||||||
|
await ctx.settings.setHost(host.id as number, "proxmoxConfig", {
|
||||||
|
...config,
|
||||||
|
source: { ...source, missingSince: source.missingSince || now },
|
||||||
|
});
|
||||||
result.markedMissing++;
|
result.markedMissing++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getProxmoxSource,
|
||||||
|
indexImportedGuests,
|
||||||
|
proxmoxSourceKey,
|
||||||
|
} from "../../src/backend/guest-sync.js";
|
||||||
|
|
||||||
|
const source = {
|
||||||
|
source: "proxmox",
|
||||||
|
sourceHostId: 1,
|
||||||
|
node: "pve",
|
||||||
|
vmid: 101,
|
||||||
|
type: "qemu",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("getProxmoxSource", () => {
|
||||||
|
it("reads the source from an object or a JSON string", () => {
|
||||||
|
expect(getProxmoxSource({ source })).toEqual(source);
|
||||||
|
expect(getProxmoxSource(JSON.stringify({ source }))).toEqual(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a malformed source", () => {
|
||||||
|
expect(getProxmoxSource(null)).toBeNull();
|
||||||
|
expect(getProxmoxSource({ source: { ...source, vmid: "101" } })).toBeNull();
|
||||||
|
expect(getProxmoxSource({ source: { ...source, type: "vm" } })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("indexImportedGuests", () => {
|
||||||
|
// The host row carries no proxmoxConfig since 2.9.0; the source only
|
||||||
|
// exists in the plugin's host settings.
|
||||||
|
const hosts = [
|
||||||
|
{ id: 10, name: "guest-a" },
|
||||||
|
{ id: 11, name: "guest-b" },
|
||||||
|
{ id: 12, name: "manual" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("matches hosts to guests through their settings", () => {
|
||||||
|
const index = indexImportedGuests(
|
||||||
|
hosts,
|
||||||
|
[
|
||||||
|
{ hostId: 10, value: { source, lastSyncAt: "x" } },
|
||||||
|
{ hostId: 11, value: { source: { ...source, sourceHostId: 2 } } },
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect([...index.keys()]).toEqual([proxmoxSourceKey(source as never)]);
|
||||||
|
const entry = index.get("1:pve:qemu:101")!;
|
||||||
|
expect(entry.host.id).toBe(10);
|
||||||
|
expect(entry.config.lastSyncAt).toBe("x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores settings for hosts the user does not own", () => {
|
||||||
|
const index = indexImportedGuests(
|
||||||
|
hosts,
|
||||||
|
[{ hostId: 99, value: { source } }],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(index.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Duplex } from "node:stream";
|
|
||||||
import type { RawData, WebSocket } from "ws";
|
|
||||||
|
|
||||||
export function waitForWebSocketOpen(
|
|
||||||
socket: WebSocket,
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const cleanup = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
socket.off("open", onOpen);
|
|
||||||
socket.off("error", onError);
|
|
||||||
};
|
|
||||||
const onOpen = () => {
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
const onError = (error: Error) => {
|
|
||||||
cleanup();
|
|
||||||
socket.terminate();
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
cleanup();
|
|
||||||
socket.terminate();
|
|
||||||
reject(new Error("Cloudflare tunnel timeout"));
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
socket.once("open", onOpen);
|
|
||||||
socket.once("error", onError);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWebSocketDuplex(socket: WebSocket): Duplex {
|
|
||||||
const duplex = new Duplex({
|
|
||||||
read() {},
|
|
||||||
write(chunk, _encoding, callback) {
|
|
||||||
try {
|
|
||||||
socket.send(chunk, (error) => callback(error || undefined));
|
|
||||||
} catch (error) {
|
|
||||||
callback(error instanceof Error ? error : new Error(String(error)));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
destroy(error, callback) {
|
|
||||||
cleanup();
|
|
||||||
socket.terminate();
|
|
||||||
callback(error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onMessage = (data: RawData) => duplex.push(data);
|
|
||||||
const onClose = () => duplex.destroy();
|
|
||||||
const onError = () => duplex.destroy();
|
|
||||||
const cleanup = () => {
|
|
||||||
socket.off("message", onMessage);
|
|
||||||
socket.off("close", onClose);
|
|
||||||
socket.off("error", onError);
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on("message", onMessage);
|
|
||||||
socket.on("close", onClose);
|
|
||||||
socket.on("error", onError);
|
|
||||||
return duplex;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* How the bytes get to a host: port knocking, a Cloudflare Access tunnel, a
|
* How the bytes get to a host: port knocking, a jump host chain or a SOCKS5
|
||||||
* jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and
|
* proxy, in that order. Sets config.sock and
|
||||||
* returns the jump client so the caller can close it with the connection.
|
* returns the jump client so the caller can close it with the connection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
|
|||||||
class SshTransportError extends Error {
|
class SshTransportError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
readonly stage: "cloudflare" | "jump-host" | "jump-forward" | "proxy",
|
readonly stage: "jump-host" | "jump-forward" | "proxy",
|
||||||
options?: { cause?: unknown },
|
options?: { cause?: unknown },
|
||||||
) {
|
) {
|
||||||
super(message, options);
|
super(message, options);
|
||||||
@@ -50,8 +50,6 @@ class SshTransportError extends Error {
|
|||||||
export interface OpenTransportOptions {
|
export interface OpenTransportOptions {
|
||||||
/** Knock before connecting when the host has a sequence. Default true. */
|
/** Knock before connecting when the host has a sequence. Default true. */
|
||||||
portKnock?: boolean;
|
portKnock?: boolean;
|
||||||
/** Use terminalConfig.cfAccessClient* when present. Default true. */
|
|
||||||
cloudflare?: boolean;
|
|
||||||
/** Resolve DNS up front for a direct connection. Default true. */
|
/** Resolve DNS up front for a direct connection. Default true. */
|
||||||
resolveDns?: boolean;
|
resolveDns?: boolean;
|
||||||
log?: SshAuthLog;
|
log?: SshAuthLog;
|
||||||
@@ -59,7 +57,7 @@ export interface OpenTransportOptions {
|
|||||||
|
|
||||||
export interface OpenedTransport {
|
export interface OpenedTransport {
|
||||||
jumpClient: Client | null;
|
jumpClient: Client | null;
|
||||||
via: "direct" | "proxy" | "jump" | "cloudflare";
|
via: "direct" | "proxy" | "jump";
|
||||||
}
|
}
|
||||||
|
|
||||||
function forwardThrough(
|
function forwardThrough(
|
||||||
@@ -94,8 +92,6 @@ export async function openSshTransport(
|
|||||||
config: MutableConnectConfig,
|
config: MutableConnectConfig,
|
||||||
options: OpenTransportOptions = {},
|
options: OpenTransportOptions = {},
|
||||||
): Promise<OpenedTransport> {
|
): Promise<OpenedTransport> {
|
||||||
const log = options.log ?? (() => {});
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
options.portKnock !== false &&
|
options.portKnock !== false &&
|
||||||
Array.isArray(host.portKnockSequence) &&
|
Array.isArray(host.portKnockSequence) &&
|
||||||
@@ -113,42 +109,6 @@ export async function openSshTransport(
|
|||||||
|
|
||||||
let via: OpenedTransport["via"] = "direct";
|
let via: OpenedTransport["via"] = "direct";
|
||||||
|
|
||||||
const terminalConfig = (host.terminalConfig ?? {}) as Record<string, unknown>;
|
|
||||||
if (
|
|
||||||
options.cloudflare !== false &&
|
|
||||||
terminalConfig.cfAccessClientId &&
|
|
||||||
terminalConfig.cfAccessClientSecret
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const WebSocket = (await import("ws")).default;
|
|
||||||
const { createWebSocketDuplex, waitForWebSocketOpen } =
|
|
||||||
await import("../cloudflare-websocket.js");
|
|
||||||
const cfHostname = (terminalConfig.cfTunnelHostname as string) || host.ip;
|
|
||||||
const cfWs = new WebSocket(
|
|
||||||
`wss://${cfHostname}/cdn-cgi/access/ssh-connect`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"CF-Access-Client-Id": terminalConfig.cfAccessClientId as string,
|
|
||||||
"CF-Access-Client-Secret":
|
|
||||||
terminalConfig.cfAccessClientSecret as string,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitForWebSocketOpen(cfWs, 30000);
|
|
||||||
config.sock = createWebSocketDuplex(
|
|
||||||
cfWs,
|
|
||||||
) as unknown as typeof config.sock;
|
|
||||||
via = "cloudflare";
|
|
||||||
log("info", "Connected via Cloudflare Tunnel");
|
|
||||||
} catch (error) {
|
|
||||||
throw new SshTransportError(
|
|
||||||
"Cloudflare tunnel connection failed: " + getErrorMessage(error),
|
|
||||||
"cloudflare",
|
|
||||||
{ cause: error },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const jumpUserId = host.userId || "";
|
const jumpUserId = host.userId || "";
|
||||||
if (host.jumpHosts && host.jumpHosts.length > 0 && jumpUserId) {
|
if (host.jumpHosts && host.jumpHosts.length > 0 && jumpUserId) {
|
||||||
const jumpClient = await createJumpHostChain(host.jumpHosts, jumpUserId);
|
const jumpClient = await createJumpHostChain(host.jumpHosts, jumpUserId);
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const HOST_REFERENCES = [
|
|||||||
},
|
},
|
||||||
{ field: "jumpHosts[].hostId", entityType: "hosts" },
|
{ field: "jumpHosts[].hostId", entityType: "hosts" },
|
||||||
{ field: "quickActions[].snippetId", entityType: "commandSnippet" },
|
{ field: "quickActions[].snippetId", entityType: "commandSnippet" },
|
||||||
|
{ field: "terminalConfig.startupSnippetId", entityType: "commandSnippet" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Columns that only mean something on the device that holds them. */
|
/** Columns that only mean something on the device that holds them. */
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { EventEmitter } from "node:events";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { WebSocket } from "ws";
|
|
||||||
import {
|
|
||||||
createWebSocketDuplex,
|
|
||||||
waitForWebSocketOpen,
|
|
||||||
} from "../../hosts/cloudflare-websocket.js";
|
|
||||||
|
|
||||||
function setupSocket() {
|
|
||||||
const socket = new EventEmitter() as EventEmitter & {
|
|
||||||
terminate: ReturnType<typeof vi.fn>;
|
|
||||||
send: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
socket.terminate = vi.fn();
|
|
||||||
socket.send = vi.fn();
|
|
||||||
return socket as unknown as WebSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("waitForWebSocketOpen", () => {
|
|
||||||
afterEach(() => vi.useRealTimers());
|
|
||||||
|
|
||||||
it("clears listeners and leaves an opened socket alive", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const socket = setupSocket();
|
|
||||||
const result = waitForWebSocketOpen(socket, 30_000);
|
|
||||||
|
|
||||||
socket.emit("open");
|
|
||||||
|
|
||||||
await expect(result).resolves.toBeUndefined();
|
|
||||||
expect(socket.terminate).not.toHaveBeenCalled();
|
|
||||||
expect(socket.listenerCount("open")).toBe(0);
|
|
||||||
expect(socket.listenerCount("error")).toBe(0);
|
|
||||||
expect(vi.getTimerCount()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("terminates a socket that does not open before the deadline", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const socket = setupSocket();
|
|
||||||
const result = waitForWebSocketOpen(socket, 30_000);
|
|
||||||
const rejection = expect(result).rejects.toThrow(
|
|
||||||
"Cloudflare tunnel timeout",
|
|
||||||
);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(30_000);
|
|
||||||
|
|
||||||
await rejection;
|
|
||||||
expect(socket.terminate).toHaveBeenCalledOnce();
|
|
||||||
expect(socket.listenerCount("open")).toBe(0);
|
|
||||||
expect(socket.listenerCount("error")).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("terminates the socket when the SSH duplex is destroyed", () => {
|
|
||||||
const socket = setupSocket();
|
|
||||||
const duplex = createWebSocketDuplex(socket);
|
|
||||||
|
|
||||||
duplex.destroy();
|
|
||||||
|
|
||||||
expect(socket.terminate).toHaveBeenCalledOnce();
|
|
||||||
expect(socket.listenerCount("message")).toBe(0);
|
|
||||||
expect(socket.listenerCount("close")).toBe(0);
|
|
||||||
expect(socket.listenerCount("error")).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles established socket errors without an unhandled event", () => {
|
|
||||||
const socket = setupSocket();
|
|
||||||
const duplex = createWebSocketDuplex(socket);
|
|
||||||
|
|
||||||
socket.emit("error", new Error("connection lost"));
|
|
||||||
|
|
||||||
expect(duplex.destroyed).toBe(true);
|
|
||||||
expect(socket.terminate).toHaveBeenCalledOnce();
|
|
||||||
expect(socket.listenerCount("error")).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { HostSessionStatus } from "./host-session-status.js";
|
import { HostSessionStatus } from "../../hosts/host-session-status.js";
|
||||||
|
|
||||||
describe("HostSessionStatus", () => {
|
describe("HostSessionStatus", () => {
|
||||||
it("reports only the first connection and last disconnection per host", () => {
|
it("reports only the first connection and last disconnection per host", () => {
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
import { getJumpHostSocks5Config } from "../../hosts/jump-host-proxy.js";
|
||||||
|
|
||||||
describe("getJumpHostSocks5Config", () => {
|
describe("getJumpHostSocks5Config", () => {
|
||||||
it("uses the first jump host proxy settings", () => {
|
it("uses the first jump host proxy settings", () => {
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveSshKeepalive } from "./ssh-keepalive.js";
|
import { resolveSshKeepalive } from "../../hosts/ssh-keepalive.js";
|
||||||
|
|
||||||
describe("resolveSshKeepalive", () => {
|
describe("resolveSshKeepalive", () => {
|
||||||
it("preserves zero to disable SSH keepalives", () => {
|
it("preserves zero to disable SSH keepalives", () => {
|
||||||
@@ -197,6 +197,16 @@ describe("core sync entities", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("translates every snippet a host points at", () => {
|
||||||
|
const fields = (getEntity("hosts")?.references ?? [])
|
||||||
|
.filter((reference) => reference.entityType === "commandSnippet")
|
||||||
|
.map((reference) => reference.field);
|
||||||
|
expect(fields).toEqual([
|
||||||
|
"quickActions[].snippetId",
|
||||||
|
"terminalConfig.startupSnippetId",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("encrypts host and credential secrets", () => {
|
it("encrypts host and credential secrets", () => {
|
||||||
expect(getEntity("hosts")?.encryptedFields).toContain("password");
|
expect(getEntity("hosts")?.encryptedFields).toContain("password");
|
||||||
expect(getEntity("sshCredentials")?.encryptedFields).toContain(
|
expect(getEntity("sshCredentials")?.encryptedFields).toContain(
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ beforeEach(() => {
|
|||||||
references: [
|
references: [
|
||||||
{ field: "parentId", syncField: "parentSyncId", entityType: "things" },
|
{ field: "parentId", syncField: "parentSyncId", entityType: "things" },
|
||||||
{ field: "hops[].thingId", entityType: "things" },
|
{ field: "hops[].thingId", entityType: "things" },
|
||||||
|
{ field: "config.startId", entityType: "things" },
|
||||||
{
|
{
|
||||||
field: "bindings[].action.snippetId",
|
field: "bindings[].action.snippetId",
|
||||||
entityType: "things",
|
entityType: "things",
|
||||||
@@ -111,6 +112,29 @@ describe("serializing references", () => {
|
|||||||
expect(back.bindings).toEqual([{ action: { snippetId: "12" } }]);
|
expect(back.bindings).toEqual([{ action: { snippetId: "12" } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rewrites an id nested in a JSON object column", async () => {
|
||||||
|
const wire = await serializeReferences(
|
||||||
|
entity("things"),
|
||||||
|
{ config: JSON.stringify({ theme: "dark", startId: 5 }) },
|
||||||
|
toSync,
|
||||||
|
);
|
||||||
|
expect(JSON.parse(wire.config as string)).toEqual({
|
||||||
|
theme: "dark",
|
||||||
|
startId: "sync-5",
|
||||||
|
});
|
||||||
|
|
||||||
|
const back = await deserializeReferences(
|
||||||
|
entity("things"),
|
||||||
|
wire,
|
||||||
|
toId,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(JSON.parse(back.config as string)).toEqual({
|
||||||
|
theme: "dark",
|
||||||
|
startId: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the current value when a referenced row is not here", async () => {
|
it("keeps the current value when a referenced row is not here", async () => {
|
||||||
const back = await deserializeReferences(
|
const back = await deserializeReferences(
|
||||||
entity("things"),
|
entity("things"),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { KeyCombo } from "@/types/keybindings";
|
|||||||
export interface DefaultKeybindingInfo {
|
export interface DefaultKeybindingInfo {
|
||||||
id: string;
|
id: string;
|
||||||
combo: KeyCombo;
|
combo: KeyCombo;
|
||||||
description: string;
|
descriptionKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [
|
export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [
|
||||||
@@ -17,7 +17,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [
|
|||||||
shift: false,
|
shift: false,
|
||||||
meta: false,
|
meta: false,
|
||||||
},
|
},
|
||||||
description: "Copy selection (Ctrl+C, only when text is selected)",
|
descriptionKey: "newUi.sidebar.keybindings.builtIn.copyCtrlC",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "default-copy-ctrlshiftc",
|
id: "default-copy-ctrlshiftc",
|
||||||
@@ -29,7 +29,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [
|
|||||||
shift: true,
|
shift: true,
|
||||||
meta: false,
|
meta: false,
|
||||||
},
|
},
|
||||||
description: "Copy selection (Ctrl+Shift+C)",
|
descriptionKey: "newUi.sidebar.keybindings.builtIn.copyCtrlShiftC",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "default-copy-cmdc",
|
id: "default-copy-cmdc",
|
||||||
@@ -41,7 +41,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [
|
|||||||
shift: false,
|
shift: false,
|
||||||
meta: true,
|
meta: true,
|
||||||
},
|
},
|
||||||
description: "Copy selection (Cmd+C)",
|
descriptionKey: "newUi.sidebar.keybindings.builtIn.copyCmdC",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "default-paste-ctrlshiftv",
|
id: "default-paste-ctrlshiftv",
|
||||||
@@ -53,7 +53,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [
|
|||||||
shift: true,
|
shift: true,
|
||||||
meta: false,
|
meta: false,
|
||||||
},
|
},
|
||||||
description: "Paste from clipboard (Ctrl+Shift+V)",
|
descriptionKey: "newUi.sidebar.keybindings.builtIn.pasteCtrlShiftV",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "default-ctrlaltw",
|
id: "default-ctrlaltw",
|
||||||
@@ -65,7 +65,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [
|
|||||||
shift: false,
|
shift: false,
|
||||||
meta: false,
|
meta: false,
|
||||||
},
|
},
|
||||||
description: "Send Ctrl+W to shell (blocked from closing browser tab)",
|
descriptionKey: "newUi.sidebar.keybindings.builtIn.ctrlW",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "default-ctrlaltt",
|
id: "default-ctrlaltt",
|
||||||
@@ -77,7 +77,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [
|
|||||||
shift: false,
|
shift: false,
|
||||||
meta: false,
|
meta: false,
|
||||||
},
|
},
|
||||||
description: "Send Ctrl+T to shell (blocked from opening browser tab)",
|
descriptionKey: "newUi.sidebar.keybindings.builtIn.ctrlT",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "default-ctrlaltn",
|
id: "default-ctrlaltn",
|
||||||
@@ -89,7 +89,7 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [
|
|||||||
shift: false,
|
shift: false,
|
||||||
meta: false,
|
meta: false,
|
||||||
},
|
},
|
||||||
description: "Send Ctrl+N to shell (blocked from opening browser window)",
|
descriptionKey: "newUi.sidebar.keybindings.builtIn.ctrlN",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "default-ctrlaltq",
|
id: "default-ctrlaltq",
|
||||||
@@ -101,6 +101,6 @@ export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [
|
|||||||
shift: false,
|
shift: false,
|
||||||
meta: false,
|
meta: false,
|
||||||
},
|
},
|
||||||
description: "Send Ctrl+Q to shell (blocked from quitting browser)",
|
descriptionKey: "newUi.sidebar.keybindings.builtIn.ctrlQ",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -815,22 +815,26 @@ export function resolveTerminalFontFamily(fontFamily?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CURSOR_STYLES = [
|
export const CURSOR_STYLES = [
|
||||||
{ value: "block", label: "Block" },
|
{ value: "block", label: "Block", labelKey: "hosts.cursorStyleBlock" },
|
||||||
{ value: "underline", label: "Underline" },
|
{
|
||||||
{ value: "bar", label: "Bar" },
|
value: "underline",
|
||||||
|
label: "Underline",
|
||||||
|
labelKey: "hosts.cursorStyleUnderline",
|
||||||
|
},
|
||||||
|
{ value: "bar", label: "Bar", labelKey: "hosts.cursorStyleBar" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const BELL_STYLES = [
|
export const BELL_STYLES = [
|
||||||
{ value: "none", label: "None" },
|
{ value: "none", label: "None", labelKey: "hosts.bellStyleNone" },
|
||||||
{ value: "sound", label: "Sound" },
|
{ value: "sound", label: "Sound", labelKey: "hosts.bellStyleSound" },
|
||||||
{ value: "visual", label: "Visual" },
|
{ value: "visual", label: "Visual", labelKey: "hosts.bellStyleVisual" },
|
||||||
{ value: "both", label: "Both" },
|
{ value: "both", label: "Both", labelKey: "hosts.bellStyleBoth" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const FAST_SCROLL_MODIFIERS = [
|
export const FAST_SCROLL_MODIFIERS = [
|
||||||
{ value: "alt", label: "Alt" },
|
{ value: "alt", label: "Alt", labelKey: "hosts.fastScrollModifierAlt" },
|
||||||
{ value: "ctrl", label: "Ctrl" },
|
{ value: "ctrl", label: "Ctrl", labelKey: "hosts.fastScrollModifierCtrl" },
|
||||||
{ value: "shift", label: "Shift" },
|
{ value: "shift", label: "Shift", labelKey: "hosts.fastScrollModifierShift" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const DEFAULT_TERMINAL_CONFIG = {
|
export const DEFAULT_TERMINAL_CONFIG = {
|
||||||
|
|||||||
+24
-2
@@ -1008,7 +1008,19 @@
|
|||||||
"localOnly": "Keep on this device only",
|
"localOnly": "Keep on this device only",
|
||||||
"localOnlyDesc": "This host is not sent to the server and does not appear on your other devices.",
|
"localOnlyDesc": "This host is not sent to the server and does not appear on your other devices.",
|
||||||
"folderLocalOnly": "Keep on this device only",
|
"folderLocalOnly": "Keep on this device only",
|
||||||
"folderLocalOnlyDesc": "This folder and every host in it stay on this device and are not sent to the server."
|
"folderLocalOnlyDesc": "This folder and every host in it stay on this device and are not sent to the server.",
|
||||||
|
"cursorStyleBlock": "Block",
|
||||||
|
"cursorStyleUnderline": "Underline",
|
||||||
|
"cursorStyleBar": "Bar",
|
||||||
|
"bellStyleNone": "None",
|
||||||
|
"bellStyleSound": "Sound",
|
||||||
|
"bellStyleVisual": "Visual",
|
||||||
|
"bellStyleBoth": "Both",
|
||||||
|
"fastScrollModifierAlt": "Alt",
|
||||||
|
"fastScrollModifierCtrl": "Ctrl",
|
||||||
|
"fastScrollModifierShift": "Shift",
|
||||||
|
"backspaceModeNormal": "Normal (DEL)",
|
||||||
|
"backspaceModeControlH": "Control-H (BS)"
|
||||||
},
|
},
|
||||||
"commandPalette": {
|
"commandPalette": {
|
||||||
"searchPlaceholder": "Search hosts, commands, or settings...",
|
"searchPlaceholder": "Search hosts, commands, or settings...",
|
||||||
@@ -2008,7 +2020,17 @@
|
|||||||
"snippetLabel": "Snippet",
|
"snippetLabel": "Snippet",
|
||||||
"selectSnippetPlaceholder": "Select a snippet",
|
"selectSnippetPlaceholder": "Select a snippet",
|
||||||
"orphanedSnippetWarning": "snippet not found",
|
"orphanedSnippetWarning": "snippet not found",
|
||||||
"clipboardPermissionNote": "May prompt for clipboard permission on first use in some browsers."
|
"clipboardPermissionNote": "May prompt for clipboard permission on first use in some browsers.",
|
||||||
|
"builtIn": {
|
||||||
|
"copyCtrlC": "Copy selection (Ctrl+C, only when text is selected)",
|
||||||
|
"copyCtrlShiftC": "Copy selection (Ctrl+Shift+C)",
|
||||||
|
"copyCmdC": "Copy selection (Cmd+C)",
|
||||||
|
"pasteCtrlShiftV": "Paste from clipboard (Ctrl+Shift+V)",
|
||||||
|
"ctrlW": "Send Ctrl+W to shell (blocked from closing browser tab)",
|
||||||
|
"ctrlT": "Send Ctrl+T to shell (blocked from opening browser tab)",
|
||||||
|
"ctrlN": "Send Ctrl+N to shell (blocked from opening browser window)",
|
||||||
|
"ctrlQ": "Send Ctrl+Q to shell (blocked from quitting browser)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"connectionDefaults": {
|
"connectionDefaults": {
|
||||||
"title": "Connection Defaults",
|
"title": "Connection Defaults",
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ export function ConnectionDefaultsSettings() {
|
|||||||
<option value="">{inheritLabel}</option>
|
<option value="">{inheritLabel}</option>
|
||||||
{CURSOR_STYLES.map((style) => (
|
{CURSOR_STYLES.map((style) => (
|
||||||
<option key={style.value} value={style.value}>
|
<option key={style.value} value={style.value}>
|
||||||
{style.label}
|
{t(style.labelKey)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export function HostTerminalSettings({
|
|||||||
>
|
>
|
||||||
{CURSOR_STYLES.map((s) => (
|
{CURSOR_STYLES.map((s) => (
|
||||||
<option key={s.value} value={s.value}>
|
<option key={s.value} value={s.value}>
|
||||||
{s.label}
|
{t(s.labelKey)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select2>
|
</Select2>
|
||||||
@@ -322,7 +322,7 @@ export function HostTerminalSettings({
|
|||||||
>
|
>
|
||||||
{BELL_STYLES.map((b) => (
|
{BELL_STYLES.map((b) => (
|
||||||
<option key={b.value} value={b.value}>
|
<option key={b.value} value={b.value}>
|
||||||
{b.label}
|
{t(b.labelKey)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select2>
|
</Select2>
|
||||||
@@ -341,8 +341,12 @@ export function HostTerminalSettings({
|
|||||||
}
|
}
|
||||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="normal">Normal (DEL)</option>
|
<option value="normal">
|
||||||
<option value="control-h">Control-H (BS)</option>
|
{t("hosts.backspaceModeNormal")}
|
||||||
|
</option>
|
||||||
|
<option value="control-h">
|
||||||
|
{t("hosts.backspaceModeControlH")}
|
||||||
|
</option>
|
||||||
</Select2>
|
</Select2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -850,7 +854,7 @@ export function HostTerminalSettings({
|
|||||||
>
|
>
|
||||||
{FAST_SCROLL_MODIFIERS.map((m) => (
|
{FAST_SCROLL_MODIFIERS.map((m) => (
|
||||||
<option key={m.value} value={m.value}>
|
<option key={m.value} value={m.value}>
|
||||||
{m.label}
|
{t(m.labelKey)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select2>
|
</Select2>
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ export function KeybindingsDialog({
|
|||||||
: formatCombo(def.combo)}
|
: formatCombo(def.combo)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
{def.description}
|
{t(def.descriptionKey)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
|||||||
File renamed without changes.
Reference in new issue
Block a user