fix: Squash commit of several fixes and features for many different elements
This commit is contained in:
@@ -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,8 +415,8 @@ 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];
|
||||||
@@ -425,12 +425,13 @@ router.get("/oidc-config", async (req, res) => {
|
|||||||
|
|
||||||
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) {
|
||||||
|
isAuthenticatedAdmin = true;
|
||||||
|
|
||||||
|
// Only decrypt for authenticated admins
|
||||||
|
if (config.client_secret?.startsWith("encrypted:")) {
|
||||||
try {
|
try {
|
||||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (adminDataKey) {
|
if (adminDataKey) {
|
||||||
@@ -450,16 +451,8 @@ router.get("/oidc-config", async (req, res) => {
|
|||||||
});
|
});
|
||||||
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
||||||
}
|
}
|
||||||
} else {
|
} else if (config.client_secret?.startsWith("encoded:")) {
|
||||||
config.client_secret = "[ENCRYPTED - ADMIN ONLY]";
|
// Decode for authenticated admins only
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
|
||||||
}
|
|
||||||
} else if (config.client_secret.startsWith("encoded:")) {
|
|
||||||
try {
|
try {
|
||||||
const decoded = Buffer.from(
|
const decoded = Buffer.from(
|
||||||
config.client_secret.substring(8),
|
config.client_secret.substring(8),
|
||||||
@@ -471,6 +464,25 @@ router.get("/oidc-config", async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
@@ -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 statusEntry = pollingManager.getStatus(id);
|
||||||
const now = new Date().toISOString();
|
if (!statusEntry) {
|
||||||
const statusEntry: StatusEntry = {
|
return res.status(404).json({ error: "Status not available" });
|
||||||
status: isOnline ? "online" : "offline",
|
}
|
||||||
lastChecked: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
hostStatuses.set(id, statusEntry);
|
|
||||||
res.json(statusEntry);
|
res.json(statusEntry);
|
||||||
} catch (err) {
|
|
||||||
statsLogger.error("Failed to check host status", err);
|
|
||||||
res.status(500).json({ error: "Failed to check host status" });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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,16 +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 },
|
cpu: { percent: null, cores: null, load: null },
|
||||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||||
disk: {
|
disk: {
|
||||||
@@ -1378,118 +1524,20 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const metrics = await collectMetrics(host);
|
res.json({
|
||||||
res.json({ ...metrics, lastChecked: new Date().toISOString() });
|
...metricsData.data,
|
||||||
} catch (err) {
|
lastChecked: new Date(metricsData.timestamp).toISOString(),
|
||||||
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 },
|
|
||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
// Only log if TOTP was not required (if TOTP is required, we'll log after verification)
|
||||||
|
if (!result?.requires_totp) {
|
||||||
logFileManagerActivity();
|
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);
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export function HostManagerEditor({
|
|||||||
const [snippets, setSnippets] = useState<
|
const [snippets, setSnippets] = useState<
|
||||||
Array<{ id: number; name: string; content: string }>
|
Array<{ id: number; name: string; content: string }>
|
||||||
>([]);
|
>([]);
|
||||||
|
const [snippetSearch, setSnippetSearch] = useState("");
|
||||||
|
|
||||||
const [authTab, setAuthTab] = useState<
|
const [authTab, setAuthTab] = useState<
|
||||||
"password" | "key" | "credential" | "none"
|
"password" | "key" | "credential" | "none"
|
||||||
@@ -128,6 +129,14 @@ export function HostManagerEditor({
|
|||||||
);
|
);
|
||||||
const isSubmittingRef = useRef(false);
|
const isSubmittingRef = useRef(false);
|
||||||
|
|
||||||
|
// Monitoring interval states
|
||||||
|
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
|
||||||
|
"seconds" | "minutes"
|
||||||
|
>("seconds");
|
||||||
|
const [metricsIntervalUnit, setMetricsIntervalUnit] = useState<
|
||||||
|
"seconds" | "minutes"
|
||||||
|
>("seconds");
|
||||||
|
|
||||||
const ipInputRef = useRef<HTMLInputElement>(null);
|
const ipInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -260,6 +269,10 @@ export function HostManagerEditor({
|
|||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.default(["cpu", "memory", "disk", "network", "uptime", "system"]),
|
.default(["cpu", "memory", "disk", "network", "uptime", "system"]),
|
||||||
|
statusCheckEnabled: z.boolean().default(true),
|
||||||
|
statusCheckInterval: z.number().min(5).max(3600).default(30),
|
||||||
|
metricsEnabled: z.boolean().default(true),
|
||||||
|
metricsInterval: z.number().min(5).max(3600).default(30),
|
||||||
})
|
})
|
||||||
.default({
|
.default({
|
||||||
enabledWidgets: [
|
enabledWidgets: [
|
||||||
@@ -270,6 +283,10 @@ export function HostManagerEditor({
|
|||||||
"uptime",
|
"uptime",
|
||||||
"system",
|
"system",
|
||||||
],
|
],
|
||||||
|
statusCheckEnabled: true,
|
||||||
|
statusCheckInterval: 30,
|
||||||
|
metricsEnabled: true,
|
||||||
|
metricsInterval: 30,
|
||||||
}),
|
}),
|
||||||
terminalConfig: z
|
terminalConfig: z
|
||||||
.object({
|
.object({
|
||||||
@@ -277,7 +294,7 @@ export function HostManagerEditor({
|
|||||||
cursorStyle: z.enum(["block", "underline", "bar"]),
|
cursorStyle: z.enum(["block", "underline", "bar"]),
|
||||||
fontSize: z.number().min(8).max(24),
|
fontSize: z.number().min(8).max(24),
|
||||||
fontFamily: z.string(),
|
fontFamily: z.string(),
|
||||||
letterSpacing: z.number().min(-2).max(5),
|
letterSpacing: z.number().min(-2).max(10),
|
||||||
lineHeight: z.number().min(1.0).max(2.0),
|
lineHeight: z.number().min(1.0).max(2.0),
|
||||||
theme: z.string(),
|
theme: z.string(),
|
||||||
scrollback: z.number().min(1000).max(50000),
|
scrollback: z.number().min(1000).max(50000),
|
||||||
@@ -427,6 +444,22 @@ export function HostManagerEditor({
|
|||||||
: "none";
|
: "none";
|
||||||
setAuthTab(defaultAuthType);
|
setAuthTab(defaultAuthType);
|
||||||
|
|
||||||
|
// Parse statsConfig from JSON string if needed
|
||||||
|
let parsedStatsConfig = DEFAULT_STATS_CONFIG;
|
||||||
|
try {
|
||||||
|
if (cleanedHost.statsConfig) {
|
||||||
|
parsedStatsConfig =
|
||||||
|
typeof cleanedHost.statsConfig === "string"
|
||||||
|
? JSON.parse(cleanedHost.statsConfig)
|
||||||
|
: cleanedHost.statsConfig;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse statsConfig:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with defaults to ensure all new fields are present
|
||||||
|
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
|
||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
name: cleanedHost.name || "",
|
name: cleanedHost.name || "",
|
||||||
ip: cleanedHost.ip || "",
|
ip: cleanedHost.ip || "",
|
||||||
@@ -446,7 +479,7 @@ export function HostManagerEditor({
|
|||||||
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
||||||
defaultPath: cleanedHost.defaultPath || "/",
|
defaultPath: cleanedHost.defaultPath || "/",
|
||||||
tunnelConnections: cleanedHost.tunnelConnections || [],
|
tunnelConnections: cleanedHost.tunnelConnections || [],
|
||||||
statsConfig: cleanedHost.statsConfig || DEFAULT_STATS_CONFIG,
|
statsConfig: parsedStatsConfig,
|
||||||
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -519,6 +552,24 @@ export function HostManagerEditor({
|
|||||||
data.name = `${data.username}@${data.ip}`;
|
data.name = `${data.username}@${data.ip}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate monitoring intervals
|
||||||
|
if (data.statsConfig) {
|
||||||
|
const statusInterval = data.statsConfig.statusCheckInterval || 30;
|
||||||
|
const metricsInterval = data.statsConfig.metricsInterval || 30;
|
||||||
|
|
||||||
|
if (statusInterval < 5 || statusInterval > 3600) {
|
||||||
|
toast.error(t("hosts.intervalValidation"));
|
||||||
|
isSubmittingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metricsInterval < 5 || metricsInterval > 3600) {
|
||||||
|
toast.error(t("hosts.intervalValidation"));
|
||||||
|
isSubmittingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const submitData: Record<string, unknown> = {
|
const submitData: Record<string, unknown> = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
ip: data.ip,
|
ip: data.ip,
|
||||||
@@ -612,6 +663,10 @@ export function HostManagerEditor({
|
|||||||
|
|
||||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||||
|
|
||||||
|
// Refresh backend polling to pick up new/updated host configuration
|
||||||
|
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
||||||
|
refreshServerPolling();
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("hosts.failedToSaveHost"));
|
toast.error(t("hosts.failedToSaveHost"));
|
||||||
@@ -773,9 +828,12 @@ export function HostManagerEditor({
|
|||||||
className="flex flex-col flex-1 min-h-0 h-full"
|
className="flex flex-col flex-1 min-h-0 h-full"
|
||||||
>
|
>
|
||||||
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
||||||
|
<div className="pr-4">
|
||||||
<Tabs defaultValue="general" className="w-full">
|
<Tabs defaultValue="general" className="w-full">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="general">{t("hosts.general")}</TabsTrigger>
|
<TabsTrigger value="general">
|
||||||
|
{t("hosts.general")}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="terminal">
|
<TabsTrigger value="terminal">
|
||||||
{t("hosts.terminal")}
|
{t("hosts.terminal")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -945,7 +1003,9 @@ export function HostManagerEditor({
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === " " && tagInput.trim() !== "") {
|
if (e.key === " " && tagInput.trim() !== "") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!field.value.includes(tagInput.trim())) {
|
if (
|
||||||
|
!field.value.includes(tagInput.trim())
|
||||||
|
) {
|
||||||
field.onChange([
|
field.onChange([
|
||||||
...field.value,
|
...field.value,
|
||||||
tagInput.trim(),
|
tagInput.trim(),
|
||||||
@@ -1054,7 +1114,9 @@ export function HostManagerEditor({
|
|||||||
name="key"
|
name="key"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="mb-4">
|
<FormItem className="mb-4">
|
||||||
<FormLabel>{t("hosts.sshPrivateKey")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.sshPrivateKey")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block">
|
||||||
<input
|
<input
|
||||||
@@ -1099,7 +1161,9 @@ export function HostManagerEditor({
|
|||||||
name="key"
|
name="key"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="mb-4">
|
<FormItem className="mb-4">
|
||||||
<FormLabel>{t("hosts.sshPrivateKey")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.sshPrivateKey")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
value={
|
value={
|
||||||
@@ -1216,7 +1280,10 @@ export function HostManagerEditor({
|
|||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
onCredentialSelect={(credential) => {
|
onCredentialSelect={(credential) => {
|
||||||
if (credential) {
|
if (credential) {
|
||||||
form.setValue("username", credential.username);
|
form.setValue(
|
||||||
|
"username",
|
||||||
|
credential.username,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1261,6 +1328,11 @@ export function HostManagerEditor({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Alert className="mt-4 mb-4">
|
||||||
|
<AlertDescription>
|
||||||
|
{t("hosts.terminalCustomizationNotice")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
<h1 className="text-xl font-semibold mt-7">
|
<h1 className="text-xl font-semibold mt-7">
|
||||||
Terminal Customization
|
Terminal Customization
|
||||||
</h1>
|
</h1>
|
||||||
@@ -1276,8 +1348,12 @@ export function HostManagerEditor({
|
|||||||
theme={form.watch("terminalConfig.theme")}
|
theme={form.watch("terminalConfig.theme")}
|
||||||
fontSize={form.watch("terminalConfig.fontSize")}
|
fontSize={form.watch("terminalConfig.fontSize")}
|
||||||
fontFamily={form.watch("terminalConfig.fontFamily")}
|
fontFamily={form.watch("terminalConfig.fontFamily")}
|
||||||
cursorStyle={form.watch("terminalConfig.cursorStyle")}
|
cursorStyle={form.watch(
|
||||||
cursorBlink={form.watch("terminalConfig.cursorBlink")}
|
"terminalConfig.cursorStyle",
|
||||||
|
)}
|
||||||
|
cursorBlink={form.watch(
|
||||||
|
"terminalConfig.cursorBlink",
|
||||||
|
)}
|
||||||
letterSpacing={form.watch(
|
letterSpacing={form.watch(
|
||||||
"terminalConfig.letterSpacing",
|
"terminalConfig.letterSpacing",
|
||||||
)}
|
)}
|
||||||
@@ -1538,7 +1614,12 @@ export function HostManagerEditor({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
How to handle terminal bell (BEL character)
|
How to handle terminal bell (BEL character,
|
||||||
|
\x07). Programs trigger this when completing
|
||||||
|
tasks, encountering errors, or for
|
||||||
|
notifications. "Sound" plays an audio beep,
|
||||||
|
"Visual" flashes the screen briefly, "Both" does
|
||||||
|
both, "None" disables bell alerts.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -1643,7 +1724,8 @@ export function HostManagerEditor({
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Automatically adjust colors for better readability
|
Automatically adjust colors for better
|
||||||
|
readability
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -1664,7 +1746,8 @@ export function HostManagerEditor({
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>SSH Agent Forwarding</FormLabel>
|
<FormLabel>SSH Agent Forwarding</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Forward SSH authentication agent to remote host
|
Forward SSH authentication agent to remote
|
||||||
|
host
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -1717,11 +1800,12 @@ export function HostManagerEditor({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Startup Snippet</FormLabel>
|
<FormLabel>Startup Snippet</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => {
|
||||||
field.onChange(
|
field.onChange(
|
||||||
value === "none" ? null : parseInt(value),
|
value === "none" ? null : parseInt(value),
|
||||||
)
|
);
|
||||||
}
|
setSnippetSearch("");
|
||||||
|
}}
|
||||||
value={field.value?.toString() || "none"}
|
value={field.value?.toString() || "none"}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -1730,8 +1814,29 @@ export function HostManagerEditor({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<div className="px-2 pb-2 sticky top-0 bg-popover z-10">
|
||||||
|
<Input
|
||||||
|
placeholder="Search snippets..."
|
||||||
|
value={snippetSearch}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSnippetSearch(e.target.value)
|
||||||
|
}
|
||||||
|
className="h-8"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-y-auto">
|
||||||
<SelectItem value="none">None</SelectItem>
|
<SelectItem value="none">None</SelectItem>
|
||||||
{snippets.map((snippet) => (
|
{snippets
|
||||||
|
.filter((snippet) =>
|
||||||
|
snippet.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(
|
||||||
|
snippetSearch.toLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((snippet) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={snippet.id}
|
key={snippet.id}
|
||||||
value={snippet.id.toString()}
|
value={snippet.id.toString()}
|
||||||
@@ -1739,6 +1844,17 @@ export function HostManagerEditor({
|
|||||||
{snippet.name}
|
{snippet.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
{snippets.filter((snippet) =>
|
||||||
|
snippet.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(snippetSearch.toLowerCase()),
|
||||||
|
).length === 0 &&
|
||||||
|
snippetSearch && (
|
||||||
|
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No snippets found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -1973,7 +2089,9 @@ export function HostManagerEditor({
|
|||||||
name="tunnelConnections"
|
name="tunnelConnections"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="mt-4">
|
<FormItem className="mt-4">
|
||||||
<FormLabel>{t("hosts.tunnelConnections")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.tunnelConnections")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{field.value.map((connection, index) => (
|
{field.value.map((connection, index) => (
|
||||||
@@ -2004,7 +2122,9 @@ export function HostManagerEditor({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`tunnelConnections.${index}.sourcePort`}
|
name={`tunnelConnections.${index}.sourcePort`}
|
||||||
render={({ field: sourcePortField }) => (
|
render={({
|
||||||
|
field: sourcePortField,
|
||||||
|
}) => (
|
||||||
<FormItem className="col-span-4">
|
<FormItem className="col-span-4">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.sourcePort")}
|
{t("hosts.sourcePort")}
|
||||||
@@ -2136,7 +2256,9 @@ export function HostManagerEditor({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`tunnelConnections.${index}.maxRetries`}
|
name={`tunnelConnections.${index}.maxRetries`}
|
||||||
render={({ field: maxRetriesField }) => (
|
render={({
|
||||||
|
field: maxRetriesField,
|
||||||
|
}) => (
|
||||||
<FormItem className="col-span-4">
|
<FormItem className="col-span-4">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.maxRetries")}
|
{t("hosts.maxRetries")}
|
||||||
@@ -2274,7 +2396,209 @@ export function HostManagerEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="statistics">
|
<TabsContent value="statistics" className="space-y-6">
|
||||||
|
{/* Monitoring Configuration Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status Check Monitoring */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="statsConfig.statusCheckEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>
|
||||||
|
{t("hosts.statusCheckEnabled")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{t("hosts.statusCheckEnabledDesc")}
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.watch("statsConfig.statusCheckEnabled") && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="statsConfig.statusCheckInterval"
|
||||||
|
render={({ field }) => {
|
||||||
|
const displayValue =
|
||||||
|
statusIntervalUnit === "minutes"
|
||||||
|
? Math.round((field.value || 30) / 60)
|
||||||
|
: field.value || 30;
|
||||||
|
|
||||||
|
const handleIntervalChange = (value: string) => {
|
||||||
|
const numValue = parseInt(value) || 0;
|
||||||
|
const seconds =
|
||||||
|
statusIntervalUnit === "minutes"
|
||||||
|
? numValue * 60
|
||||||
|
: numValue;
|
||||||
|
field.onChange(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("hosts.statusCheckInterval")}
|
||||||
|
</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={displayValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleIntervalChange(e.target.value)
|
||||||
|
}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Select
|
||||||
|
value={statusIntervalUnit}
|
||||||
|
onValueChange={(
|
||||||
|
value: "seconds" | "minutes",
|
||||||
|
) => {
|
||||||
|
setStatusIntervalUnit(value);
|
||||||
|
// Convert current value to new unit
|
||||||
|
const currentSeconds = field.value || 30;
|
||||||
|
if (value === "minutes") {
|
||||||
|
const minutes = Math.round(
|
||||||
|
currentSeconds / 60,
|
||||||
|
);
|
||||||
|
field.onChange(minutes * 60);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="seconds">
|
||||||
|
{t("hosts.intervalSeconds")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="minutes">
|
||||||
|
{t("hosts.intervalMinutes")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
{t("hosts.statusCheckIntervalDesc")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Monitoring */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="statsConfig.metricsEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>{t("hosts.metricsEnabled")}</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{t("hosts.metricsEnabledDesc")}
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.watch("statsConfig.metricsEnabled") && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="statsConfig.metricsInterval"
|
||||||
|
render={({ field }) => {
|
||||||
|
const displayValue =
|
||||||
|
metricsIntervalUnit === "minutes"
|
||||||
|
? Math.round((field.value || 30) / 60)
|
||||||
|
: field.value || 30;
|
||||||
|
|
||||||
|
const handleIntervalChange = (value: string) => {
|
||||||
|
const numValue = parseInt(value) || 0;
|
||||||
|
const seconds =
|
||||||
|
metricsIntervalUnit === "minutes"
|
||||||
|
? numValue * 60
|
||||||
|
: numValue;
|
||||||
|
field.onChange(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("hosts.metricsInterval")}
|
||||||
|
</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={displayValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleIntervalChange(e.target.value)
|
||||||
|
}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Select
|
||||||
|
value={metricsIntervalUnit}
|
||||||
|
onValueChange={(
|
||||||
|
value: "seconds" | "minutes",
|
||||||
|
) => {
|
||||||
|
setMetricsIntervalUnit(value);
|
||||||
|
// Convert current value to new unit
|
||||||
|
const currentSeconds = field.value || 30;
|
||||||
|
if (value === "minutes") {
|
||||||
|
const minutes = Math.round(
|
||||||
|
currentSeconds / 60,
|
||||||
|
);
|
||||||
|
field.onChange(minutes * 60);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="seconds">
|
||||||
|
{t("hosts.intervalSeconds")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="minutes">
|
||||||
|
{t("hosts.intervalMinutes")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
{t("hosts.metricsIntervalDesc")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Only show widget selection if metrics monitoring is enabled */}
|
||||||
|
{form.watch("statsConfig.metricsEnabled") && (
|
||||||
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="statsConfig.enabledWidgets"
|
name="statsConfig.enabledWidgets"
|
||||||
@@ -2305,22 +2629,30 @@ export function HostManagerEditor({
|
|||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
const currentWidgets = field.value || [];
|
const currentWidgets = field.value || [];
|
||||||
if (checked) {
|
if (checked) {
|
||||||
field.onChange([...currentWidgets, widget]);
|
field.onChange([
|
||||||
|
...currentWidgets,
|
||||||
|
widget,
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
field.onChange(
|
field.onChange(
|
||||||
currentWidgets.filter((w) => w !== widget),
|
currentWidgets.filter(
|
||||||
|
(w) => w !== widget,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
{widget === "cpu" && t("serverStats.cpuUsage")}
|
{widget === "cpu" &&
|
||||||
|
t("serverStats.cpuUsage")}
|
||||||
{widget === "memory" &&
|
{widget === "memory" &&
|
||||||
t("serverStats.memoryUsage")}
|
t("serverStats.memoryUsage")}
|
||||||
{widget === "disk" && t("serverStats.diskUsage")}
|
{widget === "disk" &&
|
||||||
|
t("serverStats.diskUsage")}
|
||||||
{widget === "network" &&
|
{widget === "network" &&
|
||||||
t("serverStats.networkInterfaces")}
|
t("serverStats.networkInterfaces")}
|
||||||
{widget === "uptime" && t("serverStats.uptime")}
|
{widget === "uptime" &&
|
||||||
|
t("serverStats.uptime")}
|
||||||
{widget === "processes" &&
|
{widget === "processes" &&
|
||||||
t("serverStats.processes")}
|
t("serverStats.processes")}
|
||||||
{widget === "system" &&
|
{widget === "system" &&
|
||||||
@@ -2332,8 +2664,11 @@ export function HostManagerEditor({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<footer className="shrink-0 w-full pb-0">
|
<footer className="shrink-0 w-full pb-0">
|
||||||
<Separator className="p-0.25" />
|
<Separator className="p-0.25" />
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
fetchStatus();
|
|
||||||
fetchMetrics();
|
fetchMetrics();
|
||||||
intervalId = window.setInterval(() => {
|
intervalId = window.setInterval(fetchMetrics, 10000); // Poll backend every 10 seconds
|
||||||
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>
|
||||||
|
{statusCheckEnabled && (
|
||||||
<Status
|
<Status
|
||||||
status={serverStatus}
|
status={serverStatus}
|
||||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||||
>
|
>
|
||||||
<StatusIndicator />
|
<StatusIndicator />
|
||||||
</Status>
|
</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);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
hardRefresh();
|
||||||
if (terminal && !splitScreen) {
|
// Fit the terminal to the new size
|
||||||
terminal.focus();
|
performFit();
|
||||||
}
|
// Focus will happen after isFitted becomes true
|
||||||
}, 0);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (terminal && !splitScreen) {
|
return () => {
|
||||||
setTimeout(() => {
|
if (rafId1) cancelAnimationFrame(rafId1);
|
||||||
terminal.focus();
|
if (rafId2) cancelAnimationFrame(rafId2);
|
||||||
}, 100);
|
};
|
||||||
}
|
}, [isVisible, isReady, splitScreen, terminal]);
|
||||||
}
|
|
||||||
}, [isVisible, 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();
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
}
|
}
|
||||||
}, 0);
|
}, [isFitted, isVisible, isReady, isConnecting, terminal, splitScreen]);
|
||||||
}, [splitScreen, isVisible, terminal]);
|
|
||||||
|
|
||||||
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">
|
||||||
|
{shouldShowStatus && (
|
||||||
<Status
|
<Status
|
||||||
status={serverStatus}
|
status={serverStatus}
|
||||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||||
>
|
>
|
||||||
<StatusIndicator />
|
<StatusIndicator />
|
||||||
</Status>
|
</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">
|
||||||
|
{shouldShowStatus && (
|
||||||
<Status
|
<Status
|
||||||
status={serverStatus}
|
status={serverStatus}
|
||||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||||
>
|
>
|
||||||
<StatusIndicator />
|
<StatusIndicator />
|
||||||
</Status>
|
</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