fix: Server stats not respecting interval and fixed SSH toool type issues

This commit is contained in:
LukeGus
2025-11-13 23:00:23 -06:00
parent e564748d01
commit f18fdb0a31
7 changed files with 119 additions and 67 deletions

View File

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

View File

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

View File

@@ -295,7 +295,12 @@ class SSHConnectionPool {
), ),
); );
} else if (host.password) { } 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); finish(responses);
} else { } else {
finish(prompts.map(() => "")); finish(prompts.map(() => ""));
@@ -595,7 +600,7 @@ interface SSHHostWithCredentials {
defaultPath: string; defaultPath: string;
tunnelConnections: unknown[]; tunnelConnections: unknown[];
jumpHosts?: Array<{ hostId: number }>; jumpHosts?: Array<{ hostId: number }>;
statsConfig?: string; statsConfig?: string | StatsConfig;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
userId: string; userId: string;
@@ -640,19 +645,43 @@ class PollingManager {
} }
>(); >();
parseStatsConfig(statsConfigStr?: string): StatsConfig { parseStatsConfig(statsConfigStr?: string | StatsConfig): StatsConfig {
if (!statsConfigStr) { if (!statsConfigStr) {
return DEFAULT_STATS_CONFIG; return DEFAULT_STATS_CONFIG;
} }
try {
const parsed = JSON.parse(statsConfigStr); let parsed: StatsConfig;
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch (error) { // If it's already an object, use it directly
statsLogger.warn( if (typeof statsConfigStr === "object") {
`Failed to parse statsConfig: ${error instanceof Error ? error.message : "Unknown error"}`, parsed = statsConfigStr;
); } else {
return DEFAULT_STATS_CONFIG; // 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> { async startPollingForHost(host: SSHHostWithCredentials): Promise<void> {
@@ -664,9 +693,11 @@ class PollingManager {
if (existingConfig) { if (existingConfig) {
if (existingConfig.statusTimer) { if (existingConfig.statusTimer) {
clearInterval(existingConfig.statusTimer); clearInterval(existingConfig.statusTimer);
existingConfig.statusTimer = undefined;
} }
if (existingConfig.metricsTimer) { if (existingConfig.metricsTimer) {
clearInterval(existingConfig.metricsTimer); clearInterval(existingConfig.metricsTimer);
existingConfig.metricsTimer = undefined;
} }
} }
@@ -675,14 +706,6 @@ class PollingManager {
this.pollingConfigs.delete(host.id); this.pollingConfigs.delete(host.id);
this.statusStore.delete(host.id); this.statusStore.delete(host.id);
this.metricsStore.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; return;
} }
@@ -691,23 +714,20 @@ class PollingManager {
statsConfig, statsConfig,
}; };
// Set the config FIRST to prevent race conditions with old timers
this.pollingConfigs.set(host.id, config);
if (statsConfig.statusCheckEnabled) { if (statsConfig.statusCheckEnabled) {
const intervalMs = statsConfig.statusCheckInterval * 1000; const intervalMs = statsConfig.statusCheckInterval * 1000;
this.pollHostStatus(host); this.pollHostStatus(host);
config.statusTimer = setInterval(() => { 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); }, intervalMs);
} else { } else {
this.statusStore.delete(host.id); this.statusStore.delete(host.id);
statsLogger.debug(`Status polling disabled for host ${host.id}`, {
operation: "status_polling_disabled",
hostId: host.id,
});
} }
if (statsConfig.metricsEnabled) { if (statsConfig.metricsEnabled) {
@@ -717,17 +737,17 @@ class PollingManager {
this.pollHostMetrics(host); this.pollHostMetrics(host);
config.metricsTimer = setInterval(() => { 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); }, intervalMs);
} else { } else {
this.metricsStore.delete(host.id); 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); this.pollingConfigs.set(host.id, config);
} }
@@ -787,9 +807,11 @@ class PollingManager {
if (config) { if (config) {
if (config.statusTimer) { if (config.statusTimer) {
clearInterval(config.statusTimer); clearInterval(config.statusTimer);
config.statusTimer = undefined;
} }
if (config.metricsTimer) { if (config.metricsTimer) {
clearInterval(config.metricsTimer); clearInterval(config.metricsTimer);
config.metricsTimer = undefined;
} }
this.pollingConfigs.delete(hostId); this.pollingConfigs.delete(hostId);
if (clearData) { if (clearData) {
@@ -994,6 +1016,7 @@ async function resolveHostCredentials(
tunnelConnections: host.tunnelConnections tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections as string) ? JSON.parse(host.tunnelConnections as string)
: [], : [],
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
statsConfig: host.statsConfig || undefined, statsConfig: host.statsConfig || undefined,
createdAt: host.createdAt, createdAt: host.createdAt,
updatedAt: host.updatedAt, updatedAt: host.updatedAt,

View File

@@ -827,13 +827,10 @@ export function CredentialsManager({
)} )}
<Sheet open={showDeployDialog} onOpenChange={setShowDeployDialog}> <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="px-4 py-4">
<div className="space-y-3 pb-4"> <div className="space-y-3 pb-4">
<div className="flex items-center space-x-3"> <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="flex-1">
<div className="text-lg font-semibold"> <div className="text-lg font-semibold">
{t("credentials.deploySSHKey")} {t("credentials.deploySSHKey")}
@@ -1009,7 +1006,7 @@ export function CredentialsManager({
<Button <Button
onClick={performDeploy} onClick={performDeploy}
disabled={!selectedHostId || deployLoading} disabled={!selectedHostId || deployLoading}
className="flex-1 bg-green-600 hover:bg-green-700 text-white" className="flex-1"
> >
{deployLoading ? ( {deployLoading ? (
<div className="flex items-center"> <div className="flex items-center">

View File

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

View File

@@ -137,7 +137,7 @@ export function SSHAuthDialog({
return ( return (
<div <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 }} style={{ backgroundColor }}
> >
<Card className="w-full max-w-2xl mx-4 border-2 animate-in fade-in zoom-in-95 duration-200"> <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) { if (authLoading) {
return ( 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="text-center">
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" /> <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> <p className="text-muted-foreground">{t("common.loading")}</p>