v1.8.0 #429
@@ -15,6 +15,10 @@ const authManager = AuthManager.getInstance();
|
|||||||
// Track server start time
|
// Track server start time
|
||||||
const serverStartTime = Date.now();
|
const serverStartTime = Date.now();
|
||||||
|
|
||||||
|
// In-memory rate limiter for activity logging
|
||||||
|
const activityRateLimiter = new Map<string, number>();
|
||||||
|
const RATE_LIMIT_MS = 1000; // 1 second window
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: (origin, callback) => {
|
origin: (origin, callback) => {
|
||||||
@@ -134,6 +138,32 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-memory rate limiting to prevent duplicate requests
|
||||||
|
const rateLimitKey = `${userId}:${hostId}:${type}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const lastLogged = activityRateLimiter.get(rateLimitKey);
|
||||||
|
|
||||||
|
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
|
||||||
|
// Too soon after last request, reject as duplicate
|
||||||
|
return res.json({
|
||||||
|
message: "Activity already logged recently (rate limited)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update rate limiter
|
||||||
|
activityRateLimiter.set(rateLimitKey, now);
|
||||||
|
|
||||||
|
// Clean up old entries from rate limiter (keep it from growing indefinitely)
|
||||||
|
if (activityRateLimiter.size > 10000) {
|
||||||
|
const entriesToDelete: string[] = [];
|
||||||
|
for (const [key, timestamp] of activityRateLimiter.entries()) {
|
||||||
|
if (now - timestamp > RATE_LIMIT_MS * 2) {
|
||||||
|
entriesToDelete.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the host belongs to the user
|
// Verify the host belongs to the user
|
||||||
const hosts = await SimpleDBOps.select(
|
const hosts = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
@@ -148,36 +178,6 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
return res.status(404).json({ error: "Host not found" });
|
return res.status(404).json({ error: "Host not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this activity already exists in recent history (within last 5 minutes)
|
|
||||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
|
||||||
const recentSimilar = await SimpleDBOps.select(
|
|
||||||
getDb()
|
|
||||||
.select()
|
|
||||||
.from(recentActivity)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(recentActivity.userId, userId),
|
|
||||||
eq(recentActivity.hostId, hostId),
|
|
||||||
eq(recentActivity.type, type),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(recentActivity.timestamp))
|
|
||||||
.limit(1),
|
|
||||||
"recent_activity",
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
recentSimilar.length > 0 &&
|
|
||||||
recentSimilar[0].timestamp >= fiveMinutesAgo
|
|
||||||
) {
|
|
||||||
// Activity already logged recently, don't duplicate
|
|
||||||
return res.json({
|
|
||||||
message: "Activity already logged recently",
|
|
||||||
id: recentSimilar[0].id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new activity
|
// Insert new activity
|
||||||
const result = (await SimpleDBOps.insert(
|
const result = (await SimpleDBOps.insert(
|
||||||
recentActivity,
|
recentActivity,
|
||||||
|
|||||||
@@ -415,63 +415,75 @@ router.get("/oidc-config", async (req, res) => {
|
|||||||
|
|
||||||
let config = JSON.parse((row as Record<string, unknown>).value as string);
|
let config = JSON.parse((row as Record<string, unknown>).value as string);
|
||||||
|
|
||||||
if (config.client_secret) {
|
// Check if user is authenticated admin
|
||||||
if (config.client_secret.startsWith("encrypted:")) {
|
let isAuthenticatedAdmin = false;
|
||||||
const authHeader = req.headers["authorization"];
|
const authHeader = req.headers["authorization"];
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
const token = authHeader.split(" ")[1];
|
const token = authHeader.split(" ")[1];
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const payload = await authManager.verifyJWTToken(token);
|
const payload = await authManager.verifyJWTToken(token);
|
||||||
|
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const userId = payload.userId;
|
const userId = payload.userId;
|
||||||
const user = await db
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, userId));
|
|
||||||
|
|
||||||
if (user && user.length > 0 && user[0].is_admin) {
|
if (user && user.length > 0 && user[0].is_admin) {
|
||||||
try {
|
isAuthenticatedAdmin = true;
|
||||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
|
||||||
if (adminDataKey) {
|
// Only decrypt for authenticated admins
|
||||||
config = DataCrypto.decryptRecord(
|
if (config.client_secret?.startsWith("encrypted:")) {
|
||||||
"settings",
|
try {
|
||||||
config,
|
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
userId,
|
if (adminDataKey) {
|
||||||
adminDataKey,
|
config = DataCrypto.decryptRecord(
|
||||||
);
|
"settings",
|
||||||
} else {
|
config,
|
||||||
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
authLogger.warn("Failed to decrypt OIDC config for admin", {
|
|
||||||
operation: "oidc_config_decrypt_failed",
|
|
||||||
userId,
|
userId,
|
||||||
});
|
adminDataKey,
|
||||||
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
);
|
||||||
|
} else {
|
||||||
|
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
||||||
}
|
}
|
||||||
} else {
|
} catch {
|
||||||
config.client_secret = "[ENCRYPTED - ADMIN ONLY]";
|
authLogger.warn("Failed to decrypt OIDC config for admin", {
|
||||||
|
operation: "oidc_config_decrypt_failed",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
||||||
|
}
|
||||||
|
} else if (config.client_secret?.startsWith("encoded:")) {
|
||||||
|
// Decode for authenticated admins only
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(
|
||||||
|
config.client_secret.substring(8),
|
||||||
|
"base64",
|
||||||
|
).toString("utf8");
|
||||||
|
config.client_secret = decoded;
|
||||||
|
} catch {
|
||||||
|
config.client_secret = "[ENCODING ERROR]";
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
|
||||||
}
|
|
||||||
} else if (config.client_secret.startsWith("encoded:")) {
|
|
||||||
try {
|
|
||||||
const decoded = Buffer.from(
|
|
||||||
config.client_secret.substring(8),
|
|
||||||
"base64",
|
|
||||||
).toString("utf8");
|
|
||||||
config.client_secret = decoded;
|
|
||||||
} catch {
|
|
||||||
config.client_secret = "[ENCODING ERROR]";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For non-admin users, hide sensitive fields
|
||||||
|
if (!isAuthenticatedAdmin) {
|
||||||
|
// Remove all sensitive fields for public access
|
||||||
|
delete config.client_secret;
|
||||||
|
delete config.id;
|
||||||
|
|
||||||
|
// Only return public fields needed for login page
|
||||||
|
const publicConfig = {
|
||||||
|
client_id: config.client_id,
|
||||||
|
issuer_url: config.issuer_url,
|
||||||
|
authorization_url: config.authorization_url,
|
||||||
|
scopes: config.scopes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.json(publicConfig);
|
||||||
|
}
|
||||||
|
|
||||||
res.json(config);
|
res.json(config);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to get OIDC config", err);
|
authLogger.error("Failed to get OIDC config", err);
|
||||||
@@ -940,7 +952,7 @@ router.post("/login", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
expiresIn: "24h",
|
expiresIn: "7d",
|
||||||
});
|
});
|
||||||
|
|
||||||
authLogger.success(`User logged in successfully: ${username}`, {
|
authLogger.success(`User logged in successfully: ${username}`, {
|
||||||
@@ -968,7 +980,7 @@ router.post("/login", async (req, res) => {
|
|||||||
.cookie(
|
.cookie(
|
||||||
"jwt",
|
"jwt",
|
||||||
token,
|
token,
|
||||||
authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000),
|
authManager.getSecureCookieOptions(req, 7 * 24 * 60 * 60 * 1000),
|
||||||
)
|
)
|
||||||
.json(response);
|
.json(response);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1386,9 +1398,27 @@ router.post("/complete-reset", async (req, res) => {
|
|||||||
.where(eq(users.username, username));
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Delete all encrypted data since we're creating a new DEK
|
||||||
|
// The old DEK is lost, so old encrypted data becomes unreadable
|
||||||
|
await db.delete(sshData).where(eq(sshData.userId, userId));
|
||||||
|
await db
|
||||||
|
.delete(fileManagerRecent)
|
||||||
|
.where(eq(fileManagerRecent.userId, userId));
|
||||||
|
await db
|
||||||
|
.delete(fileManagerPinned)
|
||||||
|
.where(eq(fileManagerPinned.userId, userId));
|
||||||
|
await db
|
||||||
|
.delete(fileManagerShortcuts)
|
||||||
|
.where(eq(fileManagerShortcuts.userId, userId));
|
||||||
|
await db
|
||||||
|
.delete(dismissedAlerts)
|
||||||
|
.where(eq(dismissedAlerts.userId, userId));
|
||||||
|
|
||||||
|
// Now setup new encryption with new DEK
|
||||||
await authManager.registerUser(userId, newPassword);
|
await authManager.registerUser(userId, newPassword);
|
||||||
authManager.logoutUser(userId);
|
authManager.logoutUser(userId);
|
||||||
|
|
||||||
|
// Clear TOTP settings
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
@@ -1399,16 +1429,16 @@ router.post("/complete-reset", async (req, res) => {
|
|||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
authLogger.warn(
|
authLogger.warn(
|
||||||
`Password reset completed for user: ${username}. Existing encrypted data is now inaccessible and will need to be re-entered.`,
|
`Password reset completed for user: ${username}. All encrypted data has been deleted due to lost encryption key.`,
|
||||||
{
|
{
|
||||||
operation: "password_reset_data_inaccessible",
|
operation: "password_reset_data_deleted",
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (encryptionError) {
|
} catch (encryptionError) {
|
||||||
authLogger.error(
|
authLogger.error(
|
||||||
"Failed to re-encrypt user data after password reset",
|
"Failed to setup user data encryption after password reset",
|
||||||
encryptionError,
|
encryptionError,
|
||||||
{
|
{
|
||||||
operation: "password_reset_encryption_failed",
|
operation: "password_reset_encryption_failed",
|
||||||
@@ -1417,8 +1447,7 @@ router.post("/complete-reset", async (req, res) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error:
|
error: "Password reset failed. Please contact administrator.",
|
||||||
"Password reset completed but user data encryption failed. Please contact administrator.",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -266,31 +266,44 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
|
"curve25519-sha256",
|
||||||
|
"curve25519-sha256@libssh.org",
|
||||||
|
"ecdh-sha2-nistp521",
|
||||||
|
"ecdh-sha2-nistp384",
|
||||||
|
"ecdh-sha2-nistp256",
|
||||||
|
"diffie-hellman-group-exchange-sha256",
|
||||||
"diffie-hellman-group14-sha256",
|
"diffie-hellman-group14-sha256",
|
||||||
"diffie-hellman-group14-sha1",
|
"diffie-hellman-group14-sha1",
|
||||||
"diffie-hellman-group1-sha1",
|
|
||||||
"diffie-hellman-group-exchange-sha256",
|
|
||||||
"diffie-hellman-group-exchange-sha1",
|
"diffie-hellman-group-exchange-sha1",
|
||||||
"ecdh-sha2-nistp256",
|
"diffie-hellman-group1-sha1",
|
||||||
"ecdh-sha2-nistp384",
|
],
|
||||||
"ecdh-sha2-nistp521",
|
serverHostKey: [
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"rsa-sha2-512",
|
||||||
|
"rsa-sha2-256",
|
||||||
|
"ssh-rsa",
|
||||||
|
"ssh-dss",
|
||||||
],
|
],
|
||||||
cipher: [
|
cipher: [
|
||||||
"aes128-ctr",
|
"chacha20-poly1305@openssh.com",
|
||||||
"aes192-ctr",
|
|
||||||
"aes256-ctr",
|
|
||||||
"aes128-gcm@openssh.com",
|
|
||||||
"aes256-gcm@openssh.com",
|
"aes256-gcm@openssh.com",
|
||||||
"aes128-cbc",
|
"aes128-gcm@openssh.com",
|
||||||
"aes192-cbc",
|
"aes256-ctr",
|
||||||
|
"aes192-ctr",
|
||||||
|
"aes128-ctr",
|
||||||
"aes256-cbc",
|
"aes256-cbc",
|
||||||
|
"aes192-cbc",
|
||||||
|
"aes128-cbc",
|
||||||
"3des-cbc",
|
"3des-cbc",
|
||||||
],
|
],
|
||||||
hmac: [
|
hmac: [
|
||||||
"hmac-sha2-256-etm@openssh.com",
|
|
||||||
"hmac-sha2-512-etm@openssh.com",
|
"hmac-sha2-512-etm@openssh.com",
|
||||||
"hmac-sha2-256",
|
"hmac-sha2-256-etm@openssh.com",
|
||||||
"hmac-sha2-512",
|
"hmac-sha2-512",
|
||||||
|
"hmac-sha2-256",
|
||||||
"hmac-sha1",
|
"hmac-sha1",
|
||||||
"hmac-md5",
|
"hmac-md5",
|
||||||
],
|
],
|
||||||
@@ -335,6 +348,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "Password required for password authentication" });
|
.json({ error: "Password required for password authentication" });
|
||||||
}
|
}
|
||||||
|
config.password = resolvedCredentials.password;
|
||||||
} else if (resolvedCredentials.authType === "none") {
|
} else if (resolvedCredentials.authType === "none") {
|
||||||
// Don't set password in config - rely on keyboard-interactive
|
// Don't set password in config - rely on keyboard-interactive
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -406,6 +406,197 @@ type StatusEntry = {
|
|||||||
lastChecked: string;
|
lastChecked: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface StatsConfig {
|
||||||
|
enabledWidgets: string[];
|
||||||
|
statusCheckEnabled: boolean;
|
||||||
|
statusCheckInterval: number;
|
||||||
|
metricsEnabled: boolean;
|
||||||
|
metricsInterval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||||
|
enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"],
|
||||||
|
statusCheckEnabled: true,
|
||||||
|
statusCheckInterval: 30,
|
||||||
|
metricsEnabled: true,
|
||||||
|
metricsInterval: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HostPollingConfig {
|
||||||
|
host: SSHHostWithCredentials;
|
||||||
|
statsConfig: StatsConfig;
|
||||||
|
statusTimer?: NodeJS.Timeout;
|
||||||
|
metricsTimer?: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PollingManager {
|
||||||
|
private pollingConfigs = new Map<number, HostPollingConfig>();
|
||||||
|
private statusStore = new Map<number, StatusEntry>();
|
||||||
|
private metricsStore = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
data: Awaited<ReturnType<typeof collectMetrics>>;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
parseStatsConfig(statsConfigStr?: string): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startPollingForHost(host: SSHHostWithCredentials): Promise<void> {
|
||||||
|
const statsConfig = this.parseStatsConfig(host.statsConfig);
|
||||||
|
const existingConfig = this.pollingConfigs.get(host.id);
|
||||||
|
|
||||||
|
// Clear existing timers if they exist
|
||||||
|
if (existingConfig) {
|
||||||
|
if (existingConfig.statusTimer) {
|
||||||
|
clearInterval(existingConfig.statusTimer);
|
||||||
|
}
|
||||||
|
if (existingConfig.metricsTimer) {
|
||||||
|
clearInterval(existingConfig.metricsTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: HostPollingConfig = {
|
||||||
|
host,
|
||||||
|
statsConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start status polling if enabled
|
||||||
|
if (statsConfig.statusCheckEnabled) {
|
||||||
|
const intervalMs = statsConfig.statusCheckInterval * 1000;
|
||||||
|
|
||||||
|
// Poll immediately (don't await - let it run in background)
|
||||||
|
this.pollHostStatus(host);
|
||||||
|
|
||||||
|
// Then set up interval to poll periodically
|
||||||
|
config.statusTimer = setInterval(() => {
|
||||||
|
this.pollHostStatus(host);
|
||||||
|
}, intervalMs);
|
||||||
|
} else {
|
||||||
|
// Remove status if monitoring is disabled
|
||||||
|
this.statusStore.delete(host.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start metrics polling if enabled
|
||||||
|
if (statsConfig.metricsEnabled) {
|
||||||
|
const intervalMs = statsConfig.metricsInterval * 1000;
|
||||||
|
|
||||||
|
// Poll immediately (don't await - let it run in background)
|
||||||
|
this.pollHostMetrics(host);
|
||||||
|
|
||||||
|
// Then set up interval to poll periodically
|
||||||
|
config.metricsTimer = setInterval(() => {
|
||||||
|
this.pollHostMetrics(host);
|
||||||
|
}, intervalMs);
|
||||||
|
} else {
|
||||||
|
// Remove metrics if monitoring is disabled
|
||||||
|
this.metricsStore.delete(host.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pollingConfigs.set(host.id, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollHostStatus(host: SSHHostWithCredentials): Promise<void> {
|
||||||
|
try {
|
||||||
|
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
||||||
|
const statusEntry: StatusEntry = {
|
||||||
|
status: isOnline ? "online" : "offline",
|
||||||
|
lastChecked: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.statusStore.set(host.id, statusEntry);
|
||||||
|
} catch (error) {
|
||||||
|
statsLogger.warn(
|
||||||
|
`Failed to poll status for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
const statusEntry: StatusEntry = {
|
||||||
|
status: "offline",
|
||||||
|
lastChecked: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.statusStore.set(host.id, statusEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
|
||||||
|
try {
|
||||||
|
const metrics = await collectMetrics(host);
|
||||||
|
this.metricsStore.set(host.id, {
|
||||||
|
data: metrics,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPollingForHost(hostId: number): void {
|
||||||
|
const config = this.pollingConfigs.get(hostId);
|
||||||
|
if (config) {
|
||||||
|
if (config.statusTimer) {
|
||||||
|
clearInterval(config.statusTimer);
|
||||||
|
}
|
||||||
|
if (config.metricsTimer) {
|
||||||
|
clearInterval(config.metricsTimer);
|
||||||
|
}
|
||||||
|
this.pollingConfigs.delete(hostId);
|
||||||
|
this.statusStore.delete(hostId);
|
||||||
|
this.metricsStore.delete(hostId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(hostId: number): StatusEntry | undefined {
|
||||||
|
return this.statusStore.get(hostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllStatuses(): Map<number, StatusEntry> {
|
||||||
|
return this.statusStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetrics(
|
||||||
|
hostId: number,
|
||||||
|
):
|
||||||
|
| { data: Awaited<ReturnType<typeof collectMetrics>>; timestamp: number }
|
||||||
|
| undefined {
|
||||||
|
return this.metricsStore.get(hostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializePolling(userId: string): Promise<void> {
|
||||||
|
const hosts = await fetchAllHosts(userId);
|
||||||
|
|
||||||
|
for (const host of hosts) {
|
||||||
|
await this.startPollingForHost(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshHostPolling(userId: string): Promise<void> {
|
||||||
|
// Stop all current polling
|
||||||
|
for (const hostId of this.pollingConfigs.keys()) {
|
||||||
|
this.stopPollingForHost(hostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinitialize
|
||||||
|
await this.initializePolling(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
for (const hostId of this.pollingConfigs.keys()) {
|
||||||
|
this.stopPollingForHost(hostId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollingManager = new PollingManager();
|
||||||
|
|
||||||
function validateHostId(
|
function validateHostId(
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
@@ -460,8 +651,6 @@ app.use(express.json({ limit: "1mb" }));
|
|||||||
|
|
||||||
app.use(authManager.createAuthMiddleware());
|
app.use(authManager.createAuthMiddleware());
|
||||||
|
|
||||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
|
||||||
|
|
||||||
async function fetchAllHosts(
|
async function fetchAllHosts(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<SSHHostWithCredentials[]> {
|
): Promise<SSHHostWithCredentials[]> {
|
||||||
@@ -499,11 +688,6 @@ async function fetchHostById(
|
|||||||
): Promise<SSHHostWithCredentials | undefined> {
|
): Promise<SSHHostWithCredentials | undefined> {
|
||||||
try {
|
try {
|
||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||||
statsLogger.debug("User data locked - cannot fetch host", {
|
|
||||||
operation: "fetchHostById_data_locked",
|
|
||||||
userId,
|
|
||||||
hostId: id,
|
|
||||||
});
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,31 +821,44 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
|||||||
readyTimeout: 10_000,
|
readyTimeout: 10_000,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
|
"curve25519-sha256",
|
||||||
|
"curve25519-sha256@libssh.org",
|
||||||
|
"ecdh-sha2-nistp521",
|
||||||
|
"ecdh-sha2-nistp384",
|
||||||
|
"ecdh-sha2-nistp256",
|
||||||
|
"diffie-hellman-group-exchange-sha256",
|
||||||
"diffie-hellman-group14-sha256",
|
"diffie-hellman-group14-sha256",
|
||||||
"diffie-hellman-group14-sha1",
|
"diffie-hellman-group14-sha1",
|
||||||
"diffie-hellman-group1-sha1",
|
|
||||||
"diffie-hellman-group-exchange-sha256",
|
|
||||||
"diffie-hellman-group-exchange-sha1",
|
"diffie-hellman-group-exchange-sha1",
|
||||||
"ecdh-sha2-nistp256",
|
"diffie-hellman-group1-sha1",
|
||||||
"ecdh-sha2-nistp384",
|
],
|
||||||
"ecdh-sha2-nistp521",
|
serverHostKey: [
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"rsa-sha2-512",
|
||||||
|
"rsa-sha2-256",
|
||||||
|
"ssh-rsa",
|
||||||
|
"ssh-dss",
|
||||||
],
|
],
|
||||||
cipher: [
|
cipher: [
|
||||||
"aes128-ctr",
|
"chacha20-poly1305@openssh.com",
|
||||||
"aes192-ctr",
|
|
||||||
"aes256-ctr",
|
|
||||||
"aes128-gcm@openssh.com",
|
|
||||||
"aes256-gcm@openssh.com",
|
"aes256-gcm@openssh.com",
|
||||||
"aes128-cbc",
|
"aes128-gcm@openssh.com",
|
||||||
"aes192-cbc",
|
"aes256-ctr",
|
||||||
|
"aes192-ctr",
|
||||||
|
"aes128-ctr",
|
||||||
"aes256-cbc",
|
"aes256-cbc",
|
||||||
|
"aes192-cbc",
|
||||||
|
"aes128-cbc",
|
||||||
"3des-cbc",
|
"3des-cbc",
|
||||||
],
|
],
|
||||||
hmac: [
|
hmac: [
|
||||||
"hmac-sha2-256-etm@openssh.com",
|
|
||||||
"hmac-sha2-512-etm@openssh.com",
|
"hmac-sha2-512-etm@openssh.com",
|
||||||
"hmac-sha2-256",
|
"hmac-sha2-256-etm@openssh.com",
|
||||||
"hmac-sha2-512",
|
"hmac-sha2-512",
|
||||||
|
"hmac-sha2-256",
|
||||||
"hmac-sha1",
|
"hmac-sha1",
|
||||||
"hmac-md5",
|
"hmac-md5",
|
||||||
],
|
],
|
||||||
@@ -999,7 +1196,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
);
|
);
|
||||||
const netStatOut = await execCommand(
|
const netStatOut = await execCommand(
|
||||||
client,
|
client,
|
||||||
"ip -o link show | awk '{print $2,$9}' | sed 's/:$//'",
|
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
|
||||||
);
|
);
|
||||||
|
|
||||||
const addrs = ifconfigOut.stdout
|
const addrs = ifconfigOut.stdout
|
||||||
@@ -1234,47 +1431,6 @@ function tcpPing(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollStatusesOnce(userId?: string): Promise<void> {
|
|
||||||
if (!userId) {
|
|
||||||
statsLogger.warn("Skipping status poll - no authenticated user", {
|
|
||||||
operation: "status_poll",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hosts = await fetchAllHosts(userId);
|
|
||||||
if (hosts.length === 0) {
|
|
||||||
statsLogger.warn("No hosts retrieved for status polling", {
|
|
||||||
operation: "status_poll",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checks = hosts.map(async (h) => {
|
|
||||||
const isOnline = await tcpPing(h.ip, h.port, 5000);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const statusEntry: StatusEntry = {
|
|
||||||
status: isOnline ? "online" : "offline",
|
|
||||||
lastChecked: now,
|
|
||||||
};
|
|
||||||
hostStatuses.set(h.id, statusEntry);
|
|
||||||
return isOnline;
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(checks);
|
|
||||||
const onlineCount = results.filter(
|
|
||||||
(r) => r.status === "fulfilled" && r.value === true,
|
|
||||||
).length;
|
|
||||||
const offlineCount = hosts.length - onlineCount;
|
|
||||||
statsLogger.success("Status polling completed", {
|
|
||||||
operation: "status_poll",
|
|
||||||
totalHosts: hosts.length,
|
|
||||||
onlineCount,
|
|
||||||
offlineCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get("/status", async (req, res) => {
|
app.get("/status", async (req, res) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
@@ -1285,11 +1441,14 @@ app.get("/status", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hostStatuses.size === 0) {
|
// Initialize polling if no hosts are being polled yet
|
||||||
await pollStatusesOnce(userId);
|
const statuses = pollingManager.getAllStatuses();
|
||||||
|
if (statuses.size === 0) {
|
||||||
|
await pollingManager.initializePolling(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: Record<number, StatusEntry> = {};
|
const result: Record<number, StatusEntry> = {};
|
||||||
for (const [id, entry] of hostStatuses.entries()) {
|
for (const [id, entry] of pollingManager.getAllStatuses().entries()) {
|
||||||
result[id] = entry;
|
result[id] = entry;
|
||||||
}
|
}
|
||||||
res.json(result);
|
res.json(result);
|
||||||
@@ -1306,25 +1465,18 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Initialize polling if no hosts are being polled yet
|
||||||
const host = await fetchHostById(id, userId);
|
const statuses = pollingManager.getAllStatuses();
|
||||||
if (!host) {
|
if (statuses.size === 0) {
|
||||||
return res.status(404).json({ error: "Host not found" });
|
await pollingManager.initializePolling(userId);
|
||||||
}
|
|
||||||
|
|
||||||
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const statusEntry: StatusEntry = {
|
|
||||||
status: isOnline ? "online" : "offline",
|
|
||||||
lastChecked: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
hostStatuses.set(id, statusEntry);
|
|
||||||
res.json(statusEntry);
|
|
||||||
} catch (err) {
|
|
||||||
statsLogger.error("Failed to check host status", err);
|
|
||||||
res.status(500).json({ error: "Failed to check host status" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusEntry = pollingManager.getStatus(id);
|
||||||
|
if (!statusEntry) {
|
||||||
|
return res.status(404).json({ error: "Status not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(statusEntry);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/refresh", async (req, res) => {
|
app.post("/refresh", async (req, res) => {
|
||||||
@@ -1337,8 +1489,8 @@ app.post("/refresh", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await pollStatusesOnce(userId);
|
await pollingManager.refreshHostPolling(userId);
|
||||||
res.json({ message: "Refreshed" });
|
res.json({ message: "Polling refreshed" });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/metrics/:id", validateHostId, async (req, res) => {
|
app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||||
@@ -1352,121 +1504,10 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const metricsData = pollingManager.getMetrics(id);
|
||||||
const host = await fetchHostById(id, userId);
|
if (!metricsData) {
|
||||||
if (!host) {
|
return res.status(404).json({
|
||||||
return res.status(404).json({ error: "Host not found" });
|
error: "Metrics not available",
|
||||||
}
|
|
||||||
|
|
||||||
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
|
||||||
if (!isOnline) {
|
|
||||||
return res.status(503).json({
|
|
||||||
error: "Host is offline",
|
|
||||||
cpu: { percent: null, cores: null, load: null },
|
|
||||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
|
||||||
disk: {
|
|
||||||
percent: null,
|
|
||||||
usedHuman: null,
|
|
||||||
totalHuman: null,
|
|
||||||
availableHuman: null,
|
|
||||||
},
|
|
||||||
network: { interfaces: [] },
|
|
||||||
uptime: { seconds: null, formatted: null },
|
|
||||||
processes: { total: null, running: null, top: [] },
|
|
||||||
system: { hostname: null, kernel: null, os: null },
|
|
||||||
lastChecked: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = await collectMetrics(host);
|
|
||||||
res.json({ ...metrics, lastChecked: new Date().toISOString() });
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
|
||||||
|
|
||||||
// Check if this is a skip due to auth failure tracking
|
|
||||||
if (
|
|
||||||
errorMessage.includes("TOTP authentication required") ||
|
|
||||||
errorMessage.includes("metrics unavailable")
|
|
||||||
) {
|
|
||||||
// Don't log as error - this is expected for TOTP hosts
|
|
||||||
return res.status(403).json({
|
|
||||||
error: "TOTP_REQUIRED",
|
|
||||||
message: errorMessage,
|
|
||||||
cpu: { percent: null, cores: null, load: null },
|
|
||||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
|
||||||
disk: {
|
|
||||||
percent: null,
|
|
||||||
usedHuman: null,
|
|
||||||
totalHuman: null,
|
|
||||||
availableHuman: null,
|
|
||||||
},
|
|
||||||
network: { interfaces: [] },
|
|
||||||
uptime: { seconds: null, formatted: null },
|
|
||||||
processes: { total: null, running: null, top: [] },
|
|
||||||
system: { hostname: null, kernel: null, os: null },
|
|
||||||
lastChecked: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a skip due to too many failures or config issues
|
|
||||||
if (
|
|
||||||
errorMessage.includes("Too many authentication failures") ||
|
|
||||||
errorMessage.includes("Retry in") ||
|
|
||||||
errorMessage.includes("Invalid configuration") ||
|
|
||||||
errorMessage.includes("Authentication failed")
|
|
||||||
) {
|
|
||||||
// Don't log - return error silently to avoid spam
|
|
||||||
return res.status(429).json({
|
|
||||||
error: "UNAVAILABLE",
|
|
||||||
message: errorMessage,
|
|
||||||
cpu: { percent: null, cores: null, load: null },
|
|
||||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
|
||||||
disk: {
|
|
||||||
percent: null,
|
|
||||||
usedHuman: null,
|
|
||||||
totalHuman: null,
|
|
||||||
availableHuman: null,
|
|
||||||
},
|
|
||||||
network: { interfaces: [] },
|
|
||||||
uptime: { seconds: null, formatted: null },
|
|
||||||
processes: { total: null, running: null, top: [] },
|
|
||||||
system: { hostname: null, kernel: null, os: null },
|
|
||||||
lastChecked: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only log unexpected errors
|
|
||||||
if (
|
|
||||||
!errorMessage.includes("timeout") &&
|
|
||||||
!errorMessage.includes("offline") &&
|
|
||||||
!errorMessage.includes("permanently") &&
|
|
||||||
!errorMessage.includes("none") &&
|
|
||||||
!errorMessage.includes("No password")
|
|
||||||
) {
|
|
||||||
statsLogger.error("Failed to collect metrics", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err instanceof Error && err.message.includes("timeout")) {
|
|
||||||
return res.status(504).json({
|
|
||||||
error: "Metrics collection timeout",
|
|
||||||
cpu: { percent: null, cores: null, load: null },
|
|
||||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
|
||||||
disk: {
|
|
||||||
percent: null,
|
|
||||||
usedHuman: null,
|
|
||||||
totalHuman: null,
|
|
||||||
availableHuman: null,
|
|
||||||
},
|
|
||||||
network: { interfaces: [] },
|
|
||||||
uptime: { seconds: null, formatted: null },
|
|
||||||
processes: { total: null, running: null, top: [] },
|
|
||||||
system: { hostname: null, kernel: null, os: null },
|
|
||||||
lastChecked: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(500).json({
|
|
||||||
error: "Failed to collect metrics",
|
|
||||||
cpu: { percent: null, cores: null, load: null },
|
cpu: { percent: null, cores: null, load: null },
|
||||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||||
disk: {
|
disk: {
|
||||||
@@ -1482,14 +1523,21 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
|||||||
lastChecked: new Date().toISOString(),
|
lastChecked: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...metricsData.data,
|
||||||
|
lastChecked: new Date(metricsData.timestamp).toISOString(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
|
pollingManager.destroy();
|
||||||
connectionPool.destroy();
|
connectionPool.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
|
pollingManager.destroy();
|
||||||
connectionPool.destroy();
|
connectionPool.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -870,40 +870,44 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
},
|
},
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
|
"curve25519-sha256",
|
||||||
|
"curve25519-sha256@libssh.org",
|
||||||
|
"ecdh-sha2-nistp521",
|
||||||
|
"ecdh-sha2-nistp384",
|
||||||
|
"ecdh-sha2-nistp256",
|
||||||
|
"diffie-hellman-group-exchange-sha256",
|
||||||
"diffie-hellman-group14-sha256",
|
"diffie-hellman-group14-sha256",
|
||||||
"diffie-hellman-group14-sha1",
|
"diffie-hellman-group14-sha1",
|
||||||
"diffie-hellman-group1-sha1",
|
|
||||||
"diffie-hellman-group-exchange-sha256",
|
|
||||||
"diffie-hellman-group-exchange-sha1",
|
"diffie-hellman-group-exchange-sha1",
|
||||||
"ecdh-sha2-nistp256",
|
"diffie-hellman-group1-sha1",
|
||||||
"ecdh-sha2-nistp384",
|
|
||||||
"ecdh-sha2-nistp521",
|
|
||||||
],
|
|
||||||
cipher: [
|
|
||||||
"aes128-ctr",
|
|
||||||
"aes192-ctr",
|
|
||||||
"aes256-ctr",
|
|
||||||
"aes128-gcm@openssh.com",
|
|
||||||
"aes256-gcm@openssh.com",
|
|
||||||
"aes128-cbc",
|
|
||||||
"aes192-cbc",
|
|
||||||
"aes256-cbc",
|
|
||||||
"3des-cbc",
|
|
||||||
],
|
],
|
||||||
serverHostKey: [
|
serverHostKey: [
|
||||||
"ssh-rsa",
|
|
||||||
"rsa-sha2-256",
|
|
||||||
"rsa-sha2-512",
|
|
||||||
"ecdsa-sha2-nistp256",
|
|
||||||
"ecdsa-sha2-nistp384",
|
|
||||||
"ecdsa-sha2-nistp521",
|
|
||||||
"ssh-ed25519",
|
"ssh-ed25519",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"rsa-sha2-512",
|
||||||
|
"rsa-sha2-256",
|
||||||
|
"ssh-rsa",
|
||||||
|
"ssh-dss",
|
||||||
|
],
|
||||||
|
cipher: [
|
||||||
|
"chacha20-poly1305@openssh.com",
|
||||||
|
"aes256-gcm@openssh.com",
|
||||||
|
"aes128-gcm@openssh.com",
|
||||||
|
"aes256-ctr",
|
||||||
|
"aes192-ctr",
|
||||||
|
"aes128-ctr",
|
||||||
|
"aes256-cbc",
|
||||||
|
"aes192-cbc",
|
||||||
|
"aes128-cbc",
|
||||||
|
"3des-cbc",
|
||||||
],
|
],
|
||||||
hmac: [
|
hmac: [
|
||||||
"hmac-sha2-256-etm@openssh.com",
|
|
||||||
"hmac-sha2-512-etm@openssh.com",
|
"hmac-sha2-512-etm@openssh.com",
|
||||||
"hmac-sha2-256",
|
"hmac-sha2-256-etm@openssh.com",
|
||||||
"hmac-sha2-512",
|
"hmac-sha2-512",
|
||||||
|
"hmac-sha2-256",
|
||||||
"hmac-sha1",
|
"hmac-sha1",
|
||||||
"hmac-md5",
|
"hmac-md5",
|
||||||
],
|
],
|
||||||
@@ -913,6 +917,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
if (resolvedCredentials.authType === "none") {
|
if (resolvedCredentials.authType === "none") {
|
||||||
// Don't set password in config - rely on keyboard-interactive
|
// Don't set password in config - rely on keyboard-interactive
|
||||||
|
} else if (resolvedCredentials.authType === "password") {
|
||||||
|
if (!resolvedCredentials.password) {
|
||||||
|
sshLogger.error(
|
||||||
|
"Password authentication requested but no password provided",
|
||||||
|
);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
"Password authentication requested but no password provided",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectConfig.password = resolvedCredentials.password;
|
||||||
} else if (
|
} else if (
|
||||||
resolvedCredentials.authType === "key" &&
|
resolvedCredentials.authType === "key" &&
|
||||||
resolvedCredentials.key
|
resolvedCredentials.key
|
||||||
@@ -954,20 +973,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else if (resolvedCredentials.authType === "password") {
|
|
||||||
if (!resolvedCredentials.password) {
|
|
||||||
sshLogger.error(
|
|
||||||
"Password authentication requested but no password provided",
|
|
||||||
);
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "error",
|
|
||||||
message:
|
|
||||||
"Password authentication requested but no password provided",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
sshLogger.error("No valid authentication method provided");
|
sshLogger.error("No valid authentication method provided");
|
||||||
ws.send(
|
ws.send(
|
||||||
|
|||||||
@@ -914,31 +914,44 @@ async function connectSSHTunnel(
|
|||||||
tcpKeepAliveInitialDelay: 15000,
|
tcpKeepAliveInitialDelay: 15000,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
|
"curve25519-sha256",
|
||||||
|
"curve25519-sha256@libssh.org",
|
||||||
|
"ecdh-sha2-nistp521",
|
||||||
|
"ecdh-sha2-nistp384",
|
||||||
|
"ecdh-sha2-nistp256",
|
||||||
|
"diffie-hellman-group-exchange-sha256",
|
||||||
"diffie-hellman-group14-sha256",
|
"diffie-hellman-group14-sha256",
|
||||||
"diffie-hellman-group14-sha1",
|
"diffie-hellman-group14-sha1",
|
||||||
"diffie-hellman-group1-sha1",
|
|
||||||
"diffie-hellman-group-exchange-sha256",
|
|
||||||
"diffie-hellman-group-exchange-sha1",
|
"diffie-hellman-group-exchange-sha1",
|
||||||
"ecdh-sha2-nistp256",
|
"diffie-hellman-group1-sha1",
|
||||||
"ecdh-sha2-nistp384",
|
],
|
||||||
"ecdh-sha2-nistp521",
|
serverHostKey: [
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"rsa-sha2-512",
|
||||||
|
"rsa-sha2-256",
|
||||||
|
"ssh-rsa",
|
||||||
|
"ssh-dss",
|
||||||
],
|
],
|
||||||
cipher: [
|
cipher: [
|
||||||
"aes128-ctr",
|
"chacha20-poly1305@openssh.com",
|
||||||
"aes192-ctr",
|
|
||||||
"aes256-ctr",
|
|
||||||
"aes128-gcm@openssh.com",
|
|
||||||
"aes256-gcm@openssh.com",
|
"aes256-gcm@openssh.com",
|
||||||
"aes128-cbc",
|
"aes128-gcm@openssh.com",
|
||||||
"aes192-cbc",
|
"aes256-ctr",
|
||||||
|
"aes192-ctr",
|
||||||
|
"aes128-ctr",
|
||||||
"aes256-cbc",
|
"aes256-cbc",
|
||||||
|
"aes192-cbc",
|
||||||
|
"aes128-cbc",
|
||||||
"3des-cbc",
|
"3des-cbc",
|
||||||
],
|
],
|
||||||
hmac: [
|
hmac: [
|
||||||
"hmac-sha2-256-etm@openssh.com",
|
|
||||||
"hmac-sha2-512-etm@openssh.com",
|
"hmac-sha2-512-etm@openssh.com",
|
||||||
"hmac-sha2-256",
|
"hmac-sha2-256-etm@openssh.com",
|
||||||
"hmac-sha2-512",
|
"hmac-sha2-512",
|
||||||
|
"hmac-sha2-256",
|
||||||
"hmac-sha1",
|
"hmac-sha1",
|
||||||
"hmac-md5",
|
"hmac-md5",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return jwt.sign(payload, jwtSecret, {
|
return jwt.sign(payload, jwtSecret, {
|
||||||
expiresIn: options.expiresIn || "24h",
|
expiresIn: options.expiresIn || "7d",
|
||||||
} as jwt.SignOptions);
|
} as jwt.SignOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ class AuthManager {
|
|||||||
|
|
||||||
getSecureCookieOptions(
|
getSecureCookieOptions(
|
||||||
req: RequestWithHeaders,
|
req: RequestWithHeaders,
|
||||||
maxAge: number = 24 * 60 * 60 * 1000,
|
maxAge: number = 7 * 24 * 60 * 60 * 1000,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import LanguageDetector from "i18next-browser-languagedetector";
|
|||||||
import enTranslation from "../locales/en/translation.json";
|
import enTranslation from "../locales/en/translation.json";
|
||||||
import zhTranslation from "../locales/zh/translation.json";
|
import zhTranslation from "../locales/zh/translation.json";
|
||||||
import deTranslation from "../locales/de/translation.json";
|
import deTranslation from "../locales/de/translation.json";
|
||||||
|
import ptbrTranslation from "../locales/pt-br/translation.json";
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
supportedLngs: ["en", "zh", "de"],
|
supportedLngs: ["en", "zh", "de", "ptbr"],
|
||||||
fallbackLng: "en",
|
fallbackLng: "en",
|
||||||
debug: false,
|
debug: false,
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ i18n
|
|||||||
de: {
|
de: {
|
||||||
translation: deTranslation,
|
translation: deTranslation,
|
||||||
},
|
},
|
||||||
|
ptbr: {
|
||||||
|
translation: ptbrTranslation,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
|
|||||||
@@ -529,7 +529,19 @@
|
|||||||
"passwordRequired": "Passwort erforderlich",
|
"passwordRequired": "Passwort erforderlich",
|
||||||
"confirmExport": "Export bestätigen",
|
"confirmExport": "Export bestätigen",
|
||||||
"exportDescription": "SSH-Hosts und Anmeldedaten als SQLite-Datei exportieren",
|
"exportDescription": "SSH-Hosts und Anmeldedaten als SQLite-Datei exportieren",
|
||||||
"importDescription": "SQLite-Datei mit inkrementellem Zusammenführen importieren (überspringt Duplikate)"
|
"importDescription": "SQLite-Datei mit inkrementellem Zusammenführen importieren (überspringt Duplikate)",
|
||||||
|
"criticalWarning": "Kritische Warnung",
|
||||||
|
"cannotDisablePasswordLoginWithoutOIDC": "Passwort-Login kann nicht ohne konfiguriertes OIDC deaktiviert werden! Sie müssen die OIDC-Authentifizierung konfigurieren, bevor Sie die Passwort-Anmeldung deaktivieren, sonst verlieren Sie den Zugriff auf Termix.",
|
||||||
|
"confirmDisablePasswordLogin": "Sind Sie sicher, dass Sie die Passwort-Anmeldung deaktivieren möchten? Stellen Sie sicher, dass OIDC ordnungsgemäß konfiguriert ist und funktioniert, bevor Sie fortfahren, sonst verlieren Sie den Zugriff auf Ihre Termix-Instanz.",
|
||||||
|
"passwordLoginDisabled": "Passwort-Login erfolgreich deaktiviert",
|
||||||
|
"passwordLoginAndRegistrationDisabled": "Passwort-Login und Registrierung neuer Konten erfolgreich deaktiviert",
|
||||||
|
"requiresPasswordLogin": "Erfordert aktivierte Passwort-Anmeldung",
|
||||||
|
"passwordLoginDisabledWarning": "Passwort-Login ist deaktiviert. Stellen Sie sicher, dass OIDC ordnungsgemäß konfiguriert ist, sonst können Sie sich nicht bei Termix anmelden.",
|
||||||
|
"oidcRequiredWarning": "KRITISCH: Passwort-Login ist deaktiviert. Wenn Sie OIDC zurücksetzen oder falsch konfigurieren, verlieren Sie den gesamten Zugriff auf Termix und Ihre Instanz wird unbrauchbar. Fahren Sie nur fort, wenn Sie absolut sicher sind.",
|
||||||
|
"confirmDisableOIDCWarning": "WARNUNG: Sie sind dabei, OIDC zu deaktivieren, während auch die Passwort-Anmeldung deaktiviert ist. Dies macht Ihre Termix-Instanz unbrauchbar und Sie verlieren den gesamten Zugriff. Sind Sie absolut sicher, dass Sie fortfahren möchten?",
|
||||||
|
"allowPasswordLogin": "Benutzername/Passwort-Anmeldung zulassen",
|
||||||
|
"failedToFetchPasswordLoginStatus": "Abrufen des Passwort-Login-Status fehlgeschlagen",
|
||||||
|
"failedToUpdatePasswordLoginStatus": "Aktualisierung des Passwort-Login-Status fehlgeschlagen"
|
||||||
},
|
},
|
||||||
"hosts": {
|
"hosts": {
|
||||||
"title": "Host-Manager",
|
"title": "Host-Manager",
|
||||||
@@ -623,6 +635,7 @@
|
|||||||
"password": "Passwort",
|
"password": "Passwort",
|
||||||
"key": "Schlüssel",
|
"key": "Schlüssel",
|
||||||
"credential": "Anmeldedaten",
|
"credential": "Anmeldedaten",
|
||||||
|
"none": "Keine",
|
||||||
"selectCredential": "Anmeldeinformationen auswählen",
|
"selectCredential": "Anmeldeinformationen auswählen",
|
||||||
"selectCredentialPlaceholder": "Wähle eine Anmeldedaten aus...",
|
"selectCredentialPlaceholder": "Wähle eine Anmeldedaten aus...",
|
||||||
"credentialRequired": "Für die Anmeldeauthentifizierung ist eine Anmeldeinformation erforderlich",
|
"credentialRequired": "Für die Anmeldeauthentifizierung ist eine Anmeldeinformation erforderlich",
|
||||||
@@ -659,7 +672,32 @@
|
|||||||
"folderRenamed": "Ordner „ {{oldName}} “ erfolgreich in „ {{newName}} “ umbenannt",
|
"folderRenamed": "Ordner „ {{oldName}} “ erfolgreich in „ {{newName}} “ umbenannt",
|
||||||
"failedToRenameFolder": "Ordner konnte nicht umbenannt werden",
|
"failedToRenameFolder": "Ordner konnte nicht umbenannt werden",
|
||||||
"movedToFolder": "Host \"{{name}}\" wurde erfolgreich nach \"{{folder}}\" verschoben",
|
"movedToFolder": "Host \"{{name}}\" wurde erfolgreich nach \"{{folder}}\" verschoben",
|
||||||
"failedToMoveToFolder": "Host konnte nicht in den Ordner verschoben werden"
|
"failedToMoveToFolder": "Host konnte nicht in den Ordner verschoben werden",
|
||||||
|
"statistics": "Statistiken",
|
||||||
|
"enabledWidgets": "Aktivierte Widgets",
|
||||||
|
"enabledWidgetsDesc": "Wählen Sie aus, welche Statistik-Widgets für diesen Host angezeigt werden sollen",
|
||||||
|
"monitoringConfiguration": "Überwachungskonfiguration",
|
||||||
|
"monitoringConfigurationDesc": "Konfigurieren Sie, wie oft Serverstatistiken und Status überprüft werden",
|
||||||
|
"statusCheckEnabled": "Statusüberwachung aktivieren",
|
||||||
|
"statusCheckEnabledDesc": "Prüfen Sie, ob der Server online oder offline ist",
|
||||||
|
"statusCheckInterval": "Statusprüfintervall",
|
||||||
|
"statusCheckIntervalDesc": "Wie oft überprüft werden soll, ob der Host online ist (5s - 1h)",
|
||||||
|
"metricsEnabled": "Metriküberwachung aktivieren",
|
||||||
|
"metricsEnabledDesc": "CPU-, RAM-, Festplatten- und andere Systemstatistiken erfassen",
|
||||||
|
"metricsInterval": "Metriken-Erfassungsintervall",
|
||||||
|
"metricsIntervalDesc": "Wie oft Serverstatistiken erfasst werden sollen (5s - 1h)",
|
||||||
|
"intervalSeconds": "Sekunden",
|
||||||
|
"intervalMinutes": "Minuten",
|
||||||
|
"intervalValidation": "Überwachungsintervalle müssen zwischen 5 Sekunden und 1 Stunde (3600 Sekunden) liegen",
|
||||||
|
"monitoringDisabled": "Die Serverüberwachung ist für diesen Host deaktiviert",
|
||||||
|
"enableMonitoring": "Überwachung aktivieren in Host-Manager → Statistiken-Tab",
|
||||||
|
"monitoringDisabledBadge": "Überwachung Aus",
|
||||||
|
"statusMonitoring": "Status",
|
||||||
|
"metricsMonitoring": "Metriken",
|
||||||
|
"terminalCustomizationNotice": "Hinweis: Terminal-Anpassungen funktionieren nur in der Desktop-Website-Version. Mobile und Electron-Apps verwenden die Standard-Terminaleinstellungen des Systems.",
|
||||||
|
"noneAuthTitle": "Keyboard-Interactive-Authentifizierung",
|
||||||
|
"noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die Keyboard-Interactive-Authentifizierung.",
|
||||||
|
"noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern."
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "Terminal",
|
"title": "Terminal",
|
||||||
|
|||||||
@@ -583,7 +583,16 @@
|
|||||||
"passwordRequired": "Password required",
|
"passwordRequired": "Password required",
|
||||||
"confirmExport": "Confirm Export",
|
"confirmExport": "Confirm Export",
|
||||||
"exportDescription": "Export SSH hosts and credentials as SQLite file",
|
"exportDescription": "Export SSH hosts and credentials as SQLite file",
|
||||||
"importDescription": "Import SQLite file with incremental merge (skips duplicates)"
|
"importDescription": "Import SQLite file with incremental merge (skips duplicates)",
|
||||||
|
"criticalWarning": "Critical Warning",
|
||||||
|
"cannotDisablePasswordLoginWithoutOIDC": "Cannot disable password login without OIDC configured! You must configure OIDC authentication before disabling password login, or you will lose access to Termix.",
|
||||||
|
"confirmDisablePasswordLogin": "Are you sure you want to disable password login? Make sure OIDC is properly configured and working before proceeding, or you will lose access to your Termix instance.",
|
||||||
|
"passwordLoginDisabled": "Password login disabled successfully",
|
||||||
|
"passwordLoginAndRegistrationDisabled": "Password login and new account registration disabled successfully",
|
||||||
|
"requiresPasswordLogin": "Requires password login enabled",
|
||||||
|
"passwordLoginDisabledWarning": "Password login is disabled. Ensure OIDC is properly configured or you will not be able to log in to Termix.",
|
||||||
|
"oidcRequiredWarning": "CRITICAL: Password login is disabled. If you reset or misconfigure OIDC, you will lose all access to Termix and brick your instance. Only proceed if you are absolutely certain.",
|
||||||
|
"confirmDisableOIDCWarning": "WARNING: You are about to disable OIDC while password login is also disabled. This will brick your Termix instance and you will lose all access. Are you absolutely sure you want to proceed?"
|
||||||
},
|
},
|
||||||
"hosts": {
|
"hosts": {
|
||||||
"title": "Host Manager",
|
"title": "Host Manager",
|
||||||
@@ -677,6 +686,7 @@
|
|||||||
"password": "Password",
|
"password": "Password",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
"credential": "Credential",
|
"credential": "Credential",
|
||||||
|
"none": "None",
|
||||||
"selectCredential": "Select Credential",
|
"selectCredential": "Select Credential",
|
||||||
"selectCredentialPlaceholder": "Choose a credential...",
|
"selectCredentialPlaceholder": "Choose a credential...",
|
||||||
"credentialRequired": "Credential is required when using credential authentication",
|
"credentialRequired": "Credential is required when using credential authentication",
|
||||||
@@ -733,7 +743,29 @@
|
|||||||
"failedToMoveToFolder": "Failed to move host to folder",
|
"failedToMoveToFolder": "Failed to move host to folder",
|
||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
"enabledWidgets": "Enabled Widgets",
|
"enabledWidgets": "Enabled Widgets",
|
||||||
"enabledWidgetsDesc": "Select which statistics widgets to display for this host"
|
"enabledWidgetsDesc": "Select which statistics widgets to display for this host",
|
||||||
|
"monitoringConfiguration": "Monitoring Configuration",
|
||||||
|
"monitoringConfigurationDesc": "Configure how often server statistics and status are checked",
|
||||||
|
"statusCheckEnabled": "Enable Status Monitoring",
|
||||||
|
"statusCheckEnabledDesc": "Check if the server is online or offline",
|
||||||
|
"statusCheckInterval": "Status Check Interval",
|
||||||
|
"statusCheckIntervalDesc": "How often to check if host is online (5s - 1h)",
|
||||||
|
"metricsEnabled": "Enable Metrics Monitoring",
|
||||||
|
"metricsEnabledDesc": "Collect CPU, RAM, disk, and other system statistics",
|
||||||
|
"metricsInterval": "Metrics Collection Interval",
|
||||||
|
"metricsIntervalDesc": "How often to collect server statistics (5s - 1h)",
|
||||||
|
"intervalSeconds": "seconds",
|
||||||
|
"intervalMinutes": "minutes",
|
||||||
|
"intervalValidation": "Monitoring intervals must be between 5 seconds and 1 hour (3600 seconds)",
|
||||||
|
"monitoringDisabled": "Server monitoring is disabled for this host",
|
||||||
|
"enableMonitoring": "Enable monitoring in Host Manager → Statistics tab",
|
||||||
|
"monitoringDisabledBadge": "Monitoring Off",
|
||||||
|
"statusMonitoring": "Status",
|
||||||
|
"metricsMonitoring": "Metrics",
|
||||||
|
"terminalCustomizationNotice": "Note: Terminal customizations only work on desktop (website and Electron app). Mobile apps and mobile website use system default terminal settings.",
|
||||||
|
"noneAuthTitle": "Keyboard-Interactive Authentication",
|
||||||
|
"noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.",
|
||||||
|
"noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or dynamic password entry."
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "Terminal",
|
"title": "Terminal",
|
||||||
|
|||||||
@@ -418,16 +418,16 @@
|
|||||||
"oidcConfigurationDisabled": "Configuração OIDC desativada com sucesso!",
|
"oidcConfigurationDisabled": "Configuração OIDC desativada com sucesso!",
|
||||||
"failedToUpdateOidcConfig": "Falha ao atualizar configuração OIDC",
|
"failedToUpdateOidcConfig": "Falha ao atualizar configuração OIDC",
|
||||||
"failedToDisableOidcConfig": "Falha ao desativar configuração OIDC",
|
"failedToDisableOidcConfig": "Falha ao desativar configuração OIDC",
|
||||||
"enterUsernameToMakeAdmin": "Insira o nome de usuário para tornar admin",
|
"enterUsernameToMakeAdmin": "Insira o nome de usuário para tornar admin",
|
||||||
"userIsNowAdmin": "O usuário {{username}} agora é um administrador",
|
"userIsNowAdmin": "O usuário {{username}} agora é um administrador",
|
||||||
"failedToMakeUserAdmin": "Falha ao tornar o usuário administrador",
|
"failedToMakeUserAdmin": "Falha ao tornar o usuário administrador",
|
||||||
"removeAdminStatus": "Remover status de administrador de {{username}}?",
|
"removeAdminStatus": "Remover status de administrador de {{username}}?",
|
||||||
"adminStatusRemoved": "Status de administrador removido de {{username}}",
|
"adminStatusRemoved": "Status de administrador removido de {{username}}",
|
||||||
"failedToRemoveAdminStatus": "Falha ao remover o status de administrador",
|
"failedToRemoveAdminStatus": "Falha ao remover o status de administrador",
|
||||||
"confirmDeleteUser": "Excluir usuário {{username}}? Esta ação não pode ser desfeita.",
|
"confirmDeleteUser": "Excluir usuário {{username}}? Esta ação não pode ser desfeita.",
|
||||||
"userDeletedSuccessfully": "Usuário {{username}} excluído com sucesso",
|
"userDeletedSuccessfully": "Usuário {{username}} excluído com sucesso",
|
||||||
"failedToDeleteUser": "Falha ao excluir usuário",
|
"failedToDeleteUser": "Falha ao excluir usuário",
|
||||||
"overrideUserInfoUrl": "Sobrescrever URL de informações do usuário (não obrigatório)",
|
"overrideUserInfoUrl": "Sobrescrever URL de informações do usuário (não obrigatório)",
|
||||||
"databaseSecurity": "Segurança do Banco de Dados",
|
"databaseSecurity": "Segurança do Banco de Dados",
|
||||||
"encryptionStatus": "Status da Criptografia",
|
"encryptionStatus": "Status da Criptografia",
|
||||||
"encryptionEnabled": "Criptografia Ativada",
|
"encryptionEnabled": "Criptografia Ativada",
|
||||||
@@ -546,7 +546,19 @@
|
|||||||
"passwordRequired": "Senha necessária",
|
"passwordRequired": "Senha necessária",
|
||||||
"confirmExport": "Confirmar Exportação",
|
"confirmExport": "Confirmar Exportação",
|
||||||
"exportDescription": "Exportar hosts SSH e credenciais como arquivo SQLite",
|
"exportDescription": "Exportar hosts SSH e credenciais como arquivo SQLite",
|
||||||
"importDescription": "Importar arquivo SQLite com mesclagem incremental (ignora duplicados)"
|
"importDescription": "Importar arquivo SQLite com mesclagem incremental (ignora duplicados)",
|
||||||
|
"criticalWarning": "Aviso Crítico",
|
||||||
|
"cannotDisablePasswordLoginWithoutOIDC": "Não é possível desativar o login por senha sem OIDC configurado! Você deve configurar a autenticação OIDC antes de desativar o login por senha, ou perderá o acesso ao Termix.",
|
||||||
|
"confirmDisablePasswordLogin": "Tem certeza que deseja desativar o login por senha? Certifique-se de que o OIDC está configurado corretamente e funcionando antes de continuar, ou você perderá o acesso à sua instância do Termix.",
|
||||||
|
"passwordLoginDisabled": "Login por senha desativado com sucesso",
|
||||||
|
"passwordLoginAndRegistrationDisabled": "Login por senha e registro de novas contas desativados com sucesso",
|
||||||
|
"requiresPasswordLogin": "Requer login por senha ativado",
|
||||||
|
"passwordLoginDisabledWarning": "Login por senha está desativado. Certifique-se de que o OIDC está configurado corretamente ou você não conseguirá fazer login no Termix.",
|
||||||
|
"oidcRequiredWarning": "CRÍTICO: Login por senha está desativado. Se você redefinir ou configurar incorretamente o OIDC, você perderá todo o acesso ao Termix e inutilizará sua instância. Prossiga apenas se tiver absoluta certeza.",
|
||||||
|
"confirmDisableOIDCWarning": "AVISO: Você está prestes a desativar o OIDC enquanto o login por senha também está desativado. Isso inutilizará sua instância do Termix e você perderá todo o acesso. Tem absoluta certeza de que deseja continuar?",
|
||||||
|
"allowPasswordLogin": "Permitir login com nome de usuário/senha",
|
||||||
|
"failedToFetchPasswordLoginStatus": "Falha ao buscar status do login por senha",
|
||||||
|
"failedToUpdatePasswordLoginStatus": "Falha ao atualizar status do login por senha"
|
||||||
},
|
},
|
||||||
"hosts": {
|
"hosts": {
|
||||||
"title": "Gerenciador de Hosts",
|
"title": "Gerenciador de Hosts",
|
||||||
@@ -598,22 +610,22 @@
|
|||||||
"hostAddedSuccessfully": "Host \"{{name}}\" adicionado com sucesso!",
|
"hostAddedSuccessfully": "Host \"{{name}}\" adicionado com sucesso!",
|
||||||
"hostDeletedSuccessfully": "Host \"{{name}}\" excluído com sucesso!",
|
"hostDeletedSuccessfully": "Host \"{{name}}\" excluído com sucesso!",
|
||||||
"failedToSaveHost": "Falha ao salvar host. Por favor, tente novamente.",
|
"failedToSaveHost": "Falha ao salvar host. Por favor, tente novamente.",
|
||||||
"enableTerminal": "Habilitar Terminal",
|
"enableTerminal": "Habilitar Terminal",
|
||||||
"enableTerminalDesc": "Habilitar/desabilitar visibilidade do host na aba Terminal",
|
"enableTerminalDesc": "Habilitar/desabilitar visibilidade do host na aba Terminal",
|
||||||
"enableTunnel": "Habilitar Túnel",
|
"enableTunnel": "Habilitar Túnel",
|
||||||
"enableTunnelDesc": "Habilitar/desabilitar visibilidade do host na aba Túnel",
|
"enableTunnelDesc": "Habilitar/desabilitar visibilidade do host na aba Túnel",
|
||||||
"enableFileManager": "Habilitar Gerenciador de Arquivos",
|
"enableFileManager": "Habilitar Gerenciador de Arquivos",
|
||||||
"enableFileManagerDesc": "Habilitar/desabilitar visibilidade do host na aba Gerenciador de Arquivos",
|
"enableFileManagerDesc": "Habilitar/desabilitar visibilidade do host na aba Gerenciador de Arquivos",
|
||||||
"defaultPath": "Caminho Padrão",
|
"defaultPath": "Caminho Padrão",
|
||||||
"defaultPathDesc": "Diretório padrão ao abrir o gerenciador de arquivos para este host",
|
"defaultPathDesc": "Diretório padrão ao abrir o gerenciador de arquivos para este host",
|
||||||
"tunnelConnections": "Conexões de Túnel",
|
"tunnelConnections": "Conexões de Túnel",
|
||||||
"connection": "Conexão",
|
"connection": "Conexão",
|
||||||
"remove": "Remover",
|
"remove": "Remover",
|
||||||
"sourcePort": "Porta de Origem",
|
"sourcePort": "Porta de Origem",
|
||||||
"sourcePortDesc": "(Source refere-se aos Detalhes da Conexão Atual na aba Geral)",
|
"sourcePortDesc": "(Source refere-se aos Detalhes da Conexão Atual na aba Geral)",
|
||||||
"endpointPort": "Porta de Destino",
|
"endpointPort": "Porta de Destino",
|
||||||
"endpointSshConfig": "Configuração SSH do Endpoint",
|
"endpointSshConfig": "Configuração SSH do Endpoint",
|
||||||
"tunnelForwardDescription": "Este túnel encaminhará o tráfego da porta {{sourcePort}} na máquina de origem (detalhes da conexão atual na aba Geral) para a porta {{endpointPort}} na máquina de destino.",
|
"tunnelForwardDescription": "Este túnel encaminhará o tráfego da porta {{sourcePort}} na máquina de origem (detalhes da conexão atual na aba Geral) para a porta {{endpointPort}} na máquina de destino.",
|
||||||
"maxRetries": "Máximo de Tentativas",
|
"maxRetries": "Máximo de Tentativas",
|
||||||
"maxRetriesDescription": "Número máximo de tentativas de reconexão para a conexão do túnel.",
|
"maxRetriesDescription": "Número máximo de tentativas de reconexão para a conexão do túnel.",
|
||||||
"retryInterval": "Intervalo de Tentativas (segundos)",
|
"retryInterval": "Intervalo de Tentativas (segundos)",
|
||||||
@@ -621,14 +633,14 @@
|
|||||||
"autoStartContainer": "Iniciar Automaticamente ao Lançar Container",
|
"autoStartContainer": "Iniciar Automaticamente ao Lançar Container",
|
||||||
"autoStartDesc": "Iniciar automaticamente este túnel quando o container for iniciado",
|
"autoStartDesc": "Iniciar automaticamente este túnel quando o container for iniciado",
|
||||||
"addConnection": "Adicionar Conexão de Túnel",
|
"addConnection": "Adicionar Conexão de Túnel",
|
||||||
"sshpassRequired": "sshpass é necessário para autenticação por senha",
|
"sshpassRequired": "sshpass é necessário para autenticação por senha",
|
||||||
"sshpassRequiredDesc": "Para autenticação por senha em túneis, o sshpass deve estar instalado no sistema.",
|
"sshpassRequiredDesc": "Para autenticação por senha em túneis, o sshpass deve estar instalado no sistema.",
|
||||||
"otherInstallMethods": "Outros métodos de instalação:",
|
"otherInstallMethods": "Outros métodos de instalação:",
|
||||||
"debianUbuntuEquivalent": "(Debian/Ubuntu) ou o equivalente para seu SO.",
|
"debianUbuntuEquivalent": "(Debian/Ubuntu) ou o equivalente para seu SO.",
|
||||||
"or": "ou",
|
"or": "ou",
|
||||||
"centosRhelFedora": "CentOS/RHEL/Fedora",
|
"centosRhelFedora": "CentOS/RHEL/Fedora",
|
||||||
"macos": "macOS",
|
"macos": "macOS",
|
||||||
"windows": "Windows",
|
"windows": "Windows",
|
||||||
"sshServerConfigRequired": "Configuração do Servidor SSH Necessária",
|
"sshServerConfigRequired": "Configuração do Servidor SSH Necessária",
|
||||||
"sshServerConfigDesc": "Para conexões de túnel, o servidor SSH deve ser configurado para permitir o encaminhamento de porta:",
|
"sshServerConfigDesc": "Para conexões de túnel, o servidor SSH deve ser configurado para permitir o encaminhamento de porta:",
|
||||||
"gatewayPortsYes": "para vincular portas remotas a todas as interfaces",
|
"gatewayPortsYes": "para vincular portas remotas a todas as interfaces",
|
||||||
@@ -676,7 +688,29 @@
|
|||||||
"folderRenamed": "Pasta \"{{oldName}}\" renomeada para \"{{newName}}\" com sucesso",
|
"folderRenamed": "Pasta \"{{oldName}}\" renomeada para \"{{newName}}\" com sucesso",
|
||||||
"failedToRenameFolder": "Falha ao renomear pasta",
|
"failedToRenameFolder": "Falha ao renomear pasta",
|
||||||
"movedToFolder": "Host \"{{name}}\" movido para \"{{folder}}\" com sucesso",
|
"movedToFolder": "Host \"{{name}}\" movido para \"{{folder}}\" com sucesso",
|
||||||
"failedToMoveToFolder": "Falha ao mover host para a pasta"
|
"failedToMoveToFolder": "Falha ao mover host para a pasta",
|
||||||
|
"statistics": "Estatísticas",
|
||||||
|
"enabledWidgets": "Widgets Habilitados",
|
||||||
|
"enabledWidgetsDesc": "Selecione quais widgets de estatísticas exibir para este host",
|
||||||
|
"monitoringConfiguration": "Configuração de Monitoramento",
|
||||||
|
"monitoringConfigurationDesc": "Configure com que frequência as estatísticas e o status do servidor são verificados",
|
||||||
|
"statusCheckEnabled": "Habilitar Monitoramento de Status",
|
||||||
|
"statusCheckEnabledDesc": "Verificar se o servidor está online ou offline",
|
||||||
|
"statusCheckInterval": "Intervalo de Verificação de Status",
|
||||||
|
"statusCheckIntervalDesc": "Com que frequência verificar se o host está online (5s - 1h)",
|
||||||
|
"metricsEnabled": "Habilitar Monitoramento de Métricas",
|
||||||
|
"metricsEnabledDesc": "Coletar estatísticas de CPU, RAM, disco e outros sistemas",
|
||||||
|
"metricsInterval": "Intervalo de Coleta de Métricas",
|
||||||
|
"metricsIntervalDesc": "Com que frequência coletar estatísticas do servidor (5s - 1h)",
|
||||||
|
"intervalSeconds": "segundos",
|
||||||
|
"intervalMinutes": "minutos",
|
||||||
|
"intervalValidation": "Os intervalos de monitoramento devem estar entre 5 segundos e 1 hora (3600 segundos)",
|
||||||
|
"monitoringDisabled": "O monitoramento do servidor está desabilitado para este host",
|
||||||
|
"enableMonitoring": "Habilite o monitoramento em Gerenciador de Hosts → aba Estatísticas",
|
||||||
|
"monitoringDisabledBadge": "Monitoramento Desligado",
|
||||||
|
"statusMonitoring": "Status",
|
||||||
|
"metricsMonitoring": "Métricas",
|
||||||
|
"terminalCustomizationNotice": "Nota: As personalizações do terminal funcionam apenas na versão Desktop Website. Aplicativos Mobile e Electron usam as configurações padrão do terminal do sistema."
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "Terminal",
|
"title": "Terminal",
|
||||||
@@ -753,31 +787,31 @@
|
|||||||
"createNewFolder": "Criar Nova Pasta",
|
"createNewFolder": "Criar Nova Pasta",
|
||||||
"folderName": "Nome da Pasta",
|
"folderName": "Nome da Pasta",
|
||||||
"createFolder": "Criar Pasta",
|
"createFolder": "Criar Pasta",
|
||||||
"warningCannotUndo": "Aviso: Esta ação não pode ser desfeita",
|
"warningCannotUndo": "Aviso: Esta ação não pode ser desfeita",
|
||||||
"itemPath": "Caminho do Item",
|
"itemPath": "Caminho do Item",
|
||||||
"thisIsDirectory": "Isto é um diretório (será excluído recursivamente)",
|
"thisIsDirectory": "Isto é um diretório (será excluído recursivamente)",
|
||||||
"deleting": "Excluindo...",
|
"deleting": "Excluindo...",
|
||||||
"currentPathLabel": "Caminho Atual",
|
"currentPathLabel": "Caminho Atual",
|
||||||
"newName": "Novo Nome",
|
"newName": "Novo Nome",
|
||||||
"thisIsDirectoryRename": "Isto é um diretório",
|
"thisIsDirectoryRename": "Isto é um diretório",
|
||||||
"renaming": "Renomeando...",
|
"renaming": "Renomeando...",
|
||||||
"fileUploadedSuccessfully": "Arquivo \"{{name}}\" enviado com sucesso",
|
"fileUploadedSuccessfully": "Arquivo \"{{name}}\" enviado com sucesso",
|
||||||
"failedToUploadFile": "Falha ao enviar arquivo",
|
"failedToUploadFile": "Falha ao enviar arquivo",
|
||||||
"fileDownloadedSuccessfully": "Arquivo \"{{name}}\" baixado com sucesso",
|
"fileDownloadedSuccessfully": "Arquivo \"{{name}}\" baixado com sucesso",
|
||||||
"failedToDownloadFile": "Falha ao baixar arquivo",
|
"failedToDownloadFile": "Falha ao baixar arquivo",
|
||||||
"noFileContent": "Nenhum conteúdo de arquivo recebido",
|
"noFileContent": "Nenhum conteúdo de arquivo recebido",
|
||||||
"filePath": "Caminho do Arquivo",
|
"filePath": "Caminho do Arquivo",
|
||||||
"fileCreatedSuccessfully": "Arquivo \"{{name}}\" criado com sucesso",
|
"fileCreatedSuccessfully": "Arquivo \"{{name}}\" criado com sucesso",
|
||||||
"failedToCreateFile": "Falha ao criar arquivo",
|
"failedToCreateFile": "Falha ao criar arquivo",
|
||||||
"folderCreatedSuccessfully": "Pasta \"{{name}}\" criada com sucesso",
|
"folderCreatedSuccessfully": "Pasta \"{{name}}\" criada com sucesso",
|
||||||
"failedToCreateFolder": "Falha ao criar pasta",
|
"failedToCreateFolder": "Falha ao criar pasta",
|
||||||
"failedToCreateItem": "Falha ao criar item",
|
"failedToCreateItem": "Falha ao criar item",
|
||||||
"operationFailed": "Operação {{operation}} falhou para {{name}}: {{error}}",
|
"operationFailed": "Operação {{operation}} falhou para {{name}}: {{error}}",
|
||||||
"failedToResolveSymlink": "Falha ao resolver link simbólico",
|
"failedToResolveSymlink": "Falha ao resolver link simbólico",
|
||||||
"itemDeletedSuccessfully": "{{type}} excluído com sucesso",
|
"itemDeletedSuccessfully": "{{type}} excluído com sucesso",
|
||||||
"itemsDeletedSuccessfully": "{{count}} itens excluídos com sucesso",
|
"itemsDeletedSuccessfully": "{{count}} itens excluídos com sucesso",
|
||||||
"failedToDeleteItems": "Falha ao excluir itens",
|
"failedToDeleteItems": "Falha ao excluir itens",
|
||||||
"dragFilesToUpload": "Arraste arquivos aqui para enviar",
|
"dragFilesToUpload": "Arraste arquivos aqui para enviar",
|
||||||
"emptyFolder": "Esta pasta está vazia",
|
"emptyFolder": "Esta pasta está vazia",
|
||||||
"itemCount": "{{count}} itens",
|
"itemCount": "{{count}} itens",
|
||||||
"selectedCount": "{{count}} selecionados",
|
"selectedCount": "{{count}} selecionados",
|
||||||
@@ -960,7 +994,7 @@
|
|||||||
"fileSavedSuccessfully": "Arquivo salvo com sucesso",
|
"fileSavedSuccessfully": "Arquivo salvo com sucesso",
|
||||||
"autoSaveFailed": "Falha no salvamento automático",
|
"autoSaveFailed": "Falha no salvamento automático",
|
||||||
"fileAutoSaved": "Arquivo salvo automaticamente",
|
"fileAutoSaved": "Arquivo salvo automaticamente",
|
||||||
|
|
||||||
"moveFileFailed": "Falha ao mover {{name}}",
|
"moveFileFailed": "Falha ao mover {{name}}",
|
||||||
"moveOperationFailed": "Falha na operação de mover",
|
"moveOperationFailed": "Falha na operação de mover",
|
||||||
"canOnlyCompareFiles": "Só é possível comparar dois arquivos",
|
"canOnlyCompareFiles": "Só é possível comparar dois arquivos",
|
||||||
@@ -1378,57 +1412,57 @@
|
|||||||
"passwordRequired": "Senha é obrigatória quando usar autenticação por senha",
|
"passwordRequired": "Senha é obrigatória quando usar autenticação por senha",
|
||||||
"sshKeyRequired": "Chave Privada SSH é obrigatória quando usar autenticação por chave",
|
"sshKeyRequired": "Chave Privada SSH é obrigatória quando usar autenticação por chave",
|
||||||
"keyTypeRequired": "Tipo de Chave é obrigatório quando usar autenticação por chave",
|
"keyTypeRequired": "Tipo de Chave é obrigatório quando usar autenticação por chave",
|
||||||
"validSshConfigRequired": "Deve selecionar uma configuração SSH válida da lista",
|
"validSshConfigRequired": "Deve selecionar uma configuração SSH válida da lista",
|
||||||
"updateHost": "Atualizar Host",
|
"updateHost": "Atualizar Host",
|
||||||
"addHost": "Adicionar Host",
|
"addHost": "Adicionar Host",
|
||||||
"editHost": "Editar Host",
|
"editHost": "Editar Host",
|
||||||
"pinConnection": "Fixar Conexão",
|
"pinConnection": "Fixar Conexão",
|
||||||
"authentication": "Autenticação",
|
"authentication": "Autenticação",
|
||||||
"password": "Senha",
|
"password": "Senha",
|
||||||
"key": "Chave",
|
"key": "Chave",
|
||||||
"sshPrivateKey": "Chave Privada SSH",
|
"sshPrivateKey": "Chave Privada SSH",
|
||||||
"keyPassword": "Senha da Chave",
|
"keyPassword": "Senha da Chave",
|
||||||
"keyType": "Tipo de Chave",
|
"keyType": "Tipo de Chave",
|
||||||
"enableTerminal": "Habilitar Terminal",
|
"enableTerminal": "Habilitar Terminal",
|
||||||
"enableTunnel": "Habilitar Túnel",
|
"enableTunnel": "Habilitar Túnel",
|
||||||
"enableFileManager": "Habilitar Gerenciador de Arquivos",
|
"enableFileManager": "Habilitar Gerenciador de Arquivos",
|
||||||
"defaultPath": "Caminho Padrão",
|
"defaultPath": "Caminho Padrão",
|
||||||
"tunnelConnections": "Conexões de Túnel",
|
"tunnelConnections": "Conexões de Túnel",
|
||||||
"maxRetries": "Máximo de Tentativas",
|
"maxRetries": "Máximo de Tentativas",
|
||||||
"upload": "Enviar",
|
"upload": "Enviar",
|
||||||
"updateKey": "Atualizar Chave",
|
"updateKey": "Atualizar Chave",
|
||||||
"productionFolder": "Produção",
|
"productionFolder": "Produção",
|
||||||
"databaseServer": "Servidor de Banco de Dados",
|
"databaseServer": "Servidor de Banco de Dados",
|
||||||
"developmentServer": "Servidor de Desenvolvimento",
|
"developmentServer": "Servidor de Desenvolvimento",
|
||||||
"developmentFolder": "Desenvolvimento",
|
"developmentFolder": "Desenvolvimento",
|
||||||
"webServerProduction": "Servidor Web - Produção",
|
"webServerProduction": "Servidor Web - Produção",
|
||||||
"unknownError": "Erro desconhecido",
|
"unknownError": "Erro desconhecido",
|
||||||
"failedToInitiatePasswordReset": "Falha ao iniciar redefinição de senha",
|
"failedToInitiatePasswordReset": "Falha ao iniciar redefinição de senha",
|
||||||
"failedToVerifyResetCode": "Falha ao verificar código de redefinição",
|
"failedToVerifyResetCode": "Falha ao verificar código de redefinição",
|
||||||
"failedToCompletePasswordReset": "Falha ao completar redefinição de senha",
|
"failedToCompletePasswordReset": "Falha ao completar redefinição de senha",
|
||||||
"invalidTotpCode": "Código TOTP inválido",
|
"invalidTotpCode": "Código TOTP inválido",
|
||||||
"failedToStartOidcLogin": "Falha ao iniciar login OIDC",
|
"failedToStartOidcLogin": "Falha ao iniciar login OIDC",
|
||||||
"failedToGetUserInfoAfterOidc": "Falha ao obter informações do usuário após login OIDC",
|
"failedToGetUserInfoAfterOidc": "Falha ao obter informações do usuário após login OIDC",
|
||||||
"loginWithExternalProvider": "Login com provedor externo",
|
"loginWithExternalProvider": "Login com provedor externo",
|
||||||
"loginWithExternal": "Login com Provedor Externo",
|
"loginWithExternal": "Login com Provedor Externo",
|
||||||
"sendResetCode": "Enviar Código de Redefinição",
|
"sendResetCode": "Enviar Código de Redefinição",
|
||||||
"verifyCode": "Verificar Código",
|
"verifyCode": "Verificar Código",
|
||||||
"resetPassword": "Redefinir Senha",
|
"resetPassword": "Redefinir Senha",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"signUp": "Cadastrar",
|
"signUp": "Cadastrar",
|
||||||
"failedToUpdateOidcConfig": "Falha ao atualizar configuração OIDC",
|
"failedToUpdateOidcConfig": "Falha ao atualizar configuração OIDC",
|
||||||
"failedToMakeUserAdmin": "Falha ao tornar usuário administrador",
|
"failedToMakeUserAdmin": "Falha ao tornar usuário administrador",
|
||||||
"failedToStartTotpSetup": "Falha ao iniciar configuração TOTP",
|
"failedToStartTotpSetup": "Falha ao iniciar configuração TOTP",
|
||||||
"invalidVerificationCode": "Código de verificação inválido",
|
"invalidVerificationCode": "Código de verificação inválido",
|
||||||
"failedToDisableTotp": "Falha ao desabilitar TOTP",
|
"failedToDisableTotp": "Falha ao desabilitar TOTP",
|
||||||
"failedToGenerateBackupCodes": "Falha ao gerar códigos de backup"
|
"failedToGenerateBackupCodes": "Falha ao gerar códigos de backup"
|
||||||
},
|
},
|
||||||
"mobile": {
|
"mobile": {
|
||||||
"selectHostToStart": "Selecione um host para iniciar sua sessão do terminal",
|
"selectHostToStart": "Selecione um host para iniciar sua sessão do terminal",
|
||||||
"limitedSupportMessage": "O suporte móvel do site ainda está em desenvolvimento. Use o aplicativo móvel para uma melhor experiência.",
|
"limitedSupportMessage": "O suporte móvel do site ainda está em desenvolvimento. Use o aplicativo móvel para uma melhor experiência.",
|
||||||
"mobileAppInProgress": "Aplicativo móvel em desenvolvimento",
|
"mobileAppInProgress": "Aplicativo móvel em desenvolvimento",
|
||||||
"mobileAppInProgressDesc": "Estamos trabalhando em um aplicativo móvel dedicado para proporcionar uma melhor experiência em dispositivos móveis.",
|
"mobileAppInProgressDesc": "Estamos trabalhando em um aplicativo móvel dedicado para proporcionar uma melhor experiência em dispositivos móveis.",
|
||||||
"viewMobileAppDocs": "Instalar Aplicativo Móvel",
|
"viewMobileAppDocs": "Instalar Aplicativo Móvel",
|
||||||
"mobileAppDocumentation": "Documentação do Aplicativo Móvel"
|
"mobileAppDocumentation": "Documentação do Aplicativo Móvel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -567,7 +567,17 @@
|
|||||||
"passwordRequired": "密码为必填项",
|
"passwordRequired": "密码为必填项",
|
||||||
"confirmExport": "确认导出",
|
"confirmExport": "确认导出",
|
||||||
"exportDescription": "将SSH主机和凭据导出为SQLite文件",
|
"exportDescription": "将SSH主机和凭据导出为SQLite文件",
|
||||||
"importDescription": "导入SQLite文件并进行增量合并(跳过重复项)"
|
"importDescription": "导入SQLite文件并进行增量合并(跳过重复项)",
|
||||||
|
"criticalWarning": "严重警告",
|
||||||
|
"cannotDisablePasswordLoginWithoutOIDC": "无法在未配置 OIDC 的情况下禁用密码登录!您必须先配置 OIDC 认证,然后再禁用密码登录,否则您将失去对 Termix 的访问权限。",
|
||||||
|
"confirmDisablePasswordLogin": "您确定要禁用密码登录吗?在继续之前,请确保 OIDC 已正确配置并且正常工作,否则您将失去对 Termix 实例的访问权限。",
|
||||||
|
"passwordLoginDisabled": "密码登录已成功禁用",
|
||||||
|
"passwordLoginAndRegistrationDisabled": "密码登录和新账户注册已成功禁用",
|
||||||
|
"requiresPasswordLogin": "需要启用密码登录",
|
||||||
|
"passwordLoginDisabledWarning": "密码登录已禁用。请确保 OIDC 已正确配置,否则您将无法登录 Termix。",
|
||||||
|
"oidcRequiredWarning": "严重警告:密码登录已禁用。如果您重置或错误配置 OIDC,您将失去对 Termix 的所有访问权限并使您的实例无法使用。只有在您完全确定的情况下才能继续。",
|
||||||
|
"confirmDisableOIDCWarning": "警告:您即将在密码登录也已禁用的情况下禁用 OIDC。这将使您的 Termix 实例无法使用,您将失去所有访问权限。您确定要继续吗?",
|
||||||
|
"failedToUpdatePasswordLoginStatus": "更新密码登录状态失败"
|
||||||
},
|
},
|
||||||
"hosts": {
|
"hosts": {
|
||||||
"title": "主机管理",
|
"title": "主机管理",
|
||||||
@@ -755,7 +765,26 @@
|
|||||||
"failedToMoveToFolder": "移动主机到文件夹失败",
|
"failedToMoveToFolder": "移动主机到文件夹失败",
|
||||||
"statistics": "统计",
|
"statistics": "统计",
|
||||||
"enabledWidgets": "已启用组件",
|
"enabledWidgets": "已启用组件",
|
||||||
"enabledWidgetsDesc": "选择要为此主机显示的统计组件"
|
"enabledWidgetsDesc": "选择要为此主机显示的统计组件",
|
||||||
|
"monitoringConfiguration": "监控配置",
|
||||||
|
"monitoringConfigurationDesc": "配置服务器统计信息和状态的检查频率",
|
||||||
|
"statusCheckEnabled": "启用状态监控",
|
||||||
|
"statusCheckEnabledDesc": "检查服务器是在线还是离线",
|
||||||
|
"statusCheckInterval": "状态检查间隔",
|
||||||
|
"statusCheckIntervalDesc": "检查主机是否在线的频率 (5秒 - 1小时)",
|
||||||
|
"metricsEnabled": "启用指标监控",
|
||||||
|
"metricsEnabledDesc": "收集CPU、内存、磁盘和其他系统统计信息",
|
||||||
|
"metricsInterval": "指标收集间隔",
|
||||||
|
"metricsIntervalDesc": "收集服务器统计信息的频率 (5秒 - 1小时)",
|
||||||
|
"intervalSeconds": "秒",
|
||||||
|
"intervalMinutes": "分钟",
|
||||||
|
"intervalValidation": "监控间隔必须在 5 秒到 1 小时(3600 秒)之间",
|
||||||
|
"monitoringDisabled": "此主机的服务器监控已禁用",
|
||||||
|
"enableMonitoring": "在主机管理器 → 统计选项卡中启用监控",
|
||||||
|
"monitoringDisabledBadge": "监控已关闭",
|
||||||
|
"statusMonitoring": "状态",
|
||||||
|
"metricsMonitoring": "指标",
|
||||||
|
"terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "终端",
|
"title": "终端",
|
||||||
|
|||||||
@@ -55,10 +55,18 @@ function useWindowWidth() {
|
|||||||
function RootApp() {
|
function RootApp() {
|
||||||
const width = useWindowWidth();
|
const width = useWindowWidth();
|
||||||
const isMobile = width < 768;
|
const isMobile = width < 768;
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||||
|
const isTermixMobile = /Termix-Mobile/.test(userAgent);
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
return <DesktopApp />;
|
return <DesktopApp />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTermixMobile) {
|
||||||
|
return <MobileApp key="mobile" />;
|
||||||
|
}
|
||||||
|
|
||||||
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
|
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export interface SSHHostData {
|
|||||||
enableFileManager?: boolean;
|
enableFileManager?: boolean;
|
||||||
defaultPath?: string;
|
defaultPath?: string;
|
||||||
tunnelConnections?: TunnelConnection[];
|
tunnelConnections?: TunnelConnection[];
|
||||||
statsConfig?: string;
|
statsConfig?: string | Record<string, unknown>; // Can be string (from backend) or object (from form)
|
||||||
terminalConfig?: TerminalConfig;
|
terminalConfig?: TerminalConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +374,7 @@ export interface HostManagerProps {
|
|||||||
onSelectView?: (view: string) => void;
|
onSelectView?: (view: string) => void;
|
||||||
isTopbarOpen?: boolean;
|
isTopbarOpen?: boolean;
|
||||||
initialTab?: string;
|
initialTab?: string;
|
||||||
|
hostConfig?: SSHHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHManagerHostEditorProps {
|
export interface SSHManagerHostEditorProps {
|
||||||
|
|||||||
@@ -9,8 +9,18 @@ export type WidgetType =
|
|||||||
|
|
||||||
export interface StatsConfig {
|
export interface StatsConfig {
|
||||||
enabledWidgets: WidgetType[];
|
enabledWidgets: WidgetType[];
|
||||||
|
// Status monitoring configuration
|
||||||
|
statusCheckEnabled: boolean;
|
||||||
|
statusCheckInterval: number; // seconds (5-3600)
|
||||||
|
// Metrics monitoring configuration
|
||||||
|
metricsEnabled: boolean;
|
||||||
|
metricsInterval: number; // seconds (5-3600)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||||
enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"],
|
enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"],
|
||||||
|
statusCheckEnabled: true,
|
||||||
|
statusCheckInterval: 30,
|
||||||
|
metricsEnabled: true,
|
||||||
|
metricsInterval: 30,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -231,6 +231,51 @@ export function AdminSettings({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTogglePasswordLogin = async (checked: boolean) => {
|
const handleTogglePasswordLogin = async (checked: boolean) => {
|
||||||
|
// If disabling password login, warn the user
|
||||||
|
if (!checked) {
|
||||||
|
// Check if OIDC is configured
|
||||||
|
const hasOIDCConfigured =
|
||||||
|
oidcConfig.client_id &&
|
||||||
|
oidcConfig.client_secret &&
|
||||||
|
oidcConfig.issuer_url &&
|
||||||
|
oidcConfig.authorization_url &&
|
||||||
|
oidcConfig.token_url;
|
||||||
|
|
||||||
|
if (!hasOIDCConfigured) {
|
||||||
|
toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmWithToast(
|
||||||
|
t("admin.confirmDisablePasswordLogin"),
|
||||||
|
async () => {
|
||||||
|
setPasswordLoginLoading(true);
|
||||||
|
try {
|
||||||
|
await updatePasswordLoginAllowed(checked);
|
||||||
|
setAllowPasswordLogin(checked);
|
||||||
|
|
||||||
|
// Auto-disable registration when password login is disabled
|
||||||
|
if (allowRegistration) {
|
||||||
|
await updateRegistrationAllowed(false);
|
||||||
|
setAllowRegistration(false);
|
||||||
|
toast.success(t("admin.passwordLoginAndRegistrationDisabled"));
|
||||||
|
} else {
|
||||||
|
toast.success(t("admin.passwordLoginDisabled"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("admin.failedToUpdatePasswordLoginStatus"));
|
||||||
|
} finally {
|
||||||
|
setPasswordLoginLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"destructive",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabling password login - proceed normally
|
||||||
setPasswordLoginLoading(true);
|
setPasswordLoginLoading(true);
|
||||||
try {
|
try {
|
||||||
await updatePasswordLoginAllowed(checked);
|
await updatePasswordLoginAllowed(checked);
|
||||||
@@ -552,9 +597,14 @@ export function AdminSettings({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allowRegistration}
|
checked={allowRegistration}
|
||||||
onCheckedChange={handleToggleRegistration}
|
onCheckedChange={handleToggleRegistration}
|
||||||
disabled={regLoading}
|
disabled={regLoading || !allowPasswordLogin}
|
||||||
/>
|
/>
|
||||||
{t("admin.allowNewAccountRegistration")}
|
{t("admin.allowNewAccountRegistration")}
|
||||||
|
{!allowPasswordLogin && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({t("admin.requiresPasswordLogin")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -588,6 +638,15 @@ export function AdminSettings({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!allowPasswordLogin && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>{t("admin.criticalWarning")}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("admin.oidcRequiredWarning")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{oidcError && (
|
{oidcError && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||||
@@ -733,6 +792,48 @@ export function AdminSettings({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
// Check if password login is enabled
|
||||||
|
if (!allowPasswordLogin) {
|
||||||
|
confirmWithToast(
|
||||||
|
t("admin.confirmDisableOIDCWarning"),
|
||||||
|
async () => {
|
||||||
|
const emptyConfig = {
|
||||||
|
client_id: "",
|
||||||
|
client_secret: "",
|
||||||
|
issuer_url: "",
|
||||||
|
authorization_url: "",
|
||||||
|
token_url: "",
|
||||||
|
identifier_path: "",
|
||||||
|
name_path: "",
|
||||||
|
scopes: "",
|
||||||
|
userinfo_url: "",
|
||||||
|
};
|
||||||
|
setOidcConfig(emptyConfig);
|
||||||
|
setOidcError(null);
|
||||||
|
setOidcLoading(true);
|
||||||
|
try {
|
||||||
|
await disableOIDCConfig();
|
||||||
|
toast.success(
|
||||||
|
t("admin.oidcConfigurationDisabled"),
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setOidcError(
|
||||||
|
(
|
||||||
|
err as {
|
||||||
|
response?: { data?: { error?: string } };
|
||||||
|
}
|
||||||
|
)?.response?.data?.error ||
|
||||||
|
t("admin.failedToDisableOidcConfig"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setOidcLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"destructive",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const emptyConfig = {
|
const emptyConfig = {
|
||||||
client_id: "",
|
client_id: "",
|
||||||
client_secret: "",
|
client_secret: "",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set flags IMMEDIATELY to prevent race conditions
|
||||||
activityLoggingRef.current = true;
|
activityLoggingRef.current = true;
|
||||||
activityLoggedRef.current = true;
|
activityLoggedRef.current = true;
|
||||||
|
|
||||||
@@ -240,6 +241,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
const hostName =
|
const hostName =
|
||||||
currentHost.name || `${currentHost.username}@${currentHost.ip}`;
|
currentHost.name || `${currentHost.username}@${currentHost.ip}`;
|
||||||
await logActivity("file_manager", currentHost.id, hostName);
|
await logActivity("file_manager", currentHost.id, hostName);
|
||||||
|
// Don't reset activityLoggedRef on success - we want to prevent future calls
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to log file manager activity:", err);
|
console.warn("Failed to log file manager activity:", err);
|
||||||
// Reset on error so it can be retried
|
// Reset on error so it can be retried
|
||||||
@@ -337,7 +339,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
initialLoadDoneRef.current = true;
|
initialLoadDoneRef.current = true;
|
||||||
|
|
||||||
// Log activity for recent connections (after successful directory load)
|
// Log activity for recent connections (after successful directory load)
|
||||||
logFileManagerActivity();
|
// Only log if TOTP was not required (if TOTP is required, we'll log after verification)
|
||||||
|
if (!result?.requires_totp) {
|
||||||
|
logFileManagerActivity();
|
||||||
|
}
|
||||||
} catch (dirError: unknown) {
|
} catch (dirError: unknown) {
|
||||||
console.error("Failed to load initial directory:", dirError);
|
console.error("Failed to load initial directory:", dirError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { HostManagerViewer } from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx";
|
import { HostManagerViewer } from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -17,10 +17,13 @@ import type { SSHHost, HostManagerProps } from "../../../types/index";
|
|||||||
export function HostManager({
|
export function HostManager({
|
||||||
isTopbarOpen,
|
isTopbarOpen,
|
||||||
initialTab = "host_viewer",
|
initialTab = "host_viewer",
|
||||||
|
hostConfig,
|
||||||
}: HostManagerProps): React.ReactElement {
|
}: HostManagerProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState(initialTab);
|
const [activeTab, setActiveTab] = useState(initialTab);
|
||||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
const [editingHost, setEditingHost] = useState<SSHHost | null>(
|
||||||
|
hostConfig || null,
|
||||||
|
);
|
||||||
|
|
||||||
const [editingCredential, setEditingCredential] = useState<{
|
const [editingCredential, setEditingCredential] = useState<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -28,6 +31,16 @@ export function HostManager({
|
|||||||
username: string;
|
username: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const { state: sidebarState } = useSidebar();
|
const { state: sidebarState } = useSidebar();
|
||||||
|
const prevHostConfigRef = useRef<SSHHost | undefined>(hostConfig);
|
||||||
|
|
||||||
|
// Update editing host when hostConfig prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (hostConfig && hostConfig !== prevHostConfigRef.current) {
|
||||||
|
setEditingHost(hostConfig);
|
||||||
|
setActiveTab(initialTab || "add_host");
|
||||||
|
prevHostConfigRef.current = hostConfig;
|
||||||
|
}
|
||||||
|
}, [hostConfig, initialTab]);
|
||||||
|
|
||||||
const handleEditHost = (host: SSHHost) => {
|
const handleEditHost = (host: SSHHost) => {
|
||||||
setEditingHost(host);
|
setEditingHost(host);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -43,11 +43,14 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
FolderMinus,
|
FolderMinus,
|
||||||
Copy,
|
Copy,
|
||||||
|
Activity,
|
||||||
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
SSHHost,
|
SSHHost,
|
||||||
SSHManagerHostViewerProps,
|
SSHManagerHostViewerProps,
|
||||||
} from "../../../../types/index.js";
|
} from "../../../../types/index.js";
|
||||||
|
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||||
|
|
||||||
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -122,6 +125,10 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
toast.success(t("hosts.hostDeletedSuccessfully", { name: hostName }));
|
toast.success(t("hosts.hostDeletedSuccessfully", { name: hostName }));
|
||||||
await fetchHosts();
|
await fetchHosts();
|
||||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||||
|
|
||||||
|
// Refresh backend polling to remove deleted host
|
||||||
|
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
||||||
|
refreshServerPolling();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("hosts.failedToDeleteHost"));
|
toast.error(t("hosts.failedToDeleteHost"));
|
||||||
}
|
}
|
||||||
@@ -385,6 +392,48 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to parse stats config and format monitoring status
|
||||||
|
const getMonitoringStatus = (host: SSHHost) => {
|
||||||
|
try {
|
||||||
|
const statsConfig = host.statsConfig
|
||||||
|
? JSON.parse(host.statsConfig)
|
||||||
|
: DEFAULT_STATS_CONFIG;
|
||||||
|
|
||||||
|
const formatInterval = (seconds: number): string => {
|
||||||
|
if (seconds >= 60) {
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${seconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusEnabled = statsConfig.statusCheckEnabled !== false;
|
||||||
|
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||||
|
const statusInterval = statusEnabled
|
||||||
|
? formatInterval(statsConfig.statusCheckInterval || 30)
|
||||||
|
: null;
|
||||||
|
const metricsInterval = metricsEnabled
|
||||||
|
? formatInterval(statsConfig.metricsInterval || 30)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusEnabled,
|
||||||
|
metricsEnabled,
|
||||||
|
statusInterval,
|
||||||
|
metricsInterval,
|
||||||
|
bothDisabled: !statusEnabled && !metricsEnabled,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
statusEnabled: true,
|
||||||
|
metricsEnabled: true,
|
||||||
|
statusInterval: "30s",
|
||||||
|
metricsInterval: "30s",
|
||||||
|
bothDisabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredAndSortedHosts = useMemo(() => {
|
const filteredAndSortedHosts = useMemo(() => {
|
||||||
let filtered = hosts;
|
let filtered = hosts;
|
||||||
|
|
||||||
@@ -1088,6 +1137,49 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
{t("hosts.fileManagerBadge")}
|
{t("hosts.fileManagerBadge")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Monitoring Status Badges */}
|
||||||
|
{(() => {
|
||||||
|
const monitoringStatus =
|
||||||
|
getMonitoringStatus(host);
|
||||||
|
|
||||||
|
if (monitoringStatus.bothDisabled) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs px-1 py-0 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Activity className="h-2 w-2 mr-0.5" />
|
||||||
|
{t("hosts.monitoringDisabledBadge")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{monitoringStatus.statusEnabled && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs px-1 py-0"
|
||||||
|
>
|
||||||
|
<Activity className="h-2 w-2 mr-0.5" />
|
||||||
|
{t("hosts.statusMonitoring")}:{" "}
|
||||||
|
{monitoringStatus.statusInterval}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{monitoringStatus.metricsEnabled && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs px-1 py-0"
|
||||||
|
>
|
||||||
|
<Clock className="h-2 w-2 mr-0.5" />
|
||||||
|
{t("hosts.metricsMonitoring")}:{" "}
|
||||||
|
{monitoringStatus.metricsInterval}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,22 +80,27 @@ export function Server({
|
|||||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||||
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
||||||
|
|
||||||
const enabledWidgets = React.useMemo((): WidgetType[] => {
|
// Parse stats config for monitoring settings
|
||||||
|
const statsConfig = React.useMemo((): StatsConfig => {
|
||||||
if (!currentHostConfig?.statsConfig) {
|
if (!currentHostConfig?.statsConfig) {
|
||||||
return DEFAULT_STATS_CONFIG.enabledWidgets;
|
return DEFAULT_STATS_CONFIG;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed =
|
const parsed =
|
||||||
typeof currentHostConfig.statsConfig === "string"
|
typeof currentHostConfig.statsConfig === "string"
|
||||||
? JSON.parse(currentHostConfig.statsConfig)
|
? JSON.parse(currentHostConfig.statsConfig)
|
||||||
: currentHostConfig.statsConfig;
|
: currentHostConfig.statsConfig;
|
||||||
return parsed?.enabledWidgets || DEFAULT_STATS_CONFIG.enabledWidgets;
|
return { ...DEFAULT_STATS_CONFIG, ...parsed };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to parse statsConfig:", error);
|
console.error("Failed to parse statsConfig:", error);
|
||||||
return DEFAULT_STATS_CONFIG.enabledWidgets;
|
return DEFAULT_STATS_CONFIG;
|
||||||
}
|
}
|
||||||
}, [currentHostConfig?.statsConfig]);
|
}, [currentHostConfig?.statsConfig]);
|
||||||
|
|
||||||
|
const enabledWidgets = statsConfig.enabledWidgets;
|
||||||
|
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
|
||||||
|
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setCurrentHostConfig(hostConfig);
|
setCurrentHostConfig(hostConfig);
|
||||||
}, [hostConfig]);
|
}, [hostConfig]);
|
||||||
@@ -176,7 +181,13 @@ export function Server({
|
|||||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||||
}, [hostConfig?.id]);
|
}, [hostConfig?.id]);
|
||||||
|
|
||||||
|
// Separate effect for status monitoring
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (!statusCheckEnabled || !currentHostConfig?.id || !isVisible) {
|
||||||
|
setServerStatus("offline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let intervalId: number | undefined;
|
let intervalId: number | undefined;
|
||||||
|
|
||||||
@@ -196,15 +207,34 @@ export function Server({
|
|||||||
} else if (err?.response?.status === 504) {
|
} else if (err?.response?.status === 504) {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
} else if (err?.response?.status === 404) {
|
} else if (err?.response?.status === 404) {
|
||||||
|
// Status not available - monitoring disabled
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
} else {
|
} else {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
}
|
}
|
||||||
toast.error(t("serverStats.failedToFetchStatus"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fetchStatus();
|
||||||
|
intervalId = window.setInterval(fetchStatus, 10000); // Poll backend every 10 seconds
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [currentHostConfig?.id, isVisible, statusCheckEnabled]);
|
||||||
|
|
||||||
|
// Separate effect for metrics monitoring
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!metricsEnabled || !currentHostConfig?.id || !isVisible) {
|
||||||
|
setShowStatsUI(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let intervalId: number | undefined;
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
const fetchMetrics = async () => {
|
||||||
if (!currentHostConfig?.id) return;
|
if (!currentHostConfig?.id) return;
|
||||||
try {
|
try {
|
||||||
@@ -221,19 +251,25 @@ export function Server({
|
|||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMetrics(null);
|
|
||||||
setShowStatsUI(false);
|
|
||||||
const err = error as {
|
const err = error as {
|
||||||
code?: string;
|
code?: string;
|
||||||
response?: { status?: number; data?: { error?: string } };
|
response?: { status?: number; data?: { error?: string } };
|
||||||
};
|
};
|
||||||
if (
|
if (err?.response?.status === 404) {
|
||||||
|
// Metrics not available - monitoring disabled
|
||||||
|
setMetrics(null);
|
||||||
|
setShowStatsUI(false);
|
||||||
|
} else if (
|
||||||
err?.code === "TOTP_REQUIRED" ||
|
err?.code === "TOTP_REQUIRED" ||
|
||||||
(err?.response?.status === 403 &&
|
(err?.response?.status === 403 &&
|
||||||
err?.response?.data?.error === "TOTP_REQUIRED")
|
err?.response?.data?.error === "TOTP_REQUIRED")
|
||||||
) {
|
) {
|
||||||
|
setMetrics(null);
|
||||||
|
setShowStatsUI(false);
|
||||||
toast.error(t("serverStats.totpUnavailable"));
|
toast.error(t("serverStats.totpUnavailable"));
|
||||||
} else {
|
} else {
|
||||||
|
setMetrics(null);
|
||||||
|
setShowStatsUI(false);
|
||||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,20 +280,14 @@ export function Server({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (currentHostConfig?.id && isVisible) {
|
fetchMetrics();
|
||||||
fetchStatus();
|
intervalId = window.setInterval(fetchMetrics, 10000); // Poll backend every 10 seconds
|
||||||
fetchMetrics();
|
|
||||||
intervalId = window.setInterval(() => {
|
|
||||||
fetchStatus();
|
|
||||||
fetchMetrics();
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (intervalId) window.clearInterval(intervalId);
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
}, [currentHostConfig?.id, isVisible]);
|
}, [currentHostConfig?.id, isVisible, metricsEnabled]);
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||||
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||||
@@ -297,12 +327,14 @@ export function Server({
|
|||||||
{currentHostConfig?.folder} / {title}
|
{currentHostConfig?.folder} / {title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<Status
|
{statusCheckEnabled && (
|
||||||
status={serverStatus}
|
<Status
|
||||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
status={serverStatus}
|
||||||
>
|
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||||
<StatusIndicator />
|
>
|
||||||
</Status>
|
<StatusIndicator />
|
||||||
|
</Status>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
@@ -410,7 +442,7 @@ export function Server({
|
|||||||
<Separator className="p-0.25 w-full" />
|
<Separator className="p-0.25 w-full" />
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
{showStatsUI && (
|
{metricsEnabled && showStatsUI && (
|
||||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto">
|
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto">
|
||||||
{isLoadingMetrics && !metrics ? (
|
{isLoadingMetrics && !metrics ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ export function SnippetsSidebar({
|
|||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} className="flex-1">
|
<Button onClick={handleSubmit} className="flex-1">
|
||||||
{editingSnippet ? t("common.update") : t("common.create")}
|
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,14 +95,17 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const wasDisconnectedBySSH = useRef(false);
|
const wasDisconnectedBySSH = useRef(false);
|
||||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [isFitted, setIsFitted] = useState(false);
|
||||||
const [, setConnectionError] = useState<string | null>(null);
|
const [, setConnectionError] = useState<string | null>(null);
|
||||||
const [, setIsAuthenticated] = useState(false);
|
const [, setIsAuthenticated] = useState(false);
|
||||||
const [totpRequired, setTotpRequired] = useState(false);
|
const [totpRequired, setTotpRequired] = useState(false);
|
||||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
||||||
const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
|
const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
|
||||||
const isVisibleRef = useRef<boolean>(false);
|
const isVisibleRef = useRef<boolean>(false);
|
||||||
|
const isFittingRef = useRef(false);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const reconnectAttempts = useRef(0);
|
const reconnectAttempts = useRef(0);
|
||||||
const maxReconnectAttempts = 3;
|
const maxReconnectAttempts = 3;
|
||||||
@@ -129,6 +132,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set flags IMMEDIATELY to prevent race conditions
|
||||||
activityLoggingRef.current = true;
|
activityLoggingRef.current = true;
|
||||||
activityLoggedRef.current = true;
|
activityLoggedRef.current = true;
|
||||||
|
|
||||||
@@ -136,6 +140,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const hostName =
|
const hostName =
|
||||||
hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`;
|
hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`;
|
||||||
await logActivity("terminal", hostConfig.id, hostName);
|
await logActivity("terminal", hostConfig.id, hostName);
|
||||||
|
// Don't reset activityLoggedRef on success - we want to prevent future calls
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to log terminal activity:", err);
|
console.warn("Failed to log terminal activity:", err);
|
||||||
// Reset on error so it can be retried
|
// Reset on error so it can be retried
|
||||||
@@ -186,6 +191,32 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function performFit() {
|
||||||
|
if (
|
||||||
|
!fitAddonRef.current ||
|
||||||
|
!terminal ||
|
||||||
|
!isVisibleRef.current ||
|
||||||
|
isFittingRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isFittingRef.current = true;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
|
||||||
|
scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
}
|
||||||
|
hardRefresh();
|
||||||
|
setIsFitted(true);
|
||||||
|
} finally {
|
||||||
|
isFittingRef.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleTotpSubmit(code: string) {
|
function handleTotpSubmit(code: string) {
|
||||||
if (webSocketRef.current && code) {
|
if (webSocketRef.current && code) {
|
||||||
webSocketRef.current.send(
|
webSocketRef.current.send(
|
||||||
@@ -727,7 +758,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
minimumContrastRatio: config.minimumContrastRatio,
|
minimumContrastRatio: config.minimumContrastRatio,
|
||||||
letterSpacing: config.letterSpacing,
|
letterSpacing: config.letterSpacing,
|
||||||
lineHeight: config.lineHeight,
|
lineHeight: config.lineHeight,
|
||||||
bellStyle: config.bellStyle as "none" | "sound",
|
bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
|
||||||
|
|
||||||
theme: {
|
theme: {
|
||||||
background: themeColors.background,
|
background: themeColors.background,
|
||||||
@@ -852,11 +883,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
resizeTimeout.current = setTimeout(() => {
|
resizeTimeout.current = setTimeout(() => {
|
||||||
if (!isVisibleRef.current) return;
|
if (!isVisibleRef.current || !isReady) return;
|
||||||
fitAddonRef.current?.fit();
|
performFit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
}, 50); // Reduced from 150ms to 50ms for snappier response
|
||||||
hardRefresh();
|
|
||||||
}, 150);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(xtermRef.current);
|
resizeObserver.observe(xtermRef.current);
|
||||||
@@ -868,6 +897,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
shouldNotReconnectRef.current = true;
|
shouldNotReconnectRef.current = true;
|
||||||
isReconnectingRef.current = false;
|
isReconnectingRef.current = false;
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
|
setVisible(false);
|
||||||
|
setIsReady(false);
|
||||||
|
isFittingRef.current = false;
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||||
element?.removeEventListener("keydown", handleMacKeyboard, true);
|
element?.removeEventListener("keydown", handleMacKeyboard, true);
|
||||||
@@ -899,11 +931,16 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
readyFonts.then(() => {
|
readyFonts.then(() => {
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
fitAddonRef.current?.fit();
|
fitAddonRef.current?.fit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
|
||||||
|
scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
}
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
|
|
||||||
|
setVisible(true);
|
||||||
|
setIsReady(true);
|
||||||
|
|
||||||
if (terminal && !splitScreen) {
|
if (terminal && !splitScreen) {
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
}
|
}
|
||||||
@@ -921,46 +958,74 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const rows = terminal.rows;
|
const rows = terminal.rows;
|
||||||
|
|
||||||
connectToHost(cols, rows);
|
connectToHost(cols, rows);
|
||||||
}, 200);
|
});
|
||||||
});
|
});
|
||||||
}, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]);
|
}, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible && fitAddonRef.current) {
|
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
|
||||||
setTimeout(() => {
|
// Reset fitted state when becoming invisible
|
||||||
fitAddonRef.current?.fit();
|
if (!isVisible && isFitted) {
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
setIsFitted(false);
|
||||||
hardRefresh();
|
|
||||||
if (terminal && !splitScreen) {
|
|
||||||
terminal.focus();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
if (terminal && !splitScreen) {
|
|
||||||
setTimeout(() => {
|
|
||||||
terminal.focus();
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [isVisible, splitScreen, terminal]);
|
|
||||||
|
|
||||||
|
// When becoming visible, we need to:
|
||||||
|
// 1. Mark as not fitted
|
||||||
|
// 2. Clear any rendering artifacts
|
||||||
|
// 3. Fit to the container size
|
||||||
|
// 4. Mark as fitted (happens in performFit)
|
||||||
|
setIsFitted(false);
|
||||||
|
|
||||||
|
// Use double requestAnimationFrame to ensure container has laid out
|
||||||
|
let rafId1: number;
|
||||||
|
let rafId2: number;
|
||||||
|
|
||||||
|
rafId1 = requestAnimationFrame(() => {
|
||||||
|
rafId2 = requestAnimationFrame(() => {
|
||||||
|
// Force a hard refresh to clear any artifacts
|
||||||
|
hardRefresh();
|
||||||
|
// Fit the terminal to the new size
|
||||||
|
performFit();
|
||||||
|
// Focus will happen after isFitted becomes true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafId1) cancelAnimationFrame(rafId1);
|
||||||
|
if (rafId2) cancelAnimationFrame(rafId2);
|
||||||
|
};
|
||||||
|
}, [isVisible, isReady, splitScreen, terminal]);
|
||||||
|
|
||||||
|
// Focus the terminal after it's been fitted and is visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fitAddonRef.current) return;
|
if (
|
||||||
setTimeout(() => {
|
isFitted &&
|
||||||
fitAddonRef.current?.fit();
|
isVisible &&
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
isReady &&
|
||||||
hardRefresh();
|
!isConnecting &&
|
||||||
if (terminal && !splitScreen && isVisible) {
|
terminal &&
|
||||||
|
!splitScreen
|
||||||
|
) {
|
||||||
|
// Use requestAnimationFrame to ensure the terminal is actually visible in the DOM
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
}
|
});
|
||||||
}, 0);
|
return () => cancelAnimationFrame(rafId);
|
||||||
}, [splitScreen, isVisible, terminal]);
|
}
|
||||||
|
}, [isFitted, isVisible, isReady, isConnecting, terminal, splitScreen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full relative" style={{ backgroundColor }}>
|
<div className="h-full w-full relative" style={{ backgroundColor }}>
|
||||||
<div
|
<div
|
||||||
ref={xtermRef}
|
ref={xtermRef}
|
||||||
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"}`}
|
className="h-full w-full"
|
||||||
|
style={{
|
||||||
|
visibility:
|
||||||
|
isReady && !isConnecting && isFitted ? "visible" : "hidden",
|
||||||
|
opacity: isReady && !isConnecting && isFitted ? 1 : 0,
|
||||||
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (terminal && !splitScreen) {
|
if (terminal && !splitScreen) {
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ function AppContent() {
|
|||||||
onSelectView={handleSelectView}
|
onSelectView={handleSelectView}
|
||||||
isTopbarOpen={isTopbarOpen}
|
isTopbarOpen={isTopbarOpen}
|
||||||
initialTab={currentTabData?.initialTab}
|
initialTab={currentTabData?.initialTab}
|
||||||
|
hostConfig={currentTabData?.hostConfig}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ButtonGroup } from "@/components/ui/button-group";
|
import { ButtonGroup } from "@/components/ui/button-group";
|
||||||
import { EllipsisVertical, Terminal } from "lucide-react";
|
import {
|
||||||
|
EllipsisVertical,
|
||||||
|
Terminal,
|
||||||
|
Server,
|
||||||
|
FolderOpen,
|
||||||
|
Pencil,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -12,9 +18,11 @@ import {
|
|||||||
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext";
|
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext";
|
||||||
import { getServerStatusById } from "@/ui/main-axios";
|
import { getServerStatusById } from "@/ui/main-axios";
|
||||||
import type { HostProps } from "../../../../types";
|
import type { HostProps } from "../../../../types";
|
||||||
|
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||||
|
|
||||||
export function Host({ host }: HostProps): React.ReactElement {
|
export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||||
const { addTab } = useTabs();
|
const { addTab } = useTabs();
|
||||||
|
const [host, setHost] = useState(initialHost);
|
||||||
const [serverStatus, setServerStatus] = useState<
|
const [serverStatus, setServerStatus] = useState<
|
||||||
"online" | "offline" | "degraded"
|
"online" | "offline" | "degraded"
|
||||||
>("degraded");
|
>("degraded");
|
||||||
@@ -25,7 +33,47 @@ export function Host({ host }: HostProps): React.ReactElement {
|
|||||||
? host.name
|
? host.name
|
||||||
: `${host.username}@${host.ip}:${host.port}`;
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
|
||||||
|
// Update host when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setHost(initialHost);
|
||||||
|
}, [initialHost]);
|
||||||
|
|
||||||
|
// Listen for host changes to immediately update config
|
||||||
|
useEffect(() => {
|
||||||
|
const handleHostsChanged = async () => {
|
||||||
|
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||||
|
const hosts = await getSSHHosts();
|
||||||
|
const updatedHost = hosts.find((h) => h.id === host.id);
|
||||||
|
if (updatedHost) {
|
||||||
|
setHost(updatedHost);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||||
|
}, [host.id]);
|
||||||
|
|
||||||
|
// Parse stats config for monitoring settings
|
||||||
|
const statsConfig = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return host.statsConfig
|
||||||
|
? JSON.parse(host.statsConfig)
|
||||||
|
: DEFAULT_STATS_CONFIG;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_STATS_CONFIG;
|
||||||
|
}
|
||||||
|
}, [host.statsConfig]);
|
||||||
|
|
||||||
|
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't poll if status monitoring is disabled
|
||||||
|
if (!shouldShowStatus) {
|
||||||
|
setServerStatus("offline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
const fetchStatus = async () => {
|
||||||
@@ -41,6 +89,9 @@ export function Host({ host }: HostProps): React.ReactElement {
|
|||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
} else if (err?.response?.status === 504) {
|
} else if (err?.response?.status === 504) {
|
||||||
setServerStatus("degraded");
|
setServerStatus("degraded");
|
||||||
|
} else if (err?.response?.status === 404) {
|
||||||
|
// Status not available - monitoring disabled
|
||||||
|
setServerStatus("offline");
|
||||||
} else {
|
} else {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
}
|
}
|
||||||
@@ -49,13 +100,13 @@ export function Host({ host }: HostProps): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
const intervalId = window.setInterval(fetchStatus, 30000);
|
const intervalId = window.setInterval(fetchStatus, 10000); // Poll backend every 10 seconds
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (intervalId) window.clearInterval(intervalId);
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
}, [host.id]);
|
}, [host.id, shouldShowStatus]);
|
||||||
|
|
||||||
const handleTerminalClick = () => {
|
const handleTerminalClick = () => {
|
||||||
addTab({ type: "terminal", title, hostConfig: host });
|
addTab({ type: "terminal", title, hostConfig: host });
|
||||||
@@ -64,12 +115,14 @@ export function Host({ host }: HostProps): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Status
|
{shouldShowStatus && (
|
||||||
status={serverStatus}
|
<Status
|
||||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
status={serverStatus}
|
||||||
>
|
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||||
<StatusIndicator />
|
>
|
||||||
</Status>
|
<StatusIndicator />
|
||||||
|
</Status>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||||
{host.name || host.ip}
|
{host.name || host.ip}
|
||||||
@@ -101,29 +154,39 @@ export function Host({ host }: HostProps): React.ReactElement {
|
|||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
side="right"
|
side="right"
|
||||||
className="min-w-[160px]"
|
className="w-56 bg-dark-bg border-dark-border text-white"
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="font-semibold"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
addTab({ type: "server", title, hostConfig: host })
|
addTab({ type: "server", title, hostConfig: host })
|
||||||
}
|
}
|
||||||
|
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||||
>
|
>
|
||||||
Open Server Details
|
<Server className="h-4 w-4" />
|
||||||
|
<span className="flex-1">Open Server Details</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="font-semibold"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
addTab({ type: "file_manager", title, hostConfig: host })
|
addTab({ type: "file_manager", title, hostConfig: host })
|
||||||
}
|
}
|
||||||
|
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||||
>
|
>
|
||||||
Open File Manager
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
<span className="flex-1">Open File Manager</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="font-semibold"
|
onClick={() =>
|
||||||
onClick={() => alert("Settings clicked")}
|
addTab({
|
||||||
|
type: "ssh_manager",
|
||||||
|
title: "Host Manager",
|
||||||
|
hostConfig: host,
|
||||||
|
initialTab: "add_host",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||||
>
|
>
|
||||||
Edit
|
<Pencil className="h-4 w-4" />
|
||||||
|
<span className="flex-1">Edit</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function LeftSidebar({
|
|||||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||||
const openSshManagerTab = () => {
|
const openSshManagerTab = () => {
|
||||||
if (sshManagerTab || isSplitScreenActive) return;
|
if (sshManagerTab || isSplitScreenActive) return;
|
||||||
const id = addTab({ type: "ssh_manager" });
|
const id = addTab({ type: "ssh_manager", title: "Host Manager" });
|
||||||
setCurrentTab(id);
|
setCurrentTab(id);
|
||||||
};
|
};
|
||||||
const adminTab = tabList.find((t) => t.type === "admin");
|
const adminTab = tabList.find((t) => t.type === "admin");
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface TabContextType {
|
|||||||
port: number;
|
port: number;
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
|
updateTab: (tabId: number, updates: Partial<Omit<Tab, "id">>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||||
@@ -96,6 +97,35 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addTab = (tabData: Omit<Tab, "id">): number => {
|
const addTab = (tabData: Omit<Tab, "id">): number => {
|
||||||
|
// Check if an ssh_manager tab already exists
|
||||||
|
if (tabData.type === "ssh_manager") {
|
||||||
|
const existingTab = tabs.find((t) => t.type === "ssh_manager");
|
||||||
|
if (existingTab) {
|
||||||
|
// Update the existing tab with new data
|
||||||
|
// Create a new object reference to force React to detect the change
|
||||||
|
setTabs((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === existingTab.id
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
hostConfig: tabData.hostConfig
|
||||||
|
? { ...tabData.hostConfig }
|
||||||
|
: undefined,
|
||||||
|
initialTab: tabData.initialTab,
|
||||||
|
// Add a timestamp to force re-render
|
||||||
|
_updateTimestamp: Date.now(),
|
||||||
|
}
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setCurrentTab(existingTab.id);
|
||||||
|
setAllSplitScreenTab((prev) =>
|
||||||
|
prev.filter((tid) => tid !== existingTab.id),
|
||||||
|
);
|
||||||
|
return existingTab.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const id = nextTabId.current++;
|
const id = nextTabId.current++;
|
||||||
const needsUniqueTitle =
|
const needsUniqueTitle =
|
||||||
tabData.type === "terminal" ||
|
tabData.type === "terminal" ||
|
||||||
@@ -203,6 +233,12 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateTab = (tabId: number, updates: Partial<Omit<Tab, "id">>) => {
|
||||||
|
setTabs((prev) =>
|
||||||
|
prev.map((tab) => (tab.id === tabId ? { ...tab, ...updates } : tab)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const value: TabContextType = {
|
const value: TabContextType = {
|
||||||
tabs,
|
tabs,
|
||||||
currentTab,
|
currentTab,
|
||||||
@@ -214,6 +250,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
getTab,
|
getTab,
|
||||||
reorderTabs,
|
reorderTabs,
|
||||||
updateHostConfig,
|
updateHostConfig,
|
||||||
|
updateTab,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
|
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ const languages = [
|
|||||||
{ code: "en", name: "English", nativeName: "English" },
|
{ code: "en", name: "English", nativeName: "English" },
|
||||||
{ code: "zh", name: "Chinese", nativeName: "中文" },
|
{ code: "zh", name: "Chinese", nativeName: "中文" },
|
||||||
{ code: "de", name: "German", nativeName: "Deutsch" },
|
{ code: "de", name: "German", nativeName: "Deutsch" },
|
||||||
|
{
|
||||||
|
code: "ptbr",
|
||||||
|
name: "Brazilian Portuguese",
|
||||||
|
nativeName: "Português Brasileiro",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function LanguageSwitcher() {
|
export function LanguageSwitcher() {
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ export function PasswordReset({ userInfo }: PasswordResetProps) {
|
|||||||
<>
|
<>
|
||||||
{resetStep === "initiate" && (
|
{resetStep === "initiate" && (
|
||||||
<>
|
<>
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTitle>Warning: Data Loss</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Resetting your password will delete all your saved SSH hosts,
|
||||||
|
credentials, and encrypted data. This action cannot be undone.
|
||||||
|
Only use this if you have forgotten your password.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -51,12 +51,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const wasDisconnectedBySSH = useRef(false);
|
const wasDisconnectedBySSH = useRef(false);
|
||||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [, setIsConnected] = useState(false);
|
const [, setIsConnected] = useState(false);
|
||||||
const [, setIsConnecting] = useState(false);
|
const [, setIsConnecting] = useState(false);
|
||||||
const [, setConnectionError] = useState<string | null>(null);
|
const [, setConnectionError] = useState<string | null>(null);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const isVisibleRef = useRef<boolean>(false);
|
const isVisibleRef = useRef<boolean>(false);
|
||||||
const isConnectingRef = useRef(false);
|
const isConnectingRef = useRef(false);
|
||||||
|
const isFittingRef = useRef(false);
|
||||||
|
|
||||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
@@ -104,6 +106,31 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function performFit() {
|
||||||
|
if (
|
||||||
|
!fitAddonRef.current ||
|
||||||
|
!terminal ||
|
||||||
|
!isVisibleRef.current ||
|
||||||
|
isFittingRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isFittingRef.current = true;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
fitAddonRef.current?.fit();
|
||||||
|
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
|
||||||
|
scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
}
|
||||||
|
hardRefresh();
|
||||||
|
} finally {
|
||||||
|
isFittingRef.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleNotify(cols: number, rows: number) {
|
function scheduleNotify(cols: number, rows: number) {
|
||||||
if (!(cols > 0 && rows > 0)) return;
|
if (!(cols > 0 && rows > 0)) return;
|
||||||
pendingSizeRef.current = { cols, rows };
|
pendingSizeRef.current = { cols, rows };
|
||||||
@@ -288,10 +315,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
resizeTimeout.current = setTimeout(() => {
|
resizeTimeout.current = setTimeout(() => {
|
||||||
if (!isVisibleRef.current) return;
|
if (!isVisibleRef.current || !isReady) return;
|
||||||
fitAddonRef.current?.fit();
|
performFit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
|
||||||
hardRefresh();
|
|
||||||
}, 150);
|
}, 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -302,12 +327,13 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
?.ready instanceof Promise
|
?.ready instanceof Promise
|
||||||
? (document as { fonts?: { ready?: Promise<unknown> } }).fonts.ready
|
? (document as { fonts?: { ready?: Promise<unknown> } }).fonts.ready
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
setVisible(true);
|
|
||||||
|
|
||||||
readyFonts.then(() => {
|
readyFonts.then(() => {
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
|
||||||
|
scheduleNotify(terminal.cols, terminal.rows);
|
||||||
|
}
|
||||||
hardRefresh();
|
hardRefresh();
|
||||||
|
|
||||||
const jwtToken = getCookie("jwt");
|
const jwtToken = getCookie("jwt");
|
||||||
@@ -315,6 +341,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
setConnectionError("Authentication required");
|
setConnectionError("Authentication required");
|
||||||
|
setVisible(true);
|
||||||
|
setIsReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +371,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||||
|
|
||||||
if (isConnectingRef.current) {
|
if (isConnectingRef.current) {
|
||||||
|
setVisible(true);
|
||||||
|
setIsReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +400,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
|
|
||||||
setupWebSocketListeners(ws, cols, rows);
|
setupWebSocketListeners(ws, cols, rows);
|
||||||
}, 200);
|
|
||||||
|
setVisible(true);
|
||||||
|
setIsReady(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -382,32 +415,29 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
pingIntervalRef.current = null;
|
pingIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
webSocketRef.current?.close();
|
webSocketRef.current?.close();
|
||||||
|
setVisible(false);
|
||||||
|
setIsReady(false);
|
||||||
|
isFittingRef.current = false;
|
||||||
};
|
};
|
||||||
}, [xtermRef, terminal, hostConfig]);
|
}, [xtermRef, terminal, hostConfig, isAuthenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible && fitAddonRef.current) {
|
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
|
||||||
setTimeout(() => {
|
return;
|
||||||
fitAddonRef.current?.fit();
|
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
|
||||||
hardRefresh();
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
}, [isVisible, terminal]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const fitTimeout = setTimeout(() => {
|
||||||
if (!fitAddonRef.current) return;
|
performFit();
|
||||||
setTimeout(() => {
|
}, 100);
|
||||||
fitAddonRef.current?.fit();
|
|
||||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
return () => clearTimeout(fitTimeout);
|
||||||
hardRefresh();
|
}, [isVisible, isReady, terminal]);
|
||||||
}, 0);
|
|
||||||
}, [isVisible, terminal]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={xtermRef}
|
ref={xtermRef}
|
||||||
className={`h-full w-full m-1 transition-opacity duration-200 ${visible && isVisible ? "opacity-100" : "opacity-0"} overflow-hidden`}
|
className={`h-full w-full m-1 ${isReady && isVisible ? "opacity-100" : "opacity-0"} transition-opacity duration-150 overflow-hidden`}
|
||||||
|
style={{ visibility: isReady ? "visible" : "hidden" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { ButtonGroup } from "@/components/ui/button-group.tsx";
|
import { ButtonGroup } from "@/components/ui/button-group.tsx";
|
||||||
@@ -6,6 +6,7 @@ import { Terminal } from "lucide-react";
|
|||||||
import { getServerStatusById } from "@/ui/main-axios.ts";
|
import { getServerStatusById } from "@/ui/main-axios.ts";
|
||||||
import { useTabs } from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx";
|
import { useTabs } from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx";
|
||||||
import type { HostProps } from "../../../../types/index.js";
|
import type { HostProps } from "../../../../types/index.js";
|
||||||
|
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||||
|
|
||||||
export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
||||||
const { addTab } = useTabs();
|
const { addTab } = useTabs();
|
||||||
@@ -19,7 +20,26 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
|||||||
? host.name
|
? host.name
|
||||||
: `${host.username}@${host.ip}:${host.port}`;
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
|
||||||
|
// Parse stats config for monitoring settings
|
||||||
|
const statsConfig = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return host.statsConfig
|
||||||
|
? JSON.parse(host.statsConfig)
|
||||||
|
: DEFAULT_STATS_CONFIG;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_STATS_CONFIG;
|
||||||
|
}
|
||||||
|
}, [host.statsConfig]);
|
||||||
|
|
||||||
|
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Don't poll if status monitoring is disabled
|
||||||
|
if (!shouldShowStatus) {
|
||||||
|
setServerStatus("offline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
const fetchStatus = async () => {
|
||||||
@@ -36,6 +56,7 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
|||||||
} else if (err?.response?.status === 504) {
|
} else if (err?.response?.status === 504) {
|
||||||
setServerStatus("degraded");
|
setServerStatus("degraded");
|
||||||
} else if (err?.response?.status === 404) {
|
} else if (err?.response?.status === 404) {
|
||||||
|
// Status not available - monitoring disabled
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
} else {
|
} else {
|
||||||
setServerStatus("offline");
|
setServerStatus("offline");
|
||||||
@@ -46,13 +67,13 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
|||||||
|
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
|
|
||||||
const intervalId = window.setInterval(fetchStatus, 30000);
|
const intervalId = window.setInterval(fetchStatus, 10000); // Poll backend every 10 seconds
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (intervalId) window.clearInterval(intervalId);
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
}, [host.id]);
|
}, [host.id, shouldShowStatus]);
|
||||||
|
|
||||||
const handleTerminalClick = () => {
|
const handleTerminalClick = () => {
|
||||||
addTab({ type: "terminal", title, hostConfig: host });
|
addTab({ type: "terminal", title, hostConfig: host });
|
||||||
@@ -62,12 +83,14 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Status
|
{shouldShowStatus && (
|
||||||
status={serverStatus}
|
<Status
|
||||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
status={serverStatus}
|
||||||
>
|
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||||
<StatusIndicator />
|
>
|
||||||
</Status>
|
<StatusIndicator />
|
||||||
|
</Status>
|
||||||
|
)}
|
||||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||||
{host.name || host.ip}
|
{host.name || host.ip}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -746,7 +746,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
|||||||
enableFileManager: Boolean(hostData.enableFileManager),
|
enableFileManager: Boolean(hostData.enableFileManager),
|
||||||
defaultPath: hostData.defaultPath || "/",
|
defaultPath: hostData.defaultPath || "/",
|
||||||
tunnelConnections: hostData.tunnelConnections || [],
|
tunnelConnections: hostData.tunnelConnections || [],
|
||||||
statsConfig: hostData.statsConfig || null,
|
statsConfig: hostData.statsConfig
|
||||||
|
? typeof hostData.statsConfig === "string"
|
||||||
|
? hostData.statsConfig
|
||||||
|
: JSON.stringify(hostData.statsConfig)
|
||||||
|
: null,
|
||||||
terminalConfig: hostData.terminalConfig || null,
|
terminalConfig: hostData.terminalConfig || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -804,7 +808,11 @@ export async function updateSSHHost(
|
|||||||
enableFileManager: Boolean(hostData.enableFileManager),
|
enableFileManager: Boolean(hostData.enableFileManager),
|
||||||
defaultPath: hostData.defaultPath || "/",
|
defaultPath: hostData.defaultPath || "/",
|
||||||
tunnelConnections: hostData.tunnelConnections || [],
|
tunnelConnections: hostData.tunnelConnections || [],
|
||||||
statsConfig: hostData.statsConfig || null,
|
statsConfig: hostData.statsConfig
|
||||||
|
? typeof hostData.statsConfig === "string"
|
||||||
|
? hostData.statsConfig
|
||||||
|
: JSON.stringify(hostData.statsConfig)
|
||||||
|
: null,
|
||||||
terminalConfig: hostData.terminalConfig || null,
|
terminalConfig: hostData.terminalConfig || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1632,6 +1640,15 @@ export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshServerPolling(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await statsApi.post("/refresh");
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail - this is a background operation
|
||||||
|
console.warn("Failed to refresh server polling:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// AUTHENTICATION
|
// AUTHENTICATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user