diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index d94125aa..4c4db81f 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1,14 +1,55 @@ import { WebSocketServer, WebSocket, type RawData } from "ws"; -import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2"; +import { + Client, + type ClientChannel, + type PseudoTtyOptions, + type ConnectConfig, +} from "ssh2"; import { parse as parseUrl } from "url"; import { getDb } from "../database/db/index.js"; import { sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { sshLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { AuthManager } from "../utils/auth-manager.js"; +import { AuthManager, type JWTPayload } from "../utils/auth-manager.js"; import { UserCrypto } from "../utils/user-crypto.js"; +interface ConnectToHostData { + cols: number; + rows: number; + hostConfig: { + id: number; + ip: string; + port: number; + username: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + authType?: string; + credentialId?: number; + userId?: string; + }; + initialPath?: string; + executeCommand?: string; +} + +interface ResizeData { + cols: number; + rows: number; +} + +interface TOTPResponseData { + code?: string; +} + +interface WebSocketMessage { + type: string; + data?: ConnectToHostData | ResizeData | TOTPResponseData | string | unknown; + code?: string; + [key: string]: unknown; +} + const authManager = AuthManager.getInstance(); const userCrypto = UserCrypto.getInstance(); @@ -79,7 +120,7 @@ const wss = new WebSocketServer({ wss.on("connection", async (ws: WebSocket, req) => { let userId: string | undefined; - let userPayload: any; + let userPayload: JWTPayload | undefined; try { const url = parseUrl(req.url!, true); @@ -187,9 +228,9 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - let parsed: any; + let parsed: WebSocketMessage; try { - parsed = JSON.parse(msg.toString()); + parsed = JSON.parse(msg.toString()) as WebSocketMessage; } catch (e) { sshLogger.error("Invalid JSON received", e, { operation: "websocket_message_invalid_json", @@ -203,16 +244,17 @@ wss.on("connection", async (ws: WebSocket, req) => { const { type, data } = parsed; switch (type) { - case "connectToHost": - if (data.hostConfig) { - data.hostConfig.userId = userId; + case "connectToHost": { + const connectData = data as ConnectToHostData; + if (connectData.hostConfig) { + connectData.hostConfig.userId = userId; } - handleConnectToHost(data).catch((error) => { + handleConnectToHost(connectData).catch((error) => { sshLogger.error("Failed to connect to host", error, { operation: "ssh_connect", userId, - hostId: data.hostConfig?.id, - ip: data.hostConfig?.ip, + hostId: connectData.hostConfig?.id, + ip: connectData.hostConfig?.ip, }); ws.send( JSON.stringify({ @@ -224,43 +266,52 @@ wss.on("connection", async (ws: WebSocket, req) => { ); }); break; + } - case "resize": - handleResize(data); + case "resize": { + const resizeData = data as ResizeData; + handleResize(resizeData); break; + } case "disconnect": cleanupSSH(); break; - case "input": + case "input": { + const inputData = data as string; if (sshStream) { - if (data === "\t") { - sshStream.write(data); - } else if (data.startsWith("\x1b")) { - sshStream.write(data); + if (inputData === "\t") { + sshStream.write(inputData); + } else if ( + typeof inputData === "string" && + inputData.startsWith("\x1b") + ) { + sshStream.write(inputData); } else { try { - sshStream.write(Buffer.from(data, "utf8")); + sshStream.write(Buffer.from(inputData, "utf8")); } catch (error) { sshLogger.error("Error writing input to SSH stream", error, { operation: "ssh_input_encoding", userId, - dataLength: data.length, + dataLength: inputData.length, }); - sshStream.write(Buffer.from(data, "latin1")); + sshStream.write(Buffer.from(inputData, "latin1")); } } } break; + } case "ping": ws.send(JSON.stringify({ type: "pong" })); break; - case "totp_response": - if (keyboardInteractiveFinish && data?.code) { - const totpCode = data.code; + case "totp_response": { + const totpData = data as TOTPResponseData; + if (keyboardInteractiveFinish && totpData?.code) { + const totpCode = totpData.code; sshLogger.info("TOTP code received from user", { operation: "totp_response", userId, @@ -274,7 +325,7 @@ wss.on("connection", async (ws: WebSocket, req) => { operation: "totp_response_error", userId, hasCallback: !!keyboardInteractiveFinish, - hasCode: !!data?.code, + hasCode: !!totpData?.code, }); ws.send( JSON.stringify({ @@ -284,6 +335,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ); } break; + } default: sshLogger.warn("Unknown message type received", { @@ -294,25 +346,7 @@ wss.on("connection", async (ws: WebSocket, req) => { } }); - async function handleConnectToHost(data: { - cols: number; - rows: number; - hostConfig: { - id: number; - ip: string; - port: number; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - authType?: string; - credentialId?: number; - userId?: string; - }; - initialPath?: string; - executeCommand?: string; - }) { + async function handleConnectToHost(data: ConnectToHostData) { const { cols, rows, hostConfig, initialPath, executeCommand } = data; const { id, @@ -642,7 +676,7 @@ wss.on("connection", async (ws: WebSocket, req) => { }, ); - const connectConfig: any = { + const connectConfig: ConnectConfig = { host: ip, port, username, @@ -650,21 +684,6 @@ wss.on("connection", async (ws: WebSocket, req) => { keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - - env: { - TERM: "xterm-256color", - LANG: "en_US.UTF-8", - LC_ALL: "en_US.UTF-8", - LC_CTYPE: "en_US.UTF-8", - LC_MESSAGES: "en_US.UTF-8", - LC_MONETARY: "en_US.UTF-8", - LC_NUMERIC: "en_US.UTF-8", - LC_TIME: "en_US.UTF-8", - LC_COLLATE: "en_US.UTF-8", - COLORTERM: "truecolor", - }, algorithms: { kex: [ @@ -688,6 +707,15 @@ wss.on("connection", async (ws: WebSocket, req) => { "aes256-cbc", "3des-cbc", ], + serverHostKey: [ + "ssh-rsa", + "rsa-sha2-256", + "rsa-sha2-512", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "ssh-ed25519", + ], hmac: [ "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", @@ -726,13 +754,6 @@ wss.on("connection", async (ws: WebSocket, req) => { if (resolvedCredentials.keyPassword) { connectConfig.passphrase = resolvedCredentials.keyPassword; } - - if ( - resolvedCredentials.keyType && - resolvedCredentials.keyType !== "auto" - ) { - connectConfig.privateKeyType = resolvedCredentials.keyType; - } } catch (keyError) { sshLogger.error("SSH key format error: " + keyError.message); ws.send( @@ -766,7 +787,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn.connect(connectConfig); } - function handleResize(data: { cols: number; rows: number }) { + function handleResize(data: ResizeData) { if (sshStream && sshStream.setWindow) { sshStream.setWindow(data.rows, data.cols, data.rows, data.cols); ws.send( @@ -788,8 +809,11 @@ wss.on("connection", async (ws: WebSocket, req) => { if (sshStream) { try { sshStream.end(); - } catch (e: any) { - sshLogger.error("Error closing stream: " + e.message); + } catch (e: unknown) { + sshLogger.error( + "Error closing stream: " + + (e instanceof Error ? e.message : "Unknown error"), + ); } sshStream = null; } @@ -797,8 +821,11 @@ wss.on("connection", async (ws: WebSocket, req) => { if (sshConn) { try { sshConn.end(); - } catch (e: any) { - sshLogger.error("Error closing connection: " + e.message); + } catch (e: unknown) { + sshLogger.error( + "Error closing connection: " + + (e instanceof Error ? e.message : "Unknown error"), + ); } sshConn = null; } @@ -809,8 +836,11 @@ wss.on("connection", async (ws: WebSocket, req) => { if (sshConn && sshStream) { try { sshStream.write("\x00"); - } catch (e: any) { - sshLogger.error("SSH keepalive failed: " + e.message); + } catch (e: unknown) { + sshLogger.error( + "SSH keepalive failed: " + + (e instanceof Error ? e.message : "Unknown error"), + ); cleanupSSH(); } } diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index e85bae28..2af371f3 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -23,6 +23,18 @@ interface JWTPayload { exp?: number; } +interface AuthenticatedRequest extends Request { + userId?: string; + pendingTOTP?: boolean; + dataKey?: Buffer; +} + +interface RequestWithHeaders extends Request { + headers: Request["headers"] & { + "x-forwarded-proto"?: string; + }; +} + class AuthManager { private static instance: AuthManager; private systemCrypto: SystemCrypto; @@ -163,7 +175,10 @@ class AuthManager { }); } - getSecureCookieOptions(req: any, maxAge: number = 24 * 60 * 60 * 1000) { + getSecureCookieOptions( + req: RequestWithHeaders, + maxAge: number = 24 * 60 * 60 * 1000, + ) { return { httpOnly: false, secure: req.secure || req.headers["x-forwarded-proto"] === "https", @@ -175,10 +190,11 @@ class AuthManager { createAuthMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { - let token = req.cookies?.jwt; + const authReq = req as AuthenticatedRequest; + let token = authReq.cookies?.jwt; if (!token) { - const authHeader = req.headers["authorization"]; + const authHeader = authReq.headers["authorization"]; if (authHeader?.startsWith("Bearer ")) { token = authHeader.split(" ")[1]; } @@ -194,15 +210,16 @@ class AuthManager { return res.status(401).json({ error: "Invalid token" }); } - (req as any).userId = payload.userId; - (req as any).pendingTOTP = payload.pendingTOTP; + authReq.userId = payload.userId; + authReq.pendingTOTP = payload.pendingTOTP; next(); }; } createDataAccessMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { - const userId = (req as any).userId; + const authReq = req as AuthenticatedRequest; + const userId = authReq.userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } @@ -215,7 +232,7 @@ class AuthManager { }); } - (req as any).dataKey = dataKey; + authReq.dataKey = dataKey; next(); }; } @@ -256,8 +273,9 @@ class AuthManager { return res.status(403).json({ error: "Admin access required" }); } - (req as any).userId = payload.userId; - (req as any).pendingTOTP = payload.pendingTOTP; + const authReq = req as AuthenticatedRequest; + authReq.userId = payload.userId; + authReq.pendingTOTP = payload.pendingTOTP; next(); } catch (error) { databaseLogger.error("Failed to verify admin privileges", error, { diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index 88fb655a..f50ecb32 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -3,6 +3,19 @@ import { LazyFieldEncryption } from "./lazy-field-encryption.js"; import { UserCrypto } from "./user-crypto.js"; import { databaseLogger } from "./logger.js"; +interface DatabaseInstance { + prepare: (sql: string) => { + all: (param?: unknown) => DatabaseRecord[]; + get: (param?: unknown) => DatabaseRecord; + run: (...params: unknown[]) => unknown; + }; +} + +interface DatabaseRecord { + id: number | string; + [key: string]: unknown; +} + class DataCrypto { private static userCrypto: UserCrypto; @@ -10,13 +23,13 @@ class DataCrypto { this.userCrypto = UserCrypto.getInstance(); } - static encryptRecord( + static encryptRecord>( tableName: string, - record: Record, + record: T, userId: string, userDataKey: Buffer, - ): any { - const encryptedRecord = { ...record }; + ): T { + const encryptedRecord: Record = { ...record }; const recordId = record.id || "temp-" + Date.now(); for (const [fieldName, value] of Object.entries(record)) { @@ -30,18 +43,18 @@ class DataCrypto { } } - return encryptedRecord; + return encryptedRecord as T; } - static decryptRecord( + static decryptRecord>( tableName: string, - record: Record, + record: T, userId: string, userDataKey: Buffer, - ): any { + ): T { if (!record) return record; - const decryptedRecord = { ...record }; + const decryptedRecord: Record = { ...record }; const recordId = record.id; for (const [fieldName, value] of Object.entries(record)) { @@ -55,30 +68,25 @@ class DataCrypto { } } - return decryptedRecord; + return decryptedRecord as T; } - static decryptRecords( + static decryptRecords>( tableName: string, - records: unknown[], + records: T[], userId: string, userDataKey: Buffer, - ): unknown[] { + ): T[] { if (!Array.isArray(records)) return records; return records.map((record) => - this.decryptRecord( - tableName, - record as Record, - userId, - userDataKey, - ), + this.decryptRecord(tableName, record, userId, userDataKey), ); } static async migrateUserSensitiveFields( userId: string, userDataKey: Buffer, - db: any, + db: DatabaseInstance, ): Promise<{ migrated: boolean; migratedTables: string[]; @@ -102,7 +110,7 @@ class DataCrypto { const sshDataRecords = db .prepare("SELECT * FROM ssh_data WHERE user_id = ?") - .all(userId); + .all(userId) as DatabaseRecord[]; for (const record of sshDataRecords) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data"); @@ -137,7 +145,7 @@ class DataCrypto { const sshCredentialsRecords = db .prepare("SELECT * FROM ssh_credentials WHERE user_id = ?") - .all(userId); + .all(userId) as DatabaseRecord[]; for (const record of sshCredentialsRecords) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials"); @@ -174,7 +182,7 @@ class DataCrypto { const userRecord = db .prepare("SELECT * FROM users WHERE id = ?") - .get(userId); + .get(userId) as DatabaseRecord | undefined; if (userRecord) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("users"); @@ -225,7 +233,7 @@ class DataCrypto { static async reencryptUserDataAfterPasswordReset( userId: string, newUserDataKey: Buffer, - db: any, + db: DatabaseInstance, ): Promise<{ success: boolean; reencryptedTables: string[]; @@ -267,17 +275,21 @@ class DataCrypto { try { const records = db .prepare(`SELECT * FROM ${table} WHERE user_id = ?`) - .all(userId); + .all(userId) as DatabaseRecord[]; for (const record of records) { const recordId = record.id.toString(); + const updatedRecord: DatabaseRecord = { ...record }; let needsUpdate = false; - const updatedRecord = { ...record }; for (const fieldName of fields) { const fieldValue = record[fieldName]; - if (fieldValue && fieldValue.trim() !== "") { + if ( + fieldValue && + typeof fieldValue === "string" && + fieldValue.trim() !== "" + ) { try { const reencryptedValue = FieldCrypto.encryptField( fieldValue, @@ -389,29 +401,29 @@ class DataCrypto { return userDataKey; } - static encryptRecordForUser( + static encryptRecordForUser>( tableName: string, - record: Record, + record: T, userId: string, - ): any { + ): T { const userDataKey = this.validateUserAccess(userId); return this.encryptRecord(tableName, record, userId, userDataKey); } - static decryptRecordForUser( + static decryptRecordForUser>( tableName: string, - record: Record, + record: T, userId: string, - ): any { + ): T { const userDataKey = this.validateUserAccess(userId); return this.decryptRecord(tableName, record, userId, userDataKey); } - static decryptRecordsForUser( + static decryptRecordsForUser>( tableName: string, - records: unknown[], + records: T[], userId: string, - ): unknown[] { + ): T[] { const userDataKey = this.validateUserAccess(userId); return this.decryptRecords(tableName, records, userId, userDataKey); } diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerSidebar.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerSidebar.tsx index 763f4dee..c3b0c323 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerSidebar.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerSidebar.tsx @@ -23,6 +23,35 @@ import { } from "@/ui/main-axios.ts"; import { toast } from "sonner"; +interface RecentFileData { + id: number; + name: string; + path: string; + lastOpened?: string; + [key: string]: unknown; +} + +interface PinnedFileData { + id: number; + name: string; + path: string; + [key: string]: unknown; +} + +interface ShortcutData { + id: number; + name: string; + path: string; + [key: string]: unknown; +} + +interface DirectoryItemData { + name: string; + path: string; + type: string; + [key: string]: unknown; +} + export interface SidebarItem { id: string; name: string; @@ -88,31 +117,37 @@ export function FileManagerSidebar({ try { const recentData = await getRecentFiles(currentHost.id); - const recentItems = recentData.slice(0, 5).map((item: any) => ({ - id: `recent-${item.id}`, - name: item.name, - path: item.path, - type: "recent" as const, - lastAccessed: item.lastOpened, - })); + const recentItems = (recentData as RecentFileData[]) + .slice(0, 5) + .map((item: RecentFileData) => ({ + id: `recent-${item.id}`, + name: item.name, + path: item.path, + type: "recent" as const, + lastAccessed: item.lastOpened, + })); setRecentItems(recentItems); const pinnedData = await getPinnedFiles(currentHost.id); - const pinnedItems = pinnedData.map((item: any) => ({ - id: `pinned-${item.id}`, - name: item.name, - path: item.path, - type: "pinned" as const, - })); + const pinnedItems = (pinnedData as PinnedFileData[]).map( + (item: PinnedFileData) => ({ + id: `pinned-${item.id}`, + name: item.name, + path: item.path, + type: "pinned" as const, + }), + ); setPinnedItems(pinnedItems); const shortcutData = await getFolderShortcuts(currentHost.id); - const shortcutItems = shortcutData.map((item: any) => ({ - id: `shortcut-${item.id}`, - name: item.name, - path: item.path, - type: "shortcut" as const, - })); + const shortcutItems = (shortcutData as ShortcutData[]).map( + (item: ShortcutData) => ({ + id: `shortcut-${item.id}`, + name: item.name, + path: item.path, + type: "shortcut" as const, + }), + ); setShortcuts(shortcutItems); } catch (error) { console.error("Failed to load quick access data:", error); @@ -230,12 +265,12 @@ export function FileManagerSidebar({ try { const response = await listSSHFiles(sshSessionId, "/"); - const rootFiles = response.files || []; + const rootFiles = (response.files || []) as DirectoryItemData[]; const rootFolders = rootFiles.filter( - (item: any) => item.type === "directory", + (item: DirectoryItemData) => item.type === "directory", ); - const rootTreeItems = rootFolders.map((folder: any) => ({ + const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({ id: `folder-${folder.name}`, name: folder.name, path: folder.path, @@ -298,12 +333,12 @@ export function FileManagerSidebar({ try { const subResponse = await listSSHFiles(sshSessionId, folderPath); - const subFiles = subResponse.files || []; + const subFiles = (subResponse.files || []) as DirectoryItemData[]; const subFolders = subFiles.filter( - (item: any) => item.type === "directory", + (item: DirectoryItemData) => item.type === "directory", ); - const subTreeItems = subFolders.map((folder: any) => ({ + const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({ id: `folder-${folder.path.replace(/\//g, "-")}`, name: folder.name, path: folder.path, diff --git a/src/ui/Desktop/Apps/Server/Server.tsx b/src/ui/Desktop/Apps/Server/Server.tsx index 619f5a03..9996c359 100644 --- a/src/ui/Desktop/Apps/Server/Server.tsx +++ b/src/ui/Desktop/Apps/Server/Server.tsx @@ -27,8 +27,28 @@ import { SystemWidget, } from "./widgets"; +interface HostConfig { + id: number; + name: string; + ip: string; + username: string; + folder?: string; + enableFileManager?: boolean; + tunnelConnections?: unknown[]; + statsConfig?: string | StatsConfig; + [key: string]: unknown; +} + +interface TabData { + id: number; + type: string; + title?: string; + hostConfig?: HostConfig; + [key: string]: unknown; +} + interface ServerProps { - hostConfig?: any; + hostConfig?: HostConfig; title?: string; isVisible?: boolean; isTopbarOpen?: boolean; @@ -44,7 +64,10 @@ export function Server({ }: ServerProps): React.ReactElement { const { t } = useTranslation(); const { state: sidebarState } = useSidebar(); - const { addTab, tabs } = useTabs() as any; + const { addTab, tabs } = useTabs() as { + addTab: (tab: { type: string; [key: string]: unknown }) => number; + tabs: TabData[]; + }; const [serverStatus, setServerStatus] = React.useState<"online" | "offline">( "offline", ); @@ -163,13 +186,16 @@ export function Server({ if (!cancelled) { setServerStatus(res?.status === "online" ? "online" : "offline"); } - } catch (error: any) { + } catch (error: unknown) { if (!cancelled) { - if (error?.response?.status === 503) { + const err = error as { + response?: { status?: number }; + }; + if (err?.response?.status === 503) { setServerStatus("offline"); - } else if (error?.response?.status === 504) { + } else if (err?.response?.status === 504) { setServerStatus("offline"); - } else if (error?.response?.status === 404) { + } else if (err?.response?.status === 404) { setServerStatus("offline"); } else { setServerStatus("offline"); @@ -193,14 +219,18 @@ export function Server({ }); setShowStatsUI(true); } - } catch (error: any) { + } catch (error: unknown) { if (!cancelled) { setMetrics(null); setShowStatsUI(false); + const err = error as { + code?: string; + response?: { status?: number; data?: { error?: string } }; + }; if ( - error?.code === "TOTP_REQUIRED" || - (error?.response?.status === 403 && - error?.response?.data?.error === "TOTP_REQUIRED") + err?.code === "TOTP_REQUIRED" || + (err?.response?.status === 403 && + err?.response?.data?.error === "TOTP_REQUIRED") ) { toast.error(t("serverStats.totpUnavailable")); } else { @@ -236,7 +266,7 @@ export function Server({ const isFileManagerAlreadyOpen = React.useMemo(() => { if (!currentHostConfig) return false; return tabs.some( - (tab: any) => + (tab: TabData) => tab.type === "file_manager" && tab.hostConfig?.id === currentHostConfig.id, ); @@ -291,32 +321,37 @@ export function Server({ ); setMetrics(data); setShowStatsUI(true); - } catch (error: any) { + } catch (error: unknown) { + const err = error as { + code?: string; + status?: number; + response?: { status?: number; data?: { error?: string } }; + }; if ( - error?.code === "TOTP_REQUIRED" || - (error?.response?.status === 403 && - error?.response?.data?.error === "TOTP_REQUIRED") + err?.code === "TOTP_REQUIRED" || + (err?.response?.status === 403 && + err?.response?.data?.error === "TOTP_REQUIRED") ) { toast.error(t("serverStats.totpUnavailable")); setMetrics(null); setShowStatsUI(false); } else if ( - error?.response?.status === 503 || - error?.status === 503 + err?.response?.status === 503 || + err?.status === 503 ) { setServerStatus("offline"); setMetrics(null); setShowStatsUI(false); } else if ( - error?.response?.status === 504 || - error?.status === 504 + err?.response?.status === 504 || + err?.status === 504 ) { setServerStatus("offline"); setMetrics(null); setShowStatsUI(false); } else if ( - error?.response?.status === 404 || - error?.status === 404 + err?.response?.status === 404 || + err?.status === 404 ) { setServerStatus("offline"); setMetrics(null);