v1.8.0 #429
@@ -15,6 +15,10 @@ const authManager = AuthManager.getInstance();
|
||||
// Track server start time
|
||||
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(
|
||||
cors({
|
||||
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
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
@@ -148,36 +178,6 @@ app.post("/activity/log", async (req, res) => {
|
||||
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
|
||||
const result = (await SimpleDBOps.insert(
|
||||
recentActivity,
|
||||
|
||||
@@ -415,8 +415,8 @@ router.get("/oidc-config", async (req, res) => {
|
||||
|
||||
let config = JSON.parse((row as Record<string, unknown>).value as string);
|
||||
|
||||
if (config.client_secret) {
|
||||
if (config.client_secret.startsWith("encrypted:")) {
|
||||
// Check if user is authenticated admin
|
||||
let isAuthenticatedAdmin = false;
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const token = authHeader.split(" ")[1];
|
||||
@@ -425,12 +425,13 @@ router.get("/oidc-config", async (req, res) => {
|
||||
|
||||
if (payload) {
|
||||
const userId = payload.userId;
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
|
||||
if (user && user.length > 0 && user[0].is_admin) {
|
||||
isAuthenticatedAdmin = true;
|
||||
|
||||
// Only decrypt for authenticated admins
|
||||
if (config.client_secret?.startsWith("encrypted:")) {
|
||||
try {
|
||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (adminDataKey) {
|
||||
@@ -450,16 +451,8 @@ router.get("/oidc-config", async (req, res) => {
|
||||
});
|
||||
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
||||
}
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - ADMIN ONLY]";
|
||||
}
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
||||
}
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - AUTH REQUIRED]";
|
||||
}
|
||||
} else if (config.client_secret.startsWith("encoded:")) {
|
||||
} else if (config.client_secret?.startsWith("encoded:")) {
|
||||
// Decode for authenticated admins only
|
||||
try {
|
||||
const decoded = Buffer.from(
|
||||
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);
|
||||
} catch (err) {
|
||||
@@ -940,7 +952,7 @@ router.post("/login", async (req, res) => {
|
||||
}
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
expiresIn: "24h",
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
authLogger.success(`User logged in successfully: ${username}`, {
|
||||
@@ -968,7 +980,7 @@ router.post("/login", async (req, res) => {
|
||||
.cookie(
|
||||
"jwt",
|
||||
token,
|
||||
authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000),
|
||||
authManager.getSecureCookieOptions(req, 7 * 24 * 60 * 60 * 1000),
|
||||
)
|
||||
.json(response);
|
||||
} catch (err) {
|
||||
@@ -1386,9 +1398,27 @@ router.post("/complete-reset", async (req, res) => {
|
||||
.where(eq(users.username, username));
|
||||
|
||||
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);
|
||||
authManager.logoutUser(userId);
|
||||
|
||||
// Clear TOTP settings
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
@@ -1399,16 +1429,16 @@ router.post("/complete-reset", async (req, res) => {
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
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,
|
||||
username,
|
||||
},
|
||||
);
|
||||
} catch (encryptionError) {
|
||||
authLogger.error(
|
||||
"Failed to re-encrypt user data after password reset",
|
||||
"Failed to setup user data encryption after password reset",
|
||||
encryptionError,
|
||||
{
|
||||
operation: "password_reset_encryption_failed",
|
||||
@@ -1417,8 +1447,7 @@ router.post("/complete-reset", async (req, res) => {
|
||||
},
|
||||
);
|
||||
return res.status(500).json({
|
||||
error:
|
||||
"Password reset completed but user data encryption failed. Please contact administrator.",
|
||||
error: "Password reset failed. Please contact administrator.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -266,31 +266,44 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
keepaliveCountMax: 3,
|
||||
algorithms: {
|
||||
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-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
@@ -335,6 +348,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
.status(400)
|
||||
.json({ error: "Password required for password authentication" });
|
||||
}
|
||||
config.password = resolvedCredentials.password;
|
||||
} else if (resolvedCredentials.authType === "none") {
|
||||
// Don't set password in config - rely on keyboard-interactive
|
||||
} else {
|
||||
|
||||
@@ -406,6 +406,197 @@ type StatusEntry = {
|
||||
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(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
@@ -460,8 +651,6 @@ app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||
|
||||
async function fetchAllHosts(
|
||||
userId: string,
|
||||
): Promise<SSHHostWithCredentials[]> {
|
||||
@@ -499,11 +688,6 @@ async function fetchHostById(
|
||||
): Promise<SSHHostWithCredentials | undefined> {
|
||||
try {
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
statsLogger.debug("User data locked - cannot fetch host", {
|
||||
operation: "fetchHostById_data_locked",
|
||||
userId,
|
||||
hostId: id,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -637,31 +821,44 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||
readyTimeout: 10_000,
|
||||
algorithms: {
|
||||
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-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
@@ -999,7 +1196,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
);
|
||||
const netStatOut = await execCommand(
|
||||
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
|
||||
@@ -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) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
@@ -1285,11 +1441,14 @@ app.get("/status", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (hostStatuses.size === 0) {
|
||||
await pollStatusesOnce(userId);
|
||||
// Initialize polling if no hosts are being polled yet
|
||||
const statuses = pollingManager.getAllStatuses();
|
||||
if (statuses.size === 0) {
|
||||
await pollingManager.initializePolling(userId);
|
||||
}
|
||||
|
||||
const result: Record<number, StatusEntry> = {};
|
||||
for (const [id, entry] of hostStatuses.entries()) {
|
||||
for (const [id, entry] of pollingManager.getAllStatuses().entries()) {
|
||||
result[id] = entry;
|
||||
}
|
||||
res.json(result);
|
||||
@@ -1306,25 +1465,18 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const host = await fetchHostById(id, userId);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
// Initialize polling if no hosts are being polled yet
|
||||
const statuses = pollingManager.getAllStatuses();
|
||||
if (statuses.size === 0) {
|
||||
await pollingManager.initializePolling(userId);
|
||||
}
|
||||
|
||||
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
||||
const now = new Date().toISOString();
|
||||
const statusEntry: StatusEntry = {
|
||||
status: isOnline ? "online" : "offline",
|
||||
lastChecked: now,
|
||||
};
|
||||
const statusEntry = pollingManager.getStatus(id);
|
||||
if (!statusEntry) {
|
||||
return res.status(404).json({ error: "Status not available" });
|
||||
}
|
||||
|
||||
hostStatuses.set(id, statusEntry);
|
||||
res.json(statusEntry);
|
||||
} catch (err) {
|
||||
statsLogger.error("Failed to check host status", err);
|
||||
res.status(500).json({ error: "Failed to check host status" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/refresh", async (req, res) => {
|
||||
@@ -1337,8 +1489,8 @@ app.post("/refresh", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
await pollStatusesOnce(userId);
|
||||
res.json({ message: "Refreshed" });
|
||||
await pollingManager.refreshHostPolling(userId);
|
||||
res.json({ message: "Polling refreshed" });
|
||||
});
|
||||
|
||||
app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
@@ -1352,16 +1504,10 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const host = await fetchHostById(id, userId);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
||||
if (!isOnline) {
|
||||
return res.status(503).json({
|
||||
error: "Host is offline",
|
||||
const metricsData = pollingManager.getMetrics(id);
|
||||
if (!metricsData) {
|
||||
return res.status(404).json({
|
||||
error: "Metrics not available",
|
||||
cpu: { percent: null, cores: null, load: null },
|
||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||
disk: {
|
||||
@@ -1378,118 +1524,20 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = await collectMetrics(host);
|
||||
res.json({ ...metrics, lastChecked: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
|
||||
// Check if this is a skip due to auth failure tracking
|
||||
if (
|
||||
errorMessage.includes("TOTP authentication required") ||
|
||||
errorMessage.includes("metrics unavailable")
|
||||
) {
|
||||
// Don't log as error - this is expected for TOTP hosts
|
||||
return res.status(403).json({
|
||||
error: "TOTP_REQUIRED",
|
||||
message: errorMessage,
|
||||
cpu: { percent: null, cores: null, load: null },
|
||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||
disk: {
|
||||
percent: null,
|
||||
usedHuman: null,
|
||||
totalHuman: null,
|
||||
availableHuman: null,
|
||||
},
|
||||
network: { interfaces: [] },
|
||||
uptime: { seconds: null, formatted: null },
|
||||
processes: { total: null, running: null, top: [] },
|
||||
system: { hostname: null, kernel: null, os: null },
|
||||
lastChecked: new Date().toISOString(),
|
||||
res.json({
|
||||
...metricsData.data,
|
||||
lastChecked: new Date(metricsData.timestamp).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", () => {
|
||||
pollingManager.destroy();
|
||||
connectionPool.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
pollingManager.destroy();
|
||||
connectionPool.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -870,40 +870,44 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
},
|
||||
algorithms: {
|
||||
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-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"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",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-rsa",
|
||||
"rsa-sha2-256",
|
||||
"rsa-sha2-512",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"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-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
@@ -913,6 +917,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
if (resolvedCredentials.authType === "none") {
|
||||
// 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 (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.key
|
||||
@@ -954,20 +973,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}),
|
||||
);
|
||||
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 {
|
||||
sshLogger.error("No valid authentication method provided");
|
||||
ws.send(
|
||||
|
||||
@@ -914,31 +914,44 @@ async function connectSSHTunnel(
|
||||
tcpKeepAliveInitialDelay: 15000,
|
||||
algorithms: {
|
||||
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-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
|
||||
@@ -142,7 +142,7 @@ class AuthManager {
|
||||
}
|
||||
|
||||
return jwt.sign(payload, jwtSecret, {
|
||||
expiresIn: options.expiresIn || "24h",
|
||||
expiresIn: options.expiresIn || "7d",
|
||||
} as jwt.SignOptions);
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ class AuthManager {
|
||||
|
||||
getSecureCookieOptions(
|
||||
req: RequestWithHeaders,
|
||||
maxAge: number = 24 * 60 * 60 * 1000,
|
||||
maxAge: number = 7 * 24 * 60 * 60 * 1000,
|
||||
) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
|
||||
@@ -5,12 +5,13 @@ import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import enTranslation from "../locales/en/translation.json";
|
||||
import zhTranslation from "../locales/zh/translation.json";
|
||||
import deTranslation from "../locales/de/translation.json";
|
||||
import ptbrTranslation from "../locales/pt-br/translation.json";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
supportedLngs: ["en", "zh", "de"],
|
||||
supportedLngs: ["en", "zh", "de", "ptbr"],
|
||||
fallbackLng: "en",
|
||||
debug: false,
|
||||
|
||||
@@ -32,6 +33,9 @@ i18n
|
||||
de: {
|
||||
translation: deTranslation,
|
||||
},
|
||||
ptbr: {
|
||||
translation: ptbrTranslation,
|
||||
},
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
|
||||
@@ -529,7 +529,19 @@
|
||||
"passwordRequired": "Passwort erforderlich",
|
||||
"confirmExport": "Export bestätigen",
|
||||
"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": {
|
||||
"title": "Host-Manager",
|
||||
@@ -623,6 +635,7 @@
|
||||
"password": "Passwort",
|
||||
"key": "Schlüssel",
|
||||
"credential": "Anmeldedaten",
|
||||
"none": "Keine",
|
||||
"selectCredential": "Anmeldeinformationen auswählen",
|
||||
"selectCredentialPlaceholder": "Wähle eine Anmeldedaten aus...",
|
||||
"credentialRequired": "Für die Anmeldeauthentifizierung ist eine Anmeldeinformation erforderlich",
|
||||
@@ -659,7 +672,32 @@
|
||||
"folderRenamed": "Ordner „ {{oldName}} “ erfolgreich in „ {{newName}} “ umbenannt",
|
||||
"failedToRenameFolder": "Ordner konnte nicht umbenannt werden",
|
||||
"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": {
|
||||
"title": "Terminal",
|
||||
|
||||
@@ -583,7 +583,16 @@
|
||||
"passwordRequired": "Password required",
|
||||
"confirmExport": "Confirm Export",
|
||||
"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": {
|
||||
"title": "Host Manager",
|
||||
@@ -677,6 +686,7 @@
|
||||
"password": "Password",
|
||||
"key": "Key",
|
||||
"credential": "Credential",
|
||||
"none": "None",
|
||||
"selectCredential": "Select Credential",
|
||||
"selectCredentialPlaceholder": "Choose a credential...",
|
||||
"credentialRequired": "Credential is required when using credential authentication",
|
||||
@@ -733,7 +743,29 @@
|
||||
"failedToMoveToFolder": "Failed to move host to folder",
|
||||
"statistics": "Statistics",
|
||||
"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": {
|
||||
"title": "Terminal",
|
||||
|
||||
@@ -546,7 +546,19 @@
|
||||
"passwordRequired": "Senha necessária",
|
||||
"confirmExport": "Confirmar Exportação",
|
||||
"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": {
|
||||
"title": "Gerenciador de Hosts",
|
||||
@@ -676,7 +688,29 @@
|
||||
"folderRenamed": "Pasta \"{{oldName}}\" renomeada para \"{{newName}}\" com sucesso",
|
||||
"failedToRenameFolder": "Falha ao renomear pasta",
|
||||
"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": {
|
||||
"title": "Terminal",
|
||||
|
||||
@@ -567,7 +567,17 @@
|
||||
"passwordRequired": "密码为必填项",
|
||||
"confirmExport": "确认导出",
|
||||
"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": {
|
||||
"title": "主机管理",
|
||||
@@ -755,7 +765,26 @@
|
||||
"failedToMoveToFolder": "移动主机到文件夹失败",
|
||||
"statistics": "统计",
|
||||
"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": {
|
||||
"title": "终端",
|
||||
|
||||
@@ -55,10 +55,18 @@ function useWindowWidth() {
|
||||
function RootApp() {
|
||||
const width = useWindowWidth();
|
||||
const isMobile = width < 768;
|
||||
|
||||
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
||||
const isTermixMobile = /Termix-Mobile/.test(userAgent);
|
||||
|
||||
if (isElectron()) {
|
||||
return <DesktopApp />;
|
||||
}
|
||||
|
||||
if (isTermixMobile) {
|
||||
return <MobileApp key="mobile" />;
|
||||
}
|
||||
|
||||
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface SSHHostData {
|
||||
enableFileManager?: boolean;
|
||||
defaultPath?: string;
|
||||
tunnelConnections?: TunnelConnection[];
|
||||
statsConfig?: string;
|
||||
statsConfig?: string | Record<string, unknown>; // Can be string (from backend) or object (from form)
|
||||
terminalConfig?: TerminalConfig;
|
||||
}
|
||||
|
||||
@@ -374,6 +374,7 @@ export interface HostManagerProps {
|
||||
onSelectView?: (view: string) => void;
|
||||
isTopbarOpen?: boolean;
|
||||
initialTab?: string;
|
||||
hostConfig?: SSHHost;
|
||||
}
|
||||
|
||||
export interface SSHManagerHostEditorProps {
|
||||
|
||||
@@ -9,8 +9,18 @@ export type WidgetType =
|
||||
|
||||
export interface StatsConfig {
|
||||
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 = {
|
||||
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) => {
|
||||
// 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);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(checked);
|
||||
@@ -552,9 +597,14 @@ export function AdminSettings({
|
||||
<Checkbox
|
||||
checked={allowRegistration}
|
||||
onCheckedChange={handleToggleRegistration}
|
||||
disabled={regLoading}
|
||||
disabled={regLoading || !allowPasswordLogin}
|
||||
/>
|
||||
{t("admin.allowNewAccountRegistration")}
|
||||
{!allowPasswordLogin && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("admin.requiresPasswordLogin")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
@@ -588,6 +638,15 @@ export function AdminSettings({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!allowPasswordLogin && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("admin.criticalWarning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("admin.oidcRequiredWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
@@ -733,6 +792,48 @@ export function AdminSettings({
|
||||
type="button"
|
||||
variant="outline"
|
||||
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 = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
|
||||
@@ -233,6 +233,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set flags IMMEDIATELY to prevent race conditions
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
@@ -240,6 +241,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const hostName =
|
||||
currentHost.name || `${currentHost.username}@${currentHost.ip}`;
|
||||
await logActivity("file_manager", currentHost.id, hostName);
|
||||
// Don't reset activityLoggedRef on success - we want to prevent future calls
|
||||
} catch (err) {
|
||||
console.warn("Failed to log file manager activity:", err);
|
||||
// Reset on error so it can be retried
|
||||
@@ -337,7 +339,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
initialLoadDoneRef.current = true;
|
||||
|
||||
// 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();
|
||||
}
|
||||
} catch (dirError: unknown) {
|
||||
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 {
|
||||
Tabs,
|
||||
@@ -17,10 +17,13 @@ import type { SSHHost, HostManagerProps } from "../../../types/index";
|
||||
export function HostManager({
|
||||
isTopbarOpen,
|
||||
initialTab = "host_viewer",
|
||||
hostConfig,
|
||||
}: HostManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(
|
||||
hostConfig || null,
|
||||
);
|
||||
|
||||
const [editingCredential, setEditingCredential] = useState<{
|
||||
id: number;
|
||||
@@ -28,6 +31,16 @@ export function HostManager({
|
||||
username: string;
|
||||
} | null>(null);
|
||||
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) => {
|
||||
setEditingHost(host);
|
||||
|
||||
@@ -119,6 +119,7 @@ export function HostManagerEditor({
|
||||
const [snippets, setSnippets] = useState<
|
||||
Array<{ id: number; name: string; content: string }>
|
||||
>([]);
|
||||
const [snippetSearch, setSnippetSearch] = useState("");
|
||||
|
||||
const [authTab, setAuthTab] = useState<
|
||||
"password" | "key" | "credential" | "none"
|
||||
@@ -128,6 +129,14 @@ export function HostManagerEditor({
|
||||
);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -260,6 +269,10 @@ export function HostManagerEditor({
|
||||
]),
|
||||
)
|
||||
.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({
|
||||
enabledWidgets: [
|
||||
@@ -270,6 +283,10 @@ export function HostManagerEditor({
|
||||
"uptime",
|
||||
"system",
|
||||
],
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: 30,
|
||||
metricsEnabled: true,
|
||||
metricsInterval: 30,
|
||||
}),
|
||||
terminalConfig: z
|
||||
.object({
|
||||
@@ -277,7 +294,7 @@ export function HostManagerEditor({
|
||||
cursorStyle: z.enum(["block", "underline", "bar"]),
|
||||
fontSize: z.number().min(8).max(24),
|
||||
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),
|
||||
theme: z.string(),
|
||||
scrollback: z.number().min(1000).max(50000),
|
||||
@@ -427,6 +444,22 @@ export function HostManagerEditor({
|
||||
: "none";
|
||||
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 = {
|
||||
name: cleanedHost.name || "",
|
||||
ip: cleanedHost.ip || "",
|
||||
@@ -446,7 +479,7 @@ export function HostManagerEditor({
|
||||
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
||||
defaultPath: cleanedHost.defaultPath || "/",
|
||||
tunnelConnections: cleanedHost.tunnelConnections || [],
|
||||
statsConfig: cleanedHost.statsConfig || DEFAULT_STATS_CONFIG,
|
||||
statsConfig: parsedStatsConfig,
|
||||
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
||||
};
|
||||
|
||||
@@ -519,6 +552,24 @@ export function HostManagerEditor({
|
||||
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> = {
|
||||
name: data.name,
|
||||
ip: data.ip,
|
||||
@@ -612,6 +663,10 @@ export function HostManagerEditor({
|
||||
|
||||
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();
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToSaveHost"));
|
||||
@@ -773,9 +828,12 @@ export function HostManagerEditor({
|
||||
className="flex flex-col flex-1 min-h-0 h-full"
|
||||
>
|
||||
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
||||
<div className="pr-4">
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">{t("hosts.general")}</TabsTrigger>
|
||||
<TabsTrigger value="general">
|
||||
{t("hosts.general")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="terminal">
|
||||
{t("hosts.terminal")}
|
||||
</TabsTrigger>
|
||||
@@ -945,7 +1003,9 @@ export function HostManagerEditor({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " " && tagInput.trim() !== "") {
|
||||
e.preventDefault();
|
||||
if (!field.value.includes(tagInput.trim())) {
|
||||
if (
|
||||
!field.value.includes(tagInput.trim())
|
||||
) {
|
||||
field.onChange([
|
||||
...field.value,
|
||||
tagInput.trim(),
|
||||
@@ -1054,7 +1114,9 @@ export function HostManagerEditor({
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>{t("hosts.sshPrivateKey")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("hosts.sshPrivateKey")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative inline-block">
|
||||
<input
|
||||
@@ -1099,7 +1161,9 @@ export function HostManagerEditor({
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>{t("hosts.sshPrivateKey")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("hosts.sshPrivateKey")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<CodeMirror
|
||||
value={
|
||||
@@ -1216,7 +1280,10 @@ export function HostManagerEditor({
|
||||
onValueChange={field.onChange}
|
||||
onCredentialSelect={(credential) => {
|
||||
if (credential) {
|
||||
form.setValue("username", credential.username);
|
||||
form.setValue(
|
||||
"username",
|
||||
credential.username,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1261,6 +1328,11 @@ export function HostManagerEditor({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Alert className="mt-4 mb-4">
|
||||
<AlertDescription>
|
||||
{t("hosts.terminalCustomizationNotice")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<h1 className="text-xl font-semibold mt-7">
|
||||
Terminal Customization
|
||||
</h1>
|
||||
@@ -1276,8 +1348,12 @@ export function HostManagerEditor({
|
||||
theme={form.watch("terminalConfig.theme")}
|
||||
fontSize={form.watch("terminalConfig.fontSize")}
|
||||
fontFamily={form.watch("terminalConfig.fontFamily")}
|
||||
cursorStyle={form.watch("terminalConfig.cursorStyle")}
|
||||
cursorBlink={form.watch("terminalConfig.cursorBlink")}
|
||||
cursorStyle={form.watch(
|
||||
"terminalConfig.cursorStyle",
|
||||
)}
|
||||
cursorBlink={form.watch(
|
||||
"terminalConfig.cursorBlink",
|
||||
)}
|
||||
letterSpacing={form.watch(
|
||||
"terminalConfig.letterSpacing",
|
||||
)}
|
||||
@@ -1538,7 +1614,12 @@ export function HostManagerEditor({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -1643,7 +1724,8 @@ export function HostManagerEditor({
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Automatically adjust colors for better readability
|
||||
Automatically adjust colors for better
|
||||
readability
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -1664,7 +1746,8 @@ export function HostManagerEditor({
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>SSH Agent Forwarding</FormLabel>
|
||||
<FormDescription>
|
||||
Forward SSH authentication agent to remote host
|
||||
Forward SSH authentication agent to remote
|
||||
host
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -1717,11 +1800,12 @@ export function HostManagerEditor({
|
||||
<FormItem>
|
||||
<FormLabel>Startup Snippet</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
onValueChange={(value) => {
|
||||
field.onChange(
|
||||
value === "none" ? null : parseInt(value),
|
||||
)
|
||||
}
|
||||
);
|
||||
setSnippetSearch("");
|
||||
}}
|
||||
value={field.value?.toString() || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -1730,8 +1814,29 @@ export function HostManagerEditor({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<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>
|
||||
{snippets.map((snippet) => (
|
||||
{snippets
|
||||
.filter((snippet) =>
|
||||
snippet.name
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
snippetSearch.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.map((snippet) => (
|
||||
<SelectItem
|
||||
key={snippet.id}
|
||||
value={snippet.id.toString()}
|
||||
@@ -1739,6 +1844,17 @@ export function HostManagerEditor({
|
||||
{snippet.name}
|
||||
</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>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
@@ -1973,7 +2089,9 @@ export function HostManagerEditor({
|
||||
name="tunnelConnections"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("hosts.tunnelConnections")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("hosts.tunnelConnections")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{field.value.map((connection, index) => (
|
||||
@@ -2004,7 +2122,9 @@ export function HostManagerEditor({
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.sourcePort`}
|
||||
render={({ field: sourcePortField }) => (
|
||||
render={({
|
||||
field: sourcePortField,
|
||||
}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>
|
||||
{t("hosts.sourcePort")}
|
||||
@@ -2136,7 +2256,9 @@ export function HostManagerEditor({
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.maxRetries`}
|
||||
render={({ field: maxRetriesField }) => (
|
||||
render={({
|
||||
field: maxRetriesField,
|
||||
}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>
|
||||
{t("hosts.maxRetries")}
|
||||
@@ -2274,7 +2396,209 @@ export function HostManagerEditor({
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
control={form.control}
|
||||
name="statsConfig.enabledWidgets"
|
||||
@@ -2305,22 +2629,30 @@ export function HostManagerEditor({
|
||||
onCheckedChange={(checked) => {
|
||||
const currentWidgets = field.value || [];
|
||||
if (checked) {
|
||||
field.onChange([...currentWidgets, widget]);
|
||||
field.onChange([
|
||||
...currentWidgets,
|
||||
widget,
|
||||
]);
|
||||
} else {
|
||||
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">
|
||||
{widget === "cpu" && t("serverStats.cpuUsage")}
|
||||
{widget === "cpu" &&
|
||||
t("serverStats.cpuUsage")}
|
||||
{widget === "memory" &&
|
||||
t("serverStats.memoryUsage")}
|
||||
{widget === "disk" && t("serverStats.diskUsage")}
|
||||
{widget === "disk" &&
|
||||
t("serverStats.diskUsage")}
|
||||
{widget === "network" &&
|
||||
t("serverStats.networkInterfaces")}
|
||||
{widget === "uptime" && t("serverStats.uptime")}
|
||||
{widget === "uptime" &&
|
||||
t("serverStats.uptime")}
|
||||
{widget === "processes" &&
|
||||
t("serverStats.processes")}
|
||||
{widget === "system" &&
|
||||
@@ -2332,8 +2664,11 @@ export function HostManagerEditor({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25" />
|
||||
|
||||
@@ -43,11 +43,14 @@ import {
|
||||
Pencil,
|
||||
FolderMinus,
|
||||
Copy,
|
||||
Activity,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SSHHost,
|
||||
SSHManagerHostViewerProps,
|
||||
} from "../../../../types/index.js";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
|
||||
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -122,6 +125,10 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
toast.success(t("hosts.hostDeletedSuccessfully", { name: hostName }));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
|
||||
// Refresh backend polling to remove deleted host
|
||||
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
||||
refreshServerPolling();
|
||||
} catch {
|
||||
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(() => {
|
||||
let filtered = hosts;
|
||||
|
||||
@@ -1088,6 +1137,49 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
{t("hosts.fileManagerBadge")}
|
||||
</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>
|
||||
|
||||
@@ -80,22 +80,27 @@ export function Server({
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
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) {
|
||||
return DEFAULT_STATS_CONFIG.enabledWidgets;
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
try {
|
||||
const parsed =
|
||||
typeof currentHostConfig.statsConfig === "string"
|
||||
? JSON.parse(currentHostConfig.statsConfig)
|
||||
: currentHostConfig.statsConfig;
|
||||
return parsed?.enabledWidgets || DEFAULT_STATS_CONFIG.enabledWidgets;
|
||||
return { ...DEFAULT_STATS_CONFIG, ...parsed };
|
||||
} catch (error) {
|
||||
console.error("Failed to parse statsConfig:", error);
|
||||
return DEFAULT_STATS_CONFIG.enabledWidgets;
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
}, [currentHostConfig?.statsConfig]);
|
||||
|
||||
const enabledWidgets = statsConfig.enabledWidgets;
|
||||
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
|
||||
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||
|
||||
React.useEffect(() => {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
@@ -176,7 +181,13 @@ export function Server({
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
// Separate effect for status monitoring
|
||||
React.useEffect(() => {
|
||||
if (!statusCheckEnabled || !currentHostConfig?.id || !isVisible) {
|
||||
setServerStatus("offline");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
|
||||
@@ -196,15 +207,34 @@ export function Server({
|
||||
} else if (err?.response?.status === 504) {
|
||||
setServerStatus("offline");
|
||||
} else if (err?.response?.status === 404) {
|
||||
// Status not available - monitoring disabled
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
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 () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
try {
|
||||
@@ -221,19 +251,25 @@ export function Server({
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
const err = error as {
|
||||
code?: 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?.response?.status === 403 &&
|
||||
err?.response?.data?.error === "TOTP_REQUIRED")
|
||||
) {
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
toast.error(t("serverStats.totpUnavailable"));
|
||||
} else {
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||
}
|
||||
}
|
||||
@@ -244,20 +280,14 @@ export function Server({
|
||||
}
|
||||
};
|
||||
|
||||
if (currentHostConfig?.id && isVisible) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
intervalId = window.setInterval(() => {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}, 30000);
|
||||
}
|
||||
intervalId = window.setInterval(fetchMetrics, 10000); // Poll backend every 10 seconds
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentHostConfig?.id, isVisible]);
|
||||
}, [currentHostConfig?.id, isVisible, metricsEnabled]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||
@@ -297,12 +327,14 @@ export function Server({
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
{statusCheckEnabled && (
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
@@ -410,7 +442,7 @@ export function Server({
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<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">
|
||||
{isLoadingMetrics && !metrics ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
|
||||
@@ -478,7 +478,7 @@ export function SnippetsSidebar({
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} className="flex-1">
|
||||
{editingSnippet ? t("common.update") : t("common.create")}
|
||||
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,14 +95,17 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFitted, setIsFitted] = useState(false);
|
||||
const [, setConnectionError] = useState<string | null>(null);
|
||||
const [, setIsAuthenticated] = useState(false);
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
||||
const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const isFittingRef = useRef(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 3;
|
||||
@@ -129,6 +132,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
// Set flags IMMEDIATELY to prevent race conditions
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
@@ -136,6 +140,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const hostName =
|
||||
hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`;
|
||||
await logActivity("terminal", hostConfig.id, hostName);
|
||||
// Don't reset activityLoggedRef on success - we want to prevent future calls
|
||||
} catch (err) {
|
||||
console.warn("Failed to log terminal activity:", err);
|
||||
// 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) {
|
||||
if (webSocketRef.current && code) {
|
||||
webSocketRef.current.send(
|
||||
@@ -727,7 +758,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
minimumContrastRatio: config.minimumContrastRatio,
|
||||
letterSpacing: config.letterSpacing,
|
||||
lineHeight: config.lineHeight,
|
||||
bellStyle: config.bellStyle as "none" | "sound",
|
||||
bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
|
||||
|
||||
theme: {
|
||||
background: themeColors.background,
|
||||
@@ -852,11 +883,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 150);
|
||||
if (!isVisibleRef.current || !isReady) return;
|
||||
performFit();
|
||||
}, 50); // Reduced from 150ms to 50ms for snappier response
|
||||
});
|
||||
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
@@ -868,6 +897,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
shouldNotReconnectRef.current = true;
|
||||
isReconnectingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
setVisible(false);
|
||||
setIsReady(false);
|
||||
isFittingRef.current = false;
|
||||
resizeObserver.disconnect();
|
||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||
element?.removeEventListener("keydown", handleMacKeyboard, true);
|
||||
@@ -899,11 +931,16 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
: Promise.resolve();
|
||||
|
||||
readyFonts.then(() => {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
|
||||
scheduleNotify(terminal.cols, terminal.rows);
|
||||
}
|
||||
hardRefresh();
|
||||
|
||||
setVisible(true);
|
||||
setIsReady(true);
|
||||
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
@@ -921,46 +958,74 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const rows = terminal.rows;
|
||||
|
||||
connectToHost(cols, rows);
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
}, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
|
||||
// Reset fitted state when becoming invisible
|
||||
if (!isVisible && isFitted) {
|
||||
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();
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
// Fit the terminal to the new size
|
||||
performFit();
|
||||
// Focus will happen after isFitted becomes true
|
||||
});
|
||||
});
|
||||
|
||||
if (terminal && !splitScreen) {
|
||||
setTimeout(() => {
|
||||
terminal.focus();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [isVisible, splitScreen, terminal]);
|
||||
return () => {
|
||||
if (rafId1) cancelAnimationFrame(rafId1);
|
||||
if (rafId2) cancelAnimationFrame(rafId2);
|
||||
};
|
||||
}, [isVisible, isReady, splitScreen, terminal]);
|
||||
|
||||
// Focus the terminal after it's been fitted and is visible
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current) return;
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (terminal && !splitScreen && isVisible) {
|
||||
if (
|
||||
isFitted &&
|
||||
isVisible &&
|
||||
isReady &&
|
||||
!isConnecting &&
|
||||
terminal &&
|
||||
!splitScreen
|
||||
) {
|
||||
// Use requestAnimationFrame to ensure the terminal is actually visible in the DOM
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
terminal.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}
|
||||
}, 0);
|
||||
}, [splitScreen, isVisible, terminal]);
|
||||
}, [isFitted, isVisible, isReady, isConnecting, terminal, splitScreen]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative" style={{ backgroundColor }}>
|
||||
<div
|
||||
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={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
|
||||
@@ -165,6 +165,7 @@ function AppContent() {
|
||||
onSelectView={handleSelectView}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
initialTab={currentTabData?.initialTab}
|
||||
hostConfig={currentTabData?.hostConfig}
|
||||
/>
|
||||
</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 { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import { EllipsisVertical, Terminal } from "lucide-react";
|
||||
import {
|
||||
EllipsisVertical,
|
||||
Terminal,
|
||||
Server,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -12,9 +18,11 @@ import {
|
||||
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext";
|
||||
import { getServerStatusById } from "@/ui/main-axios";
|
||||
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 [host, setHost] = useState(initialHost);
|
||||
const [serverStatus, setServerStatus] = useState<
|
||||
"online" | "offline" | "degraded"
|
||||
>("degraded");
|
||||
@@ -25,7 +33,47 @@ export function Host({ host }: HostProps): React.ReactElement {
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
// Update host when prop changes
|
||||
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;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
@@ -41,6 +89,9 @@ export function Host({ host }: HostProps): React.ReactElement {
|
||||
setServerStatus("offline");
|
||||
} else if (err?.response?.status === 504) {
|
||||
setServerStatus("degraded");
|
||||
} else if (err?.response?.status === 404) {
|
||||
// Status not available - monitoring disabled
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
}
|
||||
@@ -49,13 +100,13 @@ export function Host({ host }: HostProps): React.ReactElement {
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
const intervalId = window.setInterval(fetchStatus, 30000);
|
||||
const intervalId = window.setInterval(fetchStatus, 10000); // Poll backend every 10 seconds
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [host.id]);
|
||||
}, [host.id, shouldShowStatus]);
|
||||
|
||||
const handleTerminalClick = () => {
|
||||
addTab({ type: "terminal", title, hostConfig: host });
|
||||
@@ -64,12 +115,14 @@ export function Host({ host }: HostProps): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldShowStatus && (
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
)}
|
||||
|
||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||
{host.name || host.ip}
|
||||
@@ -101,29 +154,39 @@ export function Host({ host }: HostProps): React.ReactElement {
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side="right"
|
||||
className="min-w-[160px]"
|
||||
className="w-56 bg-dark-bg border-dark-border text-white"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="font-semibold"
|
||||
onClick={() =>
|
||||
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
|
||||
className="font-semibold"
|
||||
onClick={() =>
|
||||
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
|
||||
className="font-semibold"
|
||||
onClick={() => alert("Settings clicked")}
|
||||
onClick={() =>
|
||||
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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function LeftSidebar({
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
const openSshManagerTab = () => {
|
||||
if (sshManagerTab || isSplitScreenActive) return;
|
||||
const id = addTab({ type: "ssh_manager" });
|
||||
const id = addTab({ type: "ssh_manager", title: "Host Manager" });
|
||||
setCurrentTab(id);
|
||||
};
|
||||
const adminTab = tabList.find((t) => t.type === "admin");
|
||||
|
||||
@@ -31,6 +31,7 @@ interface TabContextType {
|
||||
port: number;
|
||||
},
|
||||
) => void;
|
||||
updateTab: (tabId: number, updates: Partial<Omit<Tab, "id">>) => void;
|
||||
}
|
||||
|
||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||
@@ -96,6 +97,35 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
}
|
||||
|
||||
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 needsUniqueTitle =
|
||||
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 = {
|
||||
tabs,
|
||||
currentTab,
|
||||
@@ -214,6 +250,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
getTab,
|
||||
reorderTabs,
|
||||
updateHostConfig,
|
||||
updateTab,
|
||||
};
|
||||
|
||||
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
|
||||
|
||||
@@ -13,6 +13,11 @@ const languages = [
|
||||
{ code: "en", name: "English", nativeName: "English" },
|
||||
{ code: "zh", name: "Chinese", nativeName: "中文" },
|
||||
{ code: "de", name: "German", nativeName: "Deutsch" },
|
||||
{
|
||||
code: "ptbr",
|
||||
name: "Brazilian Portuguese",
|
||||
nativeName: "Português Brasileiro",
|
||||
},
|
||||
];
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
|
||||
@@ -161,6 +161,14 @@ export function PasswordReset({ userInfo }: PasswordResetProps) {
|
||||
<>
|
||||
{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">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -51,12 +51,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [, setIsConnected] = useState(false);
|
||||
const [, setIsConnecting] = useState(false);
|
||||
const [, setConnectionError] = useState<string | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
const isFittingRef = useRef(false);
|
||||
|
||||
const lastSentSizeRef = 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) {
|
||||
if (!(cols > 0 && rows > 0)) return;
|
||||
pendingSizeRef.current = { cols, rows };
|
||||
@@ -288,10 +315,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (!isVisibleRef.current || !isReady) return;
|
||||
performFit();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
@@ -302,12 +327,13 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
?.ready instanceof Promise
|
||||
? (document as { fonts?: { ready?: Promise<unknown> } }).fonts.ready
|
||||
: Promise.resolve();
|
||||
setVisible(true);
|
||||
|
||||
readyFonts.then(() => {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
fitAddon.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
|
||||
scheduleNotify(terminal.cols, terminal.rows);
|
||||
}
|
||||
hardRefresh();
|
||||
|
||||
const jwtToken = getCookie("jwt");
|
||||
@@ -315,6 +341,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError("Authentication required");
|
||||
setVisible(true);
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -343,6 +371,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
if (isConnectingRef.current) {
|
||||
setVisible(true);
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -370,7 +400,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
wasDisconnectedBySSH.current = false;
|
||||
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
}, 200);
|
||||
|
||||
setVisible(true);
|
||||
setIsReady(true);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -382,32 +415,29 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
setVisible(false);
|
||||
setIsReady(false);
|
||||
isFittingRef.current = false;
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
}, [xtermRef, terminal, hostConfig, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 0);
|
||||
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
|
||||
return;
|
||||
}
|
||||
}, [isVisible, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current) return;
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 0);
|
||||
}, [isVisible, terminal]);
|
||||
const fitTimeout = setTimeout(() => {
|
||||
performFit();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(fitTimeout);
|
||||
}, [isVisible, isReady, terminal]);
|
||||
|
||||
return (
|
||||
<div
|
||||
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 { Button } from "@/components/ui/button.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 { useTabs } from "@/ui/Mobile/Navigation/Tabs/TabContext.tsx";
|
||||
import type { HostProps } from "../../../../types/index.js";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
|
||||
export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
||||
const { addTab } = useTabs();
|
||||
@@ -19,7 +20,26 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
||||
? host.name
|
||||
: `${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(() => {
|
||||
// Don't poll if status monitoring is disabled
|
||||
if (!shouldShowStatus) {
|
||||
setServerStatus("offline");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
@@ -36,6 +56,7 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
||||
} else if (err?.response?.status === 504) {
|
||||
setServerStatus("degraded");
|
||||
} else if (err?.response?.status === 404) {
|
||||
// Status not available - monitoring disabled
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
@@ -46,13 +67,13 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
||||
|
||||
fetchStatus();
|
||||
|
||||
const intervalId = window.setInterval(fetchStatus, 30000);
|
||||
const intervalId = window.setInterval(fetchStatus, 10000); // Poll backend every 10 seconds
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [host.id]);
|
||||
}, [host.id, shouldShowStatus]);
|
||||
|
||||
const handleTerminalClick = () => {
|
||||
addTab({ type: "terminal", title, hostConfig: host });
|
||||
@@ -62,12 +83,14 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldShowStatus && (
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
)}
|
||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||
{host.name || host.ip}
|
||||
</p>
|
||||
|
||||
@@ -746,7 +746,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
enableFileManager: Boolean(hostData.enableFileManager),
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -804,7 +808,11 @@ export async function updateSSHHost(
|
||||
enableFileManager: Boolean(hostData.enableFileManager),
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user