v1.9.0 #437
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user