v1.9.0 #437

Merged
LukeGus merged 33 commits from dev-1.9.0 into main 2025-11-17 15:46:05 +00:00
7 changed files with 119 additions and 67 deletions
Showing only changes of commit f18fdb0a31 - Show all commits

View File

@@ -1255,6 +1255,10 @@ async function deploySSHKeyToHost(
return rejectAdd(err);
}
stream.on("data", () => {
// Consume output
});
stream.on("close", (code) => {
clearTimeout(addTimeout);
if (code === 0) {
@@ -1515,7 +1519,8 @@ router.post(
});
}
if (!credData.publicKey) {
const publicKey = credData.public_key || credData.publicKey;
if (!publicKey) {
return res.status(400).json({
success: false,
error: "Public key is required for deployment",
@@ -1596,7 +1601,7 @@ router.post(
const deployResult = await deploySSHKeyToHost(
hostConfig,
credData.publicKey as string,
publicKey as string,
credData,
);

View File

@@ -300,13 +300,19 @@ router.post(
const { Client } = await import("ssh2");
const { sshData, sshCredentials } = await import("../db/schema.js");
// Get host configuration
const hostResult = await db
.select()
.from(sshData)
.where(
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
);
// Get host configuration using SimpleDBOps to decrypt credentials
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
const hostResult = await SimpleDBOps.select(
db
.select()
.from(sshData)
.where(
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
),
"ssh_data",
userId,
);
if (hostResult.length === 0) {
return res.status(404).json({ error: "Host not found" });
@@ -318,23 +324,31 @@ router.post(
let password = host.password;
let privateKey = host.key;
let passphrase = host.key_password;
let authType = host.authType;
if (host.credentialId) {
const credResult = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, userId),
const credResult = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
);
"ssh_credentials",
userId,
);
if (credResult.length > 0) {
const cred = credResult[0];
password = cred.password || undefined;
privateKey = cred.private_key || cred.key || undefined;
passphrase = cred.key_password || undefined;
authType = (cred.auth_type || cred.authType || authType) as string;
password = (cred.password || undefined) as string | undefined;
privateKey = (cred.private_key || cred.key || undefined) as
| string
| undefined;
passphrase = (cred.key_password || undefined) as string | undefined;
}
}
@@ -457,12 +471,24 @@ router.post(
},
};
if (password) {
// Set auth based on authType (like terminal.ts does)
if (authType === "password" && password) {
config.password = password;
}
if (privateKey) {
const cleanKey = privateKey
} else if (authType === "key" && privateKey) {
const cleanKey = (privateKey as string)
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (passphrase) {
config.passphrase = passphrase;
}
} else if (password) {
// Fallback: if authType not set but password exists
config.password = password;
} else if (privateKey) {
// Fallback: if authType not set but key exists
const cleanKey = (privateKey as string)
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");

View File

@@ -295,7 +295,12 @@ class SSHConnectionPool {
),
);
} else if (host.password) {
const responses = prompts.map(() => host.password || "");
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt)) {
return host.password || "";
}
return "";
});
finish(responses);
} else {
finish(prompts.map(() => ""));
@@ -595,7 +600,7 @@ interface SSHHostWithCredentials {
defaultPath: string;
tunnelConnections: unknown[];
jumpHosts?: Array<{ hostId: number }>;
statsConfig?: string;
statsConfig?: string | StatsConfig;
createdAt: string;
updatedAt: string;
userId: string;
@@ -640,19 +645,43 @@ class PollingManager {
}
>();
parseStatsConfig(statsConfigStr?: string): StatsConfig {
parseStatsConfig(statsConfigStr?: string | StatsConfig): StatsConfig {
if (!statsConfigStr) {
return DEFAULT_STATS_CONFIG;
}
try {
const parsed = JSON.parse(statsConfigStr);
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch (error) {
statsLogger.warn(
`Failed to parse statsConfig: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return DEFAULT_STATS_CONFIG;
let parsed: StatsConfig;
// If it's already an object, use it directly
if (typeof statsConfigStr === "object") {
parsed = statsConfigStr;
} else {
// Otherwise, parse as JSON string (may be double-encoded)
try {
let temp: any = JSON.parse(statsConfigStr);
// Check if we got a string back (double-encoded JSON)
if (typeof temp === "string") {
temp = JSON.parse(temp);
}
parsed = temp;
} catch (error) {
statsLogger.warn(
`Failed to parse statsConfig: ${error instanceof Error ? error.message : "Unknown error"}`,
{
operation: "parse_stats_config_error",
statsConfigStr,
},
);
return DEFAULT_STATS_CONFIG;
}
}
// Merge with defaults, but prioritize the provided values
const result = { ...DEFAULT_STATS_CONFIG, ...parsed };
return result;
}
async startPollingForHost(host: SSHHostWithCredentials): Promise<void> {
@@ -664,9 +693,11 @@ class PollingManager {
if (existingConfig) {
if (existingConfig.statusTimer) {
clearInterval(existingConfig.statusTimer);
existingConfig.statusTimer = undefined;
}
if (existingConfig.metricsTimer) {
clearInterval(existingConfig.metricsTimer);
existingConfig.metricsTimer = undefined;
}
}
@@ -675,14 +706,6 @@ class PollingManager {
this.pollingConfigs.delete(host.id);
this.statusStore.delete(host.id);
this.metricsStore.delete(host.id);
statsLogger.info(
`Stopped all polling for host ${host.id} (${host.name || host.ip}) - both checks disabled`,
{
operation: "polling_stopped",
hostId: host.id,
hostName: host.name || host.ip,
},
);
return;
}
@@ -691,23 +714,20 @@ class PollingManager {
statsConfig,
};
// Set the config FIRST to prevent race conditions with old timers
this.pollingConfigs.set(host.id, config);
if (statsConfig.statusCheckEnabled) {
const intervalMs = statsConfig.statusCheckInterval * 1000;
this.pollHostStatus(host);
config.statusTimer = setInterval(() => {
this.pollHostStatus(host);
// Always get the latest config to check if polling is still enabled
const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) {
this.pollHostStatus(latestConfig.host);
}
}, intervalMs);
} else {
this.statusStore.delete(host.id);
statsLogger.debug(`Status polling disabled for host ${host.id}`, {
operation: "status_polling_disabled",
hostId: host.id,
});
}
if (statsConfig.metricsEnabled) {
@@ -717,17 +737,17 @@ class PollingManager {
this.pollHostMetrics(host);
config.metricsTimer = setInterval(() => {
this.pollHostMetrics(host);
// Always get the latest config to check if polling is still enabled
const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
this.pollHostMetrics(latestConfig.host);
}
}, intervalMs);
} else {
this.metricsStore.delete(host.id);
statsLogger.debug(`Metrics polling disabled for host ${host.id}`, {
operation: "metrics_polling_disabled",
hostId: host.id,
});
}
// Update with the new timers
// Set the config with the new timers
this.pollingConfigs.set(host.id, config);
}
@@ -787,9 +807,11 @@ class PollingManager {
if (config) {
if (config.statusTimer) {
clearInterval(config.statusTimer);
config.statusTimer = undefined;
}
if (config.metricsTimer) {
clearInterval(config.metricsTimer);
config.metricsTimer = undefined;
}
this.pollingConfigs.delete(hostId);
if (clearData) {
@@ -994,6 +1016,7 @@ async function resolveHostCredentials(
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections as string)
: [],
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
statsConfig: host.statsConfig || undefined,
createdAt: host.createdAt,
updatedAt: host.updatedAt,

View File

@@ -827,13 +827,10 @@ export function CredentialsManager({
)}
<Sheet open={showDeployDialog} onOpenChange={setShowDeployDialog}>
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto">
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto bg-dark-bg">
<div className="px-4 py-4">
<div className="space-y-3 pb-4">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
<Upload className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<div className="text-lg font-semibold">
{t("credentials.deploySSHKey")}
@@ -1009,7 +1006,7 @@ export function CredentialsManager({
<Button
onClick={performDeploy}
disabled={!selectedHostId || deployLoading}
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
className="flex-1"
>
{deployLoading ? (
<div className="flex items-center">

View File

@@ -193,15 +193,16 @@ export function SSHToolsSidebar({
getCommandHistory(activeTerminalHostId)
.then((history) => {
setCommandHistory((prevHistory) => {
const newHistory = Array.isArray(history) ? history : [];
// Only update if history actually changed
if (JSON.stringify(prevHistory) !== JSON.stringify(history)) {
if (JSON.stringify(prevHistory) !== JSON.stringify(newHistory)) {
// Use requestAnimationFrame to restore scroll after React finishes rendering
requestAnimationFrame(() => {
if (commandHistoryScrollRef.current) {
commandHistoryScrollRef.current.scrollTop = scrollTop;
}
});
return history;
return newHistory;
}
return prevHistory;
});

View File

@@ -137,7 +137,7 @@ export function SSHAuthDialog({
return (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-dark-bg animate-in fade-in duration-200"
className="absolute inset-0 z-9999 flex items-center justify-center bg-dark-bg animate-in fade-in duration-200"
style={{ backgroundColor }}
>
<Card className="w-full max-w-2xl mx-4 border-2 animate-in fade-in zoom-in-95 duration-200">

View File

@@ -124,7 +124,7 @@ const AppContent: FC = () => {
if (authLoading) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-dark-bg-darkest">
<div className="h-screen w-screen flex items-center justify-center bg-dark-bg">
<div className="text-center">
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-muted-foreground">{t("common.loading")}</p>