Run prettier

This commit is contained in:
LukeGus
2025-09-12 01:00:50 -05:00
parent ad05021fc5
commit 9672a3c27b
133 changed files with 30450 additions and 26428 deletions
+3 -2
View File
@@ -3,8 +3,7 @@ name: Bug report
about: Create a report to help Termix improve about: Create a report to help Termix improve
title: "[BUG]" title: "[BUG]"
labels: bug labels: bug
assignees: '' assignees: ""
--- ---
**Describe the bug** **Describe the bug**
@@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
@@ -24,6 +24,7 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots or console/Docker logs to help explain your problem. If applicable, add screenshots or console/Docker logs to help explain your problem.
**Environment (please complete the following information):** **Environment (please complete the following information):**
- Browser [e.g. chrome, safari] - Browser [e.g. chrome, safari]
- Version [e.g. 1.6.0] - Version [e.g. 1.6.0]
+1 -2
View File
@@ -3,8 +3,7 @@ name: Feature request
about: Suggest an idea for Termix about: Suggest an idea for Termix
title: "[FEATURE]" title: "[FEATURE]"
labels: enhancement labels: enhancement
assignees: '' assignees: ""
--- ---
**Is your feature request related to a problem? Please describe.** **Is your feature request related to a problem? Please describe.**
+2 -2
View File
@@ -5,8 +5,8 @@ on:
branches: branches:
- development - development
paths-ignore: paths-ignore:
- '**.md' - "**.md"
- '.gitignore' - ".gitignore"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag_name: tag_name:
+7 -8
View File
@@ -5,9 +5,9 @@ on:
branches: branches:
- development - development
paths-ignore: paths-ignore:
- '**.md' - "**.md"
- '.gitignore' - ".gitignore"
- 'docker/**' - "docker/**"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
build_type: build_type:
@@ -34,8 +34,8 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
cache: 'npm' cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -79,8 +79,8 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
cache: 'npm' cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -100,4 +100,3 @@ jobs:
name: Termix-Linux-Portable name: Termix-Linux-Portable
path: Termix-Linux-Portable.zip path: Termix-Linux-Portable.zip
retention-days: 30 retention-days: 30
+3
View File
@@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage
+1
View File
@@ -0,0 +1 @@
{}
+5 -5
View File
@@ -1,4 +1,4 @@
_# Contributing \_# Contributing
## Prerequisites ## Prerequisites
@@ -62,7 +62,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Background Colors ### Background Colors
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|-------------------------------|-------------|-----------------------------|------------------------------------------| | ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color | | `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers | | `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) | | `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
@@ -74,7 +74,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Element-Specific Backgrounds ### Element-Specific Backgrounds
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|--------------------------|-------------|--------------------|-----------------------------------------------| | ------------------------ | ----------- | ------------------ | --------------------------------------------- |
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements | | `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements | | `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements | | `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
@@ -83,7 +83,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Border Colors ### Border Colors
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|------------------------------|-------------|-----------------|------------------------------------------| | ---------------------------- | ----------- | --------------- | ---------------------------------------- |
| `--color-dark-border` | `#303032` | Default borders | Standard border color | | `--color-dark-border` | `#303032` | Default borders | Standard border color |
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements | | `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states | | `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
@@ -94,7 +94,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Interactive States ### Interactive States
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|--------------------------|-------------|-------------------|-----------------------------------------------| | ------------------------ | ----------- | ----------------- | --------------------------------------------- |
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects | | `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements | | `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements | | `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |
-1
View File
@@ -5,7 +5,6 @@
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> <a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars)
![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks) ![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks)
![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release) ![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release)
+1 -3
View File
@@ -15,9 +15,7 @@
"!vite.config.ts", "!vite.config.ts",
"!eslint.config.js" "!eslint.config.js"
], ],
"asarUnpack": [ "asarUnpack": ["node_modules/node-fetch/**/*"],
"node_modules/node-fetch/**/*"
],
"extraMetadata": { "extraMetadata": {
"main": "electron/main.cjs" "main": "electron/main.cjs"
}, },
+135 -98
View File
@@ -1,19 +1,19 @@
const {app, BrowserWindow, shell, ipcMain} = require('electron'); const { app, BrowserWindow, shell, ipcMain } = require("electron");
const path = require('path'); const path = require("path");
const fs = require('fs'); const fs = require("fs");
let mainWindow = null; let mainWindow = null;
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) { if (!gotTheLock) {
console.log('Another instance is already running, quitting...'); console.log("Another instance is already running, quitting...");
app.quit(); app.quit();
process.exit(0); process.exit(0);
} else { } else {
app.on('second-instance', (event, commandLine, workingDirectory) => { app.on("second-instance", (event, commandLine, workingDirectory) => {
console.log('Second instance detected, focusing existing window...'); console.log("Second instance detected, focusing existing window...");
if (mainWindow) { if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore(); if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus(); mainWindow.focus();
@@ -28,90 +28,98 @@ function createWindow() {
height: 800, height: 800,
minWidth: 800, minWidth: 800,
minHeight: 600, minHeight: 600,
title: 'Termix', title: "Termix",
icon: isDev icon: isDev
? path.join(__dirname, '..', 'public', 'icon.png') ? path.join(__dirname, "..", "public", "icon.png")
: path.join(process.resourcesPath, 'public', 'icon.png'), : path.join(process.resourcesPath, "public", "icon.png"),
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
webSecurity: !isDev, webSecurity: !isDev,
preload: path.join(__dirname, 'preload.js') preload: path.join(__dirname, "preload.js"),
}, },
show: false show: false,
}); });
if (process.platform !== 'darwin') { if (process.platform !== "darwin") {
mainWindow.setMenuBarVisibility(false); mainWindow.setMenuBarVisibility(false);
} }
if (isDev) { if (isDev) {
mainWindow.loadURL('http://localhost:5173'); mainWindow.loadURL("http://localhost:5173");
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
} else { } else {
const indexPath = path.join(__dirname, '..', 'dist', 'index.html'); const indexPath = path.join(__dirname, "..", "dist", "index.html");
console.log('Loading frontend from:', indexPath); console.log("Loading frontend from:", indexPath);
mainWindow.loadFile(indexPath); mainWindow.loadFile(indexPath);
} }
mainWindow.once('ready-to-show', () => { mainWindow.once("ready-to-show", () => {
console.log('Window ready to show'); console.log("Window ready to show");
mainWindow.show(); mainWindow.show();
}); });
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { mainWindow.webContents.on(
console.error('Failed to load:', errorCode, errorDescription, validatedURL); "did-fail-load",
(event, errorCode, errorDescription, validatedURL) => {
console.error(
"Failed to load:",
errorCode,
errorDescription,
validatedURL,
);
},
);
mainWindow.webContents.on("did-finish-load", () => {
console.log("Frontend loaded successfully");
}); });
mainWindow.webContents.on('did-finish-load', () => { mainWindow.on("close", (event) => {
console.log('Frontend loaded successfully'); if (process.platform === "darwin") {
});
mainWindow.on('close', (event) => {
if (process.platform === 'darwin') {
event.preventDefault(); event.preventDefault();
mainWindow.hide(); mainWindow.hide();
} }
}); });
mainWindow.on('closed', () => { mainWindow.on("closed", () => {
mainWindow = null; mainWindow = null;
}); });
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url); shell.openExternal(url);
return {action: 'deny'}; return { action: "deny" };
}); });
} }
ipcMain.handle('get-app-version', () => { ipcMain.handle("get-app-version", () => {
return app.getVersion(); return app.getVersion();
}); });
ipcMain.handle('get-platform', () => { ipcMain.handle("get-platform", () => {
return process.platform; return process.platform;
}); });
ipcMain.handle('get-server-config', () => { ipcMain.handle("get-server-config", () => {
try { try {
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath("userData");
const configPath = path.join(userDataPath, 'server-config.json'); const configPath = path.join(userDataPath, "server-config.json");
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const configData = fs.readFileSync(configPath, 'utf8'); const configData = fs.readFileSync(configPath, "utf8");
return JSON.parse(configData); return JSON.parse(configData);
} }
return null; return null;
} catch (error) { } catch (error) {
console.error('Error reading server config:', error); console.error("Error reading server config:", error);
return null; return null;
} }
}); });
ipcMain.handle('save-server-config', (event, config) => { ipcMain.handle("save-server-config", (event, config) => {
try { try {
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath("userData");
const configPath = path.join(userDataPath, 'server-config.json'); const configPath = path.join(userDataPath, "server-config.json");
if (!fs.existsSync(userDataPath)) { if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true }); fs.mkdirSync(userDataPath, { recursive: true });
@@ -120,49 +128,52 @@ ipcMain.handle('save-server-config', (event, config) => {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error saving server config:', error); console.error("Error saving server config:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
ipcMain.handle('test-server-connection', async (event, serverUrl) => {
try { try {
let fetch; let fetch;
try { try {
fetch = globalThis.fetch || require('node:fetch'); fetch = globalThis.fetch || require("node:fetch");
} catch (e) { } catch (e) {
const https = require('https'); const https = require("https");
const http = require('http'); const http = require("http");
const {URL} = require('url'); const { URL } = require("url");
fetch = (url, options = {}) => { fetch = (url, options = {}) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const urlObj = new URL(url); const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:'; const isHttps = urlObj.protocol === "https:";
const client = isHttps ? https : http; const client = isHttps ? https : http;
const req = client.request(url, { const req = client.request(
method: options.method || 'GET', url,
{
method: options.method || "GET",
headers: options.headers || {}, headers: options.headers || {},
timeout: options.timeout || 5000 timeout: options.timeout || 5000,
}, (res) => { },
let data = ''; (res) => {
res.on('data', chunk => data += chunk); let data = "";
res.on('end', () => { res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
resolve({ resolve({
ok: res.statusCode >= 200 && res.statusCode < 300, ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode, status: res.statusCode,
text: () => Promise.resolve(data), text: () => Promise.resolve(data),
json: () => Promise.resolve(JSON.parse(data)) json: () => Promise.resolve(JSON.parse(data)),
});
}); });
}); });
},
);
req.on('error', reject); req.on("error", reject);
req.on('timeout', () => { req.on("timeout", () => {
req.destroy(); req.destroy();
reject(new Error('Request timeout')); reject(new Error("Request timeout"));
}); });
if (options.body) { if (options.body) {
@@ -173,88 +184,114 @@ ipcMain.handle('test-server-connection', async (event, serverUrl) => {
}; };
} }
const normalizedServerUrl = serverUrl.replace(/\/$/, ''); const normalizedServerUrl = serverUrl.replace(/\/$/, "");
const healthUrl = `${normalizedServerUrl}/health`; const healthUrl = `${normalizedServerUrl}/health`;
try { try {
const response = await fetch(healthUrl, { const response = await fetch(healthUrl, {
method: 'GET', method: "GET",
timeout: 5000 timeout: 5000,
}); });
if (response.ok) { if (response.ok) {
const data = await response.text(); const data = await response.text();
if (data.includes('<html') || data.includes('<!DOCTYPE') || data.includes('<head>') || data.includes('<body>')) { if (
console.log('Health endpoint returned HTML instead of JSON - not a Termix server'); data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>")
) {
console.log(
"Health endpoint returned HTML instead of JSON - not a Termix server",
);
return { return {
success: false, success: false,
error: 'Server returned HTML instead of JSON. This does not appear to be a Termix server.' error:
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
}; };
} }
try { try {
const healthData = JSON.parse(data); const healthData = JSON.parse(data);
if (healthData && ( if (
healthData.status === 'ok' || healthData &&
healthData.status === 'healthy' || (healthData.status === "ok" ||
healthData.status === "healthy" ||
healthData.healthy === true || healthData.healthy === true ||
healthData.database === 'connected' healthData.database === "connected")
)) { ) {
return {success: true, status: response.status, testedUrl: healthUrl}; return {
success: true,
status: response.status,
testedUrl: healthUrl,
};
} }
} catch (parseError) { } catch (parseError) {
console.log('Health endpoint did not return valid JSON'); console.log("Health endpoint did not return valid JSON");
} }
} }
} catch (urlError) { } catch (urlError) {
console.error('Health check failed:', urlError); console.error("Health check failed:", urlError);
} }
try { try {
const versionUrl = `${normalizedServerUrl}/version`; const versionUrl = `${normalizedServerUrl}/version`;
const response = await fetch(versionUrl, { const response = await fetch(versionUrl, {
method: 'GET', method: "GET",
timeout: 5000 timeout: 5000,
}); });
if (response.ok) { if (response.ok) {
const data = await response.text(); const data = await response.text();
if (data.includes('<html') || data.includes('<!DOCTYPE') || data.includes('<head>') || data.includes('<body>')) { if (
console.log('Version endpoint returned HTML instead of JSON - not a Termix server'); data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>")
) {
console.log(
"Version endpoint returned HTML instead of JSON - not a Termix server",
);
return { return {
success: false, success: false,
error: 'Server returned HTML instead of JSON. This does not appear to be a Termix server.' error:
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
}; };
} }
try { try {
const versionData = JSON.parse(data); const versionData = JSON.parse(data);
if (versionData && ( if (
versionData.status === 'up_to_date' || versionData &&
versionData.status === 'requires_update' || (versionData.status === "up_to_date" ||
(versionData.localVersion && versionData.version && versionData.latest_release) versionData.status === "requires_update" ||
)) { (versionData.localVersion &&
versionData.version &&
versionData.latest_release))
) {
return { return {
success: true, success: true,
status: response.status, status: response.status,
testedUrl: versionUrl, testedUrl: versionUrl,
warning: 'Health endpoint not available, but server appears to be running' warning:
"Health endpoint not available, but server appears to be running",
}; };
} }
} catch (parseError) { } catch (parseError) {
console.log('Version endpoint did not return valid JSON'); console.log("Version endpoint did not return valid JSON");
} }
} }
} catch (versionError) { } catch (versionError) {
console.error('Version check failed:', versionError); console.error("Version check failed:", versionError);
} }
return { return {
success: false, success: false,
error: 'Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.' error:
"Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.",
}; };
} catch (error) { } catch (error) {
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -263,16 +300,16 @@ ipcMain.handle('test-server-connection', async (event, serverUrl) => {
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow(); createWindow();
console.log('Termix started successfully'); console.log("Termix started successfully");
}); });
app.on('window-all-closed', () => { app.on("window-all-closed", () => {
if (process.platform !== 'darwin') { if (process.platform !== "darwin") {
app.quit(); app.quit();
} }
}); });
app.on('activate', () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
} else if (mainWindow) { } else if (mainWindow) {
@@ -280,18 +317,18 @@ app.on('activate', () => {
} }
}); });
app.on('before-quit', () => { app.on("before-quit", () => {
console.log('App is quitting...'); console.log("App is quitting...");
}); });
app.on('will-quit', () => { app.on("will-quit", () => {
console.log('App will quit...'); console.log("App will quit...");
}); });
process.on('uncaughtException', (error) => { process.on("uncaughtException", (error) => {
console.error('Uncaught Exception:', error); console.error("Uncaught Exception:", error);
}); });
process.on('unhandledRejection', (reason, promise) => { process.on("unhandledRejection", (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason); console.error("Unhandled Rejection at:", promise, "reason:", reason);
}); });
+16 -14
View File
@@ -1,27 +1,29 @@
const {contextBridge, ipcRenderer} = require('electron'); const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke('get-app-version'), getAppVersion: () => ipcRenderer.invoke("get-app-version"),
getPlatform: () => ipcRenderer.invoke('get-platform'), getPlatform: () => ipcRenderer.invoke("get-platform"),
getServerConfig: () => ipcRenderer.invoke('get-server-config'), getServerConfig: () => ipcRenderer.invoke("get-server-config"),
saveServerConfig: (config) => ipcRenderer.invoke('save-server-config', config), saveServerConfig: (config) =>
testServerConnection: (serverUrl) => ipcRenderer.invoke('test-server-connection', serverUrl), ipcRenderer.invoke("save-server-config", config),
testServerConnection: (serverUrl) =>
ipcRenderer.invoke("test-server-connection", serverUrl),
showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options), showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options), showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
onUpdateAvailable: (callback) => ipcRenderer.on('update-available', callback), onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', callback), onUpdateDownloaded: (callback) =>
ipcRenderer.on("update-downloaded", callback),
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
isElectron: true, isElectron: true,
isDev: process.env.NODE_ENV === 'development', isDev: process.env.NODE_ENV === "development",
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
}); });
window.IS_ELECTRON = true; window.IS_ELECTRON = true;
console.log('electronAPI exposed to window'); console.log("electronAPI exposed to window");
+10 -10
View File
@@ -1,18 +1,18 @@
import js from '@eslint/js' import js from "@eslint/js";
import globals from 'globals' import globals from "globals";
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from 'typescript-eslint' import tseslint from "typescript-eslint";
import { globalIgnores } from 'eslint/config' import { globalIgnores } from "eslint/config";
export default tseslint.config([ export default tseslint.config([
globalIgnores(['dist']), globalIgnores(["dist"]),
{ {
files: ['**/*.{ts,tsx}'], files: ["**/*.{ts,tsx}"],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
reactHooks.configs['recommended-latest'], reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite, reactRefresh.configs.vite,
], ],
languageOptions: { languageOptions: {
@@ -20,4 +20,4 @@ export default tseslint.config([
globals: globals.browser, globals: globals.browser,
}, },
}, },
]) ]);
+56 -13
View File
@@ -102,7 +102,10 @@
"enableTunnel": { "type": "boolean" }, "enableTunnel": { "type": "boolean" },
"enableFileManager": { "type": "boolean" }, "enableFileManager": { "type": "boolean" },
"defaultPath": { "type": "string" }, "defaultPath": { "type": "string" },
"tunnelConnections": { "type": "array", "items": { "type": "object" } }, "tunnelConnections": {
"type": "array",
"items": { "type": "object" }
},
"createdAt": { "type": "string", "format": "date-time" }, "createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" } "updatedAt": { "type": "string", "format": "date-time" }
}, },
@@ -127,7 +130,10 @@
"enableTunnel": { "type": "boolean" }, "enableTunnel": { "type": "boolean" },
"enableFileManager": { "type": "boolean" }, "enableFileManager": { "type": "boolean" },
"defaultPath": { "type": "string" }, "defaultPath": { "type": "string" },
"tunnelConnections": { "type": "array", "items": { "type": "object" } } "tunnelConnections": {
"type": "array",
"items": { "type": "object" }
}
}, },
"required": ["ip", "port", "username", "authType"] "required": ["ip", "port", "username", "authType"]
}, },
@@ -159,7 +165,18 @@
"autoStart": { "type": "boolean" }, "autoStart": { "type": "boolean" },
"isPinned": { "type": "boolean" } "isPinned": { "type": "boolean" }
}, },
"required": ["name", "hostName", "sourceIP", "sourceSSHPort", "sourceUsername", "endpointIP", "endpointSSHPort", "endpointUsername", "sourcePort", "endpointPort"] "required": [
"name",
"hostName",
"sourceIP",
"sourceSSHPort",
"sourceUsername",
"endpointIP",
"endpointSSHPort",
"endpointUsername",
"sourcePort",
"endpointPort"
]
}, },
"TunnelStatus": { "TunnelStatus": {
"type": "object", "type": "object",
@@ -188,7 +205,12 @@
"properties": { "properties": {
"percent": { "type": "number" }, "percent": { "type": "number" },
"cores": { "type": "number" }, "cores": { "type": "number" },
"load": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 } "load": {
"type": "array",
"items": { "type": "number" },
"minItems": 3,
"maxItems": 3
}
} }
}, },
"memory": { "memory": {
@@ -383,7 +405,10 @@
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"status": { "type": "string", "enum": ["up_to_date", "requires_update"] }, "status": {
"type": "string",
"enum": ["up_to_date", "requires_update"]
},
"version": { "type": "string" }, "version": { "type": "string" },
"latest_release": { "latest_release": {
"type": "object", "type": "object",
@@ -1263,7 +1288,10 @@
"properties": { "properties": {
"name": { "type": "string" }, "name": { "type": "string" },
"path": { "type": "string" }, "path": { "type": "string" },
"type": { "type": "string", "enum": ["file", "directory"] }, "type": {
"type": "string",
"enum": ["file", "directory"]
},
"size": { "type": "number" }, "size": { "type": "number" },
"modified": { "type": "string" }, "modified": { "type": "string" },
"permissions": { "type": "string" } "permissions": { "type": "string" }
@@ -1524,7 +1552,9 @@
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object", "type": "object",
"additionalProperties": { "$ref": "#/components/schemas/TunnelStatus" } "additionalProperties": {
"$ref": "#/components/schemas/TunnelStatus"
}
} }
} }
} }
@@ -1673,7 +1703,9 @@
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object", "type": "object",
"additionalProperties": { "$ref": "#/components/schemas/ServerStatus" } "additionalProperties": {
"$ref": "#/components/schemas/ServerStatus"
}
} }
} }
} }
@@ -2163,8 +2195,14 @@
"title": { "type": "string" }, "title": { "type": "string" },
"message": { "type": "string" }, "message": { "type": "string" },
"expiresAt": { "type": "string" }, "expiresAt": { "type": "string" },
"priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, "priority": {
"type": { "type": "string", "enum": ["info", "warning", "error", "success"] }, "type": "string",
"enum": ["low", "medium", "high", "critical"]
},
"type": {
"type": "string",
"enum": ["info", "warning", "error", "success"]
},
"actionUrl": { "type": "string" }, "actionUrl": { "type": "string" },
"actionText": { "type": "string" } "actionText": { "type": "string" }
} }
@@ -2204,8 +2242,14 @@
"title": { "type": "string" }, "title": { "type": "string" },
"message": { "type": "string" }, "message": { "type": "string" },
"expiresAt": { "type": "string" }, "expiresAt": { "type": "string" },
"priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, "priority": {
"type": { "type": "string", "enum": ["info", "warning", "error", "success"] }, "type": "string",
"enum": ["low", "medium", "high", "critical"]
},
"type": {
"type": "string",
"enum": ["info", "warning", "error", "success"]
},
"actionUrl": { "type": "string" }, "actionUrl": { "type": "string" },
"actionText": { "type": "string" } "actionText": { "type": "string" }
} }
@@ -2259,4 +2303,3 @@
} }
} }
} }
+17
View File
@@ -102,6 +102,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"prettier": "3.6.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tw-animate-css": "^1.3.5", "tw-animate-css": "^1.3.5",
"typescript": "~5.9.2", "typescript": "~5.9.2",
@@ -13330,6 +13331,22 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/proc-log": { "node_modules/proc-log": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz",
+2
View File
@@ -7,6 +7,7 @@
"main": "electron/main.cjs", "main": "electron/main.cjs",
"type": "module", "type": "module",
"scripts": { "scripts": {
"clean": "npx prettier . --write",
"dev": "vite", "dev": "vite",
"build": "vite build && tsc -p tsconfig.node.json", "build": "vite build && tsc -p tsconfig.node.json",
"build:backend": "tsc -p tsconfig.node.json", "build:backend": "tsc -p tsconfig.node.json",
@@ -114,6 +115,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"prettier": "3.6.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tw-animate-css": "^1.3.5", "tw-animate-css": "^1.3.5",
"typescript": "~5.9.2", "typescript": "~5.9.2",
+110 -72
View File
@@ -1,22 +1,24 @@
import express from 'express'; import express from "express";
import bodyParser from 'body-parser'; import bodyParser from "body-parser";
import userRoutes from './routes/users.js'; import userRoutes from "./routes/users.js";
import sshRoutes from './routes/ssh.js'; import sshRoutes from "./routes/ssh.js";
import alertRoutes from './routes/alerts.js'; import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from './routes/credentials.js'; import credentialsRoutes from "./routes/credentials.js";
import cors from 'cors'; import cors from "cors";
import fetch from 'node-fetch'; import fetch from "node-fetch";
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
import 'dotenv/config'; import "dotenv/config";
import {databaseLogger, apiLogger} from '../utils/logger.js'; import { databaseLogger, apiLogger } from "../utils/logger.js";
const app = express(); const app = express();
app.use(cors({ app.use(
origin: '*', cors({
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], origin: "*",
allowedHeaders: ['Content-Type', 'Authorization'] methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})); allowedHeaders: ["Content-Type", "Authorization"],
}),
);
interface CacheEntry { interface CacheEntry {
data: any; data: any;
@@ -33,7 +35,7 @@ class GitHubCache {
this.cache.set(key, { this.cache.set(key, {
data, data,
timestamp: now, timestamp: now,
expiresAt: now + this.CACHE_DURATION expiresAt: now + this.CACHE_DURATION,
}); });
} }
@@ -54,9 +56,9 @@ class GitHubCache {
const githubCache = new GitHubCache(); const githubCache = new GitHubCache();
const GITHUB_API_BASE = 'https://api.github.com'; const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = 'LukeGus'; const REPO_OWNER = "LukeGus";
const REPO_NAME = 'Termix'; const REPO_NAME = "Termix";
interface GitHubRelease { interface GitHubRelease {
id: number; id: number;
@@ -76,27 +78,32 @@ interface GitHubRelease {
draft: boolean; draft: boolean;
} }
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> { async function fetchGitHubAPI(
endpoint: string,
cacheKey: string,
): Promise<any> {
const cachedData = githubCache.get(cacheKey); const cachedData = githubCache.get(cacheKey);
if (cachedData) { if (cachedData) {
return { return {
data: cachedData, data: cachedData,
cached: true, cached: true,
cache_age: Date.now() - cachedData.timestamp cache_age: Date.now() - cachedData.timestamp,
}; };
} }
try { try {
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, { const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: { headers: {
'Accept': 'application/vnd.github+json', Accept: "application/vnd.github+json",
'User-Agent': 'TermixUpdateChecker/1.0', "User-Agent": "TermixUpdateChecker/1.0",
'X-GitHub-Api-Version': '2022-11-28' "X-GitHub-Api-Version": "2022-11-28",
} },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
} }
const data = await response.json(); const data = await response.json();
@@ -104,86 +111,101 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any>
return { return {
data: data, data: data,
cached: false cached: false,
}; };
} catch (error) { } catch (error) {
databaseLogger.error(`Failed to fetch from GitHub API`, error, {operation: 'github_api', endpoint}); databaseLogger.error(`Failed to fetch from GitHub API`, error, {
operation: "github_api",
endpoint,
});
throw error; throw error;
} }
} }
app.use(bodyParser.json()); app.use(bodyParser.json());
app.get('/health', (req, res) => { app.get("/health", (req, res) => {
res.json({status: 'ok'}); res.json({ status: "ok" });
}); });
app.get('/version', async (req, res) => { app.get("/version", async (req, res) => {
let localVersion = process.env.VERSION; let localVersion = process.env.VERSION;
if (!localVersion) { if (!localVersion) {
try { try {
const packagePath = path.resolve(process.cwd(), 'package.json'); const packagePath = path.resolve(process.cwd(), "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
localVersion = packageJson.version; localVersion = packageJson.version;
} catch (error) { } catch (error) {
databaseLogger.error('Failed to read version from package.json', error, {operation: 'version_check'}); databaseLogger.error("Failed to read version from package.json", error, {
operation: "version_check",
});
} }
} }
if (!localVersion) { if (!localVersion) {
databaseLogger.error('No version information available', undefined, {operation: 'version_check'}); databaseLogger.error("No version information available", undefined, {
return res.status(404).send('Local Version Not Set'); operation: "version_check",
});
return res.status(404).send("Local Version Not Set");
} }
try { try {
const cacheKey = 'latest_release'; const cacheKey = "latest_release";
const releaseData = await fetchGitHubAPI( const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`, `/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
cacheKey cacheKey,
); );
const rawTag = releaseData.data.tag_name || releaseData.data.name || ''; const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/); const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null; const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) { if (!remoteVersion) {
databaseLogger.warn('Remote version not found in GitHub response', {operation: 'version_check', rawTag}); databaseLogger.warn("Remote version not found in GitHub response", {
return res.status(401).send('Remote Version Not Found'); operation: "version_check",
rawTag,
});
return res.status(401).send("Remote Version Not Found");
} }
const isUpToDate = localVersion === remoteVersion; const isUpToDate = localVersion === remoteVersion;
const response = { const response = {
status: isUpToDate ? 'up_to_date' : 'requires_update', status: isUpToDate ? "up_to_date" : "requires_update",
localVersion: localVersion, localVersion: localVersion,
version: remoteVersion, version: remoteVersion,
latest_release: { latest_release: {
tag_name: releaseData.data.tag_name, tag_name: releaseData.data.tag_name,
name: releaseData.data.name, name: releaseData.data.name,
published_at: releaseData.data.published_at, published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url html_url: releaseData.data.html_url,
}, },
cached: releaseData.cached, cached: releaseData.cached,
cache_age: releaseData.cache_age cache_age: releaseData.cache_age,
}; };
res.json(response); res.json(response);
} catch (err) { } catch (err) {
databaseLogger.error('Version check failed', err, {operation: 'version_check'}); databaseLogger.error("Version check failed", err, {
res.status(500).send('Fetch Error'); operation: "version_check",
});
res.status(500).send("Fetch Error");
} }
}); });
app.get('/releases/rss', async (req, res) => { app.get("/releases/rss", async (req, res) => {
try { try {
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100); const per_page = Math.min(
parseInt(req.query.per_page as string) || 20,
100,
);
const cacheKey = `releases_rss_${page}_${per_page}`; const cacheKey = `releases_rss_${page}_${per_page}`;
const releasesData = await fetchGitHubAPI( const releasesData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`, `/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
cacheKey cacheKey,
); );
const rssItems = releasesData.data.map((release: GitHubRelease) => ({ const rssItems = releasesData.data.map((release: GitHubRelease) => ({
@@ -195,12 +217,12 @@ app.get('/releases/rss', async (req, res) => {
version: release.tag_name, version: release.tag_name,
isPrerelease: release.prerelease, isPrerelease: release.prerelease,
isDraft: release.draft, isDraft: release.draft,
assets: release.assets.map(asset => ({ assets: release.assets.map((asset) => ({
name: asset.name, name: asset.name,
size: asset.size, size: asset.size,
download_count: asset.download_count, download_count: asset.download_count,
download_url: asset.browser_download_url download_url: asset.browser_download_url,
})) })),
})); }));
const response = { const response = {
@@ -208,45 +230,61 @@ app.get('/releases/rss', async (req, res) => {
title: `${REPO_NAME} Releases`, title: `${REPO_NAME} Releases`,
description: `Latest releases from ${REPO_NAME} repository`, description: `Latest releases from ${REPO_NAME} repository`,
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`, link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
updated: new Date().toISOString() updated: new Date().toISOString(),
}, },
items: rssItems, items: rssItems,
total_count: rssItems.length, total_count: rssItems.length,
cached: releasesData.cached, cached: releasesData.cached,
cache_age: releasesData.cache_age cache_age: releasesData.cache_age,
}; };
res.json(response); res.json(response);
} catch (error) { } catch (error) {
databaseLogger.error('Failed to generate RSS format', error, {operation: 'rss_releases'}); databaseLogger.error("Failed to generate RSS format", error, {
operation: "rss_releases",
});
res.status(500).json({ res.status(500).json({
error: 'Failed to generate RSS format', error: "Failed to generate RSS format",
details: error instanceof Error ? error.message : 'Unknown error' details: error instanceof Error ? error.message : "Unknown error",
}); });
} }
}); });
app.use("/users", userRoutes);
app.use("/ssh", sshRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use('/users', userRoutes); app.use(
app.use('/ssh', sshRoutes); (
app.use('/alerts', alertRoutes); err: unknown,
app.use('/credentials', credentialsRoutes); req: express.Request,
res: express.Response,
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { next: express.NextFunction,
apiLogger.error('Unhandled error in request', err, { ) => {
operation: 'error_handler', apiLogger.error("Unhandled error in request", err, {
operation: "error_handler",
method: req.method, method: req.method,
url: req.url, url: req.url,
userAgent: req.get('User-Agent') userAgent: req.get("User-Agent"),
});
res.status(500).json({error: 'Internal Server Error'});
}); });
res.status(500).json({ error: "Internal Server Error" });
},
);
const PORT = 8081; const PORT = 8081;
app.listen(PORT, () => { app.listen(PORT, () => {
databaseLogger.success(`Database API server started on port ${PORT}`, { databaseLogger.success(`Database API server started on port ${PORT}`, {
operation: 'server_start', operation: "server_start",
port: PORT, port: PORT,
routes: ['/users', '/ssh', '/alerts', '/credentials', '/health', '/version', '/releases/rss'] routes: [
"/users",
"/ssh",
"/alerts",
"/credentials",
"/health",
"/version",
"/releases/rss",
],
}); });
}); });
+141 -61
View File
@@ -1,19 +1,25 @@
import {drizzle} from 'drizzle-orm/better-sqlite3'; import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from 'better-sqlite3'; import Database from "better-sqlite3";
import * as schema from './schema.js'; import * as schema from "./schema.js";
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
import { databaseLogger } from '../../utils/logger.js'; import { databaseLogger } from "../../utils/logger.js";
const dataDir = process.env.DATA_DIR || './db/data'; const dataDir = process.env.DATA_DIR || "./db/data";
const dbDir = path.resolve(dataDir); const dbDir = path.resolve(dataDir);
if (!fs.existsSync(dbDir)) { if (!fs.existsSync(dbDir)) {
databaseLogger.info(`Creating database directory`, { operation: 'db_init', path: dbDir }); databaseLogger.info(`Creating database directory`, {
operation: "db_init",
path: dbDir,
});
fs.mkdirSync(dbDir, { recursive: true }); fs.mkdirSync(dbDir, { recursive: true });
} }
const dbPath = path.join(dataDir, 'db.sqlite'); const dbPath = path.join(dataDir, "db.sqlite");
databaseLogger.info(`Initializing SQLite database`, { operation: 'db_init', path: dbPath }); databaseLogger.info(`Initializing SQLite database`, {
operation: "db_init",
path: dbPath,
});
const sqlite = new Database(dbPath); const sqlite = new Database(dbPath);
sqlite.exec(` sqlite.exec(`
@@ -137,90 +143,164 @@ sqlite.exec(`
); );
`); `);
const addColumnIfNotExists = (table: string, column: string, definition: string) => { const addColumnIfNotExists = (
table: string,
column: string,
definition: string,
) => {
try { try {
sqlite.prepare(`SELECT ${column} sqlite
FROM ${table} LIMIT 1`).get(); .prepare(
`SELECT ${column}
FROM ${table} LIMIT 1`,
)
.get();
} catch (e) { } catch (e) {
try { try {
databaseLogger.debug(`Adding column ${column} to ${table}`, { operation: 'schema_migration', table, column }); databaseLogger.debug(`Adding column ${column} to ${table}`, {
operation: "schema_migration",
table,
column,
});
sqlite.exec(`ALTER TABLE ${table} sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`); ADD COLUMN ${column} ${definition};`);
databaseLogger.success(`Column ${column} added to ${table}`, { operation: 'schema_migration', table, column }); databaseLogger.success(`Column ${column} added to ${table}`, {
operation: "schema_migration",
table,
column,
});
} catch (alterError) { } catch (alterError) {
databaseLogger.warn(`Failed to add column ${column} to ${table}`, { operation: 'schema_migration', table, column, error: alterError }); databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
operation: "schema_migration",
table,
column,
error: alterError,
});
} }
} }
}; };
const migrateSchema = () => { const migrateSchema = () => {
databaseLogger.info('Checking for schema updates...', { operation: 'schema_migration' }); databaseLogger.info("Checking for schema updates...", {
operation: "schema_migration",
});
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists('users', 'is_oidc', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists('users', 'oidc_identifier', 'TEXT'); addColumnIfNotExists("users", "oidc_identifier", "TEXT");
addColumnIfNotExists('users', 'client_id', 'TEXT'); addColumnIfNotExists("users", "client_id", "TEXT");
addColumnIfNotExists('users', 'client_secret', 'TEXT'); addColumnIfNotExists("users", "client_secret", "TEXT");
addColumnIfNotExists('users', 'issuer_url', 'TEXT'); addColumnIfNotExists("users", "issuer_url", "TEXT");
addColumnIfNotExists('users', 'authorization_url', 'TEXT'); addColumnIfNotExists("users", "authorization_url", "TEXT");
addColumnIfNotExists('users', 'token_url', 'TEXT'); addColumnIfNotExists("users", "token_url", "TEXT");
addColumnIfNotExists('users', 'identifier_path', 'TEXT'); addColumnIfNotExists("users", "identifier_path", "TEXT");
addColumnIfNotExists('users', 'name_path', 'TEXT'); addColumnIfNotExists("users", "name_path", "TEXT");
addColumnIfNotExists('users', 'scopes', 'TEXT'); addColumnIfNotExists("users", "scopes", "TEXT");
addColumnIfNotExists('users', 'totp_secret', 'TEXT'); addColumnIfNotExists("users", "totp_secret", "TEXT");
addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT'); addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
addColumnIfNotExists('ssh_data', 'name', 'TEXT'); addColumnIfNotExists("ssh_data", "name", "TEXT");
addColumnIfNotExists('ssh_data', 'folder', 'TEXT'); addColumnIfNotExists("ssh_data", "folder", "TEXT");
addColumnIfNotExists('ssh_data', 'tags', 'TEXT'); addColumnIfNotExists("ssh_data", "tags", "TEXT");
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"'); addColumnIfNotExists(
addColumnIfNotExists('ssh_data', 'password', 'TEXT'); "ssh_data",
addColumnIfNotExists('ssh_data', 'key', 'TEXT'); "auth_type",
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT'); 'TEXT NOT NULL DEFAULT "password"',
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT'); );
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1'); addColumnIfNotExists("ssh_data", "password", "TEXT");
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1'); addColumnIfNotExists("ssh_data", "key", "TEXT");
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT'); addColumnIfNotExists("ssh_data", "key_password", "TEXT");
addColumnIfNotExists('ssh_data', 'enable_file_manager', 'INTEGER NOT NULL DEFAULT 1'); addColumnIfNotExists("ssh_data", "key_type", "TEXT");
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT'); addColumnIfNotExists(
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP'); "ssh_data",
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP'); "enable_terminal",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"enable_tunnel",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
addColumnIfNotExists(
"ssh_data",
"enable_file_manager",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
addColumnIfNotExists(
"ssh_data",
"created_at",
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
);
addColumnIfNotExists(
"ssh_data",
"updated_at",
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
);
addColumnIfNotExists('ssh_data', 'credential_id', 'INTEGER REFERENCES ssh_credentials(id)'); addColumnIfNotExists(
"ssh_data",
"credential_id",
"INTEGER REFERENCES ssh_credentials(id)",
);
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL'); addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL'); addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL'); addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
databaseLogger.success('Schema migration completed', { operation: 'schema_migration' }); databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});
}; };
const initializeDatabase = async () => { const initializeDatabase = async () => {
migrateSchema(); migrateSchema();
try { try {
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); const row = sqlite
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
.get();
if (!row) { if (!row) {
databaseLogger.info('Initializing default settings', { operation: 'db_init', setting: 'allow_registration' }); databaseLogger.info("Initializing default settings", {
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run(); operation: "db_init",
databaseLogger.success('Default settings initialized', { operation: 'db_init' }); setting: "allow_registration",
});
sqlite
.prepare(
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
)
.run();
databaseLogger.success("Default settings initialized", {
operation: "db_init",
});
} else { } else {
databaseLogger.debug('Default settings already exist', { operation: 'db_init' }); databaseLogger.debug("Default settings already exist", {
operation: "db_init",
});
} }
} catch (e) { } catch (e) {
databaseLogger.warn('Could not initialize default settings', { operation: 'db_init', error: e }); databaseLogger.warn("Could not initialize default settings", {
operation: "db_init",
error: e,
});
} }
}; };
initializeDatabase().catch(error => { initializeDatabase().catch((error) => {
databaseLogger.error('Failed to initialize database', error, { operation: 'db_init' }); databaseLogger.error("Failed to initialize database", error, {
operation: "db_init",
});
process.exit(1); process.exit(1);
}); });
databaseLogger.success('Database connection established', { operation: 'db_init', path: dbPath }); databaseLogger.success("Database connection established", {
operation: "db_init",
path: dbPath,
});
export const db = drizzle(sqlite, { schema }); export const db = drizzle(sqlite, { schema });
+144 -94
View File
@@ -1,117 +1,167 @@
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import {sql} from 'drizzle-orm'; import { sql } from "drizzle-orm";
export const users = sqliteTable('users', { export const users = sqliteTable("users", {
id: text('id').primaryKey(), id: text("id").primaryKey(),
username: text('username').notNull(), username: text("username").notNull(),
password_hash: text('password_hash').notNull(), password_hash: text("password_hash").notNull(),
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false), is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
is_oidc: integer('is_oidc', {mode: 'boolean'}).notNull().default(false), is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
oidc_identifier: text('oidc_identifier'), oidc_identifier: text("oidc_identifier"),
client_id: text('client_id'), client_id: text("client_id"),
client_secret: text('client_secret'), client_secret: text("client_secret"),
issuer_url: text('issuer_url'), issuer_url: text("issuer_url"),
authorization_url: text('authorization_url'), authorization_url: text("authorization_url"),
token_url: text('token_url'), token_url: text("token_url"),
identifier_path: text('identifier_path'), identifier_path: text("identifier_path"),
name_path: text('name_path'), name_path: text("name_path"),
scopes: text().default("openid email profile"), scopes: text().default("openid email profile"),
totp_secret: text('totp_secret'), totp_secret: text("totp_secret"),
totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false), totp_enabled: integer("totp_enabled", { mode: "boolean" })
totp_backup_codes: text('totp_backup_codes'), .notNull()
.default(false),
totp_backup_codes: text("totp_backup_codes"),
}); });
export const settings = sqliteTable('settings', { export const settings = sqliteTable("settings", {
key: text('key').primaryKey(), key: text("key").primaryKey(),
value: text('value').notNull(), value: text("value").notNull(),
}); });
export const sshData = sqliteTable('ssh_data', { export const sshData = sqliteTable("ssh_data", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
name: text('name'), .notNull()
ip: text('ip').notNull(), .references(() => users.id),
port: integer('port').notNull(), name: text("name"),
username: text('username').notNull(), ip: text("ip").notNull(),
folder: text('folder'), port: integer("port").notNull(),
tags: text('tags'), username: text("username").notNull(),
pin: integer('pin', {mode: 'boolean'}).notNull().default(false), folder: text("folder"),
authType: text('auth_type').notNull(), tags: text("tags"),
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
authType: text("auth_type").notNull(),
password: text('password'), password: text("password"),
key: text('key', {length: 8192}), key: text("key", { length: 8192 }),
keyPassword: text('key_password'), keyPassword: text("key_password"),
keyType: text('key_type'), keyType: text("key_type"),
credentialId: integer('credential_id').references(() => sshCredentials.id), credentialId: integer("credential_id").references(() => sshCredentials.id),
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true), enableTerminal: integer("enable_terminal", { mode: "boolean" })
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true), .notNull()
tunnelConnections: text('tunnel_connections'), .default(true),
enableFileManager: integer('enable_file_manager', {mode: 'boolean'}).notNull().default(true), enableTunnel: integer("enable_tunnel", { mode: "boolean" })
defaultPath: text('default_path'), .notNull()
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), .default(true),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), tunnelConnections: text("tunnel_connections"),
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
.notNull()
.default(true),
defaultPath: text("default_path"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const fileManagerRecent = sqliteTable('file_manager_recent', { export const fileManagerRecent = sqliteTable("file_manager_recent", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
hostId: integer('host_id').notNull().references(() => sshData.id), .notNull()
name: text('name').notNull(), .references(() => users.id),
path: text('path').notNull(), hostId: integer("host_id")
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`), .notNull()
.references(() => sshData.id),
name: text("name").notNull(),
path: text("path").notNull(),
lastOpened: text("last_opened")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const fileManagerPinned = sqliteTable('file_manager_pinned', { export const fileManagerPinned = sqliteTable("file_manager_pinned", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
hostId: integer('host_id').notNull().references(() => sshData.id), .notNull()
name: text('name').notNull(), .references(() => users.id),
path: text('path').notNull(), hostId: integer("host_id")
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`), .notNull()
.references(() => sshData.id),
name: text("name").notNull(),
path: text("path").notNull(),
pinnedAt: text("pinned_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const fileManagerShortcuts = sqliteTable('file_manager_shortcuts', { export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
hostId: integer('host_id').notNull().references(() => sshData.id), .notNull()
name: text('name').notNull(), .references(() => users.id),
path: text('path').notNull(), hostId: integer("host_id")
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), .notNull()
.references(() => sshData.id),
name: text("name").notNull(),
path: text("path").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const dismissedAlerts = sqliteTable('dismissed_alerts', { export const dismissedAlerts = sqliteTable("dismissed_alerts", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
alertId: text('alert_id').notNull(), .notNull()
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`), .references(() => users.id),
alertId: text("alert_id").notNull(),
dismissedAt: text("dismissed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const sshCredentials = sqliteTable('ssh_credentials', { export const sshCredentials = sqliteTable("ssh_credentials", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
name: text('name').notNull(), .notNull()
description: text('description'), .references(() => users.id),
folder: text('folder'), name: text("name").notNull(),
tags: text('tags'), description: text("description"),
authType: text('auth_type').notNull(), folder: text("folder"),
username: text('username').notNull(), tags: text("tags"),
password: text('password'), authType: text("auth_type").notNull(),
key: text('key', {length: 16384}), username: text("username").notNull(),
keyPassword: text('key_password'), password: text("password"),
keyType: text('key_type'), key: text("key", { length: 16384 }),
usageCount: integer('usage_count').notNull().default(0), keyPassword: text("key_password"),
lastUsed: text('last_used'), keyType: text("key_type"),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), usageCount: integer("usage_count").notNull().default(0),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), lastUsed: text("last_used"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const sshCredentialUsage = sqliteTable('ssh_credential_usage', { export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
credentialId: integer('credential_id').notNull().references(() => sshCredentials.id), credentialId: integer("credential_id")
hostId: integer('host_id').notNull().references(() => sshData.id), .notNull()
userId: text('user_id').notNull().references(() => users.id), .references(() => sshCredentials.id),
usedAt: text('used_at').notNull().default(sql`CURRENT_TIMESTAMP`), hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
userId: text("user_id")
.notNull()
.references(() => users.id),
usedAt: text("used_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
+78 -65
View File
@@ -1,10 +1,9 @@
import express from 'express'; import express from "express";
import {db} from '../db/index.js'; import { db } from "../db/index.js";
import {dismissedAlerts} from '../db/schema.js'; import { dismissedAlerts } from "../db/schema.js";
import {eq, and} from 'drizzle-orm'; import { eq, and } from "drizzle-orm";
import fetch from 'node-fetch'; import fetch from "node-fetch";
import {authLogger} from '../../utils/logger.js'; import { authLogger } from "../../utils/logger.js";
interface CacheEntry { interface CacheEntry {
data: any; data: any;
@@ -21,7 +20,7 @@ class AlertCache {
this.cache.set(key, { this.cache.set(key, {
data, data,
timestamp: now, timestamp: now,
expiresAt: now + this.CACHE_DURATION expiresAt: now + this.CACHE_DURATION,
}); });
} }
@@ -42,24 +41,24 @@ class AlertCache {
const alertCache = new AlertCache(); const alertCache = new AlertCache();
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com'; const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
const REPO_OWNER = 'LukeGus'; const REPO_OWNER = "LukeGus";
const REPO_NAME = 'Termix-Docs'; const REPO_NAME = "Termix-Docs";
const ALERTS_FILE = 'main/termix-alerts.json'; const ALERTS_FILE = "main/termix-alerts.json";
interface TermixAlert { interface TermixAlert {
id: string; id: string;
title: string; title: string;
message: string; message: string;
expiresAt: string; expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical'; priority?: "low" | "medium" | "high" | "critical";
type?: 'info' | 'warning' | 'error' | 'success'; type?: "info" | "warning" | "error" | "success";
actionUrl?: string; actionUrl?: string;
actionText?: string; actionText?: string;
} }
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> { async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const cacheKey = 'termix_alerts'; const cacheKey = "termix_alerts";
const cachedData = alertCache.get(cacheKey); const cachedData = alertCache.get(cacheKey);
if (cachedData) { if (cachedData) {
return cachedData; return cachedData;
@@ -69,25 +68,27 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'Accept': 'application/json', Accept: "application/json",
'User-Agent': 'TermixAlertChecker/1.0' "User-Agent": "TermixAlertChecker/1.0",
} },
}); });
if (!response.ok) { if (!response.ok) {
authLogger.warn('GitHub API returned error status', { authLogger.warn("GitHub API returned error status", {
operation: 'alerts_fetch', operation: "alerts_fetch",
status: response.status, status: response.status,
statusText: response.statusText statusText: response.statusText,
}); });
throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`); throw new Error(
`GitHub raw content error: ${response.status} ${response.statusText}`,
);
} }
const alerts: TermixAlert[] = await response.json() as TermixAlert[]; const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
const now = new Date(); const now = new Date();
const validAlerts = alerts.filter(alert => { const validAlerts = alerts.filter((alert) => {
const expiryDate = new Date(alert.expiresAt); const expiryDate = new Date(alert.expiresAt);
const isValid = expiryDate > now; const isValid = expiryDate > now;
return isValid; return isValid;
@@ -96,9 +97,9 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
alertCache.set(cacheKey, validAlerts); alertCache.set(cacheKey, validAlerts);
return validAlerts; return validAlerts;
} catch (error) { } catch (error) {
authLogger.error('Failed to fetch alerts from GitHub', { authLogger.error("Failed to fetch alerts from GitHub", {
operation: 'alerts_fetch', operation: "alerts_fetch",
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : "Unknown error",
}); });
return []; return [];
} }
@@ -108,28 +109,28 @@ const router = express.Router();
// Route: Get all active alerts // Route: Get all active alerts
// GET /alerts // GET /alerts
router.get('/', async (req, res) => { router.get("/", async (req, res) => {
try { try {
const alerts = await fetchAlertsFromGitHub(); const alerts = await fetchAlertsFromGitHub();
res.json({ res.json({
alerts, alerts,
cached: alertCache.get('termix_alerts') !== null, cached: alertCache.get("termix_alerts") !== null,
total_count: alerts.length total_count: alerts.length,
}); });
} catch (error) { } catch (error) {
authLogger.error('Failed to get alerts', error); authLogger.error("Failed to get alerts", error);
res.status(500).json({error: 'Failed to fetch alerts'}); res.status(500).json({ error: "Failed to fetch alerts" });
} }
}); });
// Route: Get alerts for a specific user (excluding dismissed ones) // Route: Get alerts for a specific user (excluding dismissed ones)
// GET /alerts/user/:userId // GET /alerts/user/:userId
router.get('/user/:userId', async (req, res) => { router.get("/user/:userId", async (req, res) => {
try { try {
const { userId } = req.params; const { userId } = req.params;
if (!userId) { if (!userId) {
return res.status(400).json({error: 'User ID is required'}); return res.status(400).json({ error: "User ID is required" });
} }
const allAlerts = await fetchAlertsFromGitHub(); const allAlerts = await fetchAlertsFromGitHub();
@@ -139,109 +140,121 @@ router.get('/user/:userId', async (req, res) => {
.from(dismissedAlerts) .from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId)); .where(eq(dismissedAlerts.userId, userId));
const dismissedAlertIds = new Set(dismissedAlertRecords.map(record => record.alertId)); const dismissedAlertIds = new Set(
dismissedAlertRecords.map((record) => record.alertId),
);
const userAlerts = allAlerts.filter(alert => !dismissedAlertIds.has(alert.id)); const userAlerts = allAlerts.filter(
(alert) => !dismissedAlertIds.has(alert.id),
);
res.json({ res.json({
alerts: userAlerts, alerts: userAlerts,
total_count: userAlerts.length, total_count: userAlerts.length,
dismissed_count: dismissedAlertIds.size dismissed_count: dismissedAlertIds.size,
}); });
} catch (error) { } catch (error) {
authLogger.error('Failed to get user alerts', error); authLogger.error("Failed to get user alerts", error);
res.status(500).json({error: 'Failed to fetch user alerts'}); res.status(500).json({ error: "Failed to fetch user alerts" });
} }
}); });
// Route: Dismiss an alert for a user // Route: Dismiss an alert for a user
// POST /alerts/dismiss // POST /alerts/dismiss
router.post('/dismiss', async (req, res) => { router.post("/dismiss", async (req, res) => {
try { try {
const { userId, alertId } = req.body; const { userId, alertId } = req.body;
if (!userId || !alertId) { if (!userId || !alertId) {
authLogger.warn('Missing userId or alertId in dismiss request'); authLogger.warn("Missing userId or alertId in dismiss request");
return res.status(400).json({error: 'User ID and Alert ID are required'}); return res
.status(400)
.json({ error: "User ID and Alert ID are required" });
} }
const existingDismissal = await db const existingDismissal = await db
.select() .select()
.from(dismissedAlerts) .from(dismissedAlerts)
.where(and( .where(
and(
eq(dismissedAlerts.userId, userId), eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId) eq(dismissedAlerts.alertId, alertId),
)); ),
);
if (existingDismissal.length > 0) { if (existingDismissal.length > 0) {
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`); authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
return res.status(409).json({error: 'Alert already dismissed'}); return res.status(409).json({ error: "Alert already dismissed" });
} }
const result = await db.insert(dismissedAlerts).values({ const result = await db.insert(dismissedAlerts).values({
userId, userId,
alertId alertId,
}); });
res.json({message: 'Alert dismissed successfully'}); res.json({ message: "Alert dismissed successfully" });
} catch (error) { } catch (error) {
authLogger.error('Failed to dismiss alert', error); authLogger.error("Failed to dismiss alert", error);
res.status(500).json({error: 'Failed to dismiss alert'}); res.status(500).json({ error: "Failed to dismiss alert" });
} }
}); });
// Route: Get dismissed alerts for a user // Route: Get dismissed alerts for a user
// GET /alerts/dismissed/:userId // GET /alerts/dismissed/:userId
router.get('/dismissed/:userId', async (req, res) => { router.get("/dismissed/:userId", async (req, res) => {
try { try {
const { userId } = req.params; const { userId } = req.params;
if (!userId) { if (!userId) {
return res.status(400).json({error: 'User ID is required'}); return res.status(400).json({ error: "User ID is required" });
} }
const dismissedAlertRecords = await db const dismissedAlertRecords = await db
.select({ .select({
alertId: dismissedAlerts.alertId, alertId: dismissedAlerts.alertId,
dismissedAt: dismissedAlerts.dismissedAt dismissedAt: dismissedAlerts.dismissedAt,
}) })
.from(dismissedAlerts) .from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId)); .where(eq(dismissedAlerts.userId, userId));
res.json({ res.json({
dismissed_alerts: dismissedAlertRecords, dismissed_alerts: dismissedAlertRecords,
total_count: dismissedAlertRecords.length total_count: dismissedAlertRecords.length,
}); });
} catch (error) { } catch (error) {
authLogger.error('Failed to get dismissed alerts', error); authLogger.error("Failed to get dismissed alerts", error);
res.status(500).json({error: 'Failed to fetch dismissed alerts'}); res.status(500).json({ error: "Failed to fetch dismissed alerts" });
} }
}); });
// Route: Undismiss an alert for a user (remove from dismissed list) // Route: Undismiss an alert for a user (remove from dismissed list)
// DELETE /alerts/dismiss // DELETE /alerts/dismiss
router.delete('/dismiss', async (req, res) => { router.delete("/dismiss", async (req, res) => {
try { try {
const { userId, alertId } = req.body; const { userId, alertId } = req.body;
if (!userId || !alertId) { if (!userId || !alertId) {
return res.status(400).json({error: 'User ID and Alert ID are required'}); return res
.status(400)
.json({ error: "User ID and Alert ID are required" });
} }
const result = await db const result = await db
.delete(dismissedAlerts) .delete(dismissedAlerts)
.where(and( .where(
and(
eq(dismissedAlerts.userId, userId), eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId) eq(dismissedAlerts.alertId, alertId),
)); ),
);
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json({error: 'Dismissed alert not found'}); return res.status(404).json({ error: "Dismissed alert not found" });
} }
res.json({message: 'Alert undismissed successfully'}); res.json({ message: "Alert undismissed successfully" });
} catch (error) { } catch (error) {
authLogger.error('Failed to undismiss alert', error); authLogger.error("Failed to undismiss alert", error);
res.status(500).json({error: 'Failed to undismiss alert'}); res.status(500).json({ error: "Failed to undismiss alert" });
} }
}); });
+255 -167
View File
@@ -1,11 +1,10 @@
import express from 'express'; import express from "express";
import {db} from '../db/index.js'; import { db } from "../db/index.js";
import {sshCredentials, sshCredentialUsage, sshData} from '../db/schema.js'; import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
import {eq, and, desc, sql} from 'drizzle-orm'; import { eq, and, desc, sql } from "drizzle-orm";
import type {Request, Response, NextFunction} from 'express'; import type { Request, Response, NextFunction } from "express";
import jwt from 'jsonwebtoken'; import jwt from "jsonwebtoken";
import {authLogger} from '../../utils/logger.js'; import { authLogger } from "../../utils/logger.js";
const router = express.Router(); const router = express.Router();
@@ -16,30 +15,32 @@ interface JWTPayload {
} }
function isNonEmptyString(val: any): val is string { function isNonEmptyString(val: any): val is string {
return typeof val === 'string' && val.trim().length > 0; return typeof val === "string" && val.trim().length > 0;
} }
function authenticateJWT(req: Request, res: Response, next: NextFunction) { function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers['authorization']; const authHeader = req.headers["authorization"];
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith("Bearer ")) {
authLogger.warn('Missing or invalid Authorization header'); authLogger.warn("Missing or invalid Authorization header");
return res.status(401).json({error: 'Missing or invalid Authorization header'}); return res
.status(401)
.json({ error: "Missing or invalid Authorization header" });
} }
const token = authHeader.split(' ')[1]; const token = authHeader.split(" ")[1];
const jwtSecret = process.env.JWT_SECRET || 'secret'; const jwtSecret = process.env.JWT_SECRET || "secret";
try { try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload; const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId; (req as any).userId = payload.userId;
next(); next();
} catch (err) { } catch (err) {
authLogger.warn('Invalid or expired token'); authLogger.warn("Invalid or expired token");
return res.status(401).json({error: 'Invalid or expired token'}); return res.status(401).json({ error: "Invalid or expired token" });
} }
} }
// Create a new credential // Create a new credential
// POST /credentials // POST /credentials
router.post('/', authenticateJWT, async (req: Request, res: Response) => { router.post("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { const {
name, name,
@@ -51,53 +52,69 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => {
password, password,
key, key,
keyPassword, keyPassword,
keyType keyType,
} = req.body; } = req.body;
if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) { if (
authLogger.warn('Invalid credential creation data validation failed', { !isNonEmptyString(userId) ||
operation: 'credential_create', !isNonEmptyString(name) ||
!isNonEmptyString(username)
) {
authLogger.warn("Invalid credential creation data validation failed", {
operation: "credential_create",
userId, userId,
hasName: !!name, hasName: !!name,
hasUsername: !!username hasUsername: !!username,
}); });
return res.status(400).json({error: 'Name and username are required'}); return res.status(400).json({ error: "Name and username are required" });
} }
if (!['password', 'key'].includes(authType)) { if (!["password", "key"].includes(authType)) {
authLogger.warn('Invalid auth type provided', {operation: 'credential_create', userId, name, authType}); authLogger.warn("Invalid auth type provided", {
return res.status(400).json({error: 'Auth type must be "password" or "key"'}); operation: "credential_create",
userId,
name,
authType,
});
return res
.status(400)
.json({ error: 'Auth type must be "password" or "key"' });
} }
try { try {
if (authType === 'password' && !password) { if (authType === "password" && !password) {
authLogger.warn('Password required for password authentication', { authLogger.warn("Password required for password authentication", {
operation: 'credential_create', operation: "credential_create",
userId, userId,
name, name,
authType authType,
}); });
return res.status(400).json({error: 'Password is required for password authentication'}); return res
.status(400)
.json({ error: "Password is required for password authentication" });
} }
if (authType === 'key' && !key) { if (authType === "key" && !key) {
authLogger.warn('SSH key required for key authentication', { authLogger.warn("SSH key required for key authentication", {
operation: 'credential_create', operation: "credential_create",
userId, userId,
name, name,
authType authType,
}); });
return res.status(400).json({error: 'SSH key is required for key authentication'}); return res
.status(400)
.json({ error: "SSH key is required for key authentication" });
} }
const plainPassword = (authType === 'password' && password) ? password : null; const plainPassword = authType === "password" && password ? password : null;
const plainKey = (authType === 'key' && key) ? key : null; const plainKey = authType === "key" && key ? key : null;
const plainKeyPassword = (authType === 'key' && keyPassword) ? keyPassword : null; const plainKeyPassword =
authType === "key" && keyPassword ? keyPassword : null;
const credentialData = { const credentialData = {
userId, userId,
name: name.trim(), name: name.trim(),
description: description?.trim() || null, description: description?.trim() || null,
folder: folder?.trim() || null, folder: folder?.trim() || null,
tags: Array.isArray(tags) ? tags.join(',') : (tags || ''), tags: Array.isArray(tags) ? tags.join(",") : tags || "",
authType, authType,
username: username.trim(), username: username.trim(),
password: plainPassword, password: plainPassword,
@@ -108,41 +125,47 @@ router.post('/', authenticateJWT, async (req: Request, res: Response) => {
lastUsed: null, lastUsed: null,
}; };
const result = await db.insert(sshCredentials).values(credentialData).returning(); const result = await db
.insert(sshCredentials)
.values(credentialData)
.returning();
const created = result[0]; const created = result[0];
authLogger.success(`SSH credential created: ${name} (${authType}) by user ${userId}`, { authLogger.success(
operation: 'credential_create_success', `SSH credential created: ${name} (${authType}) by user ${userId}`,
{
operation: "credential_create_success",
userId, userId,
credentialId: created.id, credentialId: created.id,
name, name,
authType, authType,
username username,
}); },
);
res.status(201).json(formatCredentialOutput(created)); res.status(201).json(formatCredentialOutput(created));
} catch (err) { } catch (err) {
authLogger.error('Failed to create credential in database', err, { authLogger.error("Failed to create credential in database", err, {
operation: 'credential_create', operation: "credential_create",
userId, userId,
name, name,
authType, authType,
username username,
}); });
res.status(500).json({ res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to create credential' error: err instanceof Error ? err.message : "Failed to create credential",
}); });
} }
}); });
// Get all credentials for the authenticated user // Get all credentials for the authenticated user
// GET /credentials // GET /credentials
router.get('/', authenticateJWT, async (req: Request, res: Response) => { router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
authLogger.warn('Invalid userId for credential fetch'); authLogger.warn("Invalid userId for credential fetch");
return res.status(400).json({error: 'Invalid userId'}); return res.status(400).json({ error: "Invalid userId" });
} }
try { try {
@@ -152,21 +175,21 @@ router.get('/', authenticateJWT, async (req: Request, res: Response) => {
.where(eq(sshCredentials.userId, userId)) .where(eq(sshCredentials.userId, userId))
.orderBy(desc(sshCredentials.updatedAt)); .orderBy(desc(sshCredentials.updatedAt));
res.json(credentials.map(cred => formatCredentialOutput(cred))); res.json(credentials.map((cred) => formatCredentialOutput(cred)));
} catch (err) { } catch (err) {
authLogger.error('Failed to fetch credentials', err); authLogger.error("Failed to fetch credentials", err);
res.status(500).json({error: 'Failed to fetch credentials'}); res.status(500).json({ error: "Failed to fetch credentials" });
} }
}); });
// Get all unique credential folders for the authenticated user // Get all unique credential folders for the authenticated user
// GET /credentials/folders // GET /credentials/folders
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => { router.get("/folders", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
authLogger.warn('Invalid userId for credential folder fetch'); authLogger.warn("Invalid userId for credential folder fetch");
return res.status(400).json({error: 'Invalid userId'}); return res.status(400).json({ error: "Invalid userId" });
} }
try { try {
@@ -176,42 +199,46 @@ router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
.where(eq(sshCredentials.userId, userId)); .where(eq(sshCredentials.userId, userId));
const folderCounts: Record<string, number> = {}; const folderCounts: Record<string, number> = {};
result.forEach(r => { result.forEach((r) => {
if (r.folder && r.folder.trim() !== '') { if (r.folder && r.folder.trim() !== "") {
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1; folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
} }
}); });
const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0); const folders = Object.keys(folderCounts).filter(
(folder) => folderCounts[folder] > 0,
);
res.json(folders); res.json(folders);
} catch (err) { } catch (err) {
authLogger.error('Failed to fetch credential folders', err); authLogger.error("Failed to fetch credential folders", err);
res.status(500).json({error: 'Failed to fetch credential folders'}); res.status(500).json({ error: "Failed to fetch credential folders" });
} }
}); });
// Get a specific credential by ID (with plain text secrets) // Get a specific credential by ID (with plain text secrets)
// GET /credentials/:id // GET /credentials/:id
router.get('/:id', authenticateJWT, async (req: Request, res: Response) => { router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { id } = req.params; const { id } = req.params;
if (!isNonEmptyString(userId) || !id) { if (!isNonEmptyString(userId) || !id) {
authLogger.warn('Invalid request for credential fetch'); authLogger.warn("Invalid request for credential fetch");
return res.status(400).json({error: 'Invalid request'}); return res.status(400).json({ error: "Invalid request" });
} }
try { try {
const credentials = await db const credentials = await db
.select() .select()
.from(sshCredentials) .from(sshCredentials)
.where(and( .where(
and(
eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId) eq(sshCredentials.userId, userId),
)); ),
);
if (credentials.length === 0) { if (credentials.length === 0) {
return res.status(404).json({error: 'Credential not found'}); return res.status(404).json({ error: "Credential not found" });
} }
const credential = credentials[0]; const credential = credentials[0];
@@ -229,49 +256,59 @@ router.get('/:id', authenticateJWT, async (req: Request, res: Response) => {
res.json(output); res.json(output);
} catch (err) { } catch (err) {
authLogger.error('Failed to fetch credential', err); authLogger.error("Failed to fetch credential", err);
res.status(500).json({ res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to fetch credential' error: err instanceof Error ? err.message : "Failed to fetch credential",
}); });
} }
}); });
// Update a credential // Update a credential
// PUT /credentials/:id // PUT /credentials/:id
router.put('/:id', authenticateJWT, async (req: Request, res: Response) => { router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { id } = req.params; const { id } = req.params;
const updateData = req.body; const updateData = req.body;
if (!isNonEmptyString(userId) || !id) { if (!isNonEmptyString(userId) || !id) {
authLogger.warn('Invalid request for credential update'); authLogger.warn("Invalid request for credential update");
return res.status(400).json({error: 'Invalid request'}); return res.status(400).json({ error: "Invalid request" });
} }
try { try {
const existing = await db const existing = await db
.select() .select()
.from(sshCredentials) .from(sshCredentials)
.where(and( .where(
and(
eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId) eq(sshCredentials.userId, userId),
)); ),
);
if (existing.length === 0) { if (existing.length === 0) {
return res.status(404).json({error: 'Credential not found'}); return res.status(404).json({ error: "Credential not found" });
} }
const updateFields: any = {}; const updateFields: any = {};
if (updateData.name !== undefined) updateFields.name = updateData.name.trim(); if (updateData.name !== undefined)
if (updateData.description !== undefined) updateFields.description = updateData.description?.trim() || null; updateFields.name = updateData.name.trim();
if (updateData.folder !== undefined) updateFields.folder = updateData.folder?.trim() || null; if (updateData.description !== undefined)
updateFields.description = updateData.description?.trim() || null;
if (updateData.folder !== undefined)
updateFields.folder = updateData.folder?.trim() || null;
if (updateData.tags !== undefined) { if (updateData.tags !== undefined) {
updateFields.tags = Array.isArray(updateData.tags) ? updateData.tags.join(',') : (updateData.tags || ''); updateFields.tags = Array.isArray(updateData.tags)
? updateData.tags.join(",")
: updateData.tags || "";
} }
if (updateData.username !== undefined) updateFields.username = updateData.username.trim(); if (updateData.username !== undefined)
if (updateData.authType !== undefined) updateFields.authType = updateData.authType; updateFields.username = updateData.username.trim();
if (updateData.keyType !== undefined) updateFields.keyType = updateData.keyType; if (updateData.authType !== undefined)
updateFields.authType = updateData.authType;
if (updateData.keyType !== undefined)
updateFields.keyType = updateData.keyType;
if (updateData.password !== undefined) { if (updateData.password !== undefined) {
updateFields.password = updateData.password || null; updateFields.password = updateData.password || null;
@@ -295,10 +332,12 @@ router.put('/:id', authenticateJWT, async (req: Request, res: Response) => {
await db await db
.update(sshCredentials) .update(sshCredentials)
.set(updateFields) .set(updateFields)
.where(and( .where(
and(
eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId) eq(sshCredentials.userId, userId),
)); ),
);
const updated = await db const updated = await db
.select() .select()
@@ -306,55 +345,59 @@ router.put('/:id', authenticateJWT, async (req: Request, res: Response) => {
.where(eq(sshCredentials.id, parseInt(id))); .where(eq(sshCredentials.id, parseInt(id)));
const credential = updated[0]; const credential = updated[0];
authLogger.success(`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`, { authLogger.success(
operation: 'credential_update_success', `SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
{
operation: "credential_update_success",
userId, userId,
credentialId: parseInt(id), credentialId: parseInt(id),
name: credential.name, name: credential.name,
authType: credential.authType, authType: credential.authType,
username: credential.username username: credential.username,
}); },
);
res.json(formatCredentialOutput(updated[0])); res.json(formatCredentialOutput(updated[0]));
} catch (err) { } catch (err) {
authLogger.error('Failed to update credential', err); authLogger.error("Failed to update credential", err);
res.status(500).json({ res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to update credential' error: err instanceof Error ? err.message : "Failed to update credential",
}); });
} }
}); });
// Delete a credential // Delete a credential
// DELETE /credentials/:id // DELETE /credentials/:id
router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => { router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { id } = req.params; const { id } = req.params;
if (!isNonEmptyString(userId) || !id) { if (!isNonEmptyString(userId) || !id) {
authLogger.warn('Invalid request for credential deletion'); authLogger.warn("Invalid request for credential deletion");
return res.status(400).json({error: 'Invalid request'}); return res.status(400).json({ error: "Invalid request" });
} }
try { try {
const credentialToDelete = await db const credentialToDelete = await db
.select() .select()
.from(sshCredentials) .from(sshCredentials)
.where(and( .where(
and(
eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId) eq(sshCredentials.userId, userId),
)); ),
);
if (credentialToDelete.length === 0) { if (credentialToDelete.length === 0) {
return res.status(404).json({error: 'Credential not found'}); return res.status(404).json({ error: "Credential not found" });
} }
const hostsUsingCredential = await db const hostsUsingCredential = await db
.select() .select()
.from(sshData) .from(sshData)
.where(and( .where(
eq(sshData.credentialId, parseInt(id)), and(eq(sshData.credentialId, parseInt(id)), eq(sshData.userId, userId)),
eq(sshData.userId, userId) );
));
if (hostsUsingCredential.length > 0) { if (hostsUsingCredential.length > 0) {
await db await db
@@ -364,69 +407,83 @@ router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => {
password: null, password: null,
key: null, key: null,
keyPassword: null, keyPassword: null,
authType: 'password' authType: "password",
}) })
.where(and( .where(
and(
eq(sshData.credentialId, parseInt(id)), eq(sshData.credentialId, parseInt(id)),
eq(sshData.userId, userId) eq(sshData.userId, userId),
)); ),
);
} }
await db await db
.delete(sshCredentialUsage) .delete(sshCredentialUsage)
.where(and( .where(
and(
eq(sshCredentialUsage.credentialId, parseInt(id)), eq(sshCredentialUsage.credentialId, parseInt(id)),
eq(sshCredentialUsage.userId, userId) eq(sshCredentialUsage.userId, userId),
)); ),
);
await db await db
.delete(sshCredentials) .delete(sshCredentials)
.where(and( .where(
and(
eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId) eq(sshCredentials.userId, userId),
)); ),
);
const credential = credentialToDelete[0]; const credential = credentialToDelete[0];
authLogger.success(`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`, { authLogger.success(
operation: 'credential_delete_success', `SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`,
{
operation: "credential_delete_success",
userId, userId,
credentialId: parseInt(id), credentialId: parseInt(id),
name: credential.name, name: credential.name,
authType: credential.authType, authType: credential.authType,
username: credential.username username: credential.username,
}); },
);
res.json({message: 'Credential deleted successfully'}); res.json({ message: "Credential deleted successfully" });
} catch (err) { } catch (err) {
authLogger.error('Failed to delete credential', err); authLogger.error("Failed to delete credential", err);
res.status(500).json({ res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to delete credential' error: err instanceof Error ? err.message : "Failed to delete credential",
}); });
} }
}); });
// Apply a credential to an SSH host (for quick application) // Apply a credential to an SSH host (for quick application)
// POST /credentials/:id/apply-to-host/:hostId // POST /credentials/:id/apply-to-host/:hostId
router.post('/:id/apply-to-host/:hostId', authenticateJWT, async (req: Request, res: Response) => { router.post(
"/:id/apply-to-host/:hostId",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { id: credentialId, hostId } = req.params; const { id: credentialId, hostId } = req.params;
if (!isNonEmptyString(userId) || !credentialId || !hostId) { if (!isNonEmptyString(userId) || !credentialId || !hostId) {
authLogger.warn('Invalid request for credential application'); authLogger.warn("Invalid request for credential application");
return res.status(400).json({error: 'Invalid request'}); return res.status(400).json({ error: "Invalid request" });
} }
try { try {
const credentials = await db const credentials = await db
.select() .select()
.from(sshCredentials) .from(sshCredentials)
.where(and( .where(
and(
eq(sshCredentials.id, parseInt(credentialId)), eq(sshCredentials.id, parseInt(credentialId)),
eq(sshCredentials.userId, userId) eq(sshCredentials.userId, userId),
)); ),
);
if (credentials.length === 0) { if (credentials.length === 0) {
return res.status(404).json({error: 'Credential not found'}); return res.status(404).json({ error: "Credential not found" });
} }
const credential = credentials[0]; const credential = credentials[0];
@@ -441,12 +498,11 @@ router.post('/:id/apply-to-host/:hostId', authenticateJWT, async (req: Request,
key: null, key: null,
keyPassword: null, keyPassword: null,
keyType: null, keyType: null,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString(),
}) })
.where(and( .where(
eq(sshData.id, parseInt(hostId)), and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
eq(sshData.userId, userId) );
));
await db.insert(sshCredentialUsage).values({ await db.insert(sshCredentialUsage).values({
credentialId: parseInt(credentialId), credentialId: parseInt(credentialId),
@@ -460,46 +516,59 @@ router.post('/:id/apply-to-host/:hostId', authenticateJWT, async (req: Request,
usageCount: sql`${sshCredentials.usageCount} usageCount: sql`${sshCredentials.usageCount}
+ 1`, + 1`,
lastUsed: new Date().toISOString(), lastUsed: new Date().toISOString(),
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString(),
}) })
.where(eq(sshCredentials.id, parseInt(credentialId))); .where(eq(sshCredentials.id, parseInt(credentialId)));
res.json({message: 'Credential applied to host successfully'}); res.json({ message: "Credential applied to host successfully" });
} catch (err) { } catch (err) {
authLogger.error('Failed to apply credential to host', err); authLogger.error("Failed to apply credential to host", err);
res.status(500).json({ res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to apply credential to host' error:
err instanceof Error
? err.message
: "Failed to apply credential to host",
}); });
} }
}); },
);
// Get hosts using a specific credential // Get hosts using a specific credential
// GET /credentials/:id/hosts // GET /credentials/:id/hosts
router.get('/:id/hosts', authenticateJWT, async (req: Request, res: Response) => { router.get(
"/:id/hosts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { id: credentialId } = req.params; const { id: credentialId } = req.params;
if (!isNonEmptyString(userId) || !credentialId) { if (!isNonEmptyString(userId) || !credentialId) {
authLogger.warn('Invalid request for credential hosts fetch'); authLogger.warn("Invalid request for credential hosts fetch");
return res.status(400).json({error: 'Invalid request'}); return res.status(400).json({ error: "Invalid request" });
} }
try { try {
const hosts = await db const hosts = await db
.select() .select()
.from(sshData) .from(sshData)
.where(and( .where(
and(
eq(sshData.credentialId, parseInt(credentialId)), eq(sshData.credentialId, parseInt(credentialId)),
eq(sshData.userId, userId) eq(sshData.userId, userId),
)); ),
);
res.json(hosts.map(host => formatSSHHostOutput(host))); res.json(hosts.map((host) => formatSSHHostOutput(host)));
} catch (err) { } catch (err) {
authLogger.error('Failed to fetch hosts using credential', err); authLogger.error("Failed to fetch hosts using credential", err);
res.status(500).json({ res.status(500).json({
error: err instanceof Error ? err.message : 'Failed to fetch hosts using credential' error:
err instanceof Error
? err.message
: "Failed to fetch hosts using credential",
}); });
} }
}); },
);
function formatCredentialOutput(credential: any): any { function formatCredentialOutput(credential: any): any {
return { return {
@@ -507,8 +576,11 @@ function formatCredentialOutput(credential: any): any {
name: credential.name, name: credential.name,
description: credential.description, description: credential.description,
folder: credential.folder, folder: credential.folder,
tags: typeof credential.tags === 'string' tags:
? (credential.tags ? credential.tags.split(',').filter(Boolean) : []) typeof credential.tags === "string"
? credential.tags
? credential.tags.split(",").filter(Boolean)
: []
: [], : [],
authType: credential.authType, authType: credential.authType,
username: credential.username, username: credential.username,
@@ -529,14 +601,19 @@ function formatSSHHostOutput(host: any): any {
port: host.port, port: host.port,
username: host.username, username: host.username,
folder: host.folder, folder: host.folder,
tags: typeof host.tags === 'string' tags:
? (host.tags ? host.tags.split(',').filter(Boolean) : []) typeof host.tags === "string"
? host.tags
? host.tags.split(",").filter(Boolean)
: []
: [], : [],
pin: !!host.pin, pin: !!host.pin,
authType: host.authType, authType: host.authType,
enableTerminal: !!host.enableTerminal, enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel, enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [],
enableFileManager: !!host.enableFileManager, enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath, defaultPath: host.defaultPath,
createdAt: host.createdAt, createdAt: host.createdAt,
@@ -546,31 +623,42 @@ function formatSSHHostOutput(host: any): any {
// Rename a credential folder // Rename a credential folder
// PUT /credentials/folders/rename // PUT /credentials/folders/rename
router.put('/folders/rename', authenticateJWT, async (req: Request, res: Response) => { router.put(
"/folders/rename",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId; const userId = (req as any).userId;
const { oldName, newName } = req.body; const { oldName, newName } = req.body;
if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) { if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) {
return res.status(400).json({error: 'Both oldName and newName are required'}); return res
.status(400)
.json({ error: "Both oldName and newName are required" });
} }
if (oldName === newName) { if (oldName === newName) {
return res.status(400).json({error: 'Old name and new name cannot be the same'}); return res
.status(400)
.json({ error: "Old name and new name cannot be the same" });
} }
try { try {
await db.update(sshCredentials) await db
.update(sshCredentials)
.set({ folder: newName }) .set({ folder: newName })
.where(and( .where(
and(
eq(sshCredentials.userId, userId), eq(sshCredentials.userId, userId),
eq(sshCredentials.folder, oldName) eq(sshCredentials.folder, oldName),
)); ),
);
res.json({success: true, message: 'Folder renamed successfully'}); res.json({ success: true, message: "Folder renamed successfully" });
} catch (error) { } catch (error) {
authLogger.error('Error renaming credential folder:', error); authLogger.error("Error renaming credential folder:", error);
res.status(500).json({error: 'Failed to rename folder'}); res.status(500).json({ error: "Failed to rename folder" });
} }
}); },
);
export default router; export default router;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+268 -150
View File
@@ -1,11 +1,11 @@
import express from 'express'; import express from "express";
import net from 'net'; import net from "net";
import cors from 'cors'; import cors from "cors";
import {Client, type ConnectConfig} from 'ssh2'; import { Client, type ConnectConfig } from "ssh2";
import {db} from '../database/db/index.js'; import { db } from "../database/db/index.js";
import {sshData, sshCredentials} from '../database/db/schema.js'; import { sshData, sshCredentials } from "../database/db/schema.js";
import {eq, and} from 'drizzle-orm'; import { eq, and } from "drizzle-orm";
import {statsLogger} from '../utils/logger.js'; import { statsLogger } from "../utils/logger.js";
interface PooledConnection { interface PooledConnection {
client: Client; client: Client;
@@ -21,9 +21,12 @@ class SSHConnectionPool {
private cleanupInterval: NodeJS.Timeout; private cleanupInterval: NodeJS.Timeout;
constructor() { constructor() {
this.cleanupInterval = setInterval(() => { this.cleanupInterval = setInterval(
() => {
this.cleanup(); this.cleanup();
}, 5 * 60 * 1000); },
5 * 60 * 1000,
);
} }
private getHostKey(host: SSHHostWithCredentials): string { private getHostKey(host: SSHHostWithCredentials): string {
@@ -34,7 +37,7 @@ class SSHConnectionPool {
const hostKey = this.getHostKey(host); const hostKey = this.getHostKey(host);
const connections = this.connections.get(hostKey) || []; const connections = this.connections.get(hostKey) || [];
const available = connections.find(conn => !conn.inUse); const available = connections.find((conn) => !conn.inUse);
if (available) { if (available) {
available.inUse = true; available.inUse = true;
available.lastUsed = Date.now(); available.lastUsed = Date.now();
@@ -47,7 +50,7 @@ class SSHConnectionPool {
client, client,
lastUsed: Date.now(), lastUsed: Date.now(),
inUse: true, inUse: true,
hostKey hostKey,
}; };
connections.push(pooled); connections.push(pooled);
this.connections.set(hostKey, connections); this.connections.set(hostKey, connections);
@@ -56,7 +59,7 @@ class SSHConnectionPool {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const checkAvailable = () => { const checkAvailable = () => {
const available = connections.find(conn => !conn.inUse); const available = connections.find((conn) => !conn.inUse);
if (available) { if (available) {
available.inUse = true; available.inUse = true;
available.lastUsed = Date.now(); available.lastUsed = Date.now();
@@ -69,20 +72,22 @@ class SSHConnectionPool {
}); });
} }
private async createConnection(host: SSHHostWithCredentials): Promise<Client> { private async createConnection(
host: SSHHostWithCredentials,
): Promise<Client> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const client = new Client(); const client = new Client();
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
client.end(); client.end();
reject(new Error('SSH connection timeout')); reject(new Error("SSH connection timeout"));
}, this.connectionTimeout); }, this.connectionTimeout);
client.on('ready', () => { client.on("ready", () => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(client); resolve(client);
}); });
client.on('error', (err) => { client.on("error", (err) => {
clearTimeout(timeout); clearTimeout(timeout);
reject(err); reject(err);
}); });
@@ -99,7 +104,7 @@ class SSHConnectionPool {
releaseConnection(host: SSHHostWithCredentials, client: Client): void { releaseConnection(host: SSHHostWithCredentials, client: Client): void {
const hostKey = this.getHostKey(host); const hostKey = this.getHostKey(host);
const connections = this.connections.get(hostKey) || []; const connections = this.connections.get(hostKey) || [];
const pooled = connections.find(conn => conn.client === client); const pooled = connections.find((conn) => conn.client === client);
if (pooled) { if (pooled) {
pooled.inUse = false; pooled.inUse = false;
pooled.lastUsed = Date.now(); pooled.lastUsed = Date.now();
@@ -111,13 +116,11 @@ class SSHConnectionPool {
const maxAge = 10 * 60 * 1000; const maxAge = 10 * 60 * 1000;
for (const [hostKey, connections] of this.connections.entries()) { for (const [hostKey, connections] of this.connections.entries()) {
const activeConnections = connections.filter(conn => { const activeConnections = connections.filter((conn) => {
if (!conn.inUse && (now - conn.lastUsed) > maxAge) { if (!conn.inUse && now - conn.lastUsed > maxAge) {
try { try {
conn.client.end(); conn.client.end();
} catch { } catch {}
}
return false; return false;
} }
return true; return true;
@@ -137,9 +140,7 @@ class SSHConnectionPool {
for (const conn of connections) { for (const conn of connections) {
try { try {
conn.client.end(); conn.client.end();
} catch { } catch {}
}
} }
} }
this.connections.clear(); this.connections.clear();
@@ -177,9 +178,7 @@ class RequestQueue {
if (request) { if (request) {
try { try {
await request(); await request();
} catch (error) { } catch (error) {}
}
} }
} }
@@ -202,7 +201,7 @@ class MetricsCache {
get(hostId: number): any | null { get(hostId: number): any | null {
const cached = this.cache.get(hostId); const cached = this.cache.get(hostId);
if (cached && (Date.now() - cached.timestamp) < this.ttl) { if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data; return cached.data;
} }
return null; return null;
@@ -212,7 +211,7 @@ class MetricsCache {
this.cache.set(hostId, { this.cache.set(hostId, {
data, data,
timestamp: Date.now(), timestamp: Date.now(),
hostId hostId,
}); });
} }
@@ -229,7 +228,7 @@ const connectionPool = new SSHConnectionPool();
const requestQueue = new RequestQueue(); const requestQueue = new RequestQueue();
const metricsCache = new MetricsCache(); const metricsCache = new MetricsCache();
type HostStatus = 'online' | 'offline'; type HostStatus = "online" | "offline";
interface SSHHostWithCredentials { interface SSHHostWithCredentials {
id: number; id: number;
@@ -261,31 +260,39 @@ type StatusEntry = {
lastChecked: string; lastChecked: string;
}; };
function validateHostId(req: express.Request, res: express.Response, next: express.NextFunction) { function validateHostId(
req: express.Request,
res: express.Response,
next: express.NextFunction,
) {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!id || !Number.isInteger(id) || id <= 0) { if (!id || !Number.isInteger(id) || id <= 0) {
return res.status(400).json({error: 'Invalid host ID'}); return res.status(400).json({ error: "Invalid host ID" });
} }
next(); next();
} }
const app = express(); const app = express();
app.use(cors({ app.use(
origin: '*', cors({
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], origin: "*",
allowedHeaders: ['Content-Type', 'Authorization'] methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})); allowedHeaders: ["Content-Type", "Authorization"],
}),
);
app.use((req, res, next) => { app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*'); res.header("Access-Control-Allow-Origin", "*");
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); res.header(
if (req.method === 'OPTIONS') { "Access-Control-Allow-Methods",
"GET, POST, PUT, PATCH, DELETE, OPTIONS",
);
if (req.method === "OPTIONS") {
return res.sendStatus(204); return res.sendStatus(204);
} }
next(); next();
}); });
app.use(express.json({limit: '1mb'})); app.use(express.json({ limit: "1mb" }));
const hostStatuses: Map<number, StatusEntry> = new Map(); const hostStatuses: Map<number, StatusEntry> = new Map();
@@ -301,18 +308,22 @@ async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
hostsWithCredentials.push(hostWithCreds); hostsWithCredentials.push(hostWithCreds);
} }
} catch (err) { } catch (err) {
statsLogger.warn(`Failed to resolve credentials for host ${host.id}: ${err instanceof Error ? err.message : 'Unknown error'}`); statsLogger.warn(
`Failed to resolve credentials for host ${host.id}: ${err instanceof Error ? err.message : "Unknown error"}`,
);
} }
} }
return hostsWithCredentials.filter(h => !!h.id && !!h.ip && !!h.port); return hostsWithCredentials.filter((h) => !!h.id && !!h.ip && !!h.port);
} catch (err) { } catch (err) {
statsLogger.error('Failed to fetch hosts from database', err); statsLogger.error("Failed to fetch hosts from database", err);
return []; return [];
} }
} }
async function fetchHostById(id: number): Promise<SSHHostWithCredentials | undefined> { async function fetchHostById(
id: number,
): Promise<SSHHostWithCredentials | undefined> {
try { try {
const hosts = await db.select().from(sshData).where(eq(sshData.id, id)); const hosts = await db.select().from(sshData).where(eq(sshData.id, id));
@@ -328,7 +339,9 @@ async function fetchHostById(id: number): Promise<SSHHostWithCredentials | undef
} }
} }
async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials | undefined> { async function resolveHostCredentials(
host: any,
): Promise<SSHHostWithCredentials | undefined> {
try { try {
const baseHost: any = { const baseHost: any = {
id: host.id, id: host.id,
@@ -336,18 +349,25 @@ async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials
ip: host.ip, ip: host.ip,
port: host.port, port: host.port,
username: host.username, username: host.username,
folder: host.folder || '', folder: host.folder || "",
tags: typeof host.tags === 'string' ? (host.tags ? host.tags.split(',').filter(Boolean) : []) : [], tags:
typeof host.tags === "string"
? host.tags
? host.tags.split(",").filter(Boolean)
: []
: [],
pin: !!host.pin, pin: !!host.pin,
authType: host.authType, authType: host.authType,
enableTerminal: !!host.enableTerminal, enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel, enableTunnel: !!host.enableTunnel,
enableFileManager: !!host.enableFileManager, enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath || '/', defaultPath: host.defaultPath || "/",
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [],
createdAt: host.createdAt, createdAt: host.createdAt,
updatedAt: host.updatedAt, updatedAt: host.updatedAt,
userId: host.userId userId: host.userId,
}; };
if (host.credentialId) { if (host.credentialId) {
@@ -355,10 +375,12 @@ async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials
const credentials = await db const credentials = await db
.select() .select()
.from(sshCredentials) .from(sshCredentials)
.where(and( .where(
and(
eq(sshCredentials.id, host.credentialId), eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, host.userId) eq(sshCredentials.userId, host.userId),
)); ),
);
if (credentials.length > 0) { if (credentials.length > 0) {
const credential = credentials[0]; const credential = credentials[0];
@@ -378,13 +400,16 @@ async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials
if (credential.keyType) { if (credential.keyType) {
baseHost.keyType = credential.keyType; baseHost.keyType = credential.keyType;
} }
} else { } else {
statsLogger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`); statsLogger.warn(
`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`,
);
addLegacyCredentials(baseHost, host); addLegacyCredentials(baseHost, host);
} }
} catch (error) { } catch (error) {
statsLogger.warn(`Failed to resolve credential ${host.credentialId} for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); statsLogger.warn(
`Failed to resolve credential ${host.credentialId} for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
addLegacyCredentials(baseHost, host); addLegacyCredentials(baseHost, host);
} }
} else { } else {
@@ -393,7 +418,9 @@ async function resolveHostCredentials(host: any): Promise<SSHHostWithCredentials
return baseHost; return baseHost;
} catch (error) { } catch (error) {
statsLogger.error(`Failed to resolve host credentials for host ${host.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); statsLogger.error(
`Failed to resolve host credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return undefined; return undefined;
} }
} }
@@ -409,45 +436,55 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
const base: ConnectConfig = { const base: ConnectConfig = {
host: host.ip, host: host.ip,
port: host.port || 22, port: host.port || 22,
username: host.username || 'root', username: host.username || "root",
readyTimeout: 10_000, readyTimeout: 10_000,
algorithms: {} algorithms: {},
} as ConnectConfig; } as ConnectConfig;
if (host.authType === 'password') { if (host.authType === "password") {
if (!host.password) { if (!host.password) {
throw new Error(`No password available for host ${host.ip}`); throw new Error(`No password available for host ${host.ip}`);
} }
(base as any).password = host.password; (base as any).password = host.password;
} else if (host.authType === 'key') { } else if (host.authType === "key") {
if (!host.key) { if (!host.key) {
throw new Error(`No SSH key available for host ${host.ip}`); throw new Error(`No SSH key available for host ${host.ip}`);
} }
try { try {
if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) { if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
throw new Error('Invalid private key format'); throw new Error("Invalid private key format");
} }
const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const cleanKey = host.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
(base as any).privateKey = Buffer.from(cleanKey, 'utf8'); (base as any).privateKey = Buffer.from(cleanKey, "utf8");
if (host.keyPassword) { if (host.keyPassword) {
(base as any).passphrase = host.keyPassword; (base as any).passphrase = host.keyPassword;
} }
} catch (keyError) { } catch (keyError) {
statsLogger.error(`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : 'Unknown error'}`); statsLogger.error(
`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
);
throw new Error(`Invalid SSH key format for host ${host.ip}`); throw new Error(`Invalid SSH key format for host ${host.ip}`);
} }
} else { } else {
throw new Error(`Unsupported authentication type '${host.authType}' for host ${host.ip}`); throw new Error(
`Unsupported authentication type '${host.authType}' for host ${host.ip}`,
);
} }
return base; return base;
} }
async function withSshConnection<T>(host: SSHHostWithCredentials, fn: (client: Client) => Promise<T>): Promise<T> { async function withSshConnection<T>(
host: SSHHostWithCredentials,
fn: (client: Client) => Promise<T>,
): Promise<T> {
const client = await connectionPool.getConnection(host); const client = await connectionPool.getConnection(host);
try { try {
const result = await fn(client); const result = await fn(client);
@@ -457,7 +494,10 @@ async function withSshConnection<T>(host: SSHHostWithCredentials, fn: (client: C
} }
} }
function execCommand(client: Client, command: string): Promise<{ function execCommand(
client: Client,
command: string,
): Promise<{
stdout: string; stdout: string;
stderr: string; stderr: string;
code: number | null; code: number | null;
@@ -465,25 +505,33 @@ function execCommand(client: Client, command: string): Promise<{
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
client.exec(command, { pty: false }, (err, stream) => { client.exec(command, { pty: false }, (err, stream) => {
if (err) return reject(err); if (err) return reject(err);
let stdout = ''; let stdout = "";
let stderr = ''; let stderr = "";
let exitCode: number | null = null; let exitCode: number | null = null;
stream.on('close', (code: number | undefined) => { stream
exitCode = typeof code === 'number' ? code : null; .on("close", (code: number | undefined) => {
exitCode = typeof code === "number" ? code : null;
resolve({ stdout, stderr, code: exitCode }); resolve({ stdout, stderr, code: exitCode });
}).on('data', (data: Buffer) => { })
stdout += data.toString('utf8'); .on("data", (data: Buffer) => {
}).stderr.on('data', (data: Buffer) => { stdout += data.toString("utf8");
stderr += data.toString('utf8'); })
.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
}); });
}); });
}); });
} }
function parseCpuLine(cpuLine: string): { total: number; idle: number } | undefined { function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/); const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== 'cpu') return undefined; if (parts[0] !== "cpu") return undefined;
const nums = parts.slice(1).map(n => Number(n)).filter(n => Number.isFinite(n)); const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined; if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0); const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0); const total = nums.reduce((a, b) => a + b, 0);
@@ -491,7 +539,7 @@ function parseCpuLine(cpuLine: string): { total: number; idle: number } | undefi
} }
function toFixedNum(n: number | null | undefined, digits = 2): number | null { function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== 'number' || !Number.isFinite(n)) return null; if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits)); return Number(n.toFixed(digits));
} }
@@ -500,9 +548,21 @@ function kibToGiB(kib: number): number {
} }
async function collectMetrics(host: SSHHostWithCredentials): Promise<{ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null }; cpu: {
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null }; percent: number | null;
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null }; cores: number | null;
load: [number, number, number] | null;
};
memory: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
disk: {
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
};
}> { }> {
const cached = metricsCache.get(host.id); const cached = metricsCache.get(host.id);
if (cached) { if (cached) {
@@ -517,34 +577,53 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
try { try {
const [stat1, loadAvgOut, coresOut] = await Promise.all([ const [stat1, loadAvgOut, coresOut] = await Promise.all([
execCommand(client, 'cat /proc/stat'), execCommand(client, "cat /proc/stat"),
execCommand(client, 'cat /proc/loadavg'), execCommand(client, "cat /proc/loadavg"),
execCommand(client, 'nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo') execCommand(
client,
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
),
]); ]);
await new Promise(r => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
const stat2 = await execCommand(client, 'cat /proc/stat'); const stat2 = await execCommand(client, "cat /proc/stat");
const cpuLine1 = (stat1.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim(); const cpuLine1 = (
const cpuLine2 = (stat2.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim(); stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const cpuLine2 = (
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const a = parseCpuLine(cpuLine1); const a = parseCpuLine(cpuLine1);
const b = parseCpuLine(cpuLine2); const b = parseCpuLine(cpuLine2);
if (a && b) { if (a && b) {
const totalDiff = b.total - a.total; const totalDiff = b.total - a.total;
const idleDiff = b.idle - a.idle; const idleDiff = b.idle - a.idle;
const used = totalDiff - idleDiff; const used = totalDiff - idleDiff;
if (totalDiff > 0) cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100)); if (totalDiff > 0)
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
} }
const laParts = loadAvgOut.stdout.trim().split(/\s+/); const laParts = loadAvgOut.stdout.trim().split(/\s+/);
if (laParts.length >= 3) { if (laParts.length >= 3) {
loadTriplet = [Number(laParts[0]), Number(laParts[1]), Number(laParts[2])].map(v => Number.isFinite(v) ? Number(v) : 0) as [number, number, number]; loadTriplet = [
Number(laParts[0]),
Number(laParts[1]),
Number(laParts[2]),
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
number,
number,
number,
];
} }
const coresNum = Number((coresOut.stdout || '').trim()); const coresNum = Number((coresOut.stdout || "").trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null; cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
} catch (e) { } catch (e) {
statsLogger.warn(`Failed to collect CPU metrics for host ${host.id}`, e); statsLogger.warn(
`Failed to collect CPU metrics for host ${host.id}`,
e,
);
cpuPercent = null; cpuPercent = null;
cores = null; cores = null;
loadTriplet = null; loadTriplet = null;
@@ -554,16 +633,16 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
let usedGiB: number | null = null; let usedGiB: number | null = null;
let totalGiB: number | null = null; let totalGiB: number | null = null;
try { try {
const memInfo = await execCommand(client, 'cat /proc/meminfo'); const memInfo = await execCommand(client, "cat /proc/meminfo");
const lines = memInfo.stdout.split('\n'); const lines = memInfo.stdout.split("\n");
const getVal = (key: string) => { const getVal = (key: string) => {
const line = lines.find(l => l.startsWith(key)); const line = lines.find((l) => l.startsWith(key));
if (!line) return null; if (!line) return null;
const m = line.match(/\d+/); const m = line.match(/\d+/);
return m ? Number(m[0]) : null; return m ? Number(m[0]) : null;
}; };
const totalKb = getVal('MemTotal:'); const totalKb = getVal("MemTotal:");
const availKb = getVal('MemAvailable:'); const availKb = getVal("MemAvailable:");
if (totalKb && availKb && totalKb > 0) { if (totalKb && availKb && totalKb > 0) {
const usedKb = totalKb - availKb; const usedKb = totalKb - availKb;
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100)); memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
@@ -571,7 +650,10 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
totalGiB = kibToGiB(totalKb); totalGiB = kibToGiB(totalKb);
} }
} catch (e) { } catch (e) {
statsLogger.warn(`Failed to collect memory metrics for host ${host.id}`, e); statsLogger.warn(
`Failed to collect memory metrics for host ${host.id}`,
e,
);
memPercent = null; memPercent = null;
usedGiB = null; usedGiB = null;
totalGiB = null; totalGiB = null;
@@ -582,12 +664,20 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
let totalHuman: string | null = null; let totalHuman: string | null = null;
try { try {
const [diskOutHuman, diskOutBytes] = await Promise.all([ const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, 'df -h -P / | tail -n +2'), execCommand(client, "df -h -P / | tail -n +2"),
execCommand(client, 'df -B1 -P / | tail -n +2') execCommand(client, "df -B1 -P / | tail -n +2"),
]); ]);
const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || ''; const humanLine =
const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || ''; diskOutHuman.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const bytesLine =
diskOutBytes.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const humanParts = humanLine.split(/\s+/); const humanParts = humanLine.split(/\s+/);
const bytesParts = bytesLine.split(/\s+/); const bytesParts = bytesLine.split(/\s+/);
@@ -599,12 +689,22 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
const totalBytes = Number(bytesParts[1]); const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]); const usedBytes = Number(bytesParts[2]);
if (Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0) { if (
diskPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100)); Number.isFinite(totalBytes) &&
Number.isFinite(usedBytes) &&
totalBytes > 0
) {
diskPercent = Math.max(
0,
Math.min(100, (usedBytes / totalBytes) * 100),
);
} }
} }
} catch (e) { } catch (e) {
statsLogger.warn(`Failed to collect disk metrics for host ${host.id}`, e); statsLogger.warn(
`Failed to collect disk metrics for host ${host.id}`,
e,
);
diskPercent = null; diskPercent = null;
usedHuman = null; usedHuman = null;
totalHuman = null; totalHuman = null;
@@ -615,7 +715,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
memory: { memory: {
percent: toFixedNum(memPercent, 0), percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
}, },
disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman }, disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman },
}; };
@@ -626,7 +726,11 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
}); });
} }
function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean> { function tcpPing(
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const socket = new net.Socket(); const socket = new net.Socket();
let settled = false; let settled = false;
@@ -636,16 +740,15 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean>
settled = true; settled = true;
try { try {
socket.destroy(); socket.destroy();
} catch { } catch {}
}
resolve(result); resolve(result);
}; };
socket.setTimeout(timeoutMs); socket.setTimeout(timeoutMs);
socket.once('connect', () => onDone(true)); socket.once("connect", () => onDone(true));
socket.once('timeout', () => onDone(false)); socket.once("timeout", () => onDone(false));
socket.once('error', () => onDone(false)); socket.once("error", () => onDone(false));
socket.connect(port, host); socket.connect(port, host);
}); });
} }
@@ -653,7 +756,9 @@ function tcpPing(host: string, port: number, timeoutMs = 5000): Promise<boolean>
async function pollStatusesOnce(): Promise<void> { async function pollStatusesOnce(): Promise<void> {
const hosts = await fetchAllHosts(); const hosts = await fetchAllHosts();
if (hosts.length === 0) { if (hosts.length === 0) {
statsLogger.warn('No hosts retrieved for status polling', {operation: 'status_poll'}); statsLogger.warn("No hosts retrieved for status polling", {
operation: "status_poll",
});
return; return;
} }
@@ -662,23 +767,28 @@ async function pollStatusesOnce(): Promise<void> {
const checks = hosts.map(async (h) => { const checks = hosts.map(async (h) => {
const isOnline = await tcpPing(h.ip, h.port, 5000); const isOnline = await tcpPing(h.ip, h.port, 5000);
const now = new Date().toISOString(); const now = new Date().toISOString();
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now}; const statusEntry: StatusEntry = {
status: isOnline ? "online" : "offline",
lastChecked: now,
};
hostStatuses.set(h.id, statusEntry); hostStatuses.set(h.id, statusEntry);
return isOnline; return isOnline;
}); });
const results = await Promise.allSettled(checks); const results = await Promise.allSettled(checks);
const onlineCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length; const onlineCount = results.filter(
(r) => r.status === "fulfilled" && r.value === true,
).length;
const offlineCount = hosts.length - onlineCount; const offlineCount = hosts.length - onlineCount;
statsLogger.success('Status polling completed', { statsLogger.success("Status polling completed", {
operation: 'status_poll', operation: "status_poll",
totalHosts: hosts.length, totalHosts: hosts.length,
onlineCount, onlineCount,
offlineCount offlineCount,
}); });
} }
app.get('/status', async (req, res) => { app.get("/status", async (req, res) => {
if (hostStatuses.size === 0) { if (hostStatuses.size === 0) {
await pollStatusesOnce(); await pollStatusesOnce();
} }
@@ -689,95 +799,103 @@ app.get('/status', async (req, res) => {
res.json(result); res.json(result);
}); });
app.get('/status/:id', validateHostId, async (req, res) => { app.get("/status/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
try { try {
const host = await fetchHostById(id); const host = await fetchHostById(id);
if (!host) { if (!host) {
return res.status(404).json({error: 'Host not found'}); return res.status(404).json({ error: "Host not found" });
} }
const isOnline = await tcpPing(host.ip, host.port, 5000); const isOnline = await tcpPing(host.ip, host.port, 5000);
const now = new Date().toISOString(); const now = new Date().toISOString();
const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now}; const statusEntry: StatusEntry = {
status: isOnline ? "online" : "offline",
lastChecked: now,
};
hostStatuses.set(id, statusEntry); hostStatuses.set(id, statusEntry);
res.json(statusEntry); res.json(statusEntry);
} catch (err) { } catch (err) {
statsLogger.error('Failed to check host status', err); statsLogger.error("Failed to check host status", err);
res.status(500).json({error: 'Failed to check host status'}); res.status(500).json({ error: "Failed to check host status" });
} }
}); });
app.post('/refresh', async (req, res) => { app.post("/refresh", async (req, res) => {
await pollStatusesOnce(); await pollStatusesOnce();
res.json({message: 'Refreshed'}); res.json({ message: "Refreshed" });
}); });
app.get('/metrics/:id', validateHostId, async (req, res) => { app.get("/metrics/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
try { try {
const host = await fetchHostById(id); const host = await fetchHostById(id);
if (!host) { if (!host) {
return res.status(404).json({error: 'Host not found'}); return res.status(404).json({ error: "Host not found" });
} }
const isOnline = await tcpPing(host.ip, host.port, 5000); const isOnline = await tcpPing(host.ip, host.port, 5000);
if (!isOnline) { if (!isOnline) {
return res.status(503).json({ return res.status(503).json({
error: 'Host is offline', error: "Host is offline",
cpu: { percent: null, cores: null, load: null }, cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null }, memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedHuman: null, totalHuman: null }, disk: { percent: null, usedHuman: null, totalHuman: null },
lastChecked: new Date().toISOString() lastChecked: new Date().toISOString(),
}); });
} }
const metrics = await collectMetrics(host); const metrics = await collectMetrics(host);
res.json({ ...metrics, lastChecked: new Date().toISOString() }); res.json({ ...metrics, lastChecked: new Date().toISOString() });
} catch (err) { } catch (err) {
statsLogger.error('Failed to collect metrics', err); statsLogger.error("Failed to collect metrics", err);
if (err instanceof Error && err.message.includes('timeout')) { if (err instanceof Error && err.message.includes("timeout")) {
return res.status(504).json({ return res.status(504).json({
error: 'Metrics collection timeout', error: "Metrics collection timeout",
cpu: { percent: null, cores: null, load: null }, cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null }, memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedHuman: null, totalHuman: null }, disk: { percent: null, usedHuman: null, totalHuman: null },
lastChecked: new Date().toISOString() lastChecked: new Date().toISOString(),
}); });
} }
return res.status(500).json({ return res.status(500).json({
error: 'Failed to collect metrics', error: "Failed to collect metrics",
cpu: { percent: null, cores: null, load: null }, cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null }, memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedHuman: null, totalHuman: null }, disk: { percent: null, usedHuman: null, totalHuman: null },
lastChecked: new Date().toISOString() lastChecked: new Date().toISOString(),
}); });
} }
}); });
process.on('SIGINT', () => { process.on("SIGINT", () => {
statsLogger.info('Received SIGINT, shutting down gracefully'); statsLogger.info("Received SIGINT, shutting down gracefully");
connectionPool.destroy(); connectionPool.destroy();
process.exit(0); process.exit(0);
}); });
process.on('SIGTERM', () => { process.on("SIGTERM", () => {
statsLogger.info('Received SIGTERM, shutting down gracefully'); statsLogger.info("Received SIGTERM, shutting down gracefully");
connectionPool.destroy(); connectionPool.destroy();
process.exit(0); process.exit(0);
}); });
const PORT = 8085; const PORT = 8085;
app.listen(PORT, async () => { app.listen(PORT, async () => {
statsLogger.success('Server Stats API server started', {operation: 'server_start', port: PORT}); statsLogger.success("Server Stats API server started", {
operation: "server_start",
port: PORT,
});
try { try {
await pollStatusesOnce(); await pollStatusesOnce();
} catch (err) { } catch (err) {
statsLogger.error('Initial poll failed', err, {operation: 'initial_poll'}); statsLogger.error("Initial poll failed", err, {
operation: "initial_poll",
});
} }
}); });
+259 -160
View File
@@ -1,79 +1,89 @@
import {WebSocketServer, WebSocket, type RawData} from 'ws'; import { WebSocketServer, WebSocket, type RawData } from "ws";
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2'; import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import {db} from '../database/db/index.js'; import { db } from "../database/db/index.js";
import {sshCredentials} from '../database/db/schema.js'; import { sshCredentials } from "../database/db/schema.js";
import {eq, and} from 'drizzle-orm'; import { eq, and } from "drizzle-orm";
import {sshLogger} from '../utils/logger.js'; import { sshLogger } from "../utils/logger.js";
const wss = new WebSocketServer({ port: 8082 }); const wss = new WebSocketServer({ port: 8082 });
sshLogger.success('SSH Terminal WebSocket server started', {operation: 'server_start', port: 8082}); sshLogger.success("SSH Terminal WebSocket server started", {
operation: "server_start",
port: 8082,
});
wss.on('connection', (ws: WebSocket) => { wss.on("connection", (ws: WebSocket) => {
let sshConn: Client | null = null; let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null; let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null; let pingInterval: NodeJS.Timeout | null = null;
ws.on('close', () => { ws.on("close", () => {
cleanupSSH(); cleanupSSH();
}); });
ws.on('message', (msg: RawData) => { ws.on("message", (msg: RawData) => {
let parsed: any; let parsed: any;
try { try {
parsed = JSON.parse(msg.toString()); parsed = JSON.parse(msg.toString());
} catch (e) { } catch (e) {
sshLogger.error('Invalid JSON received', e, { sshLogger.error("Invalid JSON received", e, {
operation: 'websocket_message', operation: "websocket_message",
messageLength: msg.toString().length messageLength: msg.toString().length,
}); });
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'})); ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
return; return;
} }
const { type, data } = parsed; const { type, data } = parsed;
switch (type) { switch (type) {
case 'connectToHost': case "connectToHost":
handleConnectToHost(data).catch(error => { handleConnectToHost(data).catch((error) => {
sshLogger.error('Failed to connect to host', error, { sshLogger.error("Failed to connect to host", error, {
operation: 'ssh_connect', operation: "ssh_connect",
hostId: data.hostConfig?.id, hostId: data.hostConfig?.id,
ip: data.hostConfig?.ip ip: data.hostConfig?.ip,
}); });
ws.send(JSON.stringify({ ws.send(
type: 'error', JSON.stringify({
message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error') type: "error",
})); message:
"Failed to connect to host: " +
(error instanceof Error ? error.message : "Unknown error"),
}),
);
}); });
break; break;
case 'resize': case "resize":
handleResize(data); handleResize(data);
break; break;
case 'disconnect': case "disconnect":
cleanupSSH(); cleanupSSH();
break; break;
case 'input': case "input":
if (sshStream) { if (sshStream) {
if (data === '\t') { if (data === "\t") {
sshStream.write(data); sshStream.write(data);
} else if (data.startsWith('\x1b')) { } else if (data.startsWith("\x1b")) {
sshStream.write(data); sshStream.write(data);
} else { } else {
sshStream.write(Buffer.from(data, 'utf8')); sshStream.write(Buffer.from(data, "utf8"));
} }
} }
break; break;
case 'ping': case "ping":
ws.send(JSON.stringify({type: 'pong'})); ws.send(JSON.stringify({ type: "pong" }));
break; break;
default: default:
sshLogger.warn('Unknown message type received', {operation: 'websocket_message', messageType: type}); sshLogger.warn("Unknown message type received", {
operation: "websocket_message",
messageType: type,
});
} }
}); });
@@ -95,29 +105,54 @@ wss.on('connection', (ws: WebSocket) => {
}; };
}) { }) {
const { cols, rows, hostConfig } = data; const { cols, rows, hostConfig } = data;
const {id, ip, port, username, password, key, keyPassword, keyType, authType, credentialId} = hostConfig; const {
id,
ip,
port,
username,
password,
key,
keyPassword,
keyType,
authType,
credentialId,
} = hostConfig;
if (!username || typeof username !== 'string' || username.trim() === '') { if (!username || typeof username !== "string" || username.trim() === "") {
sshLogger.error('Invalid username provided', undefined, {operation: 'ssh_connect', hostId: id, ip}); sshLogger.error("Invalid username provided", undefined, {
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'})); operation: "ssh_connect",
hostId: id,
ip,
});
ws.send(
JSON.stringify({ type: "error", message: "Invalid username provided" }),
);
return; return;
} }
if (!ip || typeof ip !== 'string' || ip.trim() === '') { if (!ip || typeof ip !== "string" || ip.trim() === "") {
sshLogger.error('Invalid IP provided', undefined, {operation: 'ssh_connect', hostId: id, username}); sshLogger.error("Invalid IP provided", undefined, {
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'})); operation: "ssh_connect",
hostId: id,
username,
});
ws.send(
JSON.stringify({ type: "error", message: "Invalid IP provided" }),
);
return; return;
} }
if (!port || typeof port !== 'number' || port <= 0) { if (!port || typeof port !== "number" || port <= 0) {
sshLogger.error('Invalid port provided', undefined, { sshLogger.error("Invalid port provided", undefined, {
operation: 'ssh_connect', operation: "ssh_connect",
hostId: id, hostId: id,
ip, ip,
username, username,
port port,
}); });
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'})); ws.send(
JSON.stringify({ type: "error", message: "Invalid port provided" }),
);
return; return;
} }
@@ -125,14 +160,16 @@ wss.on('connection', (ws: WebSocket) => {
const connectionTimeout = setTimeout(() => { const connectionTimeout = setTimeout(() => {
if (sshConn) { if (sshConn) {
sshLogger.error('SSH connection timeout', undefined, { sshLogger.error("SSH connection timeout", undefined, {
operation: 'ssh_connect', operation: "ssh_connect",
hostId: id, hostId: id,
ip, ip,
port, port,
username username,
}); });
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'})); ws.send(
JSON.stringify({ type: "error", message: "SSH connection timeout" }),
);
cleanupSSH(connectionTimeout); cleanupSSH(connectionTimeout);
} }
}, 60000); }, 60000);
@@ -143,10 +180,12 @@ wss.on('connection', (ws: WebSocket) => {
const credentials = await db const credentials = await db
.select() .select()
.from(sshCredentials) .from(sshCredentials)
.where(and( .where(
and(
eq(sshCredentials.id, credentialId), eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, hostConfig.userId) eq(sshCredentials.userId, hostConfig.userId),
)); ),
);
if (credentials.length > 0) { if (credentials.length > 0) {
const credential = credentials[0]; const credential = credentials[0];
@@ -155,104 +194,152 @@ wss.on('connection', (ws: WebSocket) => {
key: credential.key, key: credential.key,
keyPassword: credential.keyPassword, keyPassword: credential.keyPassword,
keyType: credential.keyType, keyType: credential.keyType,
authType: credential.authType authType: credential.authType,
}; };
} else { } else {
sshLogger.warn(`No credentials found for host ${id}`, { sshLogger.warn(`No credentials found for host ${id}`, {
operation: 'ssh_credentials', operation: "ssh_credentials",
hostId: id, hostId: id,
credentialId, credentialId,
userId: hostConfig.userId userId: hostConfig.userId,
}); });
} }
} catch (error) { } catch (error) {
sshLogger.warn(`Failed to resolve credentials for host ${id}`, { sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
operation: 'ssh_credentials', operation: "ssh_credentials",
hostId: id, hostId: id,
credentialId, credentialId,
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : "Unknown error",
}); });
} }
} else if (credentialId && id) { } else if (credentialId && id) {
sshLogger.warn('Missing userId for credential resolution in terminal', { sshLogger.warn("Missing userId for credential resolution in terminal", {
operation: 'ssh_credentials', operation: "ssh_credentials",
hostId: id, hostId: id,
credentialId, credentialId,
hasUserId: !!hostConfig.userId hasUserId: !!hostConfig.userId,
}); });
} }
sshConn.on('ready', () => { sshConn.on("ready", () => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
sshConn!.shell(
sshConn!.shell({ {
rows: data.rows, rows: data.rows,
cols: data.cols, cols: data.cols,
term: 'xterm-256color' term: "xterm-256color",
} as PseudoTtyOptions, (err, stream) => { } as PseudoTtyOptions,
(err, stream) => {
if (err) { if (err) {
sshLogger.error('Shell error', err, {operation: 'ssh_shell', hostId: id, ip, port, username}); sshLogger.error("Shell error", err, {
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message})); operation: "ssh_shell",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({
type: "error",
message: "Shell error: " + err.message,
}),
);
return; return;
} }
sshStream = stream; sshStream = stream;
stream.on('data', (data: Buffer) => { stream.on("data", (data: Buffer) => {
ws.send(JSON.stringify({type: 'data', data: data.toString()})); ws.send(JSON.stringify({ type: "data", data: data.toString() }));
}); });
stream.on('close', () => { stream.on("close", () => {
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'})); ws.send(
JSON.stringify({
type: "disconnected",
message: "Connection lost",
}),
);
}); });
stream.on('error', (err: Error) => { stream.on("error", (err: Error) => {
sshLogger.error('SSH stream error', err, {operation: 'ssh_stream', hostId: id, ip, port, username}); sshLogger.error("SSH stream error", err, {
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message})); operation: "ssh_stream",
});
setupPingInterval();
ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'}));
});
});
sshConn.on('error', (err: Error) => {
clearTimeout(connectionTimeout);
sshLogger.error('SSH connection error', err, {
operation: 'ssh_connect',
hostId: id, hostId: id,
ip, ip,
port, port,
username, username,
authType: resolvedCredentials.authType });
ws.send(
JSON.stringify({
type: "error",
message: "SSH stream error: " + err.message,
}),
);
}); });
let errorMessage = 'SSH error: ' + err.message; setupPingInterval();
if (err.message.includes('No matching key exchange algorithm')) {
errorMessage = 'SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.'; ws.send(
} else if (err.message.includes('No matching cipher')) { JSON.stringify({ type: "connected", message: "SSH connected" }),
errorMessage = 'SSH error: No compatible cipher found. This may be due to an older SSH server or network device.'; );
} else if (err.message.includes('No matching MAC')) { },
errorMessage = 'SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.'; );
} else if (err.message.includes('ENOTFOUND') || err.message.includes('ENOENT')) { });
errorMessage = 'SSH error: Could not resolve hostname or connect to server.';
} else if (err.message.includes('ECONNREFUSED')) { sshConn.on("error", (err: Error) => {
errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.'; clearTimeout(connectionTimeout);
} else if (err.message.includes('ETIMEDOUT')) { sshLogger.error("SSH connection error", err, {
errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.'; operation: "ssh_connect",
} else if (err.message.includes('ECONNRESET') || err.message.includes('EPIPE')) { hostId: id,
errorMessage = 'SSH error: Connection was reset. This may be due to network issues or server timeout.'; ip,
} else if (err.message.includes('authentication failed') || err.message.includes('Permission denied')) { port,
errorMessage = 'SSH error: Authentication failed. Please check your username and password/key.'; username,
authType: resolvedCredentials.authType,
});
let errorMessage = "SSH error: " + err.message;
if (err.message.includes("No matching key exchange algorithm")) {
errorMessage =
"SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.";
} else if (err.message.includes("No matching cipher")) {
errorMessage =
"SSH error: No compatible cipher found. This may be due to an older SSH server or network device.";
} else if (err.message.includes("No matching MAC")) {
errorMessage =
"SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.";
} else if (
err.message.includes("ENOTFOUND") ||
err.message.includes("ENOENT")
) {
errorMessage =
"SSH error: Could not resolve hostname or connect to server.";
} else if (err.message.includes("ECONNREFUSED")) {
errorMessage =
"SSH error: Connection refused. The server may not be running or the port may be incorrect.";
} else if (err.message.includes("ETIMEDOUT")) {
errorMessage =
"SSH error: Connection timed out. Check your network connection and server availability.";
} else if (
err.message.includes("ECONNRESET") ||
err.message.includes("EPIPE")
) {
errorMessage =
"SSH error: Connection was reset. This may be due to network issues or server timeout.";
} else if (
err.message.includes("authentication failed") ||
err.message.includes("Permission denied")
) {
errorMessage =
"SSH error: Authentication failed. Please check your username and password/key.";
} }
ws.send(JSON.stringify({type: 'error', message: errorMessage})); ws.send(JSON.stringify({ type: "error", message: errorMessage }));
cleanupSSH(connectionTimeout); cleanupSSH(connectionTimeout);
}); });
sshConn.on('close', () => { sshConn.on("close", () => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
cleanupSSH(connectionTimeout); cleanupSSH(connectionTimeout);
}); });
@@ -268,78 +355,88 @@ wss.on('connection', (ws: WebSocket) => {
tcpKeepAliveInitialDelay: 30000, tcpKeepAliveInitialDelay: 30000,
env: { env: {
TERM: 'xterm-256color', TERM: "xterm-256color",
LANG: 'en_US.UTF-8', LANG: "en_US.UTF-8",
LC_ALL: 'en_US.UTF-8', LC_ALL: "en_US.UTF-8",
LC_CTYPE: 'en_US.UTF-8', LC_CTYPE: "en_US.UTF-8",
LC_MESSAGES: 'en_US.UTF-8', LC_MESSAGES: "en_US.UTF-8",
LC_MONETARY: 'en_US.UTF-8', LC_MONETARY: "en_US.UTF-8",
LC_NUMERIC: 'en_US.UTF-8', LC_NUMERIC: "en_US.UTF-8",
LC_TIME: 'en_US.UTF-8', LC_TIME: "en_US.UTF-8",
LC_COLLATE: 'en_US.UTF-8', LC_COLLATE: "en_US.UTF-8",
COLORTERM: 'truecolor', COLORTERM: "truecolor",
}, },
algorithms: { algorithms: {
kex: [ kex: [
'diffie-hellman-group14-sha256', "diffie-hellman-group14-sha256",
'diffie-hellman-group14-sha1', "diffie-hellman-group14-sha1",
'diffie-hellman-group1-sha1', "diffie-hellman-group1-sha1",
'diffie-hellman-group-exchange-sha256', "diffie-hellman-group-exchange-sha256",
'diffie-hellman-group-exchange-sha1', "diffie-hellman-group-exchange-sha1",
'ecdh-sha2-nistp256', "ecdh-sha2-nistp256",
'ecdh-sha2-nistp384', "ecdh-sha2-nistp384",
'ecdh-sha2-nistp521' "ecdh-sha2-nistp521",
], ],
cipher: [ cipher: [
'aes128-ctr', "aes128-ctr",
'aes192-ctr', "aes192-ctr",
'aes256-ctr', "aes256-ctr",
'aes128-gcm@openssh.com', "aes128-gcm@openssh.com",
'aes256-gcm@openssh.com', "aes256-gcm@openssh.com",
'aes128-cbc', "aes128-cbc",
'aes192-cbc', "aes192-cbc",
'aes256-cbc', "aes256-cbc",
'3des-cbc' "3des-cbc",
], ],
hmac: [ hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
'hmac-sha2-256', compress: ["none", "zlib@openssh.com", "zlib"],
'hmac-sha2-512', },
'hmac-sha1',
'hmac-md5'
],
compress: [
'none',
'zlib@openssh.com',
'zlib'
]
}
}; };
if (resolvedCredentials.authType === 'key' && resolvedCredentials.key) { if (resolvedCredentials.authType === "key" && resolvedCredentials.key) {
try { try {
if (!resolvedCredentials.key.includes('-----BEGIN') || !resolvedCredentials.key.includes('-----END')) { if (
throw new Error('Invalid private key format'); !resolvedCredentials.key.includes("-----BEGIN") ||
!resolvedCredentials.key.includes("-----END")
) {
throw new Error("Invalid private key format");
} }
const cleanKey = resolvedCredentials.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const cleanKey = resolvedCredentials.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8'); connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedCredentials.keyPassword) { if (resolvedCredentials.keyPassword) {
connectConfig.passphrase = resolvedCredentials.keyPassword; connectConfig.passphrase = resolvedCredentials.keyPassword;
} }
if (resolvedCredentials.keyType && resolvedCredentials.keyType !== 'auto') { if (
resolvedCredentials.keyType &&
resolvedCredentials.keyType !== "auto"
) {
connectConfig.privateKeyType = resolvedCredentials.keyType; connectConfig.privateKeyType = resolvedCredentials.keyType;
} }
} catch (keyError) { } catch (keyError) {
sshLogger.error('SSH key format error: ' + keyError.message); sshLogger.error("SSH key format error: " + keyError.message);
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'})); ws.send(
JSON.stringify({
type: "error",
message: "SSH key format error: Invalid private key format",
}),
);
return; return;
} }
} else if (resolvedCredentials.authType === 'key') { } else if (resolvedCredentials.authType === "key") {
sshLogger.error('SSH key authentication requested but no key provided'); sshLogger.error("SSH key authentication requested but no key provided");
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'})); ws.send(
JSON.stringify({
type: "error",
message: "SSH key authentication requested but no key provided",
}),
);
return; return;
} else { } else {
connectConfig.password = resolvedCredentials.password; connectConfig.password = resolvedCredentials.password;
@@ -351,7 +448,9 @@ wss.on('connection', (ws: WebSocket) => {
function handleResize(data: { cols: number; rows: number }) { function handleResize(data: { cols: number; rows: number }) {
if (sshStream && sshStream.setWindow) { if (sshStream && sshStream.setWindow) {
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols); sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
ws.send(JSON.stringify({type: 'resized', cols: data.cols, rows: data.rows})); ws.send(
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
);
} }
} }
@@ -369,7 +468,7 @@ wss.on('connection', (ws: WebSocket) => {
try { try {
sshStream.end(); sshStream.end();
} catch (e: any) { } catch (e: any) {
sshLogger.error('Error closing stream: ' + e.message); sshLogger.error("Error closing stream: " + e.message);
} }
sshStream = null; sshStream = null;
} }
@@ -378,7 +477,7 @@ wss.on('connection', (ws: WebSocket) => {
try { try {
sshConn.end(); sshConn.end();
} catch (e: any) { } catch (e: any) {
sshLogger.error('Error closing connection: ' + e.message); sshLogger.error("Error closing connection: " + e.message);
} }
sshConn = null; sshConn = null;
} }
@@ -388,9 +487,9 @@ wss.on('connection', (ws: WebSocket) => {
pingInterval = setInterval(() => { pingInterval = setInterval(() => {
if (sshConn && sshStream) { if (sshConn && sshStream) {
try { try {
sshStream.write('\x00'); sshStream.write("\x00");
} catch (e: any) { } catch (e: any) {
sshLogger.error('SSH keepalive failed: ' + e.message); sshLogger.error("SSH keepalive failed: " + e.message);
cleanupSSH(); cleanupSSH();
} }
} }
+317 -229
View File
File diff suppressed because it is too large Load Diff
+37 -24
View File
@@ -1,52 +1,65 @@
// npx tsc -p tsconfig.node.json // npx tsc -p tsconfig.node.json
// node ./dist/backend/starter.js // node ./dist/backend/starter.js
import './database/database.js' import "./database/database.js";
import './ssh/terminal.js'; import "./ssh/terminal.js";
import './ssh/tunnel.js'; import "./ssh/tunnel.js";
import './ssh/file-manager.js'; import "./ssh/file-manager.js";
import './ssh/server-stats.js'; import "./ssh/server-stats.js";
import { systemLogger, versionLogger } from './utils/logger.js'; import { systemLogger, versionLogger } from "./utils/logger.js";
import 'dotenv/config'; import "dotenv/config";
(async () => { (async () => {
try { try {
const version = process.env.VERSION || 'unknown'; const version = process.env.VERSION || "unknown";
versionLogger.info(`Termix Backend starting - Version: ${version}`, { versionLogger.info(`Termix Backend starting - Version: ${version}`, {
operation: 'startup', operation: "startup",
version: version version: version,
}); });
systemLogger.info("Initializing backend services...", { operation: 'startup' }); systemLogger.info("Initializing backend services...", {
operation: "startup",
});
systemLogger.success("All backend services initialized successfully", { systemLogger.success("All backend services initialized successfully", {
operation: 'startup_complete', operation: "startup_complete",
services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats'], services: ["database", "terminal", "tunnel", "file_manager", "stats"],
version: version version: version,
}); });
process.on('SIGINT', () => { process.on("SIGINT", () => {
systemLogger.info("Received SIGINT signal, initiating graceful shutdown...", { operation: 'shutdown' }); systemLogger.info(
"Received SIGINT signal, initiating graceful shutdown...",
{ operation: "shutdown" },
);
process.exit(0); process.exit(0);
}); });
process.on('SIGTERM', () => { process.on("SIGTERM", () => {
systemLogger.info("Received SIGTERM signal, initiating graceful shutdown...", { operation: 'shutdown' }); systemLogger.info(
"Received SIGTERM signal, initiating graceful shutdown...",
{ operation: "shutdown" },
);
process.exit(0); process.exit(0);
}); });
process.on('uncaughtException', (error) => { process.on("uncaughtException", (error) => {
systemLogger.error("Uncaught exception occurred", error, { operation: 'error_handling' }); systemLogger.error("Uncaught exception occurred", error, {
operation: "error_handling",
});
process.exit(1); process.exit(1);
}); });
process.on('unhandledRejection', (reason, promise) => { process.on("unhandledRejection", (reason, promise) => {
systemLogger.error("Unhandled promise rejection", reason, { operation: 'error_handling' }); systemLogger.error("Unhandled promise rejection", reason, {
operation: "error_handling",
});
process.exit(1); process.exit(1);
}); });
} catch (error) { } catch (error) {
systemLogger.error("Failed to initialize backend services", error, { operation: 'startup_failed' }); systemLogger.error("Failed to initialize backend services", error, {
operation: "startup_failed",
});
process.exit(1); process.exit(1);
} }
})(); })();
+58 -42
View File
@@ -1,6 +1,6 @@
import chalk from 'chalk'; import chalk from "chalk";
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success'; export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
export interface LogContext { export interface LogContext {
service?: string; service?: string;
@@ -29,13 +29,17 @@ class Logger {
return chalk.gray(`[${new Date().toLocaleTimeString()}]`); return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
} }
private formatMessage(level: LogLevel, message: string, context?: LogContext): string { private formatMessage(
level: LogLevel,
message: string,
context?: LogContext,
): string {
const timestamp = this.getTimeStamp(); const timestamp = this.getTimeStamp();
const levelColor = this.getLevelColor(level); const levelColor = this.getLevelColor(level);
const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`); const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`);
const levelTag = levelColor(`[${level.toUpperCase()}]`); const levelTag = levelColor(`[${level.toUpperCase()}]`);
let contextStr = ''; let contextStr = "";
if (context) { if (context) {
const contextParts = []; const contextParts = [];
if (context.operation) contextParts.push(`op:${context.operation}`); if (context.operation) contextParts.push(`op:${context.operation}`);
@@ -47,7 +51,7 @@ class Logger {
if (context.duration) contextParts.push(`duration:${context.duration}ms`); if (context.duration) contextParts.push(`duration:${context.duration}ms`);
if (contextParts.length > 0) { if (contextParts.length > 0) {
contextStr = chalk.gray(` [${contextParts.join(',')}]`); contextStr = chalk.gray(` [${contextParts.join(",")}]`);
} }
} }
@@ -56,103 +60,115 @@ class Logger {
private getLevelColor(level: LogLevel): chalk.Chalk { private getLevelColor(level: LogLevel): chalk.Chalk {
switch (level) { switch (level) {
case 'debug': return chalk.magenta; case "debug":
case 'info': return chalk.cyan; return chalk.magenta;
case 'warn': return chalk.yellow; case "info":
case 'error': return chalk.redBright; return chalk.cyan;
case 'success': return chalk.greenBright; case "warn":
default: return chalk.white; return chalk.yellow;
case "error":
return chalk.redBright;
case "success":
return chalk.greenBright;
default:
return chalk.white;
} }
} }
private shouldLog(level: LogLevel): boolean { private shouldLog(level: LogLevel): boolean {
if (level === 'debug' && process.env.NODE_ENV === 'production') { if (level === "debug" && process.env.NODE_ENV === "production") {
return false; return false;
} }
return true; return true;
} }
debug(message: string, context?: LogContext): void { debug(message: string, context?: LogContext): void {
if (!this.shouldLog('debug')) return; if (!this.shouldLog("debug")) return;
console.debug(this.formatMessage('debug', message, context)); console.debug(this.formatMessage("debug", message, context));
} }
info(message: string, context?: LogContext): void { info(message: string, context?: LogContext): void {
if (!this.shouldLog('info')) return; if (!this.shouldLog("info")) return;
console.log(this.formatMessage('info', message, context)); console.log(this.formatMessage("info", message, context));
} }
warn(message: string, context?: LogContext): void { warn(message: string, context?: LogContext): void {
if (!this.shouldLog('warn')) return; if (!this.shouldLog("warn")) return;
console.warn(this.formatMessage('warn', message, context)); console.warn(this.formatMessage("warn", message, context));
} }
error(message: string, error?: unknown, context?: LogContext): void { error(message: string, error?: unknown, context?: LogContext): void {
if (!this.shouldLog('error')) return; if (!this.shouldLog("error")) return;
console.error(this.formatMessage('error', message, context)); console.error(this.formatMessage("error", message, context));
if (error) { if (error) {
console.error(error); console.error(error);
} }
} }
success(message: string, context?: LogContext): void { success(message: string, context?: LogContext): void {
if (!this.shouldLog('success')) return; if (!this.shouldLog("success")) return;
console.log(this.formatMessage('success', message, context)); console.log(this.formatMessage("success", message, context));
} }
auth(message: string, context?: LogContext): void { auth(message: string, context?: LogContext): void {
this.info(`AUTH: ${message}`, { ...context, operation: 'auth' }); this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
} }
db(message: string, context?: LogContext): void { db(message: string, context?: LogContext): void {
this.info(`DB: ${message}`, { ...context, operation: 'database' }); this.info(`DB: ${message}`, { ...context, operation: "database" });
} }
ssh(message: string, context?: LogContext): void { ssh(message: string, context?: LogContext): void {
this.info(`SSH: ${message}`, { ...context, operation: 'ssh' }); this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
} }
tunnel(message: string, context?: LogContext): void { tunnel(message: string, context?: LogContext): void {
this.info(`TUNNEL: ${message}`, { ...context, operation: 'tunnel' }); this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
} }
file(message: string, context?: LogContext): void { file(message: string, context?: LogContext): void {
this.info(`FILE: ${message}`, { ...context, operation: 'file' }); this.info(`FILE: ${message}`, { ...context, operation: "file" });
} }
api(message: string, context?: LogContext): void { api(message: string, context?: LogContext): void {
this.info(`API: ${message}`, { ...context, operation: 'api' }); this.info(`API: ${message}`, { ...context, operation: "api" });
} }
request(message: string, context?: LogContext): void { request(message: string, context?: LogContext): void {
this.info(`REQUEST: ${message}`, { ...context, operation: 'request' }); this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
} }
response(message: string, context?: LogContext): void { response(message: string, context?: LogContext): void {
this.info(`RESPONSE: ${message}`, { ...context, operation: 'response' }); this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
} }
connection(message: string, context?: LogContext): void { connection(message: string, context?: LogContext): void {
this.info(`CONNECTION: ${message}`, { ...context, operation: 'connection' }); this.info(`CONNECTION: ${message}`, {
...context,
operation: "connection",
});
} }
disconnect(message: string, context?: LogContext): void { disconnect(message: string, context?: LogContext): void {
this.info(`DISCONNECT: ${message}`, { ...context, operation: 'disconnect' }); this.info(`DISCONNECT: ${message}`, {
...context,
operation: "disconnect",
});
} }
retry(message: string, context?: LogContext): void { retry(message: string, context?: LogContext): void {
this.warn(`RETRY: ${message}`, { ...context, operation: 'retry' }); this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
} }
} }
export const databaseLogger = new Logger('DATABASE', '🗄️', '#6366f1'); export const databaseLogger = new Logger("DATABASE", "🗄️", "#6366f1");
export const sshLogger = new Logger('SSH', '🖥️', '#0ea5e9'); export const sshLogger = new Logger("SSH", "🖥️", "#0ea5e9");
export const tunnelLogger = new Logger('TUNNEL', '📡', '#a855f7'); export const tunnelLogger = new Logger("TUNNEL", "📡", "#a855f7");
export const fileLogger = new Logger('FILE', '📁', '#f59e0b'); export const fileLogger = new Logger("FILE", "📁", "#f59e0b");
export const statsLogger = new Logger('STATS', '📊', '#22c55e'); export const statsLogger = new Logger("STATS", "📊", "#22c55e");
export const apiLogger = new Logger('API', '🌐', '#3b82f6'); export const apiLogger = new Logger("API", "🌐", "#3b82f6");
export const authLogger = new Logger('AUTH', '🔐', '#ef4444'); export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
export const systemLogger = new Logger('SYSTEM', '🚀', '#14b8a6'); export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
export const versionLogger = new Logger('VERSION', '📦', '#8b5cf6'); export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
export const logger = systemLogger; export const logger = systemLogger;
+28 -28
View File
@@ -1,24 +1,24 @@
import {createContext, useContext, useEffect, useState} from "react" import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system" type Theme = "dark" | "light" | "system";
type ThemeProviderProps = { type ThemeProviderProps = {
children: React.ReactNode children: React.ReactNode;
defaultTheme?: Theme defaultTheme?: Theme;
storageKey?: string storageKey?: string;
} };
type ThemeProviderState = { type ThemeProviderState = {
theme: Theme theme: Theme;
setTheme: (theme: Theme) => void setTheme: (theme: Theme) => void;
} };
const initialState: ThemeProviderState = { const initialState: ThemeProviderState = {
theme: "system", theme: "system",
setTheme: () => null, setTheme: () => null,
} };
const ThemeProviderContext = createContext<ThemeProviderState>(initialState) const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({ export function ThemeProvider({
children, children,
@@ -27,47 +27,47 @@ export function ThemeProvider({
...props ...props
}: ThemeProviderProps) { }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>( const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
) );
useEffect(() => { useEffect(() => {
const root = window.document.documentElement const root = window.document.documentElement;
root.classList.remove("light", "dark") root.classList.remove("light", "dark");
if (theme === "system") { if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches .matches
? "dark" ? "dark"
: "light" : "light";
root.classList.add(systemTheme) root.classList.add(systemTheme);
return return;
} }
root.classList.add(theme) root.classList.add(theme);
}, [theme]) }, [theme]);
const value = { const value = {
theme, theme,
setTheme: (theme: Theme) => { setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme) localStorage.setItem(storageKey, theme);
setTheme(theme) setTheme(theme);
}, },
} };
return ( return (
<ThemeProviderContext.Provider {...props} value={value}> <ThemeProviderContext.Provider {...props} value={value}>
{children} {children}
</ThemeProviderContext.Provider> </ThemeProviderContext.Provider>
) );
} }
export const useTheme = () => { export const useTheme = () => {
const context = useContext(ThemeProviderContext) const context = useContext(ThemeProviderContext);
if (context === undefined) if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider") throw new Error("useTheme must be used within a ThemeProvider");
return context return context;
} };
+10 -10
View File
@@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion" import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react" import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Accordion({ function Accordion({
...props ...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) { }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} /> return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
} }
function AccordionItem({ function AccordionItem({
@@ -20,7 +20,7 @@ function AccordionItem({
className={cn("border-b last:border-b-0", className)} className={cn("border-b last:border-b-0", className)}
{...props} {...props}
/> />
) );
} }
function AccordionTrigger({ function AccordionTrigger({
@@ -34,7 +34,7 @@ function AccordionTrigger({
data-slot="accordion-trigger" data-slot="accordion-trigger"
className={cn( className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180", "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className className,
)} )}
{...props} {...props}
> >
@@ -42,7 +42,7 @@ function AccordionTrigger({
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" /> <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
) );
} }
function AccordionContent({ function AccordionContent({
@@ -58,7 +58,7 @@ function AccordionContent({
> >
<div className={cn("pt-0 pb-4", className)}>{children}</div> <div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content> </AccordionPrimitive.Content>
) );
} }
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+11 -11
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
@@ -16,8 +16,8 @@ const alertVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Alert({ function Alert({
className, className,
@@ -31,7 +31,7 @@ function Alert({
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="alert-title" data-slot="alert-title"
className={cn( className={cn(
"col-start-2 font-medium tracking-tight whitespace-normal break-words", "col-start-2 font-medium tracking-tight whitespace-normal break-words",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDescription({ function AlertDescription({
@@ -56,11 +56,11 @@ function AlertDescription({
data-slot="alert-description" data-slot="alert-description"
className={cn( className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription };
+9 -9
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@@ -22,8 +22,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Badge({ function Badge({
className, className,
@@ -32,7 +32,7 @@ function Badge({
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span" const Comp = asChild ? Slot : "span";
return ( return (
<Comp <Comp
@@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };
+20 -20
View File
@@ -1,37 +1,37 @@
import { Children, ReactElement, cloneElement, isValidElement } from 'react'; import { Children, ReactElement, cloneElement, isValidElement } from "react";
import { type ButtonProps } from '@/components/ui/button'; import { type ButtonProps } from "@/components/ui/button";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
interface ButtonGroupProps { interface ButtonGroupProps {
className?: string; className?: string;
orientation?: 'horizontal' | 'vertical'; orientation?: "horizontal" | "vertical";
children: ReactElement<ButtonProps>[] | React.ReactNode; children: ReactElement<ButtonProps>[] | React.ReactNode;
} }
export const ButtonGroup = ({ export const ButtonGroup = ({
className, className,
orientation = 'horizontal', orientation = "horizontal",
children, children,
}: ButtonGroupProps) => { }: ButtonGroupProps) => {
const isHorizontal = orientation === 'horizontal'; const isHorizontal = orientation === "horizontal";
const isVertical = orientation === 'vertical'; const isVertical = orientation === "vertical";
// Normalize and filter only valid React elements // Normalize and filter only valid React elements
const childArray = Children.toArray(children).filter((child): child is ReactElement<ButtonProps> => const childArray = Children.toArray(children).filter(
isValidElement(child) (child): child is ReactElement<ButtonProps> => isValidElement(child),
); );
const totalButtons = childArray.length; const totalButtons = childArray.length;
return ( return (
<div <div
className={cn( className={cn(
'flex', "flex",
{ {
'flex-col': isVertical, "flex-col": isVertical,
'w-fit': isVertical, "w-fit": isVertical,
}, },
className className,
)} )}
> >
{childArray.map((child, index) => { {childArray.map((child, index) => {
@@ -41,15 +41,15 @@ export const ButtonGroup = ({
return cloneElement(child, { return cloneElement(child, {
className: cn( className: cn(
{ {
'rounded-l-none': isHorizontal && !isFirst, "rounded-l-none": isHorizontal && !isFirst,
'rounded-r-none': isHorizontal && !isLast, "rounded-r-none": isHorizontal && !isLast,
'border-l-0': isHorizontal && !isFirst, "border-l-0": isHorizontal && !isFirst,
'rounded-t-none': isVertical && !isFirst, "rounded-t-none": isVertical && !isFirst,
'rounded-b-none': isVertical && !isLast, "rounded-b-none": isVertical && !isLast,
'border-t-0': isVertical && !isFirst, "border-t-0": isVertical && !isFirst,
}, },
child.props.className child.props.className,
), ),
}); });
})} })}
+10 -10
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -32,13 +32,13 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
export interface ButtonProps export interface ButtonProps
extends React.ComponentProps<"button">, extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
function Button({ function Button({
@@ -48,7 +48,7 @@ function Button({
asChild = false, asChild = false,
...props ...props
}: ButtonProps) { }: ButtonProps) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
@@ -56,7 +56,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants, type ButtonProps } export { Button, buttonVariants, type ButtonProps };
+13 -13
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -89,4 +89,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };
+7 -7
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react" import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Checkbox({ function Checkbox({
className, className,
@@ -13,7 +13,7 @@ function Checkbox({
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -24,7 +24,7 @@ function Checkbox({
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) );
} }
export { Checkbox } export { Checkbox };
+40 -40
View File
@@ -1,25 +1,25 @@
import * as React from "react" import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react" import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@@ -27,16 +27,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
className={cn( className={cn(
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none", "focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto h-4 w-4" /> <ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)) ));
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -46,13 +46,13 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -64,18 +64,18 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
)) ));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef< const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@@ -83,12 +83,12 @@ const DropdownMenuItem = React.forwardRef<
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@@ -98,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)) ));
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -122,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -133,13 +133,13 @@ const DropdownMenuRadioItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
)) ));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef< const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
@@ -147,12 +147,12 @@ const DropdownMenuLabel = React.forwardRef<
className={cn( className={cn(
"px-2 py-1.5 text-sm font-semibold", "px-2 py-1.5 text-sm font-semibold",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef< const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@@ -163,8 +163,8 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("bg-muted -mx-1 my-1 h-px", className)} className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
)) ));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ const DropdownMenuShortcut = ({
className, className,
@@ -175,9 +175,9 @@ const DropdownMenuShortcut = ({
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} {...props}
/> />
) );
} };
DropdownMenuShortcut.displayName = "DropdownMenuShortcut" DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export { export {
DropdownMenu, DropdownMenu,
@@ -195,4 +195,4 @@ export {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
} };
+40 -39
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { import {
Controller, Controller,
FormProvider, FormProvider,
@@ -9,23 +9,23 @@ import {
type ControllerProps, type ControllerProps,
type FieldPath, type FieldPath,
type FieldValues, type FieldValues,
} from "react-hook-form" } from "react-hook-form";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
const Form = FormProvider const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { > = {
name: TName name: TName;
} };
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue {} as FormFieldContextValue,
) );
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
@@ -37,21 +37,21 @@ const FormField = <
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
) );
} };
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext) const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext) const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext() const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name }) const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState) const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) { if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>") throw new Error("useFormField should be used within <FormField>");
} }
const { id } = itemContext const { id } = itemContext;
return { return {
id, id,
@@ -60,19 +60,19 @@ const useFormField = () => {
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, formMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
} };
} };
type FormItemContextValue = { type FormItemContextValue = {
id: string id: string;
} };
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue {} as FormItemContextValue,
) );
function FormItem({ className, ...props }: React.ComponentProps<"div">) { function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId() const id = React.useId();
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
@@ -82,14 +82,14 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
{...props} {...props}
/> />
</FormItemContext.Provider> </FormItemContext.Provider>
) );
} }
function FormLabel({ function FormLabel({
className, className,
...props ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField() const { error, formItemId } = useFormField();
return ( return (
<Label <Label
@@ -99,11 +99,12 @@ function FormLabel({
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) );
} }
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return ( return (
<Slot <Slot
@@ -117,11 +118,11 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
) );
} }
function FormDescription({ className, ...props }: React.ComponentProps<"p">) { function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField();
return ( return (
<p <p
@@ -130,15 +131,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function FormMessage({ className, ...props }: React.ComponentProps<"p">) { function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children const body = error ? String(error?.message ?? "") : props.children;
if (!body) { if (!body) {
return null return null;
} }
return ( return (
@@ -150,7 +151,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
> >
{body} {body}
</p> </p>
) );
} }
export { export {
@@ -162,4 +163,4 @@ export {
FormDescription, FormDescription,
FormMessage, FormMessage,
FormField, FormField,
} };
+5 -5
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
@@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };
+6 -6
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Label({ function Label({
className, className,
@@ -12,11 +12,11 @@ function Label({
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };
+5 -4
View File
@@ -8,8 +8,10 @@ import { cn } from "@/lib/utils";
interface PasswordInputProps interface PasswordInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {} extends React.InputHTMLAttributes<HTMLInputElement> {}
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>( export const PasswordInput = React.forwardRef<
({ className, ...props }, ref) => { HTMLInputElement,
PasswordInputProps
>(({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false); const [showPassword, setShowPassword] = React.useState(false);
return ( return (
@@ -34,7 +36,6 @@ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputPro
</button> </button>
</div> </div>
); );
} });
);
PasswordInput.displayName = "PasswordInput"; PasswordInput.displayName = "PasswordInput";
+9 -9
View File
@@ -1,18 +1,18 @@
import * as React from "react" import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Popover({ function Popover({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ function PopoverTrigger({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
function PopoverContent({ function PopoverContent({
@@ -29,18 +29,18 @@ function PopoverContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
) );
} }
function PopoverAnchor({ function PopoverAnchor({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+6 -6
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress" import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Progress({ function Progress({
className, className,
@@ -13,7 +13,7 @@ function Progress({
data-slot="progress" data-slot="progress"
className={cn( className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className className,
)} )}
{...props} {...props}
> >
@@ -23,7 +23,7 @@ function Progress({
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
) );
} }
export { Progress } export { Progress };
+11 -11
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { GripVerticalIcon } from "lucide-react" import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels" import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function ResizablePanelGroup({ function ResizablePanelGroup({
className, className,
@@ -13,17 +13,17 @@ function ResizablePanelGroup({
data-slot="resizable-panel-group" data-slot="resizable-panel-group"
className={cn( className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col", "flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ResizablePanel({ function ResizablePanel({
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) { }: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} /> return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
} }
function ResizableHandle({ function ResizableHandle({
@@ -31,14 +31,14 @@ function ResizableHandle({
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean withHandle?: boolean;
}) { }) {
return ( return (
<ResizablePrimitive.PanelResizeHandle <ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed transition-colors duration-150", "relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed transition-colors duration-150",
className className,
)} )}
{...props} {...props}
> >
@@ -48,7 +48,7 @@ function ResizableHandle({
</div> </div>
)} )}
</ResizablePrimitive.PanelResizeHandle> </ResizablePrimitive.PanelResizeHandle>
) );
} }
export { ResizablePanelGroup, ResizablePanel, ResizableHandle } export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
+7 -7
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function ScrollArea({ function ScrollArea({
className, className,
@@ -23,7 +23,7 @@ function ScrollArea({
<ScrollBar /> <ScrollBar />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
) );
} }
function ScrollBar({ function ScrollBar({
@@ -41,7 +41,7 @@ function ScrollBar({
"h-full w-2.5 border-l border-l-transparent", "h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" && orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent", "h-2.5 flex-col border-t border-t-transparent",
className className,
)} )}
{...props} {...props}
> >
@@ -50,7 +50,7 @@ function ScrollBar({
className="bg-border relative flex-1 rounded-full" className="bg-border relative flex-1 rounded-full"
/> />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
) );
} }
export { ScrollArea, ScrollBar } export { ScrollArea, ScrollBar };
+22 -22
View File
@@ -1,25 +1,25 @@
import * as React from "react" import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) { }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} /> return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
@@ -28,7 +28,7 @@ function SelectTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@@ -36,7 +36,7 @@ function SelectTrigger({
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@@ -45,7 +45,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
@@ -62,7 +62,7 @@ function SelectContent({
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
{...props} {...props}
@@ -72,7 +72,7 @@ function SelectContent({
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)} )}
> >
{children} {children}
@@ -80,7 +80,7 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
@@ -93,7 +93,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@@ -106,7 +106,7 @@ function SelectItem({
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className,
)} )}
{...props} {...props}
> >
@@ -117,7 +117,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@@ -130,7 +130,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@@ -142,13 +142,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@@ -160,13 +160,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@@ -180,4 +180,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} };
+7 -7
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Separator({ function Separator({
className, className,
@@ -18,11 +18,11 @@ function Separator({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };
+29 -21
View File
@@ -1,15 +1,15 @@
import type { ComponentProps, HTMLAttributes } from 'react'; import type { ComponentProps, HTMLAttributes } from "react";
import { Badge } from '@/components/ui/badge'; import { Badge } from "@/components/ui/badge";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
export type StatusProps = ComponentProps<typeof Badge> & { export type StatusProps = ComponentProps<typeof Badge> & {
status: 'online' | 'offline' | 'maintenance' | 'degraded'; status: "online" | "offline" | "maintenance" | "degraded";
}; };
export const Status = ({ className, status, ...props }: StatusProps) => ( export const Status = ({ className, status, ...props }: StatusProps) => (
<Badge <Badge
className={cn('flex items-center gap-2', 'group', status, className)} className={cn("flex items-center gap-2", "group", status, className)}
variant="secondary" variant="secondary"
{...props} {...props}
/> />
@@ -24,20 +24,20 @@ export const StatusIndicator = ({
<span className="relative flex h-2 w-2" {...props}> <span className="relative flex h-2 w-2" {...props}>
<span <span
className={cn( className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75', "absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
'group-[.online]:bg-emerald-500', "group-[.online]:bg-emerald-500",
'group-[.offline]:bg-red-500', "group-[.offline]:bg-red-500",
'group-[.maintenance]:bg-blue-500', "group-[.maintenance]:bg-blue-500",
'group-[.degraded]:bg-amber-500' "group-[.degraded]:bg-amber-500",
)} )}
/> />
<span <span
className={cn( className={cn(
'relative inline-flex h-2 w-2 rounded-full', "relative inline-flex h-2 w-2 rounded-full",
'group-[.online]:bg-emerald-500', "group-[.online]:bg-emerald-500",
'group-[.offline]:bg-red-500', "group-[.offline]:bg-red-500",
'group-[.maintenance]:bg-blue-500', "group-[.maintenance]:bg-blue-500",
'group-[.degraded]:bg-amber-500' "group-[.degraded]:bg-amber-500",
)} )}
/> />
</span> </span>
@@ -52,13 +52,21 @@ export const StatusLabel = ({
}: StatusLabelProps) => { }: StatusLabelProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<span className={cn('text-muted-foreground', className)} {...props}> <span className={cn("text-muted-foreground", className)} {...props}>
{children ?? ( {children ?? (
<> <>
<span className="hidden group-[.online]:block">{t('common.online')}</span> <span className="hidden group-[.online]:block">
<span className="hidden group-[.offline]:block">{t('common.offline')}</span> {t("common.online")}
<span className="hidden group-[.maintenance]:block">{t('common.maintenance')}</span> </span>
<span className="hidden group-[.degraded]:block">{t('common.degraded')}</span> <span className="hidden group-[.offline]:block">
{t("common.offline")}
</span>
<span className="hidden group-[.maintenance]:block">
{t("common.maintenance")}
</span>
<span className="hidden group-[.degraded]:block">
{t("common.degraded")}
</span>
</> </>
)} )}
</span> </span>
+18 -18
View File
@@ -1,29 +1,29 @@
import * as React from "react" import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ function SheetOverlay({
@@ -35,11 +35,11 @@ function SheetOverlay({
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
@@ -48,7 +48,7 @@ function SheetContent({
side = "right", side = "right",
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@@ -65,7 +65,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" && side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className className,
)} )}
{...props} {...props}
> >
@@ -76,7 +76,7 @@ function SheetContent({
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -86,7 +86,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -96,7 +96,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({
@@ -109,7 +109,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@@ -122,7 +122,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -134,4 +134,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };
+122 -122
View File
@@ -1,54 +1,54 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react" import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b" const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = { type SidebarContextProps = {
state: "expanded" | "collapsed" state: "expanded" | "collapsed";
open: boolean open: boolean;
setOpen: (open: boolean) => void setOpen: (open: boolean) => void;
openMobile: boolean openMobile: boolean;
setOpenMobile: (open: boolean) => void setOpenMobile: (open: boolean) => void;
isMobile: boolean isMobile: boolean;
toggleSidebar: () => void toggleSidebar: () => void;
} };
const SidebarContext = React.createContext<SidebarContextProps | null>(null) const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() { function useSidebar() {
const context = React.useContext(SidebarContext) const context = React.useContext(SidebarContext);
if (!context) { if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.") throw new Error("useSidebar must be used within a SidebarProvider.");
} }
return context return context;
} }
function SidebarProvider({ function SidebarProvider({
@@ -60,36 +60,36 @@ function SidebarProvider({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
defaultOpen?: boolean defaultOpen?: boolean;
open?: boolean open?: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
}) { }) {
const isMobile = useIsMobile() const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen) const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open const open = openProp ?? _open;
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) { if (setOpenProp) {
setOpenProp(openState) setOpenProp(openState);
} else { } else {
_setOpen(openState) _setOpen(openState);
} }
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
[setOpenProp, open] [setOpenProp, open],
) );
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]) }, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => { React.useEffect(() => {
@@ -98,18 +98,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey) (event.metaKey || event.ctrlKey)
) { ) {
event.preventDefault() event.preventDefault();
toggleSidebar() toggleSidebar();
}
} }
};
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]) }, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed" const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>( const contextValue = React.useMemo<SidebarContextProps>(
() => ({ () => ({
@@ -121,8 +121,8 @@ function SidebarProvider({
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
) );
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
@@ -138,7 +138,7 @@ function SidebarProvider({
} }
className={cn( className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className className,
)} )}
{...props} {...props}
> >
@@ -146,7 +146,7 @@ function SidebarProvider({
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarContext.Provider> </SidebarContext.Provider>
) );
} }
function Sidebar({ function Sidebar({
@@ -157,11 +157,11 @@ function Sidebar({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
side?: "left" | "right" side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset" variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none" collapsible?: "offcanvas" | "icon" | "none";
}) { }) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") { if (collapsible === "none") {
return ( return (
@@ -169,13 +169,13 @@ function Sidebar({
data-slot="sidebar" data-slot="sidebar"
className={cn( className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
) );
} }
// Commented out mobile behavior to keep sidebar always visible // Commented out mobile behavior to keep sidebar always visible
@@ -222,7 +222,7 @@ function Sidebar({
"group-data-[side=right]:rotate-180", "group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)} )}
/> />
<div <div
@@ -236,7 +236,7 @@ function Sidebar({
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className className,
)} )}
{...props} {...props}
> >
@@ -249,7 +249,7 @@ function Sidebar({
</div> </div>
</div> </div>
</div> </div>
) );
} }
function SidebarTrigger({ function SidebarTrigger({
@@ -257,7 +257,7 @@ function SidebarTrigger({
onClick, onClick,
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<Button <Button
@@ -267,19 +267,19 @@ function SidebarTrigger({
size="icon" size="icon"
className={cn("size-7", className)} className={cn("size-7", className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event) onClick?.(event);
toggleSidebar() toggleSidebar();
}} }}
{...props} {...props}
> >
<PanelLeftIcon /> <PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) );
} }
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<button <button
@@ -296,11 +296,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
@@ -310,11 +310,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
className={cn( className={cn(
"bg-background relative flex w-full flex-1 flex-col", "bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInput({ function SidebarInput({
@@ -328,7 +328,7 @@ function SidebarInput({
className={cn("bg-background h-8 w-full shadow-none", className)} className={cn("bg-background h-8 w-full shadow-none", className)}
{...props} {...props}
/> />
) );
} }
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -339,7 +339,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -350,7 +350,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarSeparator({ function SidebarSeparator({
@@ -364,7 +364,7 @@ function SidebarSeparator({
className={cn("bg-sidebar-border mx-2 w-auto", className)} className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props} {...props}
/> />
) );
} }
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -374,11 +374,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -389,7 +389,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("relative flex w-full min-w-0 flex-col p-2", className)} className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarGroupLabel({ function SidebarGroupLabel({
@@ -397,7 +397,7 @@ function SidebarGroupLabel({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) { }: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div" const Comp = asChild ? Slot : "div";
return ( return (
<Comp <Comp
@@ -406,11 +406,11 @@ function SidebarGroupLabel({
className={cn( className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupAction({ function SidebarGroupAction({
@@ -418,7 +418,7 @@ function SidebarGroupAction({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) { }: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
@@ -429,11 +429,11 @@ function SidebarGroupAction({
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden", "after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupContent({ function SidebarGroupContent({
@@ -447,7 +447,7 @@ function SidebarGroupContent({
className={cn("w-full text-sm", className)} className={cn("w-full text-sm", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
@@ -458,7 +458,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
className={cn("flex w-full min-w-0 flex-col gap-1", className)} className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
@@ -469,7 +469,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("group/menu-item relative", className)} className={cn("group/menu-item relative", className)}
{...props} {...props}
/> />
) );
} }
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
@@ -491,8 +491,8 @@ const sidebarMenuButtonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function SidebarMenuButton({ function SidebarMenuButton({
asChild = false, asChild = false,
@@ -503,12 +503,12 @@ function SidebarMenuButton({
className, className,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
isActive?: boolean isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) { } & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const button = ( const button = (
<Comp <Comp
@@ -519,16 +519,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)} className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} {...props}
/> />
) );
if (!tooltip) { if (!tooltip) {
return button return button;
} }
if (typeof tooltip === "string") { if (typeof tooltip === "string") {
tooltip = { tooltip = {
children: tooltip, children: tooltip,
} };
} }
return ( return (
@@ -541,7 +541,7 @@ function SidebarMenuButton({
{...tooltip} {...tooltip}
/> />
</Tooltip> </Tooltip>
) );
} }
function SidebarMenuAction({ function SidebarMenuAction({
@@ -550,10 +550,10 @@ function SidebarMenuAction({
showOnHover = false, showOnHover = false,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
showOnHover?: boolean showOnHover?: boolean;
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
@@ -569,11 +569,11 @@ function SidebarMenuAction({
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
showOnHover && showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuBadge({ function SidebarMenuBadge({
@@ -591,11 +591,11 @@ function SidebarMenuBadge({
"peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5", "peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSkeleton({ function SidebarMenuSkeleton({
@@ -603,12 +603,12 @@ function SidebarMenuSkeleton({
showIcon = false, showIcon = false,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showIcon?: boolean showIcon?: boolean;
}) { }) {
// Random width between 50 to 90%. // Random width between 50 to 90%.
const width = React.useMemo(() => { const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%` return `${Math.floor(Math.random() * 40) + 50}%`;
}, []) }, []);
return ( return (
<div <div
@@ -633,7 +633,7 @@ function SidebarMenuSkeleton({
} }
/> />
</div> </div>
) );
} }
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
@@ -644,11 +644,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
className={cn( className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubItem({ function SidebarMenuSubItem({
@@ -662,7 +662,7 @@ function SidebarMenuSubItem({
className={cn("group/menu-sub-item relative", className)} className={cn("group/menu-sub-item relative", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubButton({ function SidebarMenuSubButton({
@@ -672,11 +672,11 @@ function SidebarMenuSubButton({
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
size?: "sm" | "md" size?: "sm" | "md";
isActive?: boolean isActive?: boolean;
}) { }) {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : "a";
return ( return (
<Comp <Comp
@@ -690,11 +690,11 @@ function SidebarMenuSubButton({
size === "sm" && "text-xs", size === "sm" && "text-xs",
size === "md" && "text-sm", size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -722,4 +722,4 @@ export {
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
} };
+3 -3
View File
@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
className={cn("bg-accent animate-pulse rounded-md", className)} className={cn("bg-accent animate-pulse rounded-md", className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };
+6 -6
View File
@@ -1,8 +1,8 @@
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme();
return ( return (
<Sonner <Sonner
@@ -17,7 +17,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
} }
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };
+7 -7
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch" import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Switch({ function Switch({
className, className,
@@ -12,18 +12,18 @@ function Switch({
data-slot="switch" data-slot="switch"
className={cn( className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
<SwitchPrimitive.Thumb <SwitchPrimitive.Thumb
data-slot="switch-thumb" data-slot="switch-thumb"
className={cn( className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0" "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)} )}
/> />
</SwitchPrimitive.Root> </SwitchPrimitive.Root>
) );
} }
export { Switch } export { Switch };
+15 -15
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
@@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props} {...props}
/> />
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b", className)}
{...props} {...props}
/> />
) );
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
) );
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCaption({ function TableCaption({
@@ -99,7 +99,7 @@ function TableCaption({
className={cn("text-muted-foreground mt-4 text-sm", className)} className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -111,4 +111,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };
+10 -10
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs" import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Tabs({ function Tabs({
className, className,
@@ -13,7 +13,7 @@ function Tabs({
className={cn("flex flex-col gap-2", className)} className={cn("flex flex-col gap-2", className)}
{...props} {...props}
/> />
) );
} }
function TabsList({ function TabsList({
@@ -25,11 +25,11 @@ function TabsList({
data-slot="tabs-list" data-slot="tabs-list"
className={cn( className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TabsTrigger({ function TabsTrigger({
@@ -41,11 +41,11 @@ function TabsTrigger({
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TabsContent({ function TabsContent({
@@ -58,7 +58,7 @@ function TabsContent({
className={cn("flex-1 outline-none", className)} className={cn("flex-1 outline-none", className)}
{...props} {...props}
/> />
) );
} }
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };
+8 -8
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils";
export interface TextareaProps export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
@@ -11,14 +11,14 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<textarea <textarea
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Textarea.displayName = "Textarea" Textarea.displayName = "Textarea";
export { Textarea } export { Textarea };
+10 -10
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@@ -15,7 +15,7 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({
@@ -25,13 +25,13 @@ function Tooltip({
<TooltipProvider> <TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> <TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider> </TooltipProvider>
) );
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
@@ -47,14 +47,14 @@ function TooltipContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", "bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+14 -11
View File
@@ -1,12 +1,12 @@
import {useState} from 'react'; import { useState } from "react";
import {toast} from 'sonner'; import { toast } from "sonner";
interface ConfirmationOptions { interface ConfirmationOptions {
title: string; title: string;
description: string; description: string;
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
variant?: 'default' | 'destructive'; variant?: "default" | "destructive";
} }
export function useConfirmation() { export function useConfirmation() {
@@ -35,22 +35,25 @@ export function useConfirmation() {
setOnConfirm(null); setOnConfirm(null);
}; };
const confirmWithToast = (message: string, callback: () => void, variant: 'default' | 'destructive' = 'default') => { const confirmWithToast = (
const actionText = variant === 'destructive' ? 'Delete' : 'Confirm'; message: string,
const cancelText = 'Cancel'; callback: () => void,
variant: "default" | "destructive" = "default",
) => {
const actionText = variant === "destructive" ? "Delete" : "Confirm";
const cancelText = "Cancel";
toast(message, { toast(message, {
action: { action: {
label: actionText, label: actionText,
onClick: callback onClick: callback,
}, },
cancel: { cancel: {
label: cancelText, label: cancelText,
onClick: () => { onClick: () => {},
}
}, },
duration: 10000, duration: 10000,
className: variant === 'destructive' ? 'border-red-500' : '' className: variant === "destructive" ? "border-red-500" : "",
}); });
}; };
@@ -60,6 +63,6 @@ export function useConfirmation() {
confirm, confirm,
handleConfirm, handleConfirm,
handleCancel, handleCancel,
confirmWithToast confirmWithToast,
}; };
} }
+13 -11
View File
@@ -1,19 +1,21 @@
import * as React from "react" import * as React from "react";
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
} };
mql.addEventListener("change", onChange) mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener("change", onChange);
}, []) }, []);
return !!isMobile return !!isMobile;
} }
+14 -14
View File
@@ -1,33 +1,33 @@
import i18n from 'i18next'; import i18n from "i18next";
import {initReactI18next} from 'react-i18next'; import { initReactI18next } from "react-i18next";
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from "i18next-browser-languagedetector";
import enTranslation from '../locales/en/translation.json'; import enTranslation from "../locales/en/translation.json";
import zhTranslation from '../locales/zh/translation.json'; import zhTranslation from "../locales/zh/translation.json";
i18n i18n
.use(LanguageDetector) .use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
supportedLngs: ['en', 'zh'], supportedLngs: ["en", "zh"],
fallbackLng: 'en', fallbackLng: "en",
debug: false, debug: false,
detection: { detection: {
order: ['localStorage', 'cookie'], order: ["localStorage", "cookie"],
caches: ['localStorage', 'cookie'], caches: ["localStorage", "cookie"],
lookupLocalStorage: 'i18nextLng', lookupLocalStorage: "i18nextLng",
lookupCookie: 'i18nextLng', lookupCookie: "i18nextLng",
checkWhitelist: true, checkWhitelist: true,
}, },
resources: { resources: {
en: { en: {
translation: enTranslation translation: enTranslation,
}, },
zh: { zh: {
translation: zhTranslation translation: zhTranslation,
} },
}, },
interpolation: { interpolation: {
+2 -1
View File
@@ -145,7 +145,8 @@
} }
@layer base { @layer base {
html, body { html,
body {
height: 100%; height: 100%;
} }
+131 -73
View File
@@ -1,4 +1,4 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success'; export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
export interface LogContext { export interface LogContext {
operation?: string; operation?: string;
@@ -30,20 +30,24 @@ class FrontendLogger {
this.serviceName = serviceName; this.serviceName = serviceName;
this.serviceIcon = serviceIcon; this.serviceIcon = serviceIcon;
this.serviceColor = serviceColor; this.serviceColor = serviceColor;
this.isDevelopment = process.env.NODE_ENV === 'development'; this.isDevelopment = process.env.NODE_ENV === "development";
} }
private getTimeStamp(): string { private getTimeStamp(): string {
const now = new Date(); const now = new Date();
return `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}]`; return `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, "0")}]`;
} }
private formatMessage(level: LogLevel, message: string, context?: LogContext): string { private formatMessage(
level: LogLevel,
message: string,
context?: LogContext,
): string {
const timestamp = this.getTimeStamp(); const timestamp = this.getTimeStamp();
const levelTag = this.getLevelTag(level); const levelTag = this.getLevelTag(level);
const serviceTag = this.getServiceTag(); const serviceTag = this.getServiceTag();
let contextStr = ''; let contextStr = "";
if (context && this.isDevelopment) { if (context && this.isDevelopment) {
const contextParts = []; const contextParts = [];
if (context.operation) contextParts.push(context.operation); if (context.operation) contextParts.push(context.operation);
@@ -56,7 +60,7 @@ class FrontendLogger {
if (context.errorCode) contextParts.push(`code:${context.errorCode}`); if (context.errorCode) contextParts.push(`code:${context.errorCode}`);
if (contextParts.length > 0) { if (contextParts.length > 0) {
contextStr = ` (${contextParts.join(', ')})`; contextStr = ` (${contextParts.join(", ")})`;
} }
} }
@@ -65,11 +69,11 @@ class FrontendLogger {
private getLevelTag(level: LogLevel): string { private getLevelTag(level: LogLevel): string {
const symbols = { const symbols = {
debug: '🔍', debug: "🔍",
info: '️', info: "️",
warn: '⚠️', warn: "⚠️",
error: '❌', error: "❌",
success: '✅' success: "✅",
}; };
return `${symbols[level]} [${level.toUpperCase()}]`; return `${symbols[level]} [${level.toUpperCase()}]`;
} }
@@ -79,105 +83,119 @@ class FrontendLogger {
} }
private shouldLog(level: LogLevel): boolean { private shouldLog(level: LogLevel): boolean {
if (level === 'debug' && !this.isDevelopment) { if (level === "debug" && !this.isDevelopment) {
return false; return false;
} }
return true; return true;
} }
private log(level: LogLevel, message: string, context?: LogContext, error?: unknown): void { private log(
level: LogLevel,
message: string,
context?: LogContext,
error?: unknown,
): void {
if (!this.shouldLog(level)) return; if (!this.shouldLog(level)) return;
const formattedMessage = this.formatMessage(level, message, context); const formattedMessage = this.formatMessage(level, message, context);
switch (level) { switch (level) {
case 'debug': case "debug":
console.debug(formattedMessage); console.debug(formattedMessage);
break; break;
case 'info': case "info":
console.log(formattedMessage); console.log(formattedMessage);
break; break;
case 'warn': case "warn":
console.warn(formattedMessage); console.warn(formattedMessage);
break; break;
case 'error': case "error":
console.error(formattedMessage); console.error(formattedMessage);
if (error) { if (error) {
console.error('Error details:', error); console.error("Error details:", error);
} }
break; break;
case 'success': case "success":
console.log(formattedMessage); console.log(formattedMessage);
break; break;
} }
} }
debug(message: string, context?: LogContext): void { debug(message: string, context?: LogContext): void {
this.log('debug', message, context); this.log("debug", message, context);
} }
info(message: string, context?: LogContext): void { info(message: string, context?: LogContext): void {
this.log('info', message, context); this.log("info", message, context);
} }
warn(message: string, context?: LogContext): void { warn(message: string, context?: LogContext): void {
this.log('warn', message, context); this.log("warn", message, context);
} }
error(message: string, error?: unknown, context?: LogContext): void { error(message: string, error?: unknown, context?: LogContext): void {
this.log('error', message, context, error); this.log("error", message, context, error);
} }
success(message: string, context?: LogContext): void { success(message: string, context?: LogContext): void {
this.log('success', message, context); this.log("success", message, context);
} }
api(message: string, context?: LogContext): void { api(message: string, context?: LogContext): void {
this.info(`API: ${message}`, {...context, operation: 'api'}); this.info(`API: ${message}`, { ...context, operation: "api" });
} }
request(message: string, context?: LogContext): void { request(message: string, context?: LogContext): void {
this.info(`REQUEST: ${message}`, {...context, operation: 'request'}); this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
} }
response(message: string, context?: LogContext): void { response(message: string, context?: LogContext): void {
this.info(`RESPONSE: ${message}`, {...context, operation: 'response'}); this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
} }
auth(message: string, context?: LogContext): void { auth(message: string, context?: LogContext): void {
this.info(`AUTH: ${message}`, {...context, operation: 'auth'}); this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
} }
ssh(message: string, context?: LogContext): void { ssh(message: string, context?: LogContext): void {
this.info(`SSH: ${message}`, {...context, operation: 'ssh'}); this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
} }
tunnel(message: string, context?: LogContext): void { tunnel(message: string, context?: LogContext): void {
this.info(`TUNNEL: ${message}`, {...context, operation: 'tunnel'}); this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
} }
file(message: string, context?: LogContext): void { file(message: string, context?: LogContext): void {
this.info(`FILE: ${message}`, {...context, operation: 'file'}); this.info(`FILE: ${message}`, { ...context, operation: "file" });
} }
connection(message: string, context?: LogContext): void { connection(message: string, context?: LogContext): void {
this.info(`CONNECTION: ${message}`, {...context, operation: 'connection'}); this.info(`CONNECTION: ${message}`, {
...context,
operation: "connection",
});
} }
disconnect(message: string, context?: LogContext): void { disconnect(message: string, context?: LogContext): void {
this.info(`DISCONNECT: ${message}`, {...context, operation: 'disconnect'}); this.info(`DISCONNECT: ${message}`, {
...context,
operation: "disconnect",
});
} }
retry(message: string, context?: LogContext): void { retry(message: string, context?: LogContext): void {
this.warn(`RETRY: ${message}`, {...context, operation: 'retry'}); this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
} }
performance(message: string, context?: LogContext): void { performance(message: string, context?: LogContext): void {
this.info(`PERFORMANCE: ${message}`, {...context, operation: 'performance'}); this.info(`PERFORMANCE: ${message}`, {
...context,
operation: "performance",
});
} }
security(message: string, context?: LogContext): void { security(message: string, context?: LogContext): void {
this.warn(`SECURITY: ${message}`, {...context, operation: 'security'}); this.warn(`SECURITY: ${message}`, { ...context, operation: "security" });
} }
requestStart(method: string, url: string, context?: LogContext): void { requestStart(method: string, url: string, context?: LogContext): void {
@@ -188,27 +206,43 @@ class FrontendLogger {
this.request(`→ Starting request to ${cleanUrl}`, { this.request(`→ Starting request to ${cleanUrl}`, {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
url: cleanUrl url: cleanUrl,
}); });
} }
requestSuccess(method: string, url: string, status: number, responseTime: number, context?: LogContext): void { requestSuccess(
method: string,
url: string,
status: number,
responseTime: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url); const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status); const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime); const performanceIcon = this.getPerformanceIcon(responseTime);
this.response(`${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, { this.response(
`${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
{
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
url: cleanUrl, url: cleanUrl,
status, status,
responseTime responseTime,
}); },
);
console.groupEnd(); console.groupEnd();
} }
requestError(method: string, url: string, status: number, errorMessage: string, responseTime?: number, context?: LogContext): void { requestError(
method: string,
url: string,
status: number,
errorMessage: string,
responseTime?: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url); const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status); const statusIcon = this.getStatusIcon(status);
@@ -219,12 +253,17 @@ class FrontendLogger {
url: cleanUrl, url: cleanUrl,
status, status,
errorMessage, errorMessage,
responseTime responseTime,
}); });
console.groupEnd(); console.groupEnd();
} }
networkError(method: string, url: string, errorMessage: string, context?: LogContext): void { networkError(
method: string,
url: string,
errorMessage: string,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url); const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
@@ -233,7 +272,7 @@ class FrontendLogger {
method: method.toUpperCase(), method: method.toUpperCase(),
url: cleanUrl, url: cleanUrl,
errorMessage, errorMessage,
errorCode: 'NETWORK_ERROR' errorCode: "NETWORK_ERROR",
}); });
console.groupEnd(); console.groupEnd();
} }
@@ -246,12 +285,18 @@ class FrontendLogger {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
url: cleanUrl, url: cleanUrl,
errorCode: 'AUTH_REQUIRED' errorCode: "AUTH_REQUIRED",
}); });
console.groupEnd(); console.groupEnd();
} }
retryAttempt(method: string, url: string, attempt: number, maxAttempts: number, context?: LogContext): void { retryAttempt(
method: string,
url: string,
attempt: number,
maxAttempts: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url); const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
@@ -259,23 +304,33 @@ class FrontendLogger {
...context, ...context,
method: method.toUpperCase(), method: method.toUpperCase(),
url: cleanUrl, url: cleanUrl,
retryCount: attempt retryCount: attempt,
}); });
} }
apiOperation(operation: string, details: string, context?: LogContext): void { apiOperation(operation: string, details: string, context?: LogContext): void {
this.info(`🔧 ${operation}: ${details}`, {...context, operation: 'api_operation'}); this.info(`🔧 ${operation}: ${details}`, {
...context,
operation: "api_operation",
});
} }
requestSummary(method: string, url: string, status: number, responseTime: number, context?: LogContext): void { requestSummary(
method: string,
url: string,
status: number,
responseTime: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url); const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl); const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status); const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime); const performanceIcon = this.getPerformanceIcon(responseTime);
console.log(`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, console.log(
'color: #666; font-style: italic; font-size: 0.9em;', `%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
context "color: #666; font-style: italic; font-size: 0.9em;",
context,
); );
} }
@@ -286,31 +341,34 @@ class FrontendLogger {
const query = urlObj.search; const query = urlObj.search;
return `${urlObj.hostname}${path}${query}`; return `${urlObj.hostname}${path}${query}`;
} catch { } catch {
return url.length > 50 ? url.substring(0, 47) + '...' : url; return url.length > 50 ? url.substring(0, 47) + "..." : url;
} }
} }
private getStatusIcon(status: number): string { private getStatusIcon(status: number): string {
if (status >= 200 && status < 300) return '✅'; if (status >= 200 && status < 300) return "✅";
if (status >= 300 && status < 400) return '↩️'; if (status >= 300 && status < 400) return "↩️";
if (status >= 400 && status < 500) return '⚠️'; if (status >= 400 && status < 500) return "⚠️";
if (status >= 500) return '❌'; if (status >= 500) return "❌";
return '❓'; return "❓";
} }
private getPerformanceIcon(responseTime: number): string { private getPerformanceIcon(responseTime: number): string {
if (responseTime < 100) return '⚡'; if (responseTime < 100) return "⚡";
if (responseTime < 500) return '🚀'; if (responseTime < 500) return "🚀";
if (responseTime < 1000) return '🏃'; if (responseTime < 1000) return "🏃";
if (responseTime < 3000) return '🚶'; if (responseTime < 3000) return "🚶";
return '🐌'; return "🐌";
} }
private sanitizeUrl(url: string): string { private sanitizeUrl(url: string): string {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
if (urlObj.searchParams.has('password') || urlObj.searchParams.has('token')) { if (
urlObj.search = ''; urlObj.searchParams.has("password") ||
urlObj.searchParams.has("token")
) {
urlObj.search = "";
} }
return urlObj.toString(); return urlObj.toString();
} catch { } catch {
@@ -319,12 +377,12 @@ class FrontendLogger {
} }
} }
export const apiLogger = new FrontendLogger('API', '🌐', '#3b82f6'); export const apiLogger = new FrontendLogger("API", "🌐", "#3b82f6");
export const authLogger = new FrontendLogger('AUTH', '🔐', '#dc2626'); export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
export const sshLogger = new FrontendLogger('SSH', '🖥️', '#1e3a8a'); export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
export const tunnelLogger = new FrontendLogger('TUNNEL', '📡', '#1e3a8a'); export const tunnelLogger = new FrontendLogger("TUNNEL", "📡", "#1e3a8a");
export const fileLogger = new FrontendLogger('FILE', '📁', '#1e3a8a'); export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
export const statsLogger = new FrontendLogger('STATS', '📊', '#22c55e'); export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
export const systemLogger = new FrontendLogger('SYSTEM', '🚀', '#1e3a8a'); export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
export const logger = systemLogger; export const logger = systemLogger;
+3 -3
View File
@@ -1,6 +1,6 @@
import {clsx, type ClassValue} from "clsx" import { clsx, type ClassValue } from "clsx";
import {twMerge} from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
+15 -12
View File
@@ -1,11 +1,11 @@
import {StrictMode, useEffect, useState, useRef} from 'react' import { StrictMode, useEffect, useState, useRef } from "react";
import {createRoot} from 'react-dom/client' import { createRoot } from "react-dom/client";
import './index.css' import "./index.css";
import DesktopApp from './ui/Desktop/DesktopApp.tsx' import DesktopApp from "./ui/Desktop/DesktopApp.tsx";
import {MobileApp} from './ui/Mobile/MobileApp.tsx' import { MobileApp } from "./ui/Mobile/MobileApp.tsx";
import {ThemeProvider} from "@/components/theme-provider" import { ThemeProvider } from "@/components/theme-provider";
import './i18n/i18n' import "./i18n/i18n";
import {isElectron} from './ui/main-axios.ts' import { isElectron } from "./ui/main-axios.ts";
function useWindowWidth() { function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth); const [width, setWidth] = useState(window.innerWidth);
@@ -23,12 +23,15 @@ function useWindowWidth() {
const newIsMobile = newWidth < 768; const newIsMobile = newWidth < 768;
const now = Date.now(); const now = Date.now();
if (hasSwitchedOnce.current && (now - lastSwitchTime.current) < 10000) { if (hasSwitchedOnce.current && now - lastSwitchTime.current < 10000) {
setWidth(newWidth); setWidth(newWidth);
return; return;
} }
if (newIsMobile !== isCurrentlyMobile.current && (now - lastSwitchTime.current) > 5000) { if (
newIsMobile !== isCurrentlyMobile.current &&
now - lastSwitchTime.current > 5000
) {
lastSwitchTime.current = now; lastSwitchTime.current = now;
isCurrentlyMobile.current = newIsMobile; isCurrentlyMobile.current = newIsMobile;
hasSwitchedOnce.current = true; hasSwitchedOnce.current = true;
@@ -60,10 +63,10 @@ function RootApp() {
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />; return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
} }
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<RootApp /> <RootApp />
</ThemeProvider> </ThemeProvider>
</StrictMode>, </StrictMode>,
) );
+46 -18
View File
@@ -4,7 +4,7 @@
// This file contains all shared interfaces and types used across the application // This file contains all shared interfaces and types used across the application
// to avoid duplication and ensure consistency. // to avoid duplication and ensure consistency.
import type {Client} from 'ssh2'; import type { Client } from "ssh2";
// ============================================================================ // ============================================================================
// SSH HOST TYPES // SSH HOST TYPES
@@ -19,7 +19,7 @@ export interface SSHHost {
folder: string; folder: string;
tags: string[]; tags: string[];
pin: boolean; pin: boolean;
authType: 'password' | 'key' | 'credential'; authType: "password" | "key" | "credential";
password?: string; password?: string;
key?: string; key?: string;
keyPassword?: string; keyPassword?: string;
@@ -43,7 +43,7 @@ export interface SSHHostData {
folder?: string; folder?: string;
tags?: string[]; tags?: string[];
pin?: boolean; pin?: boolean;
authType: 'password' | 'key' | 'credential'; authType: "password" | "key" | "credential";
password?: string; password?: string;
key?: File | null; key?: File | null;
keyPassword?: string; keyPassword?: string;
@@ -66,7 +66,7 @@ export interface Credential {
description?: string; description?: string;
folder?: string; folder?: string;
tags: string[]; tags: string[];
authType: 'password' | 'key'; authType: "password" | "key";
username: string; username: string;
password?: string; password?: string;
key?: string; key?: string;
@@ -83,7 +83,7 @@ export interface CredentialData {
description?: string; description?: string;
folder?: string; folder?: string;
tags: string[]; tags: string[];
authType: 'password' | 'key'; authType: "password" | "key";
username: string; username: string;
password?: string; password?: string;
key?: string; key?: string;
@@ -166,7 +166,7 @@ export interface Tab {
export interface FileManagerFile { export interface FileManagerFile {
name: string; name: string;
path: string; path: string;
type?: 'file' | 'directory'; type?: "file" | "directory";
isSSH?: boolean; isSSH?: boolean;
sshSessionId?: string; sshSessionId?: string;
} }
@@ -180,7 +180,7 @@ export interface FileItem {
name: string; name: string;
path: string; path: string;
isPinned?: boolean; isPinned?: boolean;
type: 'file' | 'directory'; type: "file" | "directory";
sshSessionId?: string; sshSessionId?: string;
} }
@@ -219,8 +219,8 @@ export interface TermixAlert {
title: string; title: string;
message: string; message: string;
expiresAt: string; expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical'; priority?: "low" | "medium" | "high" | "critical";
type?: 'info' | 'warning' | 'error' | 'success'; type?: "info" | "warning" | "error" | "success";
actionUrl?: string; actionUrl?: string;
actionText?: string; actionText?: string;
} }
@@ -231,7 +231,14 @@ export interface TermixAlert {
export interface TabContextTab { export interface TabContextTab {
id: number; id: number;
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager' | 'user_profile'; type:
| "home"
| "terminal"
| "ssh_manager"
| "server"
| "admin"
| "file_manager"
| "user_profile";
title: string; title: string;
hostConfig?: any; hostConfig?: any;
terminalRef?: React.RefObject<any>; terminalRef?: React.RefObject<any>;
@@ -250,20 +257,26 @@ export const CONNECTION_STATES = {
UNSTABLE: "unstable", UNSTABLE: "unstable",
RETRYING: "retrying", RETRYING: "retrying",
WAITING: "waiting", WAITING: "waiting",
DISCONNECTING: "disconnecting" DISCONNECTING: "disconnecting",
} as const; } as const;
export type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES]; export type ConnectionState =
(typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES];
export type ErrorType = 'CONNECTION_FAILED' | 'AUTHENTICATION_FAILED' | 'TIMEOUT' | 'NETWORK_ERROR' | 'UNKNOWN'; export type ErrorType =
| "CONNECTION_FAILED"
| "AUTHENTICATION_FAILED"
| "TIMEOUT"
| "NETWORK_ERROR"
| "UNKNOWN";
// ============================================================================ // ============================================================================
// AUTHENTICATION TYPES // AUTHENTICATION TYPES
// ============================================================================ // ============================================================================
export type AuthType = 'password' | 'key' | 'credential'; export type AuthType = "password" | "key" | "credential";
export type KeyType = 'rsa' | 'ecdsa' | 'ed25519'; export type KeyType = "rsa" | "ecdsa" | "ed25519";
// ============================================================================ // ============================================================================
// API RESPONSE TYPES // API RESPONSE TYPES
@@ -326,8 +339,19 @@ export interface SSHTunnelProps {
export interface SSHTunnelViewerProps { export interface SSHTunnelViewerProps {
hosts?: SSHHost[]; hosts?: SSHHost[];
tunnelStatuses?: Record<string, TunnelStatus>; tunnelStatuses?: Record<string, TunnelStatus>;
tunnelActions?: Record<string, (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>>; tunnelActions?: Record<
onTunnelAction?: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>; string,
(
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>
>;
onTunnelAction?: (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>;
} }
export interface FileManagerProps { export interface FileManagerProps {
@@ -371,7 +395,11 @@ export interface SSHTunnelObjectProps {
host: SSHHost; host: SSHHost;
tunnelStatuses: Record<string, TunnelStatus>; tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>; tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>; onTunnelAction: (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>;
compact?: boolean; compact?: boolean;
bare?: boolean; bare?: boolean;
} }
+327 -150
View File
@@ -7,7 +7,12 @@ import {Checkbox} from "@/components/ui/checkbox.tsx";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Label } from "@/components/ui/label.tsx"; import { Label } from "@/components/ui/label.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { import {
Table, Table,
TableBody, TableBody,
@@ -31,14 +36,16 @@ import {
removeAdminStatus, removeAdminStatus,
deleteUser, deleteUser,
getCookie, getCookie,
isElectron isElectron,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
interface AdminSettingsProps { interface AdminSettingsProps {
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
} }
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement { export function AdminSettings({
isTopbarOpen = true,
}: AdminSettingsProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
@@ -47,29 +54,33 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const [regLoading, setRegLoading] = React.useState(false); const [regLoading, setRegLoading] = React.useState(false);
const [oidcConfig, setOidcConfig] = React.useState({ const [oidcConfig, setOidcConfig] = React.useState({
client_id: '', client_id: "",
client_secret: '', client_secret: "",
issuer_url: '', issuer_url: "",
authorization_url: '', authorization_url: "",
token_url: '', token_url: "",
identifier_path: 'sub', identifier_path: "sub",
name_path: 'name', name_path: "name",
scopes: 'openid email profile', scopes: "openid email profile",
userinfo_url: '' userinfo_url: "",
}); });
const [oidcLoading, setOidcLoading] = React.useState(false); const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null); const [oidcError, setOidcError] = React.useState<string | null>(null);
const [users, setUsers] = React.useState<Array<{ const [users, setUsers] = React.useState<
Array<{
id: string; id: string;
username: string; username: string;
is_admin: boolean; is_admin: boolean;
is_oidc: boolean is_oidc: boolean;
}>>([]); }>
>([]);
const [usersLoading, setUsersLoading] = React.useState(false); const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState(""); const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null); const [makeAdminError, setMakeAdminError] = React.useState<string | null>(
null,
);
React.useEffect(() => { React.useEffect(() => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
@@ -83,12 +94,12 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
} }
getOIDCConfig() getOIDCConfig()
.then(res => { .then((res) => {
if (res) setOidcConfig(res); if (res) setOidcConfig(res);
}) })
.catch((err) => { .catch((err) => {
if (!err.message?.includes('No server configured')) { if (!err.message?.includes("No server configured")) {
toast.error(t('admin.failedToFetchOidcConfig')); toast.error(t("admin.failedToFetchOidcConfig"));
} }
}); });
fetchUsers(); fetchUsers();
@@ -103,14 +114,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
} }
getRegistrationAllowed() getRegistrationAllowed()
.then(res => { .then((res) => {
if (typeof res?.allowed === 'boolean') { if (typeof res?.allowed === "boolean") {
setAllowRegistration(res.allowed); setAllowRegistration(res.allowed);
} }
}) })
.catch((err) => { .catch((err) => {
if (!err.message?.includes('No server configured')) { if (!err.message?.includes("No server configured")) {
toast.error(t('admin.failedToFetchRegistrationStatus')); toast.error(t("admin.failedToFetchRegistrationStatus"));
} }
}); });
}, []); }, []);
@@ -131,8 +142,8 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const response = await getUserList(); const response = await getUserList();
setUsers(response.users); setUsers(response.users);
} catch (err) { } catch (err) {
if (!err.message?.includes('No server configured')) { if (!err.message?.includes("No server configured")) {
toast.error(t('admin.failedToFetchUsers')); toast.error(t("admin.failedToFetchUsers"));
} }
} finally { } finally {
setUsersLoading(false); setUsersLoading(false);
@@ -155,10 +166,20 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
setOidcLoading(true); setOidcLoading(true);
setOidcError(null); setOidcError(null);
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url']; const required = [
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]); "client_id",
"client_secret",
"issuer_url",
"authorization_url",
"token_url",
];
const missing = required.filter(
(f) => !oidcConfig[f as keyof typeof oidcConfig],
);
if (missing.length > 0) { if (missing.length > 0) {
setOidcError(t('admin.missingRequiredFields', {fields: missing.join(', ')})); setOidcError(
t("admin.missingRequiredFields", { fields: missing.join(", ") }),
);
setOidcLoading(false); setOidcLoading(false);
return; return;
} }
@@ -166,16 +187,18 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await updateOIDCConfig(oidcConfig); await updateOIDCConfig(oidcConfig);
toast.success(t('admin.oidcConfigurationUpdated')); toast.success(t("admin.oidcConfigurationUpdated"));
} catch (err: any) { } catch (err: any) {
setOidcError(err?.response?.data?.error || t('admin.failedToUpdateOidcConfig')); setOidcError(
err?.response?.data?.error || t("admin.failedToUpdateOidcConfig"),
);
} finally { } finally {
setOidcLoading(false); setOidcLoading(false);
} }
}; };
const handleOIDCConfigChange = (field: string, value: string) => { const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig(prev => ({...prev, [field]: value})); setOidcConfig((prev) => ({ ...prev, [field]: value }));
}; };
const handleMakeUserAdmin = async (e: React.FormEvent) => { const handleMakeUserAdmin = async (e: React.FormEvent) => {
@@ -186,75 +209,79 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await makeUserAdmin(newAdminUsername.trim()); await makeUserAdmin(newAdminUsername.trim());
toast.success(t('admin.userIsNowAdmin', {username: newAdminUsername})); toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername }));
setNewAdminUsername(""); setNewAdminUsername("");
fetchUsers(); fetchUsers();
} catch (err: any) { } catch (err: any) {
setMakeAdminError(err?.response?.data?.error || t('admin.failedToMakeUserAdmin')); setMakeAdminError(
err?.response?.data?.error || t("admin.failedToMakeUserAdmin"),
);
} finally { } finally {
setMakeAdminLoading(false); setMakeAdminLoading(false);
} }
}; };
const handleRemoveAdminStatus = async (username: string) => { const handleRemoveAdminStatus = async (username: string) => {
confirmWithToast( confirmWithToast(t("admin.removeAdminStatus", { username }), async () => {
t('admin.removeAdminStatus', {username}),
async () => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await removeAdminStatus(username); await removeAdminStatus(username);
toast.success(t('admin.adminStatusRemoved', {username})); toast.success(t("admin.adminStatusRemoved", { username }));
fetchUsers(); fetchUsers();
} catch (err: any) { } catch (err: any) {
toast.error(t('admin.failedToRemoveAdminStatus')); toast.error(t("admin.failedToRemoveAdminStatus"));
} }
} });
);
}; };
const handleDeleteUser = async (username: string) => { const handleDeleteUser = async (username: string) => {
confirmWithToast( confirmWithToast(
t('admin.deleteUser', {username}), t("admin.deleteUser", { username }),
async () => { async () => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await deleteUser(username); await deleteUser(username);
toast.success(t('admin.userDeletedSuccessfully', {username})); toast.success(t("admin.userDeletedSuccessfully", { username }));
fetchUsers(); fetchUsers();
} catch (err: any) { } catch (err: any) {
toast.error(t('admin.failedToDeleteUser')); toast.error(t("admin.failedToDeleteUser"));
} }
}, },
'destructive' "destructive",
); );
}; };
const topMarginPx = isTopbarOpen ? 74 : 26; const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8; const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = { const wrapperStyle: React.CSSProperties = {
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
marginRight: 17, marginRight: 17,
marginTop: topMarginPx, marginTop: topMarginPx,
marginBottom: bottomMarginPx, marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)` height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}; };
return ( return (
<div style={wrapperStyle} <div
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"> style={wrapperStyle}
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"
>
<div className="h-full w-full flex flex-col"> <div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2"> <div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">{t('admin.title')}</h1> <h1 className="font-bold text-lg">{t("admin.title")}</h1>
</div> </div>
<Separator className="p-0.25 w-full" /> <Separator className="p-0.25 w-full" />
<div className="px-6 py-4 overflow-auto"> <div className="px-6 py-4 overflow-auto">
<Tabs defaultValue="registration" className="w-full"> <Tabs defaultValue="registration" className="w-full">
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border"> <TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
<TabsTrigger value="registration" className="flex items-center gap-2"> <TabsTrigger
value="registration"
className="flex items-center gap-2"
>
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
{t('admin.general')} {t("admin.general")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2"> <TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
@@ -262,130 +289,226 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2"> <TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
{t('admin.users')} {t("admin.users")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2"> <TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
{t('admin.adminManagement')} {t("admin.adminManagement")}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="registration" className="space-y-6"> <TabsContent value="registration" className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold">{t('admin.userRegistration')}</h3> <h3 className="text-lg font-semibold">
{t("admin.userRegistration")}
</h3>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration} <Checkbox
disabled={regLoading}/> checked={allowRegistration}
{t('admin.allowNewAccountRegistration')} onCheckedChange={handleToggleRegistration}
disabled={regLoading}
/>
{t("admin.allowNewAccountRegistration")}
</label> </label>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="oidc" className="space-y-6"> <TabsContent value="oidc" className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3> <h3 className="text-lg font-semibold">
{t("admin.externalAuthentication")}
</h3>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p> <p className="text-sm text-muted-foreground">
{t("admin.configureExternalProvider")}
</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 px-3 text-xs" className="h-8 px-3 text-xs"
onClick={() => window.open('https://docs.termix.site/oidc', '_blank')} onClick={() =>
window.open("https://docs.termix.site/oidc", "_blank")
}
> >
{t('common.documentation')} {t("common.documentation")}
</Button> </Button>
</div> </div>
{oidcError && ( {oidcError && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle> <AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription> <AlertDescription>{oidcError}</AlertDescription>
</Alert> </Alert>
)} )}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4"> <form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="client_id">{t('admin.clientId')}</Label> <Label htmlFor="client_id">{t("admin.clientId")}</Label>
<Input id="client_id" value={oidcConfig.client_id} <Input
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)} id="client_id"
placeholder={t('placeholders.clientId')} required/> value={oidcConfig.client_id}
onChange={(e) =>
handleOIDCConfigChange("client_id", e.target.value)
}
placeholder={t("placeholders.clientId")}
required
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="client_secret">{t('admin.clientSecret')}</Label> <Label htmlFor="client_secret">
<PasswordInput id="client_secret" value={oidcConfig.client_secret} {t("admin.clientSecret")}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)} </Label>
placeholder={t('placeholders.clientSecret')} required/> <PasswordInput
id="client_secret"
value={oidcConfig.client_secret}
onChange={(e) =>
handleOIDCConfigChange("client_secret", e.target.value)
}
placeholder={t("placeholders.clientSecret")}
required
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="authorization_url">{t('admin.authorizationUrl')}</Label> <Label htmlFor="authorization_url">
<Input id="authorization_url" value={oidcConfig.authorization_url} {t("admin.authorizationUrl")}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)} </Label>
placeholder={t('placeholders.authUrl')} <Input
required/> id="authorization_url"
value={oidcConfig.authorization_url}
onChange={(e) =>
handleOIDCConfigChange(
"authorization_url",
e.target.value,
)
}
placeholder={t("placeholders.authUrl")}
required
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="issuer_url">{t('admin.issuerUrl')}</Label> <Label htmlFor="issuer_url">{t("admin.issuerUrl")}</Label>
<Input id="issuer_url" value={oidcConfig.issuer_url} <Input
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)} id="issuer_url"
placeholder={t('placeholders.redirectUrl')} required/> value={oidcConfig.issuer_url}
onChange={(e) =>
handleOIDCConfigChange("issuer_url", e.target.value)
}
placeholder={t("placeholders.redirectUrl")}
required
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="token_url">{t('admin.tokenUrl')}</Label> <Label htmlFor="token_url">{t("admin.tokenUrl")}</Label>
<Input id="token_url" value={oidcConfig.token_url} <Input
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)} id="token_url"
placeholder={t('placeholders.tokenUrl')} required/> value={oidcConfig.token_url}
onChange={(e) =>
handleOIDCConfigChange("token_url", e.target.value)
}
placeholder={t("placeholders.tokenUrl")}
required
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="identifier_path">{t('admin.userIdentifierPath')}</Label> <Label htmlFor="identifier_path">
<Input id="identifier_path" value={oidcConfig.identifier_path} {t("admin.userIdentifierPath")}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)} </Label>
placeholder={t('placeholders.userIdField')} required/> <Input
id="identifier_path"
value={oidcConfig.identifier_path}
onChange={(e) =>
handleOIDCConfigChange(
"identifier_path",
e.target.value,
)
}
placeholder={t("placeholders.userIdField")}
required
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name_path">{t('admin.displayNamePath')}</Label> <Label htmlFor="name_path">
<Input id="name_path" value={oidcConfig.name_path} {t("admin.displayNamePath")}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)} </Label>
placeholder={t('placeholders.usernameField')} required/> <Input
id="name_path"
value={oidcConfig.name_path}
onChange={(e) =>
handleOIDCConfigChange("name_path", e.target.value)
}
placeholder={t("placeholders.usernameField")}
required
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="scopes">{t('admin.scopes')}</Label> <Label htmlFor="scopes">{t("admin.scopes")}</Label>
<Input id="scopes" value={oidcConfig.scopes} <Input
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)} id="scopes"
placeholder={t('placeholders.scopes')} required/> value={oidcConfig.scopes}
onChange={(e) =>
handleOIDCConfigChange("scopes", e.target.value)
}
placeholder={t("placeholders.scopes")}
required
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="userinfo_url">{t('admin.overrideUserInfoUrl')}</Label> <Label htmlFor="userinfo_url">
<Input id="userinfo_url" value={oidcConfig.userinfo_url} {t("admin.overrideUserInfoUrl")}
onChange={(e) => handleOIDCConfigChange('userinfo_url', e.target.value)} </Label>
placeholder="https://your-provider.com/application/o/userinfo/"/> <Input
id="userinfo_url"
value={oidcConfig.userinfo_url}
onChange={(e) =>
handleOIDCConfigChange("userinfo_url", e.target.value)
}
placeholder="https://your-provider.com/application/o/userinfo/"
/>
</div> </div>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1" <Button
disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')}</Button> type="submit"
<Button type="button" variant="outline" onClick={async () => { className="flex-1"
disabled={oidcLoading}
>
{oidcLoading
? t("admin.saving")
: t("admin.saveConfiguration")}
</Button>
<Button
type="button"
variant="outline"
onClick={async () => {
const emptyConfig = { const emptyConfig = {
client_id: '', client_id: "",
client_secret: '', client_secret: "",
issuer_url: '', issuer_url: "",
authorization_url: '', authorization_url: "",
token_url: '', token_url: "",
identifier_path: '', identifier_path: "",
name_path: '', name_path: "",
scopes: '', scopes: "",
userinfo_url: '' userinfo_url: "",
}; };
setOidcConfig(emptyConfig); setOidcConfig(emptyConfig);
setOidcError(null); setOidcError(null);
setOidcLoading(true); setOidcLoading(true);
try { try {
await disableOIDCConfig(); await disableOIDCConfig();
toast.success(t('admin.oidcConfigurationDisabled')); toast.success(t("admin.oidcConfigurationDisabled"));
} catch (err: any) { } catch (err: any) {
setOidcError(err?.response?.data?.error || t('admin.failedToDisableOidcConfig')); setOidcError(
err?.response?.data?.error ||
t("admin.failedToDisableOidcConfig"),
);
} finally { } finally {
setOidcLoading(false); setOidcLoading(false);
} }
}} disabled={oidcLoading}>{t('admin.reset')}</Button> }}
disabled={oidcLoading}
>
{t("admin.reset")}
</Button>
</div> </div>
</form> </form>
</div> </div>
@@ -394,21 +517,36 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TabsContent value="users" className="space-y-6"> <TabsContent value="users" className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{t('admin.userManagement')}</h3> <h3 className="text-lg font-semibold">
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline" {t("admin.userManagement")}
size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}</Button> </h3>
<Button
onClick={fetchUsers}
disabled={usersLoading}
variant="outline"
size="sm"
>
{usersLoading ? t("admin.loading") : t("admin.refresh")}
</Button>
</div> </div>
{usersLoading ? ( {usersLoading ? (
<div <div className="text-center py-8 text-muted-foreground">
className="text-center py-8 text-muted-foreground">{t('admin.loadingUsers')}</div> {t("admin.loadingUsers")}
</div>
) : ( ) : (
<div className="border rounded-md overflow-hidden"> <div className="border rounded-md overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="px-4">{t('admin.username')}</TableHead> <TableHead className="px-4">
<TableHead className="px-4">{t('admin.type')}</TableHead> {t("admin.username")}
<TableHead className="px-4">{t('admin.actions')}</TableHead> </TableHead>
<TableHead className="px-4">
{t("admin.type")}
</TableHead>
<TableHead className="px-4">
{t("admin.actions")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -417,17 +555,24 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TableCell className="px-4 font-medium"> <TableCell className="px-4 font-medium">
{user.username} {user.username}
{user.is_admin && ( {user.is_admin && (
<span <span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span> {t("admin.adminBadge")}
</span>
)} )}
</TableCell> </TableCell>
<TableCell
className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<Button variant="ghost" size="sm" {user.is_oidc
? t("admin.external")
: t("admin.local")}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteUser(user.username)} onClick={() => handleDeleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50" className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}> disabled={user.is_admin}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</TableCell> </TableCell>
@@ -442,57 +587,89 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TabsContent value="admins" className="space-y-6"> <TabsContent value="admins" className="space-y-6">
<div className="space-y-6"> <div className="space-y-6">
<h3 className="text-lg font-semibold">{t('admin.adminManagement')}</h3> <h3 className="text-lg font-semibold">
{t("admin.adminManagement")}
</h3>
<div className="space-y-4 p-6 border rounded-md bg-muted/50"> <div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">{t('admin.makeUserAdmin')}</h4> <h4 className="font-medium">{t("admin.makeUserAdmin")}</h4>
<form onSubmit={handleMakeUserAdmin} className="space-y-4"> <form onSubmit={handleMakeUserAdmin} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new-admin-username">{t('admin.username')}</Label> <Label htmlFor="new-admin-username">
{t("admin.username")}
</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input id="new-admin-username" value={newAdminUsername} <Input
id="new-admin-username"
value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)} onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder={t('admin.enterUsernameToMakeAdmin')} required/> placeholder={t("admin.enterUsernameToMakeAdmin")}
<Button type="submit" required
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')}</Button> />
<Button
type="submit"
disabled={
makeAdminLoading || !newAdminUsername.trim()
}
>
{makeAdminLoading
? t("admin.adding")
: t("admin.makeAdmin")}
</Button>
</div> </div>
</div> </div>
{makeAdminError && ( {makeAdminError && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle> <AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription> <AlertDescription>{makeAdminError}</AlertDescription>
</Alert> </Alert>
)} )}
</form> </form>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-medium">{t('admin.currentAdmins')}</h4> <h4 className="font-medium">{t("admin.currentAdmins")}</h4>
<div className="border rounded-md overflow-hidden"> <div className="border rounded-md overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="px-4">{t('admin.username')}</TableHead> <TableHead className="px-4">
<TableHead className="px-4">{t('admin.type')}</TableHead> {t("admin.username")}
<TableHead className="px-4">{t('admin.actions')}</TableHead> </TableHead>
<TableHead className="px-4">
{t("admin.type")}
</TableHead>
<TableHead className="px-4">
{t("admin.actions")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.filter(u => u.is_admin).map((admin) => ( {users
.filter((u) => u.is_admin)
.map((admin) => (
<TableRow key={admin.id}> <TableRow key={admin.id}>
<TableCell className="px-4 font-medium"> <TableCell className="px-4 font-medium">
{admin.username} {admin.username}
<span <span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span> {t("admin.adminBadge")}
</span>
</TableCell> </TableCell>
<TableCell
className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<Button variant="ghost" size="sm" {admin.is_oidc
onClick={() => handleRemoveAdminStatus(admin.username)} ? t("admin.external")
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"> : t("admin.local")}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRemoveAdminStatus(admin.username)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
>
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
{t('admin.removeAdminButton')} {t("admin.removeAdminButton")}
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -1,35 +1,50 @@
import {zodResolver} from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod";
import {Controller, useForm} from "react-hook-form" import { Controller, useForm } from "react-hook-form";
import {z} from "zod" import { z } from "zod";
import {Button} from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
} from "@/components/ui/form" } from "@/components/ui/form";
import {Input} from "@/components/ui/input" import { Input } from "@/components/ui/input";
import {PasswordInput} from "@/components/ui/password-input" import { PasswordInput } from "@/components/ui/password-input";
import {ScrollArea} from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area";
import {Separator} from "@/components/ui/separator" import { Separator } from "@/components/ui/separator";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import React, {useEffect, useRef, useState} from "react" import React, { useEffect, useRef, useState } from "react";
import {toast} from "sonner" import { toast } from "sonner";
import {createCredential, updateCredential, getCredentials, getCredentialDetails} from '@/ui/main-axios' import {
import {useTranslation} from "react-i18next" createCredential,
import type {Credential, CredentialEditorProps, CredentialData} from '../../../../types/index.js' updateCredential,
getCredentials,
getCredentialDetails,
} from "@/ui/main-axios";
import { useTranslation } from "react-i18next";
import type {
Credential,
CredentialEditorProps,
CredentialData,
} from "../../../../types/index.js";
export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEditorProps) { export function CredentialEditor({
editingCredential,
onFormSubmit,
}: CredentialEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]); const [credentials, setCredentials] = useState<Credential[]>([]);
const [folders, setFolders] = useState<string[]>([]); const [folders, setFolders] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fullCredentialDetails, setFullCredentialDetails] = useState<Credential | null>(null); const [fullCredentialDetails, setFullCredentialDetails] =
useState<Credential | null>(null);
const [authTab, setAuthTab] = useState<'password' | 'key'>('password'); const [authTab, setAuthTab] = useState<"password" | "key">("password");
const [keyInputMethod, setKeyInputMethod] = useState<'upload' | 'paste'>('upload'); const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
"upload",
);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -38,11 +53,16 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
const credentialsData = await getCredentials(); const credentialsData = await getCredentials();
setCredentials(credentialsData); setCredentials(credentialsData);
const uniqueFolders = [...new Set( const uniqueFolders = [
...new Set(
credentialsData credentialsData
.filter(credential => credential.folder && credential.folder.trim() !== '') .filter(
.map(credential => credential.folder!) (credential) =>
)].sort() as string[]; credential.folder && credential.folder.trim() !== "",
)
.map((credential) => credential.folder!),
),
].sort() as string[];
setFolders(uniqueFolders); setFolders(uniqueFolders);
} catch (error) { } catch (error) {
@@ -61,7 +81,7 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
const fullDetails = await getCredentialDetails(editingCredential.id); const fullDetails = await getCredentialDetails(editingCredential.id);
setFullCredentialDetails(fullDetails); setFullCredentialDetails(fullDetails);
} catch (error) { } catch (error) {
toast.error(t('credentials.failedToFetchCredentialDetails')); toast.error(t("credentials.failedToFetchCredentialDetails"));
} }
} else { } else {
setFullCredentialDetails(null); setFullCredentialDetails(null);
@@ -71,42 +91,46 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
fetchCredentialDetails(); fetchCredentialDetails();
}, [editingCredential, t]); }, [editingCredential, t]);
const formSchema = z.object({ const formSchema = z
.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
folder: z.string().optional(), folder: z.string().optional(),
tags: z.array(z.string().min(1)).default([]), tags: z.array(z.string().min(1)).default([]),
authType: z.enum(['password', 'key']), authType: z.enum(["password", "key"]),
username: z.string().min(1), username: z.string().min(1),
password: z.string().optional(), password: z.string().optional(),
key: z.any().optional().nullable(), key: z.any().optional().nullable(),
keyPassword: z.string().optional(), keyPassword: z.string().optional(),
keyType: z.enum([ keyType: z
'auto', .enum([
'ssh-rsa', "auto",
'ssh-ed25519', "ssh-rsa",
'ecdsa-sha2-nistp256', "ssh-ed25519",
'ecdsa-sha2-nistp384', "ecdsa-sha2-nistp256",
'ecdsa-sha2-nistp521', "ecdsa-sha2-nistp384",
'ssh-dss', "ecdsa-sha2-nistp521",
'ssh-rsa-sha2-256', "ssh-dss",
'ssh-rsa-sha2-512', "ssh-rsa-sha2-256",
]).optional(), "ssh-rsa-sha2-512",
}).superRefine((data, ctx) => { ])
if (data.authType === 'password') { .optional(),
if (!data.password || data.password.trim() === '') { })
.superRefine((data, ctx) => {
if (data.authType === "password") {
if (!data.password || data.password.trim() === "") {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: t('credentials.passwordRequired'), message: t("credentials.passwordRequired"),
path: ['password'] path: ["password"],
}); });
} }
} else if (data.authType === 'key') { } else if (data.authType === "key") {
if (!data.key && !editingCredential) { if (!data.key && !editingCredential) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: t('credentials.sshKeyRequired'), message: t("credentials.sshKeyRequired"),
path: ['key'] path: ["key"],
}); });
} }
} }
@@ -127,7 +151,7 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
key: null, key: null,
keyPassword: "", keyPassword: "",
keyType: "auto", keyType: "auto",
} },
}); });
useEffect(() => { useEffect(() => {
@@ -141,7 +165,7 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
description: fullCredentialDetails.description || "", description: fullCredentialDetails.description || "",
folder: fullCredentialDetails.folder || "", folder: fullCredentialDetails.folder || "",
tags: fullCredentialDetails.tags || [], tags: fullCredentialDetails.tags || [],
authType: defaultAuthType as 'password' | 'key', authType: defaultAuthType as "password" | "key",
username: fullCredentialDetails.username || "", username: fullCredentialDetails.username || "",
password: "", password: "",
key: null, key: null,
@@ -149,19 +173,20 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
keyType: "auto" as const, keyType: "auto" as const,
}; };
if (defaultAuthType === 'password') { if (defaultAuthType === "password") {
formData.password = fullCredentialDetails.password || ""; formData.password = fullCredentialDetails.password || "";
} else if (defaultAuthType === 'key') { } else if (defaultAuthType === "key") {
formData.key = "existing_key"; formData.key = "existing_key";
formData.keyPassword = fullCredentialDetails.keyPassword || ""; formData.keyPassword = fullCredentialDetails.keyPassword || "";
formData.keyType = (fullCredentialDetails.keyType as any) || "auto" as const; formData.keyType =
(fullCredentialDetails.keyType as any) || ("auto" as const);
} }
form.reset(formData); form.reset(formData);
setTagInput(""); setTagInput("");
}, 100); }, 100);
} else if (!editingCredential) { } else if (!editingCredential) {
setAuthTab('password'); setAuthTab("password");
form.reset({ form.reset({
name: "", name: "",
description: "", description: "",
@@ -180,7 +205,7 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
const onSubmit = async (data: FormData) => { const onSubmit = async (data: FormData) => {
try { try {
if (!data.name || data.name.trim() === '') { if (!data.name || data.name.trim() === "") {
data.name = data.username; data.name = data.username;
} }
@@ -191,7 +216,7 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
tags: data.tags, tags: data.tags,
authType: data.authType, authType: data.authType,
username: data.username, username: data.username,
keyType: data.keyType keyType: data.keyType,
}; };
submitData.password = null; submitData.password = null;
@@ -199,9 +224,9 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
submitData.keyPassword = null; submitData.keyPassword = null;
submitData.keyType = null; submitData.keyType = null;
if (data.authType === 'password') { if (data.authType === "password") {
submitData.password = data.password; submitData.password = data.password;
} else if (data.authType === 'key') { } else if (data.authType === "key") {
if (data.key instanceof File) { if (data.key instanceof File) {
const keyContent = await data.key.text(); const keyContent = await data.key.text();
submitData.key = keyContent; submitData.key = keyContent;
@@ -216,21 +241,25 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
if (editingCredential) { if (editingCredential) {
await updateCredential(editingCredential.id, submitData); await updateCredential(editingCredential.id, submitData);
toast.success(t('credentials.credentialUpdatedSuccessfully', {name: data.name})); toast.success(
t("credentials.credentialUpdatedSuccessfully", { name: data.name }),
);
} else { } else {
await createCredential(submitData); await createCredential(submitData);
toast.success(t('credentials.credentialAddedSuccessfully', {name: data.name})); toast.success(
t("credentials.credentialAddedSuccessfully", { name: data.name }),
);
} }
if (onFormSubmit) { if (onFormSubmit) {
onFormSubmit(); onFormSubmit();
} }
window.dispatchEvent(new CustomEvent('credentials:changed')); window.dispatchEvent(new CustomEvent("credentials:changed"));
form.reset(); form.reset();
} catch (error) { } catch (error) {
toast.error(t('credentials.failedToSaveCredential')); toast.error(t("credentials.failedToSaveCredential"));
} }
}; };
@@ -240,14 +269,16 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
const folderInputRef = useRef<HTMLInputElement>(null); const folderInputRef = useRef<HTMLInputElement>(null);
const folderDropdownRef = useRef<HTMLDivElement>(null); const folderDropdownRef = useRef<HTMLDivElement>(null);
const folderValue = form.watch('folder'); const folderValue = form.watch("folder");
const filteredFolders = React.useMemo(() => { const filteredFolders = React.useMemo(() => {
if (!folderValue) return folders; if (!folderValue) return folders;
return folders.filter(f => f.toLowerCase().includes(folderValue.toLowerCase())); return folders.filter((f) =>
f.toLowerCase().includes(folderValue.toLowerCase()),
);
}, [folderValue, folders]); }, [folderValue, folders]);
const handleFolderClick = (folder: string) => { const handleFolderClick = (folder: string) => {
form.setValue('folder', folder); form.setValue("folder", folder);
setFolderDropdownOpen(false); setFolderDropdownOpen(false);
}; };
@@ -264,26 +295,26 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
} }
if (folderDropdownOpen) { if (folderDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
} else { } else {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
} }
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
}; };
}, [folderDropdownOpen]); }, [folderDropdownOpen]);
const keyTypeOptions = [ const keyTypeOptions = [
{value: 'auto', label: t('hosts.autoDetect')}, { value: "auto", label: t("hosts.autoDetect") },
{value: 'ssh-rsa', label: t('hosts.rsa')}, { value: "ssh-rsa", label: t("hosts.rsa") },
{value: 'ssh-ed25519', label: t('hosts.ed25519')}, { value: "ssh-ed25519", label: t("hosts.ed25519") },
{value: 'ecdsa-sha2-nistp256', label: t('hosts.ecdsaNistP256')}, { value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") },
{value: 'ecdsa-sha2-nistp384', label: t('hosts.ecdsaNistP384')}, { value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") },
{value: 'ecdsa-sha2-nistp521', label: t('hosts.ecdsaNistP521')}, { value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") },
{value: 'ssh-dss', label: t('hosts.dsa')}, { value: "ssh-dss", label: t("hosts.dsa") },
{value: 'ssh-rsa-sha2-256', label: t('hosts.rsaSha2256')}, { value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") },
{value: 'ssh-rsa-sha2-512', label: t('hosts.rsaSha2512')}, { value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") },
]; ];
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false); const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
@@ -308,26 +339,41 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
}, [keyTypeDropdownOpen]); }, [keyTypeDropdownOpen]);
return ( return (
<div className="flex-1 flex flex-col h-full min-h-0 w-full" key={editingCredential?.id || 'new'}> <div
className="flex-1 flex flex-col h-full min-h-0 w-full"
key={editingCredential?.id || "new"}
>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 h-full"> <form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col flex-1 min-h-0 h-full"
>
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2"> <ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<Tabs defaultValue="general" className="w-full"> <Tabs defaultValue="general" className="w-full">
<TabsList> <TabsList>
<TabsTrigger value="general">{t('credentials.general')}</TabsTrigger> <TabsTrigger value="general">
<TabsTrigger value="authentication">{t('credentials.authentication')}</TabsTrigger> {t("credentials.general")}
</TabsTrigger>
<TabsTrigger value="authentication">
{t("credentials.authentication")}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="general" className="pt-2"> <TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">{t('credentials.basicInformation')}</FormLabel> <FormLabel className="mb-3 font-bold">
{t("credentials.basicInformation")}
</FormLabel>
<div className="grid grid-cols-12 gap-4"> <div className="grid grid-cols-12 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-6"> <FormItem className="col-span-6">
<FormLabel>{t('credentials.credentialName')}</FormLabel> <FormLabel>{t("credentials.credentialName")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder={t('placeholders.credentialName')} {...field} /> <Input
placeholder={t("placeholders.credentialName")}
{...field}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@@ -338,24 +384,32 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
name="username" name="username"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-6"> <FormItem className="col-span-6">
<FormLabel>{t('credentials.username')}</FormLabel> <FormLabel>{t("credentials.username")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder={t('placeholders.username')} {...field} /> <Input
placeholder={t("placeholders.username")}
{...field}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
<FormLabel className="mb-3 mt-3 font-bold">{t('credentials.organization')}</FormLabel> <FormLabel className="mb-3 mt-3 font-bold">
{t("credentials.organization")}
</FormLabel>
<div className="grid grid-cols-26 gap-4"> <div className="grid grid-cols-26 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-10"> <FormItem className="col-span-10">
<FormLabel>{t('credentials.description')}</FormLabel> <FormLabel>{t("credentials.description")}</FormLabel>
<FormControl> <FormControl>
<Input placeholder={t('placeholders.description')} {...field} /> <Input
placeholder={t("placeholders.description")}
{...field}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@@ -366,16 +420,16 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
name="folder" name="folder"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-10 relative"> <FormItem className="col-span-10 relative">
<FormLabel>{t('credentials.folder')}</FormLabel> <FormLabel>{t("credentials.folder")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
ref={folderInputRef} ref={folderInputRef}
placeholder={t('placeholders.folder')} placeholder={t("placeholders.folder")}
className="min-h-[40px]" className="min-h-[40px]"
autoComplete="off" autoComplete="off"
value={field.value} value={field.value}
onFocus={() => setFolderDropdownOpen(true)} onFocus={() => setFolderDropdownOpen(true)}
onChange={e => { onChange={(e) => {
field.onChange(e); field.onChange(e);
setFolderDropdownOpen(true); setFolderDropdownOpen(true);
}} }}
@@ -411,13 +465,15 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
name="tags" name="tags"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-10 overflow-visible"> <FormItem className="col-span-10 overflow-visible">
<FormLabel>{t('credentials.tags')}</FormLabel> <FormLabel>{t("credentials.tags")}</FormLabel>
<FormControl> <FormControl>
<div <div className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-dark-bg-input focus-within:ring-2 ring-ring min-h-[40px]">
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-dark-bg-input focus-within:ring-2 ring-ring min-h-[40px]"> {(field.value || []).map(
{(field.value || []).map((tag: string, idx: number) => ( (tag: string, idx: number) => (
<span key={`${tag}-${idx}`} <span
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs"> key={`${tag}-${idx}`}
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs"
>
{tag} {tag}
<button <button
type="button" type="button"
@@ -425,40 +481,58 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const newTags = (field.value || []).filter((_: string, i: number) => i !== idx); const newTags = (
field.value || []
).filter(
(_: string, i: number) => i !== idx,
);
field.onChange(newTags); field.onChange(newTags);
}} }}
> >
× ×
</button> </button>
</span> </span>
))} ),
)}
<input <input
type="text" type="text"
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6 text-sm" className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6 text-sm"
value={tagInput} value={tagInput}
onChange={e => setTagInput(e.target.value)} onChange={(e) => setTagInput(e.target.value)}
onKeyDown={e => { onKeyDown={(e) => {
if (e.key === " " && tagInput.trim() !== "") { if (e.key === " " && tagInput.trim() !== "") {
e.preventDefault(); e.preventDefault();
const currentTags = field.value || []; const currentTags = field.value || [];
if (!currentTags.includes(tagInput.trim())) { if (!currentTags.includes(tagInput.trim())) {
field.onChange([...currentTags, tagInput.trim()]); field.onChange([
...currentTags,
tagInput.trim(),
]);
} }
setTagInput(""); setTagInput("");
} else if (e.key === "Enter" && tagInput.trim() !== "") { } else if (
e.key === "Enter" &&
tagInput.trim() !== ""
) {
e.preventDefault(); e.preventDefault();
const currentTags = field.value || []; const currentTags = field.value || [];
if (!currentTags.includes(tagInput.trim())) { if (!currentTags.includes(tagInput.trim())) {
field.onChange([...currentTags, tagInput.trim()]); field.onChange([
...currentTags,
tagInput.trim(),
]);
} }
setTagInput(""); setTagInput("");
} else if (e.key === "Backspace" && tagInput === "" && (field.value || []).length > 0) { } else if (
e.key === "Backspace" &&
tagInput === "" &&
(field.value || []).length > 0
) {
const currentTags = field.value || []; const currentTags = field.value || [];
field.onChange(currentTags.slice(0, -1)); field.onChange(currentTags.slice(0, -1));
} }
}} }}
placeholder={t('credentials.addTagsSpaceToAdd')} placeholder={t("credentials.addTagsSpaceToAdd")}
/> />
</div> </div>
</FormControl> </FormControl>
@@ -468,28 +542,34 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="authentication"> <TabsContent value="authentication">
<FormLabel className="mb-3 font-bold">{t('credentials.authentication')}</FormLabel> <FormLabel className="mb-3 font-bold">
{t("credentials.authentication")}
</FormLabel>
<Tabs <Tabs
value={authTab} value={authTab}
onValueChange={(value) => { onValueChange={(value) => {
const newAuthType = value as 'password' | 'key'; const newAuthType = value as "password" | "key";
setAuthTab(newAuthType); setAuthTab(newAuthType);
form.setValue('authType', newAuthType); form.setValue("authType", newAuthType);
form.setValue('password', ''); form.setValue("password", "");
form.setValue('key', null); form.setValue("key", null);
form.setValue('keyPassword', ''); form.setValue("keyPassword", "");
form.setValue('keyType', 'auto'); form.setValue("keyType", "auto");
if (newAuthType === 'password') { if (newAuthType === "password") {
} else if (newAuthType === 'key') { } else if (newAuthType === "key") {
} }
}} }}
className="flex-1 flex flex-col h-full min-h-0" className="flex-1 flex flex-col h-full min-h-0"
> >
<TabsList> <TabsList>
<TabsTrigger value="password">{t('credentials.password')}</TabsTrigger> <TabsTrigger value="password">
<TabsTrigger value="key">{t('credentials.key')}</TabsTrigger> {t("credentials.password")}
</TabsTrigger>
<TabsTrigger value="key">
{t("credentials.key")}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="password"> <TabsContent value="password">
<FormField <FormField
@@ -497,10 +577,12 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('credentials.password')}</FormLabel> <FormLabel>{t("credentials.password")}</FormLabel>
<FormControl> <FormControl>
<PasswordInput <PasswordInput
placeholder={t('placeholders.password')} {...field} /> placeholder={t("placeholders.password")}
{...field}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@@ -510,19 +592,22 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
<Tabs <Tabs
value={keyInputMethod} value={keyInputMethod}
onValueChange={(value) => { onValueChange={(value) => {
setKeyInputMethod(value as 'upload' | 'paste'); setKeyInputMethod(value as "upload" | "paste");
if (value === 'upload') { if (value === "upload") {
form.setValue('key', null); form.setValue("key", null);
} else { } else {
form.setValue('key', ''); form.setValue("key", "");
} }
}} }}
className="w-full" className="w-full"
> >
<TabsList <TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground"> <TabsTrigger value="upload">
<TabsTrigger value="upload">{t('hosts.uploadFile')}</TabsTrigger> {t("hosts.uploadFile")}
<TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger> </TabsTrigger>
<TabsTrigger value="paste">
{t("hosts.pasteKey")}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="upload" className="mt-4"> <TabsContent value="upload" className="mt-4">
<Controller <Controller
@@ -530,7 +615,9 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
name="key" name="key"
render={({ field }) => ( render={({ field }) => (
<FormItem className="mb-4"> <FormItem className="mb-4">
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel> <FormLabel>
{t("credentials.sshPrivateKey")}
</FormLabel>
<FormControl> <FormControl>
<div className="relative inline-block"> <div className="relative inline-block">
<input <input
@@ -548,10 +635,20 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
variant="outline" variant="outline"
className="justify-start text-left" className="justify-start text-left"
> >
<span className="truncate" <span
title={field.value?.name || t('credentials.upload')}> className="truncate"
{field.value === "existing_key" ? t('hosts.existingKey') : title={
field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')} field.value?.name ||
t("credentials.upload")
}
>
{field.value === "existing_key"
? t("hosts.existingKey")
: field.value
? editingCredential
? t("credentials.updateKey")
: field.value.name
: t("credentials.upload")}
</span> </span>
</Button> </Button>
</div> </div>
@@ -565,10 +662,12 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
name="keyPassword" name="keyPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-8"> <FormItem className="col-span-8">
<FormLabel>{t('credentials.keyPassword')}</FormLabel> <FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl> <FormControl>
<PasswordInput <PasswordInput
placeholder={t('placeholders.keyPassword')} placeholder={t("placeholders.keyPassword")}
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -580,7 +679,9 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
name="keyType" name="keyType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="relative col-span-3"> <FormItem className="relative col-span-3">
<FormLabel>{t('credentials.keyType')}</FormLabel> <FormLabel>
{t("credentials.keyType")}
</FormLabel>
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<Button <Button
@@ -588,17 +689,20 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
type="button" type="button"
variant="outline" variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground" className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
onClick={() => setKeyTypeDropdownOpen((open) => !open)} onClick={() =>
setKeyTypeDropdownOpen((open) => !open)
}
> >
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('credentials.keyTypeRSA')} {keyTypeOptions.find(
(opt) => opt.value === field.value,
)?.label || t("credentials.keyTypeRSA")}
</Button> </Button>
{keyTypeDropdownOpen && ( {keyTypeDropdownOpen && (
<div <div
ref={keyTypeDropdownRef} ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1" className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
> >
<div <div className="grid grid-cols-1 gap-1 p-0">
className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => ( {keyTypeOptions.map((opt) => (
<Button <Button
key={opt.value} key={opt.value}
@@ -630,13 +734,23 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
name="key" name="key"
render={({ field }) => ( render={({ field }) => (
<FormItem className="mb-4"> <FormItem className="mb-4">
<FormLabel>{t('credentials.sshPrivateKey')}</FormLabel> <FormLabel>
{t("credentials.sshPrivateKey")}
</FormLabel>
<FormControl> <FormControl>
<textarea <textarea
placeholder={t('placeholders.pastePrivateKey')} placeholder={t(
"placeholders.pastePrivateKey",
)}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={typeof field.value === 'string' ? field.value : ''} value={
onChange={(e) => field.onChange(e.target.value)} typeof field.value === "string"
? field.value
: ""
}
onChange={(e) =>
field.onChange(e.target.value)
}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
@@ -648,10 +762,12 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
name="keyPassword" name="keyPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-8"> <FormItem className="col-span-8">
<FormLabel>{t('credentials.keyPassword')}</FormLabel> <FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl> <FormControl>
<PasswordInput <PasswordInput
placeholder={t('placeholders.keyPassword')} placeholder={t("placeholders.keyPassword")}
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -663,7 +779,9 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
name="keyType" name="keyType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="relative col-span-3"> <FormItem className="relative col-span-3">
<FormLabel>{t('credentials.keyType')}</FormLabel> <FormLabel>
{t("credentials.keyType")}
</FormLabel>
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<Button <Button
@@ -671,17 +789,20 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
type="button" type="button"
variant="outline" variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground" className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
onClick={() => setKeyTypeDropdownOpen((open) => !open)} onClick={() =>
setKeyTypeDropdownOpen((open) => !open)
}
> >
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('credentials.keyTypeRSA')} {keyTypeOptions.find(
(opt) => opt.value === field.value,
)?.label || t("credentials.keyTypeRSA")}
</Button> </Button>
{keyTypeDropdownOpen && ( {keyTypeDropdownOpen && (
<div <div
ref={keyTypeDropdownRef} ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1" className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
> >
<div <div className="grid grid-cols-1 gap-1 p-0">
className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => ( {keyTypeOptions.map((opt) => (
<Button <Button
key={opt.value} key={opt.value}
@@ -715,12 +836,10 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
</ScrollArea> </ScrollArea>
<footer className="shrink-0 w-full pb-0"> <footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" /> <Separator className="p-0.25" />
<Button <Button className="translate-y-2" type="submit" variant="outline">
className="translate-y-2" {editingCredential
type="submit" ? t("credentials.updateCredential")
variant="outline" : t("credentials.addCredential")}
>
{editingCredential ? t('credentials.updateCredential') : t('credentials.addCredential')}
</Button> </Button>
</footer> </footer>
</form> </form>
@@ -1,10 +1,10 @@
import React, {useState, useEffect, useRef} from 'react'; import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx"; import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
import {getCredentials} from '@/ui/main-axios.ts'; import { getCredentials } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type {Credential} from '../../../../types'; import type { Credential } from "../../../../types";
interface CredentialSelectorProps { interface CredentialSelectorProps {
value?: number | null; value?: number | null;
@@ -12,12 +12,16 @@ interface CredentialSelectorProps {
onCredentialSelect?: (credential: Credential | null) => void; onCredentialSelect?: (credential: Credential | null) => void;
} }
export function CredentialSelector({value, onValueChange, onCredentialSelect}: CredentialSelectorProps) { export function CredentialSelector({
value,
onValueChange,
onCredentialSelect,
}: CredentialSelectorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]); const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState("");
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -27,11 +31,13 @@ export function CredentialSelector({value, onValueChange, onCredentialSelect}: C
try { try {
setLoading(true); setLoading(true);
const data = await getCredentials(); const data = await getCredentials();
const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []); const credentialsArray = Array.isArray(data)
? data
: data.credentials || data.data || [];
setCredentials(credentialsArray); setCredentials(credentialsArray);
} catch (error) { } catch (error) {
const {toast} = await import('sonner'); const { toast } = await import("sonner");
toast.error(t('credentials.failedToFetchCredentials')); toast.error(t("credentials.failedToFetchCredentials"));
setCredentials([]); setCredentials([]);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -54,25 +60,26 @@ export function CredentialSelector({value, onValueChange, onCredentialSelect}: C
} }
if (dropdownOpen) { if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
} else { } else {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
} }
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
}; };
}, [dropdownOpen]); }, [dropdownOpen]);
const selectedCredential = credentials.find(c => c.id === value); const selectedCredential = credentials.find((c) => c.id === value);
const filteredCredentials = credentials.filter(credential => { const filteredCredentials = credentials.filter((credential) => {
if (!searchQuery) return true; if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase(); const searchLower = searchQuery.toLowerCase();
return ( return (
credential.name.toLowerCase().includes(searchLower) || credential.name.toLowerCase().includes(searchLower) ||
credential.username.toLowerCase().includes(searchLower) || credential.username.toLowerCase().includes(searchLower) ||
(credential.folder && credential.folder.toLowerCase().includes(searchLower)) (credential.folder &&
credential.folder.toLowerCase().includes(searchLower))
); );
}); });
@@ -82,7 +89,7 @@ export function CredentialSelector({value, onValueChange, onCredentialSelect}: C
onCredentialSelect(credential); onCredentialSelect(credential);
} }
setDropdownOpen(false); setDropdownOpen(false);
setSearchQuery(''); setSearchQuery("");
}; };
const handleClear = () => { const handleClear = () => {
@@ -91,12 +98,12 @@ export function CredentialSelector({value, onValueChange, onCredentialSelect}: C
onCredentialSelect(null); onCredentialSelect(null);
} }
setDropdownOpen(false); setDropdownOpen(false);
setSearchQuery(''); setSearchQuery("");
}; };
return ( return (
<FormItem> <FormItem>
<FormLabel>{t('hosts.selectCredential')}</FormLabel> <FormLabel>{t("hosts.selectCredential")}</FormLabel>
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<Button <Button
@@ -107,11 +114,13 @@ export function CredentialSelector({value, onValueChange, onCredentialSelect}: C
onClick={() => setDropdownOpen(!dropdownOpen)} onClick={() => setDropdownOpen(!dropdownOpen)}
> >
{loading ? ( {loading ? (
t('common.loading') t("common.loading")
) : value === "existing_credential" ? ( ) : value === "existing_credential" ? (
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div> <div>
<span className="font-medium">{t('hosts.existingCredential')}</span> <span className="font-medium">
{t("hosts.existingCredential")}
</span>
</div> </div>
</div> </div>
) : selectedCredential ? ( ) : selectedCredential ? (
@@ -119,15 +128,26 @@ export function CredentialSelector({value, onValueChange, onCredentialSelect}: C
<div> <div>
<span className="font-medium">{selectedCredential.name}</span> <span className="font-medium">{selectedCredential.name}</span>
<span className="text-sm text-muted-foreground ml-2"> <span className="text-sm text-muted-foreground ml-2">
({selectedCredential.username} {selectedCredential.authType}) ({selectedCredential.username} {" "}
{selectedCredential.authType})
</span> </span>
</div> </div>
</div> </div>
) : ( ) : (
t('hosts.selectCredentialPlaceholder') t("hosts.selectCredentialPlaceholder")
)} )}
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7"/> className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</Button> </Button>
@@ -138,7 +158,7 @@ export function CredentialSelector({value, onValueChange, onCredentialSelect}: C
> >
<div className="p-2 border-b border-border"> <div className="p-2 border-b border-border">
<Input <Input
placeholder={t('credentials.searchCredentials')} placeholder={t("credentials.searchCredentials")}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="h-8" className="h-8"
@@ -148,11 +168,13 @@ export function CredentialSelector({value, onValueChange, onCredentialSelect}: C
<div className="max-h-60 overflow-y-auto p-2"> <div className="max-h-60 overflow-y-auto p-2">
{loading ? ( {loading ? (
<div className="p-3 text-center text-sm text-muted-foreground"> <div className="p-3 text-center text-sm text-muted-foreground">
{t('common.loading')} {t("common.loading")}
</div> </div>
) : filteredCredentials.length === 0 ? ( ) : filteredCredentials.length === 0 ? (
<div className="p-3 text-center text-sm text-muted-foreground"> <div className="p-3 text-center text-sm text-muted-foreground">
{searchQuery ? t('credentials.noCredentialsMatchFilters') : t('credentials.noCredentialsYet')} {searchQuery
? t("credentials.noCredentialsMatchFilters")
: t("credentials.noCredentialsYet")}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-2.5"> <div className="grid grid-cols-1 gap-2.5">
@@ -164,7 +186,7 @@ export function CredentialSelector({value, onValueChange, onCredentialSelect}: C
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200" className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
onClick={handleClear} onClick={handleClear}
> >
{t('common.clear')} {t("common.clear")}
</Button> </Button>
)} )}
{filteredCredentials.map((credential) => ( {filteredCredentials.map((credential) => (
@@ -174,17 +196,20 @@ export function CredentialSelector({value, onValueChange, onCredentialSelect}: C
variant="ghost" variant="ghost"
size="sm" size="sm"
className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${ className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
credential.id === value ? 'bg-muted' : '' credential.id === value ? "bg-muted" : ""
}`} }`}
onClick={() => handleCredentialSelect(credential)} onClick={() => handleCredentialSelect(credential)}
> >
<div className="w-full"> <div className="w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium">{credential.name}</span> <span className="font-medium">
{credential.name}
</span>
</div> </div>
<div className="text-xs text-muted-foreground mt-0.5"> <div className="text-xs text-muted-foreground mt-0.5">
{credential.username} {credential.authType} {credential.username} {credential.authType}
{credential.description && `${credential.description}`} {credential.description &&
`${credential.description}`}
</div> </div>
</div> </div>
</Button> </Button>
@@ -192,7 +217,6 @@ export function CredentialSelector({value, onValueChange, onCredentialSelect}: C
</div> </div>
)} )}
</div> </div>
</div> </div>
)} )}
</div> </div>
@@ -1,10 +1,22 @@
import React, {useState, useEffect} from 'react'; import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import {Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet"; import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { import {
Key, Key,
User, User,
@@ -20,20 +32,34 @@ import {
EyeOff, EyeOff,
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
FileText FileText,
} from 'lucide-react'; } from "lucide-react";
import {getCredentialDetails, getCredentialHosts} from '@/ui/main-axios'; import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios";
import {toast} from 'sonner'; import { toast } from "sonner";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import type {Credential, HostInfo, CredentialViewerProps} from '../../../types/index.js'; import type {
Credential,
HostInfo,
CredentialViewerProps,
} from "../../../types/index.js";
const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose, onEdit}) => { const CredentialViewer: React.FC<CredentialViewerProps> = ({
credential,
onClose,
onEdit,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(null); const [credentialDetails, setCredentialDetails] = useState<Credential | null>(
null,
);
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]); const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>({}); const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>(
const [activeTab, setActiveTab] = useState<'overview' | 'security' | 'usage'>('overview'); {},
);
const [activeTab, setActiveTab] = useState<"overview" | "security" | "usage">(
"overview",
);
useEffect(() => { useEffect(() => {
fetchCredentialDetails(); fetchCredentialDetails();
@@ -45,7 +71,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
const response = await getCredentialDetails(credential.id); const response = await getCredentialDetails(credential.id);
setCredentialDetails(response); setCredentialDetails(response);
} catch (error) { } catch (error) {
toast.error(t('credentials.failedToFetchCredentialDetails')); toast.error(t("credentials.failedToFetchCredentialDetails"));
} }
}; };
@@ -54,25 +80,25 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
const response = await getCredentialHosts(credential.id); const response = await getCredentialHosts(credential.id);
setHostsUsing(response); setHostsUsing(response);
} catch (error) { } catch (error) {
toast.error(t('credentials.failedToFetchHostsUsing')); toast.error(t("credentials.failedToFetchHostsUsing"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const toggleSensitiveVisibility = (field: string) => { const toggleSensitiveVisibility = (field: string) => {
setShowSensitive(prev => ({ setShowSensitive((prev) => ({
...prev, ...prev,
[field]: !prev[field] [field]: !prev[field],
})); }));
}; };
const copyToClipboard = async (text: string, fieldName: string) => { const copyToClipboard = async (text: string, fieldName: string) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
toast.success(t('copiedToClipboard', {field: fieldName})); toast.success(t("copiedToClipboard", { field: fieldName }));
} catch (error) { } catch (error) {
toast.error(t('credentials.failedToCopy')); toast.error(t("credentials.failedToCopy"));
} }
}; };
@@ -81,7 +107,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
}; };
const getAuthIcon = (authType: string) => { const getAuthIcon = (authType: string) => {
return authType === 'password' ? ( return authType === "password" ? (
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" /> <Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
) : ( ) : (
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" /> <Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
@@ -92,7 +118,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
value: string | undefined, value: string | undefined,
fieldName: string, fieldName: string,
label: string, label: string,
isMultiline = false isMultiline = false,
) => { ) => {
if (!value) return null; if (!value) return null;
@@ -110,7 +136,11 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
size="sm" size="sm"
onClick={() => toggleSensitiveVisibility(fieldName)} onClick={() => toggleSensitiveVisibility(fieldName)}
> >
{isVisible ? <EyeOff className="h-4 w-4"/> : <Eye className="h-4 w-4"/>} {isVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -121,15 +151,18 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
</Button> </Button>
</div> </div>
</div> </div>
<div className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}> <div
className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? "" : "min-h-[2.5rem]"}`}
>
{isVisible ? ( {isVisible ? (
<pre <pre
className={`text-sm ${isMultiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'} font-mono`}> className={`text-sm ${isMultiline ? "whitespace-pre-wrap" : "whitespace-nowrap"} font-mono`}
>
{value} {value}
</pre> </pre>
) : ( ) : (
<div className="text-sm text-zinc-500 dark:text-zinc-400"> <div className="text-sm text-zinc-500 dark:text-zinc-400">
{'•'.repeat(isMultiline ? 50 : 20)} {"•".repeat(isMultiline ? 50 : 20)}
</div> </div>
)} )}
</div> </div>
@@ -158,19 +191,25 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
{getAuthIcon(credentialDetails.authType)} {getAuthIcon(credentialDetails.authType)}
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="text-xl font-semibold">{credentialDetails.name}</div> <div className="text-xl font-semibold">
{credentialDetails.name}
</div>
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1"> <div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
{credentialDetails.description} {credentialDetails.description}
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Badge variant="outline" <Badge
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"> variant="outline"
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"
>
{credentialDetails.authType} {credentialDetails.authType}
</Badge> </Badge>
{credentialDetails.keyType && ( {credentialDetails.keyType && (
<Badge variant="secondary" <Badge
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"> variant="secondary"
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
>
{credentialDetails.keyType} {credentialDetails.keyType}
</Badge> </Badge>
)} )}
@@ -180,44 +219,44 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
<div className="space-y-10"> <div className="space-y-10">
{/* Tab Navigation */} {/* Tab Navigation */}
<div <div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
<Button <Button
variant={activeTab === 'overview' ? 'default' : 'ghost'} variant={activeTab === "overview" ? "default" : "ghost"}
size="sm" size="sm"
onClick={() => setActiveTab('overview')} onClick={() => setActiveTab("overview")}
className="flex-1 h-10" className="flex-1 h-10"
> >
<FileText className="h-4 w-4 mr-2" /> <FileText className="h-4 w-4 mr-2" />
{t('credentials.overview')} {t("credentials.overview")}
</Button> </Button>
<Button <Button
variant={activeTab === 'security' ? 'default' : 'ghost'} variant={activeTab === "security" ? "default" : "ghost"}
size="sm" size="sm"
onClick={() => setActiveTab('security')} onClick={() => setActiveTab("security")}
className="flex-1 h-10" className="flex-1 h-10"
> >
<Shield className="h-4 w-4 mr-2" /> <Shield className="h-4 w-4 mr-2" />
{t('credentials.security')} {t("credentials.security")}
</Button> </Button>
<Button <Button
variant={activeTab === 'usage' ? 'default' : 'ghost'} variant={activeTab === "usage" ? "default" : "ghost"}
size="sm" size="sm"
onClick={() => setActiveTab('usage')} onClick={() => setActiveTab("usage")}
className="flex-1 h-10" className="flex-1 h-10"
> >
<Server className="h-4 w-4 mr-2" /> <Server className="h-4 w-4 mr-2" />
{t('credentials.usage')} {t("credentials.usage")}
</Button> </Button>
</div> </div>
{/* Tab Content */} {/* Tab Content */}
{activeTab === 'overview' && ( {activeTab === "overview" && (
<div className="grid gap-10 lg:grid-cols-2"> <div className="grid gap-10 lg:grid-cols-2">
<Card className="border-zinc-200 dark:border-zinc-700"> <Card className="border-zinc-200 dark:border-zinc-700">
<CardHeader className="pb-8"> <CardHeader className="pb-8">
<CardTitle <CardTitle className="text-lg font-semibold">
className="text-lg font-semibold">{t('credentials.basicInformation')}</CardTitle> {t("credentials.basicInformation")}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-8"> <CardContent className="space-y-8">
<div className="flex items-center space-x-5"> <div className="flex items-center space-x-5">
@@ -225,10 +264,12 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" /> <User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
</div> </div>
<div> <div>
<div <div className="text-sm text-zinc-500 dark:text-zinc-400">
className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.username')}</div> {t("common.username")}
<div </div>
className="font-medium text-zinc-800 dark:text-zinc-200">{credentialDetails.username}</div> <div className="font-medium text-zinc-800 dark:text-zinc-200">
{credentialDetails.username}
</div>
</div> </div>
</div> </div>
@@ -236,9 +277,12 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" /> <Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<div> <div>
<div <div className="text-sm text-zinc-500 dark:text-zinc-400">
className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.folder')}</div> {t("common.folder")}
<div className="font-medium">{credentialDetails.folder}</div> </div>
<div className="font-medium">
{credentialDetails.folder}
</div>
</div> </div>
</div> </div>
)} )}
@@ -247,11 +291,16 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
<div className="flex items-start space-x-4"> <div className="flex items-start space-x-4">
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" /> <Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
<div className="flex-1"> <div className="flex-1">
<div <div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">
className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">{t('hosts.tags')}</div> {t("hosts.tags")}
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{credentialDetails.tags.map((tag, index) => ( {credentialDetails.tags.map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs"> <Badge
key={index}
variant="outline"
className="text-xs"
>
{tag} {tag}
</Badge> </Badge>
))} ))}
@@ -265,18 +314,24 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" /> <Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<div> <div>
<div <div className="text-sm text-zinc-500 dark:text-zinc-400">
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.created')}</div> {t("credentials.created")}
<div className="font-medium">{formatDate(credentialDetails.createdAt)}</div> </div>
<div className="font-medium">
{formatDate(credentialDetails.createdAt)}
</div>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" /> <Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<div> <div>
<div <div className="text-sm text-zinc-500 dark:text-zinc-400">
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastModified')}</div> {t("credentials.lastModified")}
<div className="font-medium">{formatDate(credentialDetails.updatedAt)}</div> </div>
<div className="font-medium">
{formatDate(credentialDetails.updatedAt)}
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -284,7 +339,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">{t('credentials.usageStatistics')}</CardTitle> <CardTitle className="text-lg">
{t("credentials.usageStatistics")}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg"> <div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
@@ -292,29 +349,30 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
{credentialDetails.usageCount} {credentialDetails.usageCount}
</div> </div>
<div className="text-sm text-zinc-600 dark:text-zinc-400"> <div className="text-sm text-zinc-600 dark:text-zinc-400">
{t('credentials.timesUsed')} {t("credentials.timesUsed")}
</div> </div>
</div> </div>
{credentialDetails.lastUsed && ( {credentialDetails.lastUsed && (
<div <div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" /> <Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<div> <div>
<div <div className="text-sm text-zinc-500 dark:text-zinc-400">
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastUsed')}</div> {t("credentials.lastUsed")}
<div </div>
className="font-medium">{formatDate(credentialDetails.lastUsed)}</div> <div className="font-medium">
{formatDate(credentialDetails.lastUsed)}
</div>
</div> </div>
</div> </div>
)} )}
<div <div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" /> <Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<div> <div>
<div <div className="text-sm text-zinc-500 dark:text-zinc-400">
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.connectedHosts')}</div> {t("credentials.connectedHosts")}
</div>
<div className="font-medium">{hostsUsing.length}</div> <div className="font-medium">{hostsUsing.length}</div>
</div> </div>
</div> </div>
@@ -323,73 +381,85 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
</div> </div>
)} )}
{activeTab === 'security' && ( {activeTab === "security" && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center space-x-2"> <CardTitle className="text-lg flex items-center space-x-2">
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" /> <Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<span>{t('credentials.securityDetails')}</span> <span>{t("credentials.securityDetails")}</span>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t('credentials.securityDetailsDescription')} {t("credentials.securityDetailsDescription")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div <div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" /> <CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
<div> <div>
<div className="font-medium text-zinc-800 dark:text-zinc-200"> <div className="font-medium text-zinc-800 dark:text-zinc-200">
{t('credentials.credentialSecured')} {t("credentials.credentialSecured")}
</div> </div>
<div className="text-sm text-zinc-700 dark:text-zinc-300"> <div className="text-sm text-zinc-700 dark:text-zinc-300">
{t('credentials.credentialSecuredDescription')} {t("credentials.credentialSecuredDescription")}
</div> </div>
</div> </div>
</div> </div>
{credentialDetails.authType === 'password' && ( {credentialDetails.authType === "password" && (
<div> <div>
<h3 className="font-semibold mb-4">{t('credentials.passwordAuthentication')}</h3> <h3 className="font-semibold mb-4">
{renderSensitiveField(credentialDetails.password, 'password', t('common.password'))} {t("credentials.passwordAuthentication")}
</h3>
{renderSensitiveField(
credentialDetails.password,
"password",
t("common.password"),
)}
</div> </div>
)} )}
{credentialDetails.authType === 'key' && ( {credentialDetails.authType === "key" && (
<div className="space-y-6"> <div className="space-y-6">
<h3 className="font-semibold mb-2">{t('credentials.keyAuthentication')}</h3> <h3 className="font-semibold mb-2">
{t("credentials.keyAuthentication")}
</h3>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div> <div>
<div <div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3"> {t("credentials.keyType")}
{t('credentials.keyType')}
</div> </div>
<Badge variant="outline" className="text-sm"> <Badge variant="outline" className="text-sm">
{credentialDetails.keyType?.toUpperCase() || t('unknown').toUpperCase()} {credentialDetails.keyType?.toUpperCase() ||
t("unknown").toUpperCase()}
</Badge> </Badge>
</div> </div>
</div> </div>
{renderSensitiveField(credentialDetails.key, 'key', t('credentials.privateKey'), true)} {renderSensitiveField(
credentialDetails.key,
"key",
t("credentials.privateKey"),
true,
)}
{credentialDetails.keyPassword && renderSensitiveField( {credentialDetails.keyPassword &&
renderSensitiveField(
credentialDetails.keyPassword, credentialDetails.keyPassword,
'keyPassword', "keyPassword",
t('credentials.keyPassphrase') t("credentials.keyPassphrase"),
)} )}
</div> </div>
)} )}
<div <div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" /> <AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
<div className="text-sm"> <div className="text-sm">
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2"> <div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
{t('credentials.securityReminder')} {t("credentials.securityReminder")}
</div> </div>
<div className="text-zinc-700 dark:text-zinc-300"> <div className="text-zinc-700 dark:text-zinc-300">
{t('credentials.securityReminderText')} {t("credentials.securityReminderText")}
</div> </div>
</div> </div>
</div> </div>
@@ -397,12 +467,12 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
</Card> </Card>
)} )}
{activeTab === 'usage' && ( {activeTab === "usage" && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center space-x-2"> <CardTitle className="text-lg flex items-center space-x-2">
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" /> <Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<span>{t('credentials.hostsUsingCredential')}</span> <span>{t("credentials.hostsUsingCredential")}</span>
<Badge variant="secondary">{hostsUsing.length}</Badge> <Badge variant="secondary">{hostsUsing.length}</Badge>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -410,7 +480,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
{hostsUsing.length === 0 ? ( {hostsUsing.length === 0 ? (
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400"> <div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" /> <Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
<p>{t('credentials.noHostsUsingCredential')}</p> <p>{t("credentials.noHostsUsingCredential")}</p>
</div> </div>
) : ( ) : (
<ScrollArea className="h-64"> <ScrollArea className="h-64">
@@ -422,8 +492,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded"> <div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
<Server <Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
className="h-4 w-4 text-zinc-600 dark:text-zinc-400"/>
</div> </div>
<div> <div>
<div className="font-medium"> <div className="font-medium">
@@ -434,8 +503,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
</div> </div>
</div> </div>
</div> </div>
<div <div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
className="text-right text-sm text-zinc-500 dark:text-zinc-400">
{formatDate(host.createdAt)} {formatDate(host.createdAt)}
</div> </div>
</div> </div>
@@ -450,11 +518,11 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
<SheetFooter> <SheetFooter>
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
{t('common.close')} {t("common.close")}
</Button> </Button>
<Button onClick={onEdit}> <Button onClick={onEdit}>
<Edit3 className="h-4 w-4 mr-2" /> <Edit3 className="h-4 w-4 mr-2" />
{t('credentials.editCredential')} {t("credentials.editCredential")}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
@@ -1,10 +1,20 @@
import React, {useState, useEffect, useMemo, useRef} from 'react'; import React, { useState, useEffect, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; import {
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { import {
Search, Search,
Key, Key,
@@ -18,25 +28,39 @@ import {
FolderMinus, FolderMinus,
Pencil, Pencil,
X, X,
Check Check,
} from 'lucide-react'; } from "lucide-react";
import {getCredentials, deleteCredential, updateCredential, renameCredentialFolder} from '@/ui/main-axios'; import {
import {toast} from 'sonner'; getCredentials,
import {useTranslation} from 'react-i18next'; deleteCredential,
import {useConfirmation} from '@/hooks/use-confirmation.ts'; updateCredential,
import CredentialViewer from './CredentialViewer'; renameCredentialFolder,
import type {Credential, CredentialsManagerProps} from '../../../../types/index.js'; } from "@/ui/main-axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import CredentialViewer from "./CredentialViewer";
import type {
Credential,
CredentialsManagerProps,
} from "../../../../types/index.js";
export function CredentialsManager({onEditCredential}: CredentialsManagerProps) { export function CredentialsManager({
onEditCredential,
}: CredentialsManagerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
const [credentials, setCredentials] = useState<Credential[]>([]); const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState("");
const [showViewer, setShowViewer] = useState(false); const [showViewer, setShowViewer] = useState(false);
const [viewingCredential, setViewingCredential] = useState<Credential | null>(null); const [viewingCredential, setViewingCredential] = useState<Credential | null>(
const [draggedCredential, setDraggedCredential] = useState<Credential | null>(null); null,
);
const [draggedCredential, setDraggedCredential] = useState<Credential | null>(
null,
);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null); const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const [editingFolder, setEditingFolder] = useState<string | null>(null); const [editingFolder, setEditingFolder] = useState<string | null>(null);
const [editingFolderName, setEditingFolderName] = useState(""); const [editingFolderName, setEditingFolderName] = useState("");
@@ -54,82 +78,94 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
setCredentials(data); setCredentials(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(t('credentials.failedToFetchCredentials')); setError(t("credentials.failedToFetchCredentials"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleEdit = (credential: Credential) => { const handleEdit = (credential: Credential) => {
if (onEditCredential) { if (onEditCredential) {
onEditCredential(credential); onEditCredential(credential);
} }
}; };
const handleDelete = async (credentialId: number, credentialName: string) => { const handleDelete = async (credentialId: number, credentialName: string) => {
confirmWithToast( confirmWithToast(
t('credentials.confirmDeleteCredential', {name: credentialName}), t("credentials.confirmDeleteCredential", { name: credentialName }),
async () => { async () => {
try { try {
await deleteCredential(credentialId); await deleteCredential(credentialId);
toast.success(t('credentials.credentialDeletedSuccessfully', {name: credentialName})); toast.success(
t("credentials.credentialDeletedSuccessfully", {
name: credentialName,
}),
);
await fetchCredentials(); await fetchCredentials();
window.dispatchEvent(new CustomEvent('credentials:changed')); window.dispatchEvent(new CustomEvent("credentials:changed"));
} catch (err: any) { } catch (err: any) {
if (err.response?.data?.details) { if (err.response?.data?.details) {
toast.error(`${err.response.data.error}\n${err.response.data.details}`); toast.error(
`${err.response.data.error}\n${err.response.data.details}`,
);
} else { } else {
toast.error(t('credentials.failedToDeleteCredential')); toast.error(t("credentials.failedToDeleteCredential"));
} }
} }
}, },
'destructive' "destructive",
); );
}; };
const handleRemoveFromFolder = async (credential: Credential) => { const handleRemoveFromFolder = async (credential: Credential) => {
confirmWithToast( confirmWithToast(
t('credentials.confirmRemoveFromFolder', { t("credentials.confirmRemoveFromFolder", {
name: credential.name || credential.username, name: credential.name || credential.username,
folder: credential.folder folder: credential.folder,
}), }),
async () => { async () => {
try { try {
setOperationLoading(true); setOperationLoading(true);
const updatedCredential = {...credential, folder: ''}; const updatedCredential = { ...credential, folder: "" };
await updateCredential(credential.id, updatedCredential); await updateCredential(credential.id, updatedCredential);
toast.success(t('credentials.removedFromFolder', {name: credential.name || credential.username})); toast.success(
t("credentials.removedFromFolder", {
name: credential.name || credential.username,
}),
);
await fetchCredentials(); await fetchCredentials();
window.dispatchEvent(new CustomEvent('credentials:changed')); window.dispatchEvent(new CustomEvent("credentials:changed"));
} catch (err) { } catch (err) {
toast.error(t('credentials.failedToRemoveFromFolder')); toast.error(t("credentials.failedToRemoveFromFolder"));
} finally { } finally {
setOperationLoading(false); setOperationLoading(false);
} }
} },
); );
}; };
const handleFolderRename = async (oldName: string) => { const handleFolderRename = async (oldName: string) => {
if (!editingFolderName.trim() || editingFolderName === oldName) { if (!editingFolderName.trim() || editingFolderName === oldName) {
setEditingFolder(null); setEditingFolder(null);
setEditingFolderName(''); setEditingFolderName("");
return; return;
} }
try { try {
setOperationLoading(true); setOperationLoading(true);
await renameCredentialFolder(oldName, editingFolderName.trim()); await renameCredentialFolder(oldName, editingFolderName.trim());
toast.success(t('credentials.folderRenamed', {oldName, newName: editingFolderName.trim()})); toast.success(
t("credentials.folderRenamed", {
oldName,
newName: editingFolderName.trim(),
}),
);
await fetchCredentials(); await fetchCredentials();
window.dispatchEvent(new CustomEvent('credentials:changed')); window.dispatchEvent(new CustomEvent("credentials:changed"));
setEditingFolder(null); setEditingFolder(null);
setEditingFolderName(''); setEditingFolderName("");
} catch (err) { } catch (err) {
toast.error(t('credentials.failedToRenameFolder')); toast.error(t("credentials.failedToRenameFolder"));
} finally { } finally {
setOperationLoading(false); setOperationLoading(false);
} }
@@ -142,13 +178,13 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
const cancelFolderEdit = () => { const cancelFolderEdit = () => {
setEditingFolder(null); setEditingFolder(null);
setEditingFolderName(''); setEditingFolderName("");
}; };
const handleDragStart = (e: React.DragEvent, credential: Credential) => { const handleDragStart = (e: React.DragEvent, credential: Credential) => {
setDraggedCredential(credential); setDraggedCredential(credential);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData('text/plain', ''); e.dataTransfer.setData("text/plain", "");
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
@@ -159,7 +195,7 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = "move";
}; };
const handleDragEnter = (e: React.DragEvent, folderName: string) => { const handleDragEnter = (e: React.DragEvent, folderName: string) => {
@@ -182,7 +218,8 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
if (!draggedCredential) return; if (!draggedCredential) return;
const newFolder = targetFolder === t('credentials.uncategorized') ? '' : targetFolder; const newFolder =
targetFolder === t("credentials.uncategorized") ? "" : targetFolder;
if (draggedCredential.folder === newFolder) { if (draggedCredential.folder === newFolder) {
setDraggedCredential(null); setDraggedCredential(null);
@@ -193,14 +230,16 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
setOperationLoading(true); setOperationLoading(true);
const updatedCredential = { ...draggedCredential, folder: newFolder }; const updatedCredential = { ...draggedCredential, folder: newFolder };
await updateCredential(draggedCredential.id, updatedCredential); await updateCredential(draggedCredential.id, updatedCredential);
toast.success(t('credentials.movedToFolder', { toast.success(
t("credentials.movedToFolder", {
name: draggedCredential.name || draggedCredential.username, name: draggedCredential.name || draggedCredential.username,
folder: targetFolder folder: targetFolder,
})); }),
);
await fetchCredentials(); await fetchCredentials();
window.dispatchEvent(new CustomEvent('credentials:changed')); window.dispatchEvent(new CustomEvent("credentials:changed"));
} catch (err) { } catch (err) {
toast.error(t('credentials.failedToMoveToFolder')); toast.error(t("credentials.failedToMoveToFolder"));
} finally { } finally {
setOperationLoading(false); setOperationLoading(false);
setDraggedCredential(null); setDraggedCredential(null);
@@ -212,15 +251,17 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
if (searchQuery.trim()) { if (searchQuery.trim()) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
filtered = credentials.filter(credential => { filtered = credentials.filter((credential) => {
const searchableText = [ const searchableText = [
credential.name || '', credential.name || "",
credential.username, credential.username,
credential.description || '', credential.description || "",
...(credential.tags || []), ...(credential.tags || []),
credential.authType, credential.authType,
credential.keyType || '' credential.keyType || "",
].join(' ').toLowerCase(); ]
.join(" ")
.toLowerCase();
return searchableText.includes(query); return searchableText.includes(query);
}); });
} }
@@ -235,8 +276,8 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
const credentialsByFolder = useMemo(() => { const credentialsByFolder = useMemo(() => {
const grouped: { [key: string]: Credential[] } = {}; const grouped: { [key: string]: Credential[] } = {};
filteredAndSortedCredentials.forEach(credential => { filteredAndSortedCredentials.forEach((credential) => {
const folder = credential.folder || t('credentials.uncategorized'); const folder = credential.folder || t("credentials.uncategorized");
if (!grouped[folder]) { if (!grouped[folder]) {
grouped[folder] = []; grouped[folder] = [];
} }
@@ -244,13 +285,13 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
}); });
const sortedFolders = Object.keys(grouped).sort((a, b) => { const sortedFolders = Object.keys(grouped).sort((a, b) => {
if (a === t('credentials.uncategorized')) return -1; if (a === t("credentials.uncategorized")) return -1;
if (b === t('credentials.uncategorized')) return 1; if (b === t("credentials.uncategorized")) return 1;
return a.localeCompare(b); return a.localeCompare(b);
}); });
const sortedGrouped: { [key: string]: Credential[] } = {}; const sortedGrouped: { [key: string]: Credential[] } = {};
sortedFolders.forEach(folder => { sortedFolders.forEach((folder) => {
sortedGrouped[folder] = grouped[folder]; sortedGrouped[folder] = grouped[folder];
}); });
@@ -262,7 +303,9 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">{t('credentials.loadingCredentials')}</p> <p className="text-muted-foreground">
{t("credentials.loadingCredentials")}
</p>
</div> </div>
</div> </div>
); );
@@ -274,7 +317,7 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
<div className="text-center"> <div className="text-center">
<p className="text-red-500 mb-4">{error}</p> <p className="text-red-500 mb-4">{error}</p>
<Button onClick={fetchCredentials} variant="outline"> <Button onClick={fetchCredentials} variant="outline">
{t('credentials.retry')} {t("credentials.retry")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -286,14 +329,16 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2> <h2 className="text-xl font-semibold">
{t("credentials.sshCredentials")}
</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('credentials.credentialsCount', {count: 0})} {t("credentials.credentialsCount", { count: 0 })}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={fetchCredentials} variant="outline" size="sm"> <Button onClick={fetchCredentials} variant="outline" size="sm">
{t('credentials.refresh')} {t("credentials.refresh")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -301,9 +346,11 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
<div className="flex items-center justify-center flex-1"> <div className="flex items-center justify-center flex-1">
<div className="text-center"> <div className="text-center">
<Key className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> <Key className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">{t('credentials.noCredentials')}</h3> <h3 className="text-lg font-semibold mb-2">
{t("credentials.noCredentials")}
</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
{t('credentials.noCredentialsMessage')} {t("credentials.noCredentialsMessage")}
</p> </p>
</div> </div>
</div> </div>
@@ -315,14 +362,18 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div> <div>
<h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2> <h2 className="text-xl font-semibold">
{t("credentials.sshCredentials")}
</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('credentials.credentialsCount', {count: filteredAndSortedCredentials.length})} {t("credentials.credentialsCount", {
count: filteredAndSortedCredentials.length,
})}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={fetchCredentials} variant="outline" size="sm"> <Button onClick={fetchCredentials} variant="outline" size="sm">
{t('credentials.refresh')} {t("credentials.refresh")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -330,7 +381,7 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
<div className="relative mb-3"> <div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder={t('placeholders.searchCredentials')} placeholder={t("placeholders.searchCredentials")}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10" className="pl-10"
@@ -339,32 +390,42 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
<div className="space-y-2 pb-20"> <div className="space-y-2 pb-20">
{Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => ( {Object.entries(credentialsByFolder).map(
([folder, folderCredentials]) => (
<div <div
key={folder} key={folder}
className={`border rounded-md transition-all duration-200 ${ className={`border rounded-md transition-all duration-200 ${
dragOverFolder === folder ? 'border-blue-500 bg-blue-500/10' : '' dragOverFolder === folder
? "border-blue-500 bg-blue-500/10"
: ""
}`} }`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnter={(e) => handleDragEnter(e, folder)} onDragEnter={(e) => handleDragEnter(e, folder)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, folder)} onDrop={(e) => handleDrop(e, folder)}
> >
<Accordion type="multiple" defaultValue={Object.keys(credentialsByFolder)}> <Accordion
type="multiple"
defaultValue={Object.keys(credentialsByFolder)}
>
<AccordionItem value={folder} className="border-none"> <AccordionItem value={folder} className="border-none">
<AccordionTrigger <AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
<Folder className="h-4 w-4" /> <Folder className="h-4 w-4" />
{editingFolder === folder ? ( {editingFolder === folder ? (
<div className="flex items-center gap-2" <div
onClick={(e) => e.stopPropagation()}> className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Input <Input
value={editingFolderName} value={editingFolderName}
onChange={(e) => setEditingFolderName(e.target.value)} onChange={(e) =>
setEditingFolderName(e.target.value)
}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') handleFolderRename(folder); if (e.key === "Enter")
if (e.key === 'Escape') cancelFolderEdit(); handleFolderRename(folder);
if (e.key === "Escape") cancelFolderEdit();
}} }}
className="h-6 text-sm px-2 flex-1" className="h-6 text-sm px-2 flex-1"
autoFocus autoFocus
@@ -401,15 +462,19 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
className="font-medium cursor-pointer hover:text-blue-400 transition-colors" className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (folder !== t('credentials.uncategorized')) { if (folder !== t("credentials.uncategorized")) {
startFolderEdit(folder); startFolderEdit(folder);
} }
}} }}
title={folder !== t('credentials.uncategorized') ? 'Click to rename folder' : ''} title={
folder !== t("credentials.uncategorized")
? "Click to rename folder"
: ""
}
> >
{folder} {folder}
</span> </span>
{folder !== t('credentials.uncategorized') && ( {folder !== t("credentials.uncategorized") && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -438,10 +503,14 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
draggable draggable
onDragStart={(e) => handleDragStart(e, credential)} onDragStart={(e) =>
handleDragStart(e, credential)
}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
className={`bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group relative ${ className={`bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group relative ${
draggedCredential?.id === credential.id ? 'opacity-50 scale-95' : '' draggedCredential?.id === credential.id
? "opacity-50 scale-95"
: ""
}`} }`}
onClick={() => handleEdit(credential)} onClick={() => handleEdit(credential)}
> >
@@ -449,18 +518,22 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<h3 className="font-medium truncate text-sm"> <h3 className="font-medium truncate text-sm">
{credential.name || `${credential.username}`} {credential.name ||
`${credential.username}`}
</h3> </h3>
</div> </div>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{credential.username} {credential.username}
</p> </p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{credential.authType === 'password' ? t('credentials.password') : t('credentials.sshKey')} {credential.authType === "password"
? t("credentials.password")
: t("credentials.sshKey")}
</p> </p>
</div> </div>
<div className="flex gap-1 flex-shrink-0 ml-1"> <div className="flex gap-1 flex-shrink-0 ml-1">
{credential.folder && credential.folder !== '' && ( {credential.folder &&
credential.folder !== "" && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -468,18 +541,21 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
variant="ghost" variant="ghost"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleRemoveFromFolder(credential); handleRemoveFromFolder(
credential,
);
}} }}
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10" className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
disabled={operationLoading} disabled={operationLoading}
> >
<FolderMinus <FolderMinus className="h-3 w-3" />
className="h-3 w-3"/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Remove from folder <p>
"{credential.folder}"</p> Remove from folder "
{credential.folder}"
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
@@ -508,7 +584,11 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
variant="ghost" variant="ghost"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDelete(credential.id, credential.name || credential.username); handleDelete(
credential.id,
credential.name ||
credential.username,
);
}} }}
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10" className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
> >
@@ -523,18 +603,26 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
</div> </div>
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{credential.tags && credential.tags.length > 0 && ( {credential.tags &&
credential.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{credential.tags.slice(0, 6).map((tag, index) => ( {credential.tags
<Badge key={index} variant="outline" .slice(0, 6)
className="text-xs px-1 py-0"> .map((tag, index) => (
<Badge
key={index}
variant="outline"
className="text-xs px-1 py-0"
>
<Tag className="h-2 w-2 mr-0.5" /> <Tag className="h-2 w-2 mr-0.5" />
{tag} {tag}
</Badge> </Badge>
))} ))}
{credential.tags.length > 6 && ( {credential.tags.length > 6 && (
<Badge variant="outline" <Badge
className="text-xs px-1 py-0"> variant="outline"
className="text-xs px-1 py-0"
>
+{credential.tags.length - 6} +{credential.tags.length - 6}
</Badge> </Badge>
)} )}
@@ -542,18 +630,23 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
)} )}
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
<Badge variant="outline" <Badge
className="text-xs px-1 py-0"> variant="outline"
{credential.authType === 'password' ? ( className="text-xs px-1 py-0"
>
{credential.authType === "password" ? (
<Key className="h-2 w-2 mr-0.5" /> <Key className="h-2 w-2 mr-0.5" />
) : ( ) : (
<Shield className="h-2 w-2 mr-0.5" /> <Shield className="h-2 w-2 mr-0.5" />
)} )}
{credential.authType} {credential.authType}
</Badge> </Badge>
{credential.authType === 'key' && credential.keyType && ( {credential.authType === "key" &&
<Badge variant="outline" credential.keyType && (
className="text-xs px-1 py-0"> <Badge
variant="outline"
className="text-xs px-1 py-0"
>
{credential.keyType} {credential.keyType}
</Badge> </Badge>
)} )}
@@ -563,9 +656,12 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<div className="text-center"> <div className="text-center">
<p className="font-medium">Click to edit credential</p> <p className="font-medium">
<p className="text-xs text-muted-foreground">Drag to Click to edit credential
move between folders</p> </p>
<p className="text-xs text-muted-foreground">
Drag to move between folders
</p>
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -576,7 +672,8 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
</div> </div>
))} ),
)}
</div> </div>
</ScrollArea> </ScrollArea>
@@ -2,14 +2,16 @@ import React from "react";
import { FileManagerTabList } from "./FileManagerTabList.tsx"; import { FileManagerTabList } from "./FileManagerTabList.tsx";
interface FileManagerTopNavbarProps { interface FileManagerTopNavbarProps {
tabs: { id: string | number, title: string }[]; tabs: { id: string | number; title: string }[];
activeTab: string | number; activeTab: string | number;
setActiveTab: (tab: string | number) => void; setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void; closeTab: (tab: string | number) => void;
onHomeClick: () => void; onHomeClick: () => void;
} }
export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement { export function FIleManagerTopNavbar(
props: FileManagerTopNavbarProps,
): React.ReactElement {
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props; const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
return ( return (
+222 -141
View File
@@ -3,12 +3,12 @@ import {FileManagerLeftSidebar} from "@/ui/Desktop/Apps/File Manager/FileManager
import { FileManagerHomeView } from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx"; import { FileManagerHomeView } from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx";
import { FileManagerFileEditor } from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx"; import { FileManagerFileEditor } from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx";
import { FileManagerOperations } from "@/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx"; import { FileManagerOperations } from "@/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx";
import {Button} from '@/components/ui/button.tsx'; import { Button } from "@/components/ui/button.tsx";
import { FIleManagerTopNavbar } from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx"; import { FIleManagerTopNavbar } from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx";
import {cn} from '@/lib/utils.ts'; import { cn } from "@/lib/utils.ts";
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react'; import { Save, RefreshCw, Settings, Trash2 } from "lucide-react";
import {toast} from 'sonner'; import { toast } from "sonner";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import { import {
getFileManagerRecent, getFileManagerRecent,
getFileManagerPinned, getFileManagerPinned,
@@ -22,19 +22,23 @@ import {
readSSHFile, readSSHFile,
writeSSHFile, writeSSHFile,
getSSHStatus, getSSHStatus,
connectSSH connectSSH,
} from '@/ui/main-axios.ts'; } from "@/ui/main-axios.ts";
import type {SSHHost, Tab} from '../../../types/index.js'; import type { SSHHost, Tab } from "../../../types/index.js";
export function FileManager({onSelectView, initialHost = null, onClose}: { export function FileManager({
onSelectView?: (view: string) => void, onSelectView,
embedded?: boolean, initialHost = null,
initialHost?: SSHHost | null, onClose,
onClose?: () => void }: {
onSelectView?: (view: string) => void;
embedded?: boolean;
initialHost?: SSHHost | null;
onClose?: () => void;
}): React.ReactElement { }): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const [tabs, setTabs] = useState<Tab[]>([]); const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTab, setActiveTab] = useState<string | number>('home'); const [activeTab, setActiveTab] = useState<string | number>("home");
const [recent, setRecent] = useState<any[]>([]); const [recent, setRecent] = useState<any[]>([]);
const [pinned, setPinned] = useState<any[]>([]); const [pinned, setPinned] = useState<any[]>([]);
const [shortcuts, setShortcuts] = useState<any[]>([]); const [shortcuts, setShortcuts] = useState<any[]>([]);
@@ -43,7 +47,7 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [showOperations, setShowOperations] = useState(false); const [showOperations, setShowOperations] = useState(false);
const [currentPath, setCurrentPath] = useState('/'); const [currentPath, setCurrentPath] = useState("/");
const [deletingItem, setDeletingItem] = useState<any | null>(null); const [deletingItem, setDeletingItem] = useState<any | null>(null);
@@ -54,12 +58,11 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
setCurrentHost(initialHost); setCurrentHost(initialHost);
setTimeout(() => { setTimeout(() => {
try { try {
const path = initialHost.defaultPath || '/'; const path = initialHost.defaultPath || "/";
if (sidebarRef.current && sidebarRef.current.openFolder) { if (sidebarRef.current && sidebarRef.current.openFolder) {
sidebarRef.current.openFolder(initialHost, path); sidebarRef.current.openFolder(initialHost, path);
} }
} catch (e) { } catch (e) {}
}
}, 0); }, 0);
} }
}, [initialHost]); }, [initialHost]);
@@ -75,7 +78,7 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
}, [currentHost]); }, [currentHost]);
useEffect(() => { useEffect(() => {
if (activeTab === 'home' && currentHost) { if (activeTab === "home" && currentHost) {
const interval = setInterval(() => { const interval = setInterval(() => {
fetchHomeData(); fetchHomeData();
}, 2000); }, 2000);
@@ -95,33 +98,42 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
]); ]);
const timeoutPromise = new Promise((_, reject) => const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(t('fileManager.fetchHomeDataTimeout'))), 15000) setTimeout(
() => reject(new Error(t("fileManager.fetchHomeDataTimeout"))),
15000,
),
); );
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]) as [any, any, any]; const [recentRes, pinnedRes, shortcutsRes] = (await Promise.race([
homeDataPromise,
timeoutPromise,
])) as [any, any, any];
const recentWithPinnedStatus = (recentRes || []).map(file => ({ const recentWithPinnedStatus = (recentRes || []).map((file) => ({
...file, ...file,
type: 'file', type: "file",
isPinned: (pinnedRes || []).some(pinnedFile => isPinned: (pinnedRes || []).some(
pinnedFile.path === file.path && pinnedFile.name === file.name (pinnedFile) =>
) pinnedFile.path === file.path && pinnedFile.name === file.name,
),
})); }));
const pinnedWithType = (pinnedRes || []).map(file => ({ const pinnedWithType = (pinnedRes || []).map((file) => ({
...file, ...file,
type: 'file' type: "file",
})); }));
setRecent(recentWithPinnedStatus); setRecent(recentWithPinnedStatus);
setPinned(pinnedWithType); setPinned(pinnedWithType);
setShortcuts((shortcutsRes || []).map(shortcut => ({ setShortcuts(
(shortcutsRes || []).map((shortcut) => ({
...shortcut, ...shortcut,
type: 'directory' type: "directory",
}))); })),
);
} catch (err: any) { } catch (err: any) {
const {toast} = await import('sonner'); const { toast } = await import("sonner");
toast.error(t('fileManager.failedToFetchHomeData')); toast.error(t("fileManager.failedToFetchHomeData"));
if (onClose) { if (onClose) {
onClose(); onClose();
@@ -130,61 +142,77 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
} }
const formatErrorMessage = (err: any, defaultMessage: string): string => { const formatErrorMessage = (err: any, defaultMessage: string): string => {
if (typeof err === 'object' && err !== null && 'response' in err) { if (typeof err === "object" && err !== null && "response" in err) {
const axiosErr = err as any; const axiosErr = err as any;
if (axiosErr.response?.status === 403) { if (axiosErr.response?.status === 403) {
return `${t('fileManager.permissionDenied')}. ${defaultMessage}. ${t('fileManager.checkDockerLogs')}.`; return `${t("fileManager.permissionDenied")}. ${defaultMessage}. ${t("fileManager.checkDockerLogs")}.`;
} else if (axiosErr.response?.status === 500) { } else if (axiosErr.response?.status === 500) {
const backendError = axiosErr.response?.data?.error || t('fileManager.internalServerError'); const backendError =
return `${t('fileManager.serverError')} (500): ${backendError}. ${t('fileManager.checkDockerLogs')}.`; axiosErr.response?.data?.error ||
t("fileManager.internalServerError");
return `${t("fileManager.serverError")} (500): ${backendError}. ${t("fileManager.checkDockerLogs")}.`;
} else if (axiosErr.response?.data?.error) { } else if (axiosErr.response?.data?.error) {
const backendError = axiosErr.response.data.error; const backendError = axiosErr.response.data.error;
return `${axiosErr.response?.status ? `${t('fileManager.error')} ${axiosErr.response.status}: ` : ''}${backendError}. ${t('fileManager.checkDockerLogs')}.`; return `${axiosErr.response?.status ? `${t("fileManager.error")} ${axiosErr.response.status}: ` : ""}${backendError}. ${t("fileManager.checkDockerLogs")}.`;
} else { } else {
return `${t('fileManager.requestFailed')} ${axiosErr.response?.status || t('fileManager.unknown')}. ${t('fileManager.checkDockerLogs')}.`; return `${t("fileManager.requestFailed")} ${axiosErr.response?.status || t("fileManager.unknown")}. ${t("fileManager.checkDockerLogs")}.`;
} }
} else if (err instanceof Error) { } else if (err instanceof Error) {
return `${err.message}. ${t('fileManager.checkDockerLogs')}.`; return `${err.message}. ${t("fileManager.checkDockerLogs")}.`;
} else { } else {
return `${defaultMessage}. ${t('fileManager.checkDockerLogs')}.`; return `${defaultMessage}. ${t("fileManager.checkDockerLogs")}.`;
} }
}; };
const handleOpenFile = async (file: any) => { const handleOpenFile = async (file: any) => {
const tabId = file.path; const tabId = file.path;
if (!tabs.find(t => t.id === tabId)) { if (!tabs.find((t) => t.id === tabId)) {
const currentSshSessionId = currentHost?.id.toString(); const currentSshSessionId = currentHost?.id.toString();
setTabs([...tabs, { setTabs([
...tabs,
{
id: tabId, id: tabId,
title: file.name, title: file.name,
fileName: file.name, fileName: file.name,
content: '', content: "",
filePath: file.path, filePath: file.path,
isSSH: true, isSSH: true,
sshSessionId: currentSshSessionId, sshSessionId: currentSshSessionId,
loading: true loading: true,
}]); },
]);
try { try {
const res = await readSSHFile(currentSshSessionId, file.path); const res = await readSSHFile(currentSshSessionId, file.path);
setTabs(tabs => tabs.map(t => t.id === tabId ? { setTabs((tabs) =>
tabs.map((t) =>
t.id === tabId
? {
...t, ...t,
content: res.content, content: res.content,
loading: false, loading: false,
error: undefined error: undefined,
} : t)); }
: t,
),
);
await addFileManagerRecent({ await addFileManagerRecent({
name: file.name, name: file.name,
path: file.path, path: file.path,
isSSH: true, isSSH: true,
sshSessionId: currentSshSessionId, sshSessionId: currentSshSessionId,
hostId: currentHost?.id hostId: currentHost?.id,
}); });
} catch (err: any) { } catch (err: any) {
const errorMessage = formatErrorMessage(err, t('fileManager.cannotReadFile')); const errorMessage = formatErrorMessage(
err,
t("fileManager.cannotReadFile"),
);
toast.error(errorMessage); toast.error(errorMessage);
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false} : t)); setTabs((tabs) =>
tabs.map((t) => (t.id === tabId ? { ...t, loading: false } : t)),
);
} }
} }
setActiveTab(tabId); setActiveTab(tabId);
@@ -197,11 +225,10 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
path: file.path, path: file.path,
isSSH: true, isSSH: true,
sshSessionId: file.sshSessionId, sshSessionId: file.sshSessionId,
hostId: currentHost?.id hostId: currentHost?.id,
}); });
fetchHomeData(); fetchHomeData();
} catch (err) { } catch (err) {}
}
}; };
const handlePinFile = async (file: any) => { const handlePinFile = async (file: any) => {
@@ -211,13 +238,12 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
path: file.path, path: file.path,
isSSH: true, isSSH: true,
sshSessionId: file.sshSessionId, sshSessionId: file.sshSessionId,
hostId: currentHost?.id hostId: currentHost?.id,
}); });
if (sidebarRef.current && sidebarRef.current.fetchFiles) { if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles(); sidebarRef.current.fetchFiles();
} }
} catch (err) { } catch (err) {}
}
}; };
const handleUnpinFile = async (file: any) => { const handleUnpinFile = async (file: any) => {
@@ -227,13 +253,12 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
path: file.path, path: file.path,
isSSH: true, isSSH: true,
sshSessionId: file.sshSessionId, sshSessionId: file.sshSessionId,
hostId: currentHost?.id hostId: currentHost?.id,
}); });
if (sidebarRef.current && sidebarRef.current.fetchFiles) { if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles(); sidebarRef.current.fetchFiles();
} }
} catch (err) { } catch (err) {}
}
}; };
const handleOpenShortcut = async (shortcut: any) => { const handleOpenShortcut = async (shortcut: any) => {
@@ -245,7 +270,9 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
try { try {
sidebarRef.current.isOpeningShortcut = true; sidebarRef.current.isOpeningShortcut = true;
const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`; const normalizedPath = shortcut.path.startsWith("/")
? shortcut.path
: `/${shortcut.path}`;
await sidebarRef.current.openFolder(currentHost, normalizedPath); await sidebarRef.current.openFolder(currentHost, normalizedPath);
} catch (err) { } catch (err) {
@@ -260,16 +287,15 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
const handleAddShortcut = async (folderPath: string) => { const handleAddShortcut = async (folderPath: string) => {
try { try {
const name = folderPath.split('/').pop() || folderPath; const name = folderPath.split("/").pop() || folderPath;
await addFileManagerShortcut({ await addFileManagerShortcut({
name, name,
path: folderPath, path: folderPath,
isSSH: true, isSSH: true,
sshSessionId: currentHost?.id.toString(), sshSessionId: currentHost?.id.toString(),
hostId: currentHost?.id hostId: currentHost?.id,
}); });
} catch (err) { } catch (err) {}
}
}; };
const handleRemoveShortcut = async (shortcut: any) => { const handleRemoveShortcut = async (shortcut: any) => {
@@ -279,30 +305,35 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
path: shortcut.path, path: shortcut.path,
isSSH: true, isSSH: true,
sshSessionId: currentHost?.id.toString(), sshSessionId: currentHost?.id.toString(),
hostId: currentHost?.id hostId: currentHost?.id,
}); });
} catch (err) { } catch (err) {}
}
}; };
const closeTab = (tabId: string | number) => { const closeTab = (tabId: string | number) => {
const idx = tabs.findIndex(t => t.id === tabId); const idx = tabs.findIndex((t) => t.id === tabId);
const newTabs = tabs.filter(t => t.id !== tabId); const newTabs = tabs.filter((t) => t.id !== tabId);
setTabs(newTabs); setTabs(newTabs);
if (activeTab === tabId) { if (activeTab === tabId) {
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id); if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
else setActiveTab('home'); else setActiveTab("home");
} }
}; };
const setTabContent = (tabId: string | number, content: string) => { const setTabContent = (tabId: string | number, content: string) => {
setTabs(tabs => tabs.map(t => t.id === tabId ? { setTabs((tabs) =>
tabs.map((t) =>
t.id === tabId
? {
...t, ...t,
content, content,
dirty: true, dirty: true,
error: undefined, error: undefined,
success: undefined success: undefined,
} : t)); }
: t,
),
);
}; };
const handleSave = async (tab: Tab) => { const handleSave = async (tab: Tab) => {
@@ -314,24 +345,30 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
try { try {
if (!tab.sshSessionId) { if (!tab.sshSessionId) {
throw new Error(t('fileManager.noSshSessionId')); throw new Error(t("fileManager.noSshSessionId"));
} }
if (!tab.filePath) { if (!tab.filePath) {
throw new Error(t('fileManager.noFilePath')); throw new Error(t("fileManager.noFilePath"));
} }
if (!currentHost?.id) { if (!currentHost?.id) {
throw new Error(t('fileManager.noCurrentHost')); throw new Error(t("fileManager.noCurrentHost"));
} }
try { try {
const statusPromise = getSSHStatus(tab.sshSessionId); const statusPromise = getSSHStatus(tab.sshSessionId);
const statusTimeoutPromise = new Promise((_, reject) => const statusTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(t('fileManager.sshStatusCheckTimeout'))), 10000) setTimeout(
() => reject(new Error(t("fileManager.sshStatusCheckTimeout"))),
10000,
),
); );
const status = await Promise.race([statusPromise, statusTimeoutPromise]) as { connected: boolean }; const status = (await Promise.race([
statusPromise,
statusTimeoutPromise,
])) as { connected: boolean };
if (!status.connected) { if (!status.connected) {
const connectPromise = connectSSH(tab.sshSessionId, { const connectPromise = connectSSH(tab.sshSessionId, {
@@ -344,34 +381,46 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
keyPassword: currentHost.keyPassword, keyPassword: currentHost.keyPassword,
authType: currentHost.authType, authType: currentHost.authType,
credentialId: currentHost.credentialId, credentialId: currentHost.credentialId,
userId: currentHost.userId userId: currentHost.userId,
}); });
const connectTimeoutPromise = new Promise((_, reject) => const connectTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(t('fileManager.sshReconnectionTimeout'))), 15000) setTimeout(
() => reject(new Error(t("fileManager.sshReconnectionTimeout"))),
15000,
),
); );
await Promise.race([connectPromise, connectTimeoutPromise]); await Promise.race([connectPromise, connectTimeoutPromise]);
} }
} catch (statusErr) { } catch (statusErr) {}
}
const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content); const savePromise = writeSSHFile(
tab.sshSessionId,
tab.filePath,
tab.content,
);
const timeoutPromise = new Promise((_, reject) => const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => { setTimeout(() => {
reject(new Error(t('fileManager.saveOperationTimeout'))); reject(new Error(t("fileManager.saveOperationTimeout")));
}, 30000) }, 30000),
); );
const result = await Promise.race([savePromise, timeoutPromise]); const result = await Promise.race([savePromise, timeoutPromise]);
setTabs(tabs => tabs.map(t => t.id === tab.id ? { setTabs((tabs) =>
tabs.map((t) =>
t.id === tab.id
? {
...t, ...t,
loading: false loading: false,
} : t)); }
: t,
),
);
if (result?.toast) { if (result?.toast) {
toast[result.toast.type](result.toast.message); toast[result.toast.type](result.toast.message);
} else { } else {
toast.success(t('fileManager.fileSavedSuccessfully')); toast.success(t("fileManager.fileSavedSuccessfully"));
} }
Promise.allSettled([ Promise.allSettled([
@@ -382,33 +431,41 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
path: tab.filePath, path: tab.filePath,
isSSH: true, isSSH: true,
sshSessionId: tab.sshSessionId, sshSessionId: tab.sshSessionId,
hostId: currentHost.id hostId: currentHost.id,
}); });
} catch (recentErr) { } catch (recentErr) {}
}
})(), })(),
]).then(() => { ]).then(() => {});
});
} catch (err) { } catch (err) {
let errorMessage = formatErrorMessage(err, t('fileManager.cannotSaveFile')); let errorMessage = formatErrorMessage(
err,
t("fileManager.cannotSaveFile"),
);
if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) { if (
errorMessage = t('fileManager.saveTimeout'); errorMessage.includes("timed out") ||
errorMessage.includes("timeout")
) {
errorMessage = t("fileManager.saveTimeout");
} }
toast.error(`${t('fileManager.failedToSaveFile')}: ${errorMessage}`); toast.error(`${t("fileManager.failedToSaveFile")}: ${errorMessage}`);
setTabs(tabs => tabs.map(t => t.id === tab.id ? { setTabs((tabs) =>
tabs.map((t) =>
t.id === tab.id
? {
...t, ...t,
loading: false loading: false,
} : t)); }
: t,
),
);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}; };
const handleHostChange = (_host: SSHHost | null) => { const handleHostChange = (_host: SSHHost | null) => {};
};
const handleOperationComplete = () => { const handleOperationComplete = () => {
if (sidebarRef.current && sidebarRef.current.fetchFiles) { if (sidebarRef.current && sidebarRef.current.fetchFiles) {
@@ -436,19 +493,27 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
if (!currentHost?.id) return; if (!currentHost?.id) return;
try { try {
const {deleteSSHItem} = await import('@/ui/main-axios.ts'); const { deleteSSHItem } = await import("@/ui/main-axios.ts");
const response = await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory'); const response = await deleteSSHItem(
currentHost.id.toString(),
item.path,
item.type === "directory",
);
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } else {
toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`); toast.success(
`${item.type === "directory" ? t("fileManager.folder") : t("fileManager.file")} ${t("fileManager.deletedSuccessfully")}`,
);
} }
setDeletingItem(null); setDeletingItem(null);
handleOperationComplete(); handleOperationComplete();
} catch (error: any) { } catch (error: any) {
handleError(error?.response?.data?.error || t('fileManager.failedToDeleteItem')); handleError(
error?.response?.data?.error || t("fileManager.failedToDeleteItem"),
);
} }
}; };
@@ -457,8 +522,7 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
<div className="absolute inset-0 overflow-hidden rounded-md"> <div className="absolute inset-0 overflow-hidden rounded-md">
<div className="absolute top-0 left-0 w-64 h-full z-[20]"> <div className="absolute top-0 left-0 w-64 h-full z-[20]">
<FileManagerLeftSidebar <FileManagerLeftSidebar
onSelectView={onSelectView || (() => { onSelectView={onSelectView || (() => {})}
})}
onOpenFile={handleOpenFile} onOpenFile={handleOpenFile}
tabs={tabs} tabs={tabs}
ref={sidebarRef} ref={sidebarRef}
@@ -469,11 +533,14 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
onPathChange={updateCurrentPath} onPathChange={updateCurrentPath}
/> />
</div> </div>
<div <div className="absolute top-0 left-64 right-0 bottom-0 flex items-center justify-center bg-dark-bg-darkest">
className="absolute top-0 left-64 right-0 bottom-0 flex items-center justify-center bg-dark-bg-darkest">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-semibold text-white mb-2">{t('fileManager.connectToServer')}</h2> <h2 className="text-xl font-semibold text-white mb-2">
<p className="text-muted-foreground">{t('fileManager.selectServerToEdit')}</p> {t("fileManager.connectToServer")}
</h2>
<p className="text-muted-foreground">
{t("fileManager.selectServerToEdit")}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -484,8 +551,7 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
<div className="absolute inset-0 overflow-hidden rounded-md"> <div className="absolute inset-0 overflow-hidden rounded-md">
<div className="absolute top-0 left-0 w-64 h-full z-[20]"> <div className="absolute top-0 left-0 w-64 h-full z-[20]">
<FileManagerLeftSidebar <FileManagerLeftSidebar
onSelectView={onSelectView || (() => { onSelectView={onSelectView || (() => {})}
})}
onOpenFile={handleOpenFile} onOpenFile={handleOpenFile}
tabs={tabs} tabs={tabs}
ref={sidebarRef} ref={sidebarRef}
@@ -499,15 +565,14 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
</div> </div>
<div className="absolute top-0 left-64 right-0 h-[50px] z-[30]"> <div className="absolute top-0 left-64 right-0 h-[50px] z-[30]">
<div className="flex items-center w-full bg-dark-bg border-b-2 border-dark-border h-[50px] relative"> <div className="flex items-center w-full bg-dark-bg border-b-2 border-dark-border h-[50px] relative">
<div <div className="h-full p-1 pr-2 border-r-2 border-dark-border w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
className="h-full p-1 pr-2 border-r-2 border-dark-border w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
<FIleManagerTopNavbar <FIleManagerTopNavbar
tabs={tabs.map(t => ({id: t.id, title: t.title}))} tabs={tabs.map((t) => ({ id: t.id, title: t.title }))}
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
closeTab={closeTab} closeTab={closeTab}
onHomeClick={() => { onHomeClick={() => {
setActiveTab('home'); setActiveTab("home");
}} }}
/> />
</div> </div>
@@ -516,10 +581,10 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
variant="outline" variant="outline"
onClick={() => setShowOperations(!showOperations)} onClick={() => setShowOperations(!showOperations)}
className={cn( className={cn(
'w-[30px] h-[30px]', "w-[30px] h-[30px]",
showOperations ? 'bg-dark-hover border-dark-border-hover' : '' showOperations ? "bg-dark-hover border-dark-border-hover" : "",
)} )}
title={t('fileManager.fileOperations')} title={t("fileManager.fileOperations")}
> >
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
</Button> </Button>
@@ -527,25 +592,36 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
const tab = tabs.find(t => t.id === activeTab); const tab = tabs.find((t) => t.id === activeTab);
if (tab && !isSaving) handleSave(tab); if (tab && !isSaving) handleSave(tab);
}} }}
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving} disabled={
activeTab === "home" ||
!tabs.find((t) => t.id === activeTab)?.dirty ||
isSaving
}
className={cn( className={cn(
'w-[30px] h-[30px]', "w-[30px] h-[30px]",
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : '' activeTab === "home" ||
!tabs.find((t) => t.id === activeTab)?.dirty ||
isSaving
? "opacity-60 cursor-not-allowed"
: "",
)} )}
> >
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin"/> : <Save className="h-4 w-4"/>} {isSaving ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<div <div className="absolute top-[44px] left-64 right-0 bottom-0 overflow-hidden z-[10] bg-dark-bg-very-light flex flex-col">
className="absolute top-[44px] left-64 right-0 bottom-0 overflow-hidden z-[10] bg-dark-bg-very-light flex flex-col">
<div className="flex h-full"> <div className="flex h-full">
<div className="flex-1"> <div className="flex-1">
{activeTab === 'home' ? ( {activeTab === "home" ? (
<FileManagerHomeView <FileManagerHomeView
recent={recent} recent={recent}
pinned={pinned} pinned={pinned}
@@ -560,7 +636,7 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
/> />
) : ( ) : (
(() => { (() => {
const tab = tabs.find(t => t.id === activeTab); const tab = tabs.find((t) => t.id === activeTab);
if (!tab) return null; if (!tab) return null;
return ( return (
<div className="flex flex-col h-full flex-1 min-h-0"> <div className="flex flex-col h-full flex-1 min-h-0">
@@ -568,7 +644,9 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
<FileManagerFileEditor <FileManagerFileEditor
content={tab.content} content={tab.content}
fileName={tab.fileName} fileName={tab.fileName}
onContentChange={content => setTabContent(tab.id, content)} onContentChange={(content) =>
setTabContent(tab.id, content)
}
/> />
</div> </div>
</div> </div>
@@ -598,14 +676,17 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md mx-4 shadow-2xl"> <div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md mx-4 shadow-2xl">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Trash2 className="w-5 h-5 text-red-400" /> <Trash2 className="w-5 h-5 text-red-400" />
{t('fileManager.confirmDelete')} {t("fileManager.confirmDelete")}
</h3> </h3>
<p className="text-white mb-4"> <p className="text-white mb-4">
{t('fileManager.confirmDeleteMessage', {name: deletingItem.name})} {t("fileManager.confirmDeleteMessage", {
{deletingItem.type === 'directory' && ` ${t('fileManager.deleteDirectoryWarning')}`} name: deletingItem.name,
})}
{deletingItem.type === "directory" &&
` ${t("fileManager.deleteDirectoryWarning")}`}
</p> </p>
<p className="text-red-400 text-sm mb-6"> <p className="text-red-400 text-sm mb-6">
{t('fileManager.actionCannotBeUndone')} {t("fileManager.actionCannotBeUndone")}
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<Button <Button
@@ -613,14 +694,14 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
onClick={() => performDelete(deletingItem)} onClick={() => performDelete(deletingItem)}
className="flex-1" className="flex-1"
> >
{t('common.delete')} {t("common.delete")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => setDeletingItem(null)} onClick={() => setDeletingItem(null)}
className="flex-1" className="flex-1"
> >
{t('common.cancel')} {t("common.cancel")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,9 +1,9 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import CodeMirror from "@uiw/react-codemirror"; import CodeMirror from "@uiw/react-codemirror";
import {loadLanguage} from '@uiw/codemirror-extensions-langs'; import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link'; import { hyperLink } from "@uiw/codemirror-extensions-hyper-link";
import {oneDark} from '@codemirror/theme-one-dark'; import { oneDark } from "@codemirror/theme-one-dark";
import {EditorView} from '@codemirror/view'; import { EditorView } from "@codemirror/view";
interface FileManagerCodeEditorProps { interface FileManagerCodeEditorProps {
content: string; content: string;
@@ -11,317 +11,320 @@ interface FileManagerCodeEditorProps {
onContentChange: (value: string) => void; onContentChange: (value: string) => void;
} }
export function FileManagerFileEditor({content, fileName, onContentChange}: FileManagerCodeEditorProps) { export function FileManagerFileEditor({
content,
fileName,
onContentChange,
}: FileManagerCodeEditorProps) {
function getLanguageName(filename: string): string { function getLanguageName(filename: string): string {
if (!filename || typeof filename !== 'string') { if (!filename || typeof filename !== "string") {
return 'text'; return "text";
} }
const lastDotIndex = filename.lastIndexOf('.'); const lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex === -1) { if (lastDotIndex === -1) {
return 'text'; return "text";
} }
const ext = filename.slice(lastDotIndex + 1).toLowerCase(); const ext = filename.slice(lastDotIndex + 1).toLowerCase();
switch (ext) { switch (ext) {
case 'ng': case "ng":
return 'angular'; return "angular";
case 'apl': case "apl":
return 'apl'; return "apl";
case 'asc': case "asc":
return 'asciiArmor'; return "asciiArmor";
case 'ast': case "ast":
return 'asterisk'; return "asterisk";
case 'bf': case "bf":
return 'brainfuck'; return "brainfuck";
case 'c': case "c":
return 'c'; return "c";
case 'ceylon': case "ceylon":
return 'ceylon'; return "ceylon";
case 'clj': case "clj":
return 'clojure'; return "clojure";
case 'cmake': case "cmake":
return 'cmake'; return "cmake";
case 'cob': case "cob":
case 'cbl': case "cbl":
return 'cobol'; return "cobol";
case 'coffee': case "coffee":
return 'coffeescript'; return "coffeescript";
case 'lisp': case "lisp":
return 'commonLisp'; return "commonLisp";
case 'cpp': case "cpp":
case 'cc': case "cc":
case 'cxx': case "cxx":
return 'cpp'; return "cpp";
case 'cr': case "cr":
return 'crystal'; return "crystal";
case 'cs': case "cs":
return 'csharp'; return "csharp";
case 'css': case "css":
return 'css'; return "css";
case 'cypher': case "cypher":
return 'cypher'; return "cypher";
case 'd': case "d":
return 'd'; return "d";
case 'dart': case "dart":
return 'dart'; return "dart";
case 'diff': case "diff":
case 'patch': case "patch":
return 'diff'; return "diff";
case 'dockerfile': case "dockerfile":
return 'dockerfile'; return "dockerfile";
case 'dtd': case "dtd":
return 'dtd'; return "dtd";
case 'dylan': case "dylan":
return 'dylan'; return "dylan";
case 'ebnf': case "ebnf":
return 'ebnf'; return "ebnf";
case 'ecl': case "ecl":
return 'ecl'; return "ecl";
case 'eiffel': case "eiffel":
return 'eiffel'; return "eiffel";
case 'elm': case "elm":
return 'elm'; return "elm";
case 'erl': case "erl":
return 'erlang'; return "erlang";
case 'factor': case "factor":
return 'factor'; return "factor";
case 'fcl': case "fcl":
return 'fcl'; return "fcl";
case 'fs': case "fs":
return 'forth'; return "forth";
case 'f90': case "f90":
case 'for': case "for":
return 'fortran'; return "fortran";
case 's': case "s":
return 'gas'; return "gas";
case 'feature': case "feature":
return 'gherkin'; return "gherkin";
case 'go': case "go":
return 'go'; return "go";
case 'groovy': case "groovy":
return 'groovy'; return "groovy";
case 'hs': case "hs":
return 'haskell'; return "haskell";
case 'hx': case "hx":
return 'haxe'; return "haxe";
case 'html': case "html":
case 'htm': case "htm":
return 'html'; return "html";
case 'http': case "http":
return 'http'; return "http";
case 'idl': case "idl":
return 'idl'; return "idl";
case 'java': case "java":
return 'java'; return "java";
case 'js': case "js":
case 'mjs': case "mjs":
case 'cjs': case "cjs":
return 'javascript'; return "javascript";
case 'jinja2': case "jinja2":
case 'j2': case "j2":
return 'jinja2'; return "jinja2";
case 'json': case "json":
return 'json'; return "json";
case 'jsx': case "jsx":
return 'jsx'; return "jsx";
case 'jl': case "jl":
return 'julia'; return "julia";
case 'kt': case "kt":
case 'kts': case "kts":
return 'kotlin'; return "kotlin";
case 'less': case "less":
return 'less'; return "less";
case 'lezer': case "lezer":
return 'lezer'; return "lezer";
case 'liquid': case "liquid":
return 'liquid'; return "liquid";
case 'litcoffee': case "litcoffee":
return 'livescript'; return "livescript";
case 'lua': case "lua":
return 'lua'; return "lua";
case 'md': case "md":
return 'markdown'; return "markdown";
case 'nb': case "nb":
case 'mat': case "mat":
return 'mathematica'; return "mathematica";
case 'mbox': case "mbox":
return 'mbox'; return "mbox";
case 'mmd': case "mmd":
return 'mermaid'; return "mermaid";
case 'mrc': case "mrc":
return 'mirc'; return "mirc";
case 'moo': case "moo":
return 'modelica'; return "modelica";
case 'mscgen': case "mscgen":
return 'mscgen'; return "mscgen";
case 'm': case "m":
return 'mumps'; return "mumps";
case 'sql': case "sql":
return 'mysql'; return "mysql";
case 'nc': case "nc":
return 'nesC'; return "nesC";
case 'nginx': case "nginx":
return 'nginx'; return "nginx";
case 'nix': case "nix":
return 'nix'; return "nix";
case 'nsi': case "nsi":
return 'nsis'; return "nsis";
case 'nt': case "nt":
return 'ntriples'; return "ntriples";
case 'mm': case "mm":
return 'objectiveCpp'; return "objectiveCpp";
case 'octave': case "octave":
return 'octave'; return "octave";
case 'oz': case "oz":
return 'oz'; return "oz";
case 'pas': case "pas":
return 'pascal'; return "pascal";
case 'pl': case "pl":
case 'pm': case "pm":
return 'perl'; return "perl";
case 'pgsql': case "pgsql":
return 'pgsql'; return "pgsql";
case 'php': case "php":
return 'php'; return "php";
case 'pig': case "pig":
return 'pig'; return "pig";
case 'ps1': case "ps1":
return 'powershell'; return "powershell";
case 'properties': case "properties":
return 'properties'; return "properties";
case 'proto': case "proto":
return 'protobuf'; return "protobuf";
case 'pp': case "pp":
return 'puppet'; return "puppet";
case 'py': case "py":
return 'python'; return "python";
case 'q': case "q":
return 'q'; return "q";
case 'r': case "r":
return 'r'; return "r";
case 'rb': case "rb":
return 'ruby'; return "ruby";
case 'rs': case "rs":
return 'rust'; return "rust";
case 'sas': case "sas":
return 'sas'; return "sas";
case 'sass': case "sass":
case 'scss': case "scss":
return 'sass'; return "sass";
case 'scala': case "scala":
return 'scala'; return "scala";
case 'scm': case "scm":
return 'scheme'; return "scheme";
case 'shader': case "shader":
return 'shader'; return "shader";
case 'sh': case "sh":
case 'bash': case "bash":
return 'shell'; return "shell";
case 'siv': case "siv":
return 'sieve'; return "sieve";
case 'st': case "st":
return 'smalltalk'; return "smalltalk";
case 'sol': case "sol":
return 'solidity'; return "solidity";
case 'solr': case "solr":
return 'solr'; return "solr";
case 'rq': case "rq":
return 'sparql'; return "sparql";
case 'xlsx': case "xlsx":
case 'ods': case "ods":
case 'csv': case "csv":
return 'spreadsheet'; return "spreadsheet";
case 'nut': case "nut":
return 'squirrel'; return "squirrel";
case 'tex': case "tex":
return 'stex'; return "stex";
case 'styl': case "styl":
return 'stylus'; return "stylus";
case 'svelte': case "svelte":
return 'svelte'; return "svelte";
case 'swift': case "swift":
return 'swift'; return "swift";
case 'tcl': case "tcl":
return 'tcl'; return "tcl";
case 'textile': case "textile":
return 'textile'; return "textile";
case 'tiddlywiki': case "tiddlywiki":
return 'tiddlyWiki'; return "tiddlyWiki";
case 'tiki': case "tiki":
return 'tiki'; return "tiki";
case 'toml': case "toml":
return 'toml'; return "toml";
case 'troff': case "troff":
return 'troff'; return "troff";
case 'tsx': case "tsx":
return 'tsx'; return "tsx";
case 'ttcn': case "ttcn":
return 'ttcn'; return "ttcn";
case 'ttl': case "ttl":
case 'turtle': case "turtle":
return 'turtle'; return "turtle";
case 'ts': case "ts":
return 'typescript'; return "typescript";
case 'vb': case "vb":
return 'vb'; return "vb";
case 'vbs': case "vbs":
return 'vbscript'; return "vbscript";
case 'vm': case "vm":
return 'velocity'; return "velocity";
case 'v': case "v":
return 'verilog'; return "verilog";
case 'vhd': case "vhd":
case 'vhdl': case "vhdl":
return 'vhdl'; return "vhdl";
case 'vue': case "vue":
return 'vue'; return "vue";
case 'wat': case "wat":
return 'wast'; return "wast";
case 'webidl': case "webidl":
return 'webIDL'; return "webIDL";
case 'xq': case "xq":
case 'xquery': case "xquery":
return 'xQuery'; return "xQuery";
case 'xml': case "xml":
return 'xml'; return "xml";
case 'yacas': case "yacas":
return 'yacas'; return "yacas";
case 'yaml': case "yaml":
case 'yml': case "yml":
return 'yaml'; return "yaml";
case 'z80': case "z80":
return 'z80'; return "z80";
default: default:
return 'text'; return "text";
} }
} }
useEffect(() => { useEffect(() => {
document.body.style.overflowX = 'hidden'; document.body.style.overflowX = "hidden";
return () => { return () => {
document.body.style.overflowX = ''; document.body.style.overflowX = "";
}; };
}, []); }, []);
return ( return (
<div className="w-full h-full relative overflow-hidden flex flex-col"> <div className="w-full h-full relative overflow-hidden flex flex-col">
<div <div className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper">
className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper"
>
<CodeMirror <CodeMirror
value={content} value={content}
extensions={[ extensions={[
loadLanguage(getLanguageName(fileName || 'untitled.txt') as any) || [], loadLanguage(getLanguageName(fileName || "untitled.txt") as any) ||
[],
hyperLink, hyperLink,
oneDark, oneDark,
EditorView.theme({ EditorView.theme({
'&': { "&": {
backgroundColor: 'var(--color-dark-bg-darkest) !important', backgroundColor: "var(--color-dark-bg-darkest) !important",
}, },
'.cm-gutters': { ".cm-gutters": {
backgroundColor: 'var(--color-dark-bg) !important', backgroundColor: "var(--color-dark-bg) !important",
}, },
}) }),
]} ]}
onChange={(value: any) => onContentChange(value)} onChange={(value: any) => onContentChange(value)}
theme={undefined} theme={undefined}
@@ -1,11 +1,16 @@
import React from 'react'; import React from "react";
import {Button} from '@/components/ui/button.tsx'; import { Button } from "@/components/ui/button.tsx";
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react'; import { Trash2, Folder, File, Plus, Pin } from "lucide-react";
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx'; import {
import {Input} from '@/components/ui/input.tsx'; Tabs,
import {useState} from 'react'; TabsList,
import {useTranslation} from 'react-i18next'; TabsTrigger,
import type {FileItem, ShortcutItem} from '../../../types/index'; TabsContent,
} from "@/components/ui/tabs.tsx";
import { Input } from "@/components/ui/input.tsx";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { FileItem, ShortcutItem } from "../../../types/index";
interface FileManagerHomeViewProps { interface FileManagerHomeViewProps {
recent: FileItem[]; recent: FileItem[];
@@ -30,24 +35,31 @@ export function FileManagerHomeView({
onUnpinFile, onUnpinFile,
onOpenShortcut, onOpenShortcut,
onRemoveShortcut, onRemoveShortcut,
onAddShortcut onAddShortcut,
}: FileManagerHomeViewProps) { }: FileManagerHomeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent'); const [tab, setTab] = useState<"recent" | "pinned" | "shortcuts">("recent");
const [newShortcut, setNewShortcut] = useState(''); const [newShortcut, setNewShortcut] = useState("");
const renderFileCard = (
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => ( file: FileItem,
<div key={file.path} onRemove: () => void,
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"> onPin?: () => void,
isPinned = false,
) => (
<div
key={file.path}
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
>
<div <div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0" className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenFile(file)} onClick={() => onOpenFile(file)}
> >
{file.type === 'directory' ? {file.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> : <Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" /> <File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight"> <div className="text-sm font-medium text-white break-words leading-tight">
{file.name} {file.name}
@@ -63,7 +75,8 @@ export function FileManagerHomeView({
onClick={onPin} onClick={onPin}
> >
<Pin <Pin
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/> className={`w-3 h-3 ${isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
/>
</Button> </Button>
)} )}
{onRemove && ( {onRemove && (
@@ -81,8 +94,10 @@ export function FileManagerHomeView({
); );
const renderShortcutCard = (shortcut: ShortcutItem) => ( const renderShortcutCard = (shortcut: ShortcutItem) => (
<div key={shortcut.path} <div
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"> key={shortcut.path}
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
>
<div <div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0" className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenShortcut(shortcut)} onClick={() => onOpenShortcut(shortcut)}
@@ -109,47 +124,64 @@ export function FileManagerHomeView({
return ( return (
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest"> <div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full"> <Tabs
value={tab}
onValueChange={(v) => setTab(v as "recent" | "pinned" | "shortcuts")}
className="w-full"
>
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border"> <TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
<TabsTrigger value="recent" <TabsTrigger
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.recent')}</TabsTrigger> value="recent"
<TabsTrigger value="pinned" className="data-[state=active]:bg-dark-bg-button"
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.pinned')}</TabsTrigger> >
<TabsTrigger value="shortcuts" {t("fileManager.recent")}
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.folderShortcuts')}</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="pinned"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.pinned")}
</TabsTrigger>
<TabsTrigger
value="shortcuts"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.folderShortcuts")}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="recent" className="mt-0"> <TabsContent value="recent" className="mt-0">
<div <div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? ( {recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full"> <div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">{t('fileManager.noRecentFiles')}</span> <span className="text-sm text-muted-foreground">
{t("fileManager.noRecentFiles")}
</span>
</div> </div>
) : recent.map((file) => ) : (
recent.map((file) =>
renderFileCard( renderFileCard(
file, file,
() => onRemoveRecent(file), () => onRemoveRecent(file),
() => file.isPinned ? onUnpinFile(file) : onPinFile(file), () => (file.isPinned ? onUnpinFile(file) : onPinFile(file)),
file.isPinned file.isPinned,
),
) )
)} )}
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="pinned" className="mt-0"> <TabsContent value="pinned" className="mt-0">
<div <div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? ( {pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full"> <div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">{t('fileManager.noPinnedFiles')}</span> <span className="text-sm text-muted-foreground">
{t("fileManager.noPinnedFiles")}
</span>
</div> </div>
) : pinned.map((file) => ) : (
renderFileCard( pinned.map((file) =>
file, renderFileCard(file, undefined, () => onUnpinFile(file), true),
undefined,
() => onUnpinFile(file),
true
) )
)} )}
</div> </div>
@@ -158,14 +190,14 @@ export function FileManagerHomeView({
<TabsContent value="shortcuts" className="mt-0"> <TabsContent value="shortcuts" className="mt-0">
<div className="flex items-center gap-3 mb-4 p-3 bg-dark-bg border-2 border-dark-border rounded-lg"> <div className="flex items-center gap-3 mb-4 p-3 bg-dark-bg border-2 border-dark-border rounded-lg">
<Input <Input
placeholder={t('fileManager.enterFolderPath')} placeholder={t("fileManager.enterFolderPath")}
value={newShortcut} value={newShortcut}
onChange={e => setNewShortcut(e.target.value)} onChange={(e) => setNewShortcut(e.target.value)}
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground" className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && newShortcut.trim()) { if (e.key === "Enter" && newShortcut.trim()) {
onAddShortcut(newShortcut.trim()); onAddShortcut(newShortcut.trim());
setNewShortcut(''); setNewShortcut("");
} }
}} }}
/> />
@@ -176,22 +208,23 @@ export function FileManagerHomeView({
onClick={() => { onClick={() => {
if (newShortcut.trim()) { if (newShortcut.trim()) {
onAddShortcut(newShortcut.trim()); onAddShortcut(newShortcut.trim());
setNewShortcut(''); setNewShortcut("");
} }
}} }}
> >
<Plus className="w-3.5 h-3.5 mr-1" /> <Plus className="w-3.5 h-3.5 mr-1" />
{t('common.add')} {t("common.add")}
</Button> </Button>
</div> </div>
<div <div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{shortcuts.length === 0 ? ( {shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full"> <div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">{t('fileManager.noShortcuts')}</span> <span className="text-sm text-muted-foreground">
{t("fileManager.noShortcuts")}
</span>
</div> </div>
) : shortcuts.map((shortcut) => ) : (
renderShortcutCard(shortcut) shortcuts.map((shortcut) => renderShortcutCard(shortcut))
)} )}
</div> </div>
</TabsContent> </TabsContent>
@@ -1,11 +1,25 @@
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react'; import React, {
import {Folder, File, ArrowUp, Pin, MoreVertical, Trash2, Edit3} from 'lucide-react'; useEffect,
import {ScrollArea} from '@/components/ui/scroll-area.tsx'; useState,
import {cn} from '@/lib/utils.ts'; useRef,
import {Input} from '@/components/ui/input.tsx'; forwardRef,
import {Button} from '@/components/ui/button.tsx'; useImperativeHandle,
import {toast} from 'sonner'; } from "react";
import {useTranslation} from 'react-i18next'; import {
Folder,
File,
ArrowUp,
Pin,
MoreVertical,
Trash2,
Edit3,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { cn } from "@/lib/utils.ts";
import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { import {
listSSHFiles, listSSHFiles,
renameSSHItem, renameSSHItem,
@@ -14,12 +28,19 @@ import {
addFileManagerPinned, addFileManagerPinned,
removeFileManagerPinned, removeFileManagerPinned,
getSSHStatus, getSSHStatus,
connectSSH connectSSH,
} from '@/ui/main-axios.ts'; } from "@/ui/main-axios.ts";
import type {SSHHost} from '../../../types/index.js'; import type { SSHHost } from "../../../types/index.js";
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{onOpenFile, tabs, host, onOperationComplete, onPathChange, onDeleteItem}: { {
onOpenFile,
tabs,
host,
onOperationComplete,
onPathChange,
onDeleteItem,
}: {
onSelectView?: (view: string) => void; onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void; onOpenFile: (file: any) => void;
tabs: any[]; tabs: any[];
@@ -30,17 +51,17 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
onPathChange?: (path: string) => void; onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void; onDeleteItem?: (item: any) => void;
}, },
ref ref,
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
const [currentPath, setCurrentPath] = useState('/'); const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<any[]>([]); const [files, setFiles] = useState<any[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null); const pathInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState("");
const [fileSearch, setFileSearch] = useState(''); const [fileSearch, setFileSearch] = useState("");
const [debouncedFileSearch, setDebouncedFileSearch] = useState(''); const [debouncedFileSearch, setDebouncedFileSearch] = useState("");
useEffect(() => { useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200); const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler); return () => clearTimeout(handler);
@@ -53,10 +74,15 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
const [sshSessionId, setSshSessionId] = useState<string | null>(null); const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false); const [filesLoading, setFilesLoading] = useState(false);
const [connectingSSH, setConnectingSSH] = useState(false); const [connectingSSH, setConnectingSSH] = useState(false);
const [connectionCache, setConnectionCache] = useState<Record<string, { const [connectionCache, setConnectionCache] = useState<
Record<
string,
{
sessionId: string; sessionId: string;
timestamp: number timestamp: number;
}>>({}); }
>
>({});
const [fetchingFiles, setFetchingFiles] = useState(false); const [fetchingFiles, setFetchingFiles] = useState(false);
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
@@ -68,7 +94,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
visible: false, visible: false,
x: 0, x: 0,
y: 0, y: 0,
item: null item: null,
}); });
const [renamingItem, setRenamingItem] = useState<{ const [renamingItem, setRenamingItem] = useState<{
@@ -77,7 +103,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
} | null>(null); } | null>(null);
useEffect(() => { useEffect(() => {
const nextPath = host?.defaultPath || '/'; const nextPath = host?.defaultPath || "/";
setCurrentPath(nextPath); setCurrentPath(nextPath);
onPathChange?.(nextPath); onPathChange?.(nextPath);
(async () => { (async () => {
@@ -102,7 +128,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
try { try {
if (!server.password && !server.key) { if (!server.password && !server.key) {
toast.error(t('common.noAuthCredentials')); toast.error(t("common.noAuthCredentials"));
return null; return null;
} }
@@ -123,14 +149,16 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
setSshSessionId(sessionId); setSshSessionId(sessionId);
setConnectionCache(prev => ({ setConnectionCache((prev) => ({
...prev, ...prev,
[sessionId]: {sessionId, timestamp: Date.now()} [sessionId]: { sessionId, timestamp: Date.now() },
})); }));
return sessionId; return sessionId;
} catch (err: any) { } catch (err: any) {
toast.error(err?.response?.data?.error || t('fileManager.failedToConnectSSH')); toast.error(
err?.response?.data?.error || t("fileManager.failedToConnectSSH"),
);
setSshSessionId(null); setSshSessionId(null);
return null; return null;
} finally { } finally {
@@ -153,8 +181,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
if (host) { if (host) {
pinnedFiles = await getFileManagerPinned(host.id); pinnedFiles = await getFileManagerPinned(host.id);
} }
} catch (err) { } catch (err) {}
}
if (host && sshSessionId) { if (host && sshSessionId) {
let res: any[] = []; let res: any[] = [];
@@ -167,7 +194,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
setSshSessionId(newSessionId); setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath); res = await listSSHFiles(newSessionId, currentPath);
} else { } else {
throw new Error(t('fileManager.failedToReconnectSSH')); throw new Error(t("fileManager.failedToReconnectSSH"));
} }
} else { } else {
res = await listSSHFiles(sshSessionId, currentPath); res = await listSSHFiles(sshSessionId, currentPath);
@@ -183,14 +210,17 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
} }
const processedFiles = (res || []).map((f: any) => { const processedFiles = (res || []).map((f: any) => {
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name; const filePath =
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath); currentPath + (currentPath.endsWith("/") ? "" : "/") + f.name;
const isPinned = pinnedFiles.some(
(pinned) => pinned.path === filePath,
);
return { return {
...f, ...f,
path: filePath, path: filePath,
isPinned, isPinned,
isSSH: true, isSSH: true,
sshSessionId: sshSessionId sshSessionId: sshSessionId,
}; };
}); });
@@ -198,7 +228,11 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
} }
} catch (err: any) { } catch (err: any) {
setFiles([]); setFiles([]);
toast.error(err?.response?.data?.error || err?.message || t('fileManager.failedToListFiles')); toast.error(
err?.response?.data?.error ||
err?.message ||
t("fileManager.failedToListFiles"),
);
} finally { } finally {
setFilesLoading(false); setFilesLoading(false);
setFetchingFiles(false); setFetchingFiles(false);
@@ -241,7 +275,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
fetchFiles(); fetchFiles();
} }
}, },
getCurrentPath: () => currentPath getCurrentPath: () => currentPath,
})); }));
useEffect(() => { useEffect(() => {
@@ -250,7 +284,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
} }
}, [currentPath]); }, [currentPath]);
const filteredFiles = files.filter(file => { const filteredFiles = files.filter((file) => {
const q = debouncedFileSearch.trim().toLowerCase(); const q = debouncedFileSearch.trim().toLowerCase();
if (!q) return true; if (!q) return true;
return file.name.toLowerCase().includes(q); return file.name.toLowerCase().includes(q);
@@ -288,7 +322,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
visible: true, visible: true,
x, x,
y, y,
item item,
}); });
}; };
@@ -304,7 +338,9 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
try { try {
await renameSSHItem(sshSessionId, item.path, newName.trim()); await renameSSHItem(sshSessionId, item.path, newName.trim());
toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.renamedSuccessfully')}`); toast.success(
`${item.type === "directory" ? t("common.folder") : t("common.file")} ${t("common.renamedSuccessfully")}`,
);
setRenamingItem(null); setRenamingItem(null);
if (onOperationComplete) { if (onOperationComplete) {
onOperationComplete(); onOperationComplete();
@@ -312,7 +348,9 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
fetchFiles(); fetchFiles();
} }
} catch (error: any) { } catch (error: any) {
toast.error(error?.response?.data?.error || t('fileManager.failedToRenameItem')); toast.error(
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
);
} }
}; };
@@ -328,8 +366,8 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
useEffect(() => { useEffect(() => {
const handleClickOutside = () => closeContextMenu(); const handleClickOutside = () => closeContextMenu();
document.addEventListener('click', handleClickOutside); document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside); return () => document.removeEventListener("click", handleClickOutside);
}, []); }, []);
const handlePathChange = (newPath: string) => { const handlePathChange = (newPath: string) => {
@@ -340,60 +378,66 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
return ( return (
<div className="flex flex-col h-full w-[256px] max-w-[256px]"> <div className="flex flex-col h-full w-[256px] max-w-[256px]">
<div className="flex flex-col flex-grow min-h-0"> <div className="flex flex-col flex-grow min-h-0">
<div <div className="flex-1 w-full h-full flex flex-col bg-dark-bg-darkest border-r-2 border-dark-border overflow-hidden p-0 relative min-h-0">
className="flex-1 w-full h-full flex flex-col bg-dark-bg-darkest border-r-2 border-dark-border overflow-hidden p-0 relative min-h-0">
{host && ( {host && (
<div className="flex flex-col h-full w-full max-w-[260px]"> <div className="flex flex-col h-full w-full max-w-[260px]">
<div <div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-dark-border bg-dark-bg z-20 max-w-[260px]">
className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-dark-border bg-dark-bg z-20 max-w-[260px]">
<Button <Button
size="icon" size="icon"
variant="outline" variant="outline"
className="h-9 w-9 bg-dark-bg border-2 border-dark-border rounded-md hover:bg-dark-hover focus:outline-none focus:ring-2 focus:ring-ring" className="h-9 w-9 bg-dark-bg border-2 border-dark-border rounded-md hover:bg-dark-hover focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => { onClick={() => {
let path = currentPath; let path = currentPath;
if (path && path !== '/' && path !== '') { if (path && path !== "/" && path !== "") {
if (path.endsWith('/')) path = path.slice(0, -1); if (path.endsWith("/")) path = path.slice(0, -1);
const lastSlash = path.lastIndexOf('/'); const lastSlash = path.lastIndexOf("/");
if (lastSlash > 0) { if (lastSlash > 0) {
handlePathChange(path.slice(0, lastSlash)); handlePathChange(path.slice(0, lastSlash));
} else { } else {
handlePathChange('/'); handlePathChange("/");
} }
} else { } else {
handlePathChange('/'); handlePathChange("/");
} }
}} }}
> >
<ArrowUp className="w-4 h-4" /> <ArrowUp className="w-4 h-4" />
</Button> </Button>
<Input ref={pathInputRef} value={currentPath} <Input
onChange={e => handlePathChange(e.target.value)} ref={pathInputRef}
value={currentPath}
onChange={(e) => handlePathChange(e.target.value)}
className="flex-1 bg-dark-bg border-2 border-dark-border-hover text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-dark-border-light" className="flex-1 bg-dark-bg border-2 border-dark-border-hover text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-dark-border-light"
/> />
</div> </div>
<div className="px-2 py-2 border-b-1 border-dark-border bg-dark-bg"> <div className="px-2 py-2 border-b-1 border-dark-border bg-dark-bg">
<Input <Input
placeholder={t('fileManager.searchFilesAndFolders')} placeholder={t("fileManager.searchFilesAndFolders")}
className="w-full h-7 text-sm bg-dark-bg-button border-2 border-dark-border-hover text-white placeholder:text-muted-foreground rounded-md" className="w-full h-7 text-sm bg-dark-bg-button border-2 border-dark-border-hover text-white placeholder:text-muted-foreground rounded-md"
autoComplete="off" autoComplete="off"
value={fileSearch} value={fileSearch}
onChange={e => setFileSearch(e.target.value)} onChange={(e) => setFileSearch(e.target.value)}
/> />
</div> </div>
<div className="flex-1 min-h-0 w-full bg-dark-bg-darkest border-t-1 border-dark-border"> <div className="flex-1 min-h-0 w-full bg-dark-bg-darkest border-t-1 border-dark-border">
<ScrollArea className="h-full w-full bg-dark-bg-darkest"> <ScrollArea className="h-full w-full bg-dark-bg-darkest">
<div className="p-2 pb-0"> <div className="p-2 pb-0">
{connectingSSH || filesLoading ? ( {connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">{t('common.loading')}</div> <div className="text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : filteredFiles.length === 0 ? ( ) : filteredFiles.length === 0 ? (
<div <div className="text-xs text-muted-foreground">
className="text-xs text-muted-foreground">{t('fileManager.noFilesOrFoldersFound')}</div> {t("fileManager.noFilesOrFoldersFound")}
</div>
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => { {filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some((t: any) => t.id === item.path); const isOpen = (tabs || []).some(
const isRenaming = renamingItem?.item?.path === item.path; (t: any) => t.id === item.path,
);
const isRenaming =
renamingItem?.item?.path === item.path;
const isDeleting = false; const isDeleting = false;
return ( return (
@@ -401,57 +445,79 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
key={item.path} key={item.path}
className={cn( className={cn(
"flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded group max-w-[220px] mb-2 relative", "flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded group max-w-[220px] mb-2 relative",
isOpen && "opacity-60 cursor-not-allowed pointer-events-none" isOpen &&
"opacity-60 cursor-not-allowed pointer-events-none",
)} )}
onContextMenu={(e) => !isOpen && handleContextMenu(e, item)} onContextMenu={(e) =>
!isOpen && handleContextMenu(e, item)
}
> >
{isRenaming ? ( {isRenaming ? (
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === 'directory' ? {item.type === "directory" ? (
<Folder <Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
className="w-4 h-4 text-blue-400 flex-shrink-0"/> : ) : (
<File <File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
className="w-4 h-4 text-muted-foreground flex-shrink-0"/>} )}
<Input <Input
value={renamingItem.newName} value={renamingItem.newName}
onChange={(e) => setRenamingItem(prev => prev ? { onChange={(e) =>
setRenamingItem((prev) =>
prev
? {
...prev, ...prev,
newName: e.target.value newName: e.target.value,
} : null)} }
: null,
)
}
className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white" className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white"
autoFocus autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
handleRename(item, renamingItem.newName); handleRename(
} else if (e.key === 'Escape') { item,
renamingItem.newName,
);
} else if (e.key === "Escape") {
setRenamingItem(null); setRenamingItem(null);
} }
}} }}
onBlur={() => handleRename(item, renamingItem.newName)} onBlur={() =>
handleRename(item, renamingItem.newName)
}
/> />
</div> </div>
) : ( ) : (
<> <>
<div <div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0" className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => !isOpen && (item.type === 'directory' ? handlePathChange(item.path) : onOpenFile({ onClick={() =>
!isOpen &&
(item.type === "directory"
? handlePathChange(item.path)
: onOpenFile({
name: item.name, name: item.name,
path: item.path, path: item.path,
isSSH: item.isSSH, isSSH: item.isSSH,
sshSessionId: item.sshSessionId sshSessionId: item.sshSessionId,
}))} }))
}
> >
{item.type === 'directory' ? {item.type === "directory" ? (
<Folder <Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
className="w-4 h-4 text-blue-400 flex-shrink-0"/> : ) : (
<File <File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
className="w-4 h-4 text-muted-foreground flex-shrink-0"/>} )}
<span <span className="text-sm text-white truncate flex-1 min-w-0">
className="text-sm text-white truncate flex-1 min-w-0">{item.name}</span> {item.name}
</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{item.type === 'file' && ( {item.type === "file" && (
<Button size="icon" variant="ghost" <Button
size="icon"
variant="ghost"
className="h-7 w-7" className="h-7 w-7"
disabled={isOpen} disabled={isOpen}
onClick={async (e) => { onClick={async (e) => {
@@ -463,35 +529,45 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
path: item.path, path: item.path,
hostId: host?.id, hostId: host?.id,
isSSH: true, isSSH: true,
sshSessionId: host?.id.toString() sshSessionId:
host?.id.toString(),
}); });
setFiles(files.map(f => setFiles(
f.path === item.path ? { files.map((f) =>
f.path === item.path
? {
...f, ...f,
isPinned: false isPinned: false,
} : f }
)); : f,
),
);
} else { } else {
await addFileManagerPinned({ await addFileManagerPinned({
name: item.name, name: item.name,
path: item.path, path: item.path,
hostId: host?.id, hostId: host?.id,
isSSH: true, isSSH: true,
sshSessionId: host?.id.toString() sshSessionId:
host?.id.toString(),
}); });
setFiles(files.map(f => setFiles(
f.path === item.path ? { files.map((f) =>
f.path === item.path
? {
...f, ...f,
isPinned: true isPinned: true,
} : f
));
} }
} catch (err) { : f,
),
);
} }
} catch (err) {}
}} }}
> >
<Pin <Pin
className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/> className={`w-1 h-1 ${item.isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
/>
</Button> </Button>
)} )}
{!isOpen && ( {!isOpen && (
@@ -1,8 +1,8 @@
import React from 'react'; import React from "react";
import {Button} from '@/components/ui/button.tsx'; import { Button } from "@/components/ui/button.tsx";
import {Card} from '@/components/ui/card.tsx'; import { Card } from "@/components/ui/card.tsx";
import {Folder, File, Trash2, Pin} from 'lucide-react'; import { Folder, File, Trash2, Pin } from "lucide-react";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
interface SSHConnection { interface SSHConnection {
id: string; id: string;
@@ -15,7 +15,7 @@ interface SSHConnection {
interface FileItem { interface FileItem {
name: string; name: string;
type: 'file' | 'directory' | 'link'; type: "file" | "directory" | "link";
path: string; path: string;
isStarred?: boolean; isStarred?: boolean;
} }
@@ -58,40 +58,68 @@ export function FileManagerLeftSidebarFileViewer({
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto"> <div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span <span className="text-xs text-muted-foreground font-semibold">
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? t('common.sshPath') : t('common.localPath')}</span> {isSSHMode ? t("common.sshPath") : t("common.localPath")}
</span>
<span className="text-xs text-white truncate">{currentPath}</span> <span className="text-xs text-white truncate">{currentPath}</span>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="text-xs text-muted-foreground">{t('common.loading')}</div> <div className="text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : error ? ( ) : error ? (
<div className="text-xs text-red-500">{error}</div> <div className="text-xs text-red-500">{error}</div>
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{files.map((item) => ( {files.map((item) => (
<Card key={item.path} <Card
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded"> key={item.path}
<div className="flex items-center gap-2 flex-1 cursor-pointer" className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded"
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}> >
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> : <div
<File className="w-4 h-4 text-muted-foreground"/>} className="flex items-center gap-2 flex-1 cursor-pointer"
<span className="text-sm text-white truncate">{item.name}</span> onClick={() =>
item.type === "directory"
? onOpenFolder(item)
: onOpenFile(item)
}
>
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400" />
) : (
<File className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm text-white truncate">
{item.name}
</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button size="icon" variant="ghost" className="h-7 w-7" <Button
onClick={() => onStarFile(item)}> size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onStarFile(item)}
>
<Pin <Pin
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/> className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
/>
</Button> </Button>
<Button size="icon" variant="ghost" className="h-7 w-7" <Button
onClick={() => onDeleteFile(item)}> size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onDeleteFile(item)}
>
<Trash2 className="w-4 h-4 text-red-500" /> <Trash2 className="w-4 h-4 text-red-500" />
</Button> </Button>
</div> </div>
</Card> </Card>
))} ))}
{files.length === 0 && {files.length === 0 && (
<div className="text-xs text-muted-foreground">No files or folders found.</div>} <div className="text-xs text-muted-foreground">
No files or folders found.
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -1,8 +1,8 @@
import React, {useState, useRef, useEffect} from 'react'; import React, { useState, useRef, useEffect } from "react";
import {Button} from '@/components/ui/button.tsx'; import { Button } from "@/components/ui/button.tsx";
import {Input} from '@/components/ui/input.tsx'; import { Input } from "@/components/ui/input.tsx";
import {Card} from '@/components/ui/card.tsx'; import { Card } from "@/components/ui/card.tsx";
import {Separator} from '@/components/ui/separator.tsx'; import { Separator } from "@/components/ui/separator.tsx";
import { import {
Upload, Upload,
FilePlus, FilePlus,
@@ -12,18 +12,18 @@ import {
X, X,
AlertCircle, AlertCircle,
FileText, FileText,
Folder Folder,
} from 'lucide-react'; } from "lucide-react";
import {cn} from '@/lib/utils.ts'; import { cn } from "@/lib/utils.ts";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import type {FileManagerOperationsProps} from '../../../types/index.js'; import type { FileManagerOperationsProps } from "../../../types/index.js";
export function FileManagerOperations({ export function FileManagerOperations({
currentPath, currentPath,
sshSessionId, sshSessionId,
onOperationComplete, onOperationComplete,
onError, onError,
onSuccess onSuccess,
}: FileManagerOperationsProps) { }: FileManagerOperationsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
@@ -33,13 +33,13 @@ export function FileManagerOperations({
const [showRename, setShowRename] = useState(false); const [showRename, setShowRename] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null); const [uploadFile, setUploadFile] = useState<File | null>(null);
const [newFileName, setNewFileName] = useState(''); const [newFileName, setNewFileName] = useState("");
const [newFolderName, setNewFolderName] = useState(''); const [newFolderName, setNewFolderName] = useState("");
const [deletePath, setDeletePath] = useState(''); const [deletePath, setDeletePath] = useState("");
const [deleteIsDirectory, setDeleteIsDirectory] = useState(false); const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
const [renamePath, setRenamePath] = useState(''); const [renamePath, setRenamePath] = useState("");
const [renameIsDirectory, setRenameIsDirectory] = useState(false); const [renameIsDirectory, setRenameIsDirectory] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [showTextLabels, setShowTextLabels] = useState(true); const [showTextLabels, setShowTextLabels] = useState(true);
@@ -71,21 +71,30 @@ export function FileManagerOperations({
setIsLoading(true); setIsLoading(true);
const {toast} = await import('sonner'); const { toast } = await import("sonner");
const loadingToast = toast.loading(t('fileManager.uploadingFile', {name: uploadFile.name})); const loadingToast = toast.loading(
t("fileManager.uploadingFile", { name: uploadFile.name }),
);
try { try {
const content = await uploadFile.text(); const content = await uploadFile.text();
const {uploadSSHFile} = await import('@/ui/main-axios.ts'); const { uploadSSHFile } = await import("@/ui/main-axios.ts");
const response = await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content); const response = await uploadSSHFile(
sshSessionId,
currentPath,
uploadFile.name,
content,
);
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } else {
onSuccess(t('fileManager.fileUploadedSuccessfully', {name: uploadFile.name})); onSuccess(
t("fileManager.fileUploadedSuccessfully", { name: uploadFile.name }),
);
} }
setShowUpload(false); setShowUpload(false);
@@ -93,7 +102,9 @@ export function FileManagerOperations({
onOperationComplete(); onOperationComplete();
} catch (error: any) { } catch (error: any) {
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
onError(error?.response?.data?.error || t('fileManager.failedToUploadFile')); onError(
error?.response?.data?.error || t("fileManager.failedToUploadFile"),
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -104,28 +115,40 @@ export function FileManagerOperations({
setIsLoading(true); setIsLoading(true);
const {toast} = await import('sonner'); const { toast } = await import("sonner");
const loadingToast = toast.loading(t('fileManager.creatingFile', {name: newFileName.trim()})); const loadingToast = toast.loading(
t("fileManager.creatingFile", { name: newFileName.trim() }),
);
try { try {
const {createSSHFile} = await import('@/ui/main-axios.ts'); const { createSSHFile } = await import("@/ui/main-axios.ts");
const response = await createSSHFile(sshSessionId, currentPath, newFileName.trim()); const response = await createSSHFile(
sshSessionId,
currentPath,
newFileName.trim(),
);
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } else {
onSuccess(t('fileManager.fileCreatedSuccessfully', {name: newFileName.trim()})); onSuccess(
t("fileManager.fileCreatedSuccessfully", {
name: newFileName.trim(),
}),
);
} }
setShowCreateFile(false); setShowCreateFile(false);
setNewFileName(''); setNewFileName("");
onOperationComplete(); onOperationComplete();
} catch (error: any) { } catch (error: any) {
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
onError(error?.response?.data?.error || t('fileManager.failedToCreateFile')); onError(
error?.response?.data?.error || t("fileManager.failedToCreateFile"),
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -136,28 +159,40 @@ export function FileManagerOperations({
setIsLoading(true); setIsLoading(true);
const {toast} = await import('sonner'); const { toast } = await import("sonner");
const loadingToast = toast.loading(t('fileManager.creatingFolder', {name: newFolderName.trim()})); const loadingToast = toast.loading(
t("fileManager.creatingFolder", { name: newFolderName.trim() }),
);
try { try {
const {createSSHFolder} = await import('@/ui/main-axios.ts'); const { createSSHFolder } = await import("@/ui/main-axios.ts");
const response = await createSSHFolder(sshSessionId, currentPath, newFolderName.trim()); const response = await createSSHFolder(
sshSessionId,
currentPath,
newFolderName.trim(),
);
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } else {
onSuccess(t('fileManager.folderCreatedSuccessfully', {name: newFolderName.trim()})); onSuccess(
t("fileManager.folderCreatedSuccessfully", {
name: newFolderName.trim(),
}),
);
} }
setShowCreateFolder(false); setShowCreateFolder(false);
setNewFolderName(''); setNewFolderName("");
onOperationComplete(); onOperationComplete();
} catch (error: any) { } catch (error: any) {
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder')); onError(
error?.response?.data?.error || t("fileManager.failedToCreateFolder"),
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -168,32 +203,48 @@ export function FileManagerOperations({
setIsLoading(true); setIsLoading(true);
const {toast} = await import('sonner'); const { toast } = await import("sonner");
const loadingToast = toast.loading(t('fileManager.deletingItem', { const loadingToast = toast.loading(
type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file'), t("fileManager.deletingItem", {
name: deletePath.split('/').pop() type: deleteIsDirectory
})); ? t("fileManager.folder")
: t("fileManager.file"),
name: deletePath.split("/").pop(),
}),
);
try { try {
const {deleteSSHItem} = await import('@/ui/main-axios.ts'); const { deleteSSHItem } = await import("@/ui/main-axios.ts");
const response = await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory); const response = await deleteSSHItem(
sshSessionId,
deletePath,
deleteIsDirectory,
);
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } else {
onSuccess(t('fileManager.itemDeletedSuccessfully', {type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file')})); onSuccess(
t("fileManager.itemDeletedSuccessfully", {
type: deleteIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
}),
);
} }
setShowDelete(false); setShowDelete(false);
setDeletePath(''); setDeletePath("");
setDeleteIsDirectory(false); setDeleteIsDirectory(false);
onOperationComplete(); onOperationComplete();
} catch (error: any) { } catch (error: any) {
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem')); onError(
error?.response?.data?.error || t("fileManager.failedToDeleteItem"),
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -204,34 +255,50 @@ export function FileManagerOperations({
setIsLoading(true); setIsLoading(true);
const {toast} = await import('sonner'); const { toast } = await import("sonner");
const loadingToast = toast.loading(t('fileManager.renamingItem', { const loadingToast = toast.loading(
type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file'), t("fileManager.renamingItem", {
oldName: renamePath.split('/').pop(), type: renameIsDirectory
newName: newName.trim() ? t("fileManager.folder")
})); : t("fileManager.file"),
oldName: renamePath.split("/").pop(),
newName: newName.trim(),
}),
);
try { try {
const {renameSSHItem} = await import('@/ui/main-axios.ts'); const { renameSSHItem } = await import("@/ui/main-axios.ts");
const response = await renameSSHItem(sshSessionId, renamePath, newName.trim()); const response = await renameSSHItem(
sshSessionId,
renamePath,
newName.trim(),
);
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
if (response?.toast) { if (response?.toast) {
toast[response.toast.type](response.toast.message); toast[response.toast.type](response.toast.message);
} else { } else {
onSuccess(t('fileManager.itemRenamedSuccessfully', {type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file')})); onSuccess(
t("fileManager.itemRenamedSuccessfully", {
type: renameIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
}),
);
} }
setShowRename(false); setShowRename(false);
setRenamePath(''); setRenamePath("");
setRenameIsDirectory(false); setRenameIsDirectory(false);
setNewName(''); setNewName("");
onOperationComplete(); onOperationComplete();
} catch (error: any) { } catch (error: any) {
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
onError(error?.response?.data?.error || t('fileManager.failedToRenameItem')); onError(
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -255,20 +322,22 @@ export function FileManagerOperations({
setShowDelete(false); setShowDelete(false);
setShowRename(false); setShowRename(false);
setUploadFile(null); setUploadFile(null);
setNewFileName(''); setNewFileName("");
setNewFolderName(''); setNewFolderName("");
setDeletePath(''); setDeletePath("");
setDeleteIsDirectory(false); setDeleteIsDirectory(false);
setRenamePath(''); setRenamePath("");
setRenameIsDirectory(false); setRenameIsDirectory(false);
setNewName(''); setNewName("");
}; };
if (!sshSessionId) { if (!sshSessionId) {
return ( return (
<div className="p-4 text-center"> <div className="p-4 text-center">
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" /> <AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">{t('fileManager.connectToSsh')}</p> <p className="text-sm text-muted-foreground">
{t("fileManager.connectToSsh")}
</p>
</div> </div>
); );
} }
@@ -281,50 +350,60 @@ export function FileManagerOperations({
size="sm" size="sm"
onClick={() => setShowUpload(true)} onClick={() => setShowUpload(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover" className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t('fileManager.uploadFile')} title={t("fileManager.uploadFile")}
> >
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} /> <Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && <span className="truncate">{t('fileManager.uploadFile')}</span>} {showTextLabels && (
<span className="truncate">{t("fileManager.uploadFile")}</span>
)}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setShowCreateFile(true)} onClick={() => setShowCreateFile(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover" className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t('fileManager.newFile')} title={t("fileManager.newFile")}
> >
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} /> <FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && <span className="truncate">{t('fileManager.newFile')}</span>} {showTextLabels && (
<span className="truncate">{t("fileManager.newFile")}</span>
)}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setShowCreateFolder(true)} onClick={() => setShowCreateFolder(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover" className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t('fileManager.newFolder')} title={t("fileManager.newFolder")}
> >
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} /> <FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && <span className="truncate">{t('fileManager.newFolder')}</span>} {showTextLabels && (
<span className="truncate">{t("fileManager.newFolder")}</span>
)}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setShowRename(true)} onClick={() => setShowRename(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover" className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t('fileManager.rename')} title={t("fileManager.rename")}
> >
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} /> <Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && <span className="truncate">{t('fileManager.rename')}</span>} {showTextLabels && (
<span className="truncate">{t("fileManager.rename")}</span>
)}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setShowDelete(true)} onClick={() => setShowDelete(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-2" className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-2"
title={t('fileManager.deleteItem')} title={t("fileManager.deleteItem")}
> >
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} /> <Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && <span className="truncate">{t('fileManager.deleteItem')}</span>} {showTextLabels && (
<span className="truncate">{t("fileManager.deleteItem")}</span>
)}
</Button> </Button>
</div> </div>
@@ -332,8 +411,12 @@ export function FileManagerOperations({
<div className="flex items-start gap-2 text-sm"> <div className="flex items-start gap-2 text-sm">
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" /> <Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span className="text-muted-foreground block mb-1">{t('fileManager.currentPath')}:</span> <span className="text-muted-foreground block mb-1">
<span className="text-white font-mono text-xs break-all leading-relaxed">{currentPath}</span> {t("fileManager.currentPath")}:
</span>
<span className="text-white font-mono text-xs break-all leading-relaxed">
{currentPath}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -346,10 +429,12 @@ export function FileManagerOperations({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1"> <h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" /> <Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span className="break-words">{t('fileManager.uploadFileTitle')}</span> <span className="break-words">
{t("fileManager.uploadFileTitle")}
</span>
</h3> </h3>
<p className="text-xs text-muted-foreground break-words"> <p className="text-xs text-muted-foreground break-words">
{t('fileManager.maxFileSize')} {t("fileManager.maxFileSize")}
</p> </p>
</div> </div>
<Button <Button
@@ -367,7 +452,9 @@ export function FileManagerOperations({
{uploadFile ? ( {uploadFile ? (
<div className="space-y-3"> <div className="space-y-3">
<FileText className="w-12 h-12 text-blue-400 mx-auto" /> <FileText className="w-12 h-12 text-blue-400 mx-auto" />
<p className="text-white font-medium text-sm break-words px-2">{uploadFile.name}</p> <p className="text-white font-medium text-sm break-words px-2">
{uploadFile.name}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{(uploadFile.size / 1024).toFixed(2)} KB {(uploadFile.size / 1024).toFixed(2)} KB
</p> </p>
@@ -377,20 +464,22 @@ export function FileManagerOperations({
onClick={() => setUploadFile(null)} onClick={() => setUploadFile(null)}
className="w-full text-sm h-8" className="w-full text-sm h-8"
> >
{t('fileManager.removeFile')} {t("fileManager.removeFile")}
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<Upload className="w-12 h-12 text-muted-foreground mx-auto" /> <Upload className="w-12 h-12 text-muted-foreground mx-auto" />
<p className="text-white text-sm break-words px-2">{t('fileManager.clickToSelectFile')}</p> <p className="text-white text-sm break-words px-2">
{t("fileManager.clickToSelectFile")}
</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={openFileDialog} onClick={openFileDialog}
className="w-full text-sm h-8" className="w-full text-sm h-8"
> >
{t('fileManager.chooseFile')} {t("fileManager.chooseFile")}
</Button> </Button>
</div> </div>
)} )}
@@ -410,7 +499,9 @@ export function FileManagerOperations({
disabled={!uploadFile || isLoading} disabled={!uploadFile || isLoading}
className="w-full text-sm h-9" className="w-full text-sm h-9"
> >
{isLoading ? t('fileManager.uploading') : t('fileManager.uploadFile')} {isLoading
? t("fileManager.uploading")
: t("fileManager.uploadFile")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -418,7 +509,7 @@ export function FileManagerOperations({
disabled={isLoading} disabled={isLoading}
className="w-full text-sm h-9" className="w-full text-sm h-9"
> >
{t('common.cancel')} {t("common.cancel")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -431,7 +522,9 @@ export function FileManagerOperations({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2"> <h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" /> <FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span className="break-words">{t('fileManager.createNewFile')}</span> <span className="break-words">
{t("fileManager.createNewFile")}
</span>
</h3> </h3>
</div> </div>
<Button <Button
@@ -447,14 +540,14 @@ export function FileManagerOperations({
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-sm font-medium text-white mb-2 block"> <label className="text-sm font-medium text-white mb-2 block">
{t('fileManager.fileName')} {t("fileManager.fileName")}
</label> </label>
<Input <Input
value={newFileName} value={newFileName}
onChange={(e) => setNewFileName(e.target.value)} onChange={(e) => setNewFileName(e.target.value)}
placeholder={t('placeholders.fileName')} placeholder={t("placeholders.fileName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm" className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()} onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
/> />
</div> </div>
@@ -464,7 +557,9 @@ export function FileManagerOperations({
disabled={!newFileName.trim() || isLoading} disabled={!newFileName.trim() || isLoading}
className="w-full text-sm h-9" className="w-full text-sm h-9"
> >
{isLoading ? t('fileManager.creating') : t('fileManager.createFile')} {isLoading
? t("fileManager.creating")
: t("fileManager.createFile")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -472,7 +567,7 @@ export function FileManagerOperations({
disabled={isLoading} disabled={isLoading}
className="w-full text-sm h-9" className="w-full text-sm h-9"
> >
{t('common.cancel')} {t("common.cancel")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -485,7 +580,9 @@ export function FileManagerOperations({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2"> <h3 className="text-base font-semibold text-white flex items-center gap-2">
<FolderPlus className="w-6 h-6 flex-shrink-0" /> <FolderPlus className="w-6 h-6 flex-shrink-0" />
<span className="break-words">{t('fileManager.createNewFolder')}</span> <span className="break-words">
{t("fileManager.createNewFolder")}
</span>
</h3> </h3>
</div> </div>
<Button <Button
@@ -501,14 +598,14 @@ export function FileManagerOperations({
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-sm font-medium text-white mb-2 block"> <label className="text-sm font-medium text-white mb-2 block">
{t('fileManager.folderName')} {t("fileManager.folderName")}
</label> </label>
<Input <Input
value={newFolderName} value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)} onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t('placeholders.folderName')} placeholder={t("placeholders.folderName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm" className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()} onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
/> />
</div> </div>
@@ -518,7 +615,9 @@ export function FileManagerOperations({
disabled={!newFolderName.trim() || isLoading} disabled={!newFolderName.trim() || isLoading}
className="w-full text-sm h-9" className="w-full text-sm h-9"
> >
{isLoading ? t('fileManager.creating') : t('fileManager.createFolder')} {isLoading
? t("fileManager.creating")
: t("fileManager.createFolder")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -526,7 +625,7 @@ export function FileManagerOperations({
disabled={isLoading} disabled={isLoading}
className="w-full text-sm h-9" className="w-full text-sm h-9"
> >
{t('common.cancel')} {t("common.cancel")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -539,7 +638,9 @@ export function FileManagerOperations({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2"> <h3 className="text-base font-semibold text-white flex items-center gap-2">
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0" /> <Trash2 className="w-6 h-6 text-red-400 flex-shrink-0" />
<span className="break-words">{t('fileManager.deleteItem')}</span> <span className="break-words">
{t("fileManager.deleteItem")}
</span>
</h3> </h3>
</div> </div>
<Button <Button
@@ -556,19 +657,20 @@ export function FileManagerOperations({
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3"> <div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
<div className="flex items-start gap-2 text-red-300"> <div className="flex items-start gap-2 text-red-300">
<AlertCircle className="w-5 h-5 flex-shrink-0" /> <AlertCircle className="w-5 h-5 flex-shrink-0" />
<span <span className="text-sm font-medium break-words">
className="text-sm font-medium break-words">{t('fileManager.warningCannotUndo')}</span> {t("fileManager.warningCannotUndo")}
</span>
</div> </div>
</div> </div>
<div> <div>
<label className="text-sm font-medium text-white mb-2 block"> <label className="text-sm font-medium text-white mb-2 block">
{t('fileManager.itemPath')} {t("fileManager.itemPath")}
</label> </label>
<Input <Input
value={deletePath} value={deletePath}
onChange={(e) => setDeletePath(e.target.value)} onChange={(e) => setDeletePath(e.target.value)}
placeholder={t('placeholders.fullPath')} placeholder={t("placeholders.fullPath")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm" className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
/> />
</div> </div>
@@ -581,8 +683,11 @@ export function FileManagerOperations({
onChange={(e) => setDeleteIsDirectory(e.target.checked)} onChange={(e) => setDeleteIsDirectory(e.target.checked)}
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0" className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
/> />
<label htmlFor="deleteIsDirectory" className="text-sm text-white break-words"> <label
{t('fileManager.thisIsDirectory')} htmlFor="deleteIsDirectory"
className="text-sm text-white break-words"
>
{t("fileManager.thisIsDirectory")}
</label> </label>
</div> </div>
@@ -593,7 +698,9 @@ export function FileManagerOperations({
variant="destructive" variant="destructive"
className="w-full text-sm h-9" className="w-full text-sm h-9"
> >
{isLoading ? t('fileManager.deleting') : t('fileManager.deleteItem')} {isLoading
? t("fileManager.deleting")
: t("fileManager.deleteItem")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -601,7 +708,7 @@ export function FileManagerOperations({
disabled={isLoading} disabled={isLoading}
className="w-full text-sm h-9" className="w-full text-sm h-9"
> >
{t('common.cancel')} {t("common.cancel")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -614,7 +721,9 @@ export function FileManagerOperations({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2"> <h3 className="text-base font-semibold text-white flex items-center gap-2">
<Edit3 className="w-6 h-6 flex-shrink-0" /> <Edit3 className="w-6 h-6 flex-shrink-0" />
<span className="break-words">{t('fileManager.renameItem')}</span> <span className="break-words">
{t("fileManager.renameItem")}
</span>
</h3> </h3>
</div> </div>
<Button <Button
@@ -630,26 +739,26 @@ export function FileManagerOperations({
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-sm font-medium text-white mb-2 block"> <label className="text-sm font-medium text-white mb-2 block">
{t('fileManager.currentPathLabel')} {t("fileManager.currentPathLabel")}
</label> </label>
<Input <Input
value={renamePath} value={renamePath}
onChange={(e) => setRenamePath(e.target.value)} onChange={(e) => setRenamePath(e.target.value)}
placeholder={t('placeholders.currentPath')} placeholder={t("placeholders.currentPath")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm" className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
/> />
</div> </div>
<div> <div>
<label className="text-sm font-medium text-white mb-2 block"> <label className="text-sm font-medium text-white mb-2 block">
{t('fileManager.newName')} {t("fileManager.newName")}
</label> </label>
<Input <Input
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder={t('placeholders.newName')} placeholder={t("placeholders.newName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm" className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === 'Enter' && handleRename()} onKeyDown={(e) => e.key === "Enter" && handleRename()}
/> />
</div> </div>
@@ -661,8 +770,11 @@ export function FileManagerOperations({
onChange={(e) => setRenameIsDirectory(e.target.checked)} onChange={(e) => setRenameIsDirectory(e.target.checked)}
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0" className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
/> />
<label htmlFor="renameIsDirectory" className="text-sm text-white break-words"> <label
{t('fileManager.thisIsDirectoryRename')} htmlFor="renameIsDirectory"
className="text-sm text-white break-words"
>
{t("fileManager.thisIsDirectoryRename")}
</label> </label>
</div> </div>
@@ -672,7 +784,9 @@ export function FileManagerOperations({
disabled={!renamePath || !newName.trim() || isLoading} disabled={!renamePath || !newName.trim() || isLoading}
className="w-full text-sm h-9" className="w-full text-sm h-9"
> >
{isLoading ? t('fileManager.renaming') : t('fileManager.renameItem')} {isLoading
? t("fileManager.renaming")
: t("fileManager.renameItem")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -680,7 +794,7 @@ export function FileManagerOperations({
disabled={isLoading} disabled={isLoading}
className="w-full text-sm h-9" className="w-full text-sm h-9"
> >
{t('common.cancel')} {t("common.cancel")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,6 +1,6 @@
import React from 'react'; import React from "react";
import {Button} from '@/components/ui/button.tsx'; import { Button } from "@/components/ui/button.tsx";
import {X, Home} from 'lucide-react'; import { X, Home } from "lucide-react";
interface FileManagerTab { interface FileManagerTab {
id: string | number; id: string | number;
@@ -15,24 +15,34 @@ interface FileManagerTabList {
onHomeClick: () => void; onHomeClick: () => void;
} }
export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) { export function FileManagerTabList({
tabs,
activeTab,
setActiveTab,
closeTab,
onHomeClick,
}: FileManagerTabList) {
return ( return (
<div className="inline-flex items-center h-full gap-2"> <div className="inline-flex items-center h-full gap-2">
<Button <Button
onClick={onHomeClick} onClick={onHomeClick}
variant="outline" variant="outline"
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === 'home' ? '!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white' : ''}`} className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === "home" ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
> >
<Home className="w-4 h-4" /> <Home className="w-4 h-4" />
</Button> </Button>
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = tab.id === activeTab; const isActive = tab.id === activeTab;
return ( return (
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group"> <div
key={tab.id}
className="inline-flex rounded-md shadow-sm"
role="group"
>
<Button <Button
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
variant="outline" variant="outline"
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white' : ''}`} className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
> >
{tab.title} {tab.title}
</Button> </Button>
@@ -1,15 +1,23 @@
import React, { useState } from "react"; import React, { useState } from "react";
import {HostManagerViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx" import { HostManagerViewer } from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { HostManagerEditor } from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx"; import { HostManagerEditor } from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx";
import { CredentialsManager } from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx"; import { CredentialsManager } from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
import { CredentialEditor } from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx"; import { CredentialEditor } from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
import { useSidebar } from "@/components/ui/sidebar.tsx"; import { useSidebar } from "@/components/ui/sidebar.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type {SSHHost, HostManagerProps} from '../../../types/index'; import type { SSHHost, HostManagerProps } from "../../../types/index";
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement { export function HostManager({
onSelectView,
isTopbarOpen,
}: HostManagerProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState("host_viewer"); const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState<SSHHost | null>(null); const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
@@ -37,7 +45,6 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
setActiveTab("credentials"); setActiveTab("credentials");
}; };
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
setActiveTab(value); setActiveTab(value);
if (value !== "add_host") { if (value !== "add_host") {
@@ -49,7 +56,7 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
}; };
const topMarginPx = isTopbarOpen ? 74 : 26; const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8; const bottomMarginPx = 8;
return ( return (
@@ -62,27 +69,42 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
marginRight: 17, marginRight: 17,
marginTop: topMarginPx, marginTop: topMarginPx,
marginBottom: bottomMarginPx, marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)` height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}} }}
> >
<Tabs value={activeTab} onValueChange={handleTabChange} <Tabs
className="flex-1 flex flex-col h-full min-h-0"> value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col h-full min-h-0"
>
<TabsList className="bg-dark-bg border-2 border-dark-border mt-1.5"> <TabsList className="bg-dark-bg border-2 border-dark-border mt-1.5">
<TabsTrigger value="host_viewer">{t('hosts.hostViewer')}</TabsTrigger> <TabsTrigger value="host_viewer">
{t("hosts.hostViewer")}
</TabsTrigger>
<TabsTrigger value="add_host"> <TabsTrigger value="add_host">
{editingHost ? t('hosts.editHost') : t('hosts.addHost')} {editingHost ? t("hosts.editHost") : t("hosts.addHost")}
</TabsTrigger> </TabsTrigger>
<div className="h-6 w-px bg-dark-border mx-1"></div> <div className="h-6 w-px bg-dark-border mx-1"></div>
<TabsTrigger value="credentials">{t('credentials.credentialsViewer')}</TabsTrigger> <TabsTrigger value="credentials">
{t("credentials.credentialsViewer")}
</TabsTrigger>
<TabsTrigger value="add_credential"> <TabsTrigger value="add_credential">
{editingCredential ? t('credentials.editCredential') : t('credentials.addCredential')} {editingCredential
? t("credentials.editCredential")
: t("credentials.addCredential")}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0"> <TabsContent
value="host_viewer"
className="flex-1 flex flex-col h-full min-h-0"
>
<Separator className="p-0.25 -mt-0.5 mb-1" /> <Separator className="p-0.25 -mt-0.5 mb-1" />
<HostManagerViewer onEditHost={handleEditHost} /> <HostManagerViewer onEditHost={handleEditHost} />
</TabsContent> </TabsContent>
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0"> <TabsContent
value="add_host"
className="flex-1 flex flex-col h-full min-h-0"
>
<Separator className="p-0.25 -mt-0.5 mb-1" /> <Separator className="p-0.25 -mt-0.5 mb-1" />
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<HostManagerEditor <HostManagerEditor
@@ -91,13 +113,19 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
/> />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="credentials" className="flex-1 flex flex-col h-full min-h-0"> <TabsContent
value="credentials"
className="flex-1 flex flex-col h-full min-h-0"
>
<Separator className="p-0.25 -mt-0.5 mb-1" /> <Separator className="p-0.25 -mt-0.5 mb-1" />
<div className="flex flex-col h-full min-h-0 overflow-auto"> <div className="flex flex-col h-full min-h-0 overflow-auto">
<CredentialsManager onEditCredential={handleEditCredential} /> <CredentialsManager onEditCredential={handleEditCredential} />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="add_credential" className="flex-1 flex flex-col h-full min-h-0"> <TabsContent
value="add_credential"
className="flex-1 flex flex-col h-full min-h-0"
>
<Separator className="p-0.25 -mt-0.5 mb-1" /> <Separator className="p-0.25 -mt-0.5 mb-1" />
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<CredentialEditor <CredentialEditor
@@ -110,5 +138,5 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
</div> </div>
</div> </div>
</div> </div>
) );
} }
File diff suppressed because it is too large Load Diff
@@ -3,9 +3,25 @@ import {Button} from "@/components/ui/button.tsx";
import { Badge } from "@/components/ui/badge.tsx"; import { Badge } from "@/components/ui/badge.tsx";
import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx"; import {
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx"; Accordion,
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts, updateSSHHost, renameFolder} from "@/ui/main-axios.ts"; AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx";
import {
getSSHHosts,
deleteSSHHost,
bulkImportSSHHosts,
updateSSHHost,
renameFolder,
} from "@/ui/main-axios.ts";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { useConfirmation } from "@/hooks/use-confirmation.ts";
@@ -24,9 +40,12 @@ import {
X, X,
Check, Check,
Pencil, Pencil,
FolderMinus FolderMinus,
} from "lucide-react"; } from "lucide-react";
import type {SSHHost, SSHManagerHostViewerProps} from '../../../../types/index.js'; import type {
SSHHost,
SSHManagerHostViewerProps,
} from "../../../../types/index.js";
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -50,14 +69,14 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
fetchHosts(); fetchHosts();
}; };
window.addEventListener('hosts:refresh', handleHostsRefresh); window.addEventListener("hosts:refresh", handleHostsRefresh);
window.addEventListener('ssh-hosts:changed', handleHostsRefresh); window.addEventListener("ssh-hosts:changed", handleHostsRefresh);
window.addEventListener('folders:changed', handleHostsRefresh); window.addEventListener("folders:changed", handleHostsRefresh);
return () => { return () => {
window.removeEventListener('hosts:refresh', handleHostsRefresh); window.removeEventListener("hosts:refresh", handleHostsRefresh);
window.removeEventListener('ssh-hosts:changed', handleHostsRefresh); window.removeEventListener("ssh-hosts:changed", handleHostsRefresh);
window.removeEventListener('folders:changed', handleHostsRefresh); window.removeEventListener("folders:changed", handleHostsRefresh);
}; };
}, []); }, []);
@@ -66,19 +85,19 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
setLoading(true); setLoading(true);
const data = await getSSHHosts(); const data = await getSSHHosts();
const cleanedHosts = data.map(host => { const cleanedHosts = data.map((host) => {
const cleanedHost = { ...host }; const cleanedHost = { ...host };
if (cleanedHost.credentialId && cleanedHost.key) { if (cleanedHost.credentialId && cleanedHost.key) {
cleanedHost.key = undefined; cleanedHost.key = undefined;
cleanedHost.keyPassword = undefined; cleanedHost.keyPassword = undefined;
cleanedHost.keyType = undefined; cleanedHost.keyType = undefined;
cleanedHost.authType = 'credential'; cleanedHost.authType = "credential";
} else if (cleanedHost.credentialId && cleanedHost.password) { } else if (cleanedHost.credentialId && cleanedHost.password) {
cleanedHost.password = undefined; cleanedHost.password = undefined;
cleanedHost.authType = 'credential'; cleanedHost.authType = "credential";
} else if (cleanedHost.key && cleanedHost.password) { } else if (cleanedHost.key && cleanedHost.password) {
cleanedHost.password = undefined; cleanedHost.password = undefined;
cleanedHost.authType = 'key'; cleanedHost.authType = "key";
} }
return cleanedHost; return cleanedHost;
}); });
@@ -86,7 +105,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
setHosts(cleanedHosts); setHosts(cleanedHosts);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(t('hosts.failedToLoadHosts')); setError(t("hosts.failedToLoadHosts"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -94,36 +113,40 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const handleDelete = async (hostId: number, hostName: string) => { const handleDelete = async (hostId: number, hostName: string) => {
confirmWithToast( confirmWithToast(
t('hosts.confirmDelete', {name: hostName}), t("hosts.confirmDelete", { name: hostName }),
async () => { async () => {
try { try {
await deleteSSHHost(hostId); await deleteSSHHost(hostId);
toast.success(t('hosts.hostDeletedSuccessfully', {name: hostName})); toast.success(t("hosts.hostDeletedSuccessfully", { name: hostName }));
await fetchHosts(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
} catch (err) { } catch (err) {
toast.error(t('hosts.failedToDeleteHost')); toast.error(t("hosts.failedToDeleteHost"));
} }
}, },
'destructive' "destructive",
); );
}; };
const handleExport = (host: SSHHost) => { const handleExport = (host: SSHHost) => {
const actualAuthType = host.credentialId ? 'credential' : (host.key ? 'key' : 'password'); const actualAuthType = host.credentialId
? "credential"
: host.key
? "key"
: "password";
if (actualAuthType === 'credential') { if (actualAuthType === "credential") {
const confirmMessage = t('hosts.exportCredentialWarning', { const confirmMessage = t("hosts.exportCredentialWarning", {
name: host.name || `${host.username}@${host.ip}` name: host.name || `${host.username}@${host.ip}`,
}); });
confirmWithToast(confirmMessage, () => { confirmWithToast(confirmMessage, () => {
performExport(host, actualAuthType); performExport(host, actualAuthType);
}); });
return; return;
} else if (actualAuthType === 'password' || actualAuthType === 'key') { } else if (actualAuthType === "password" || actualAuthType === "key") {
const confirmMessage = t('hosts.exportSensitiveDataWarning', { const confirmMessage = t("hosts.exportSensitiveDataWarning", {
name: host.name || `${host.username}@${host.ip}` name: host.name || `${host.username}@${host.ip}`,
}); });
confirmWithToast(confirmMessage, () => { confirmWithToast(confirmMessage, () => {
@@ -136,7 +159,6 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
}; };
const performExport = (host: SSHHost, actualAuthType: string) => { const performExport = (host: SSHHost, actualAuthType: string) => {
const exportData: any = { const exportData: any = {
name: host.name, name: host.name,
ip: host.ip, ip: host.ip,
@@ -153,29 +175,31 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
tunnelConnections: host.tunnelConnections, tunnelConnections: host.tunnelConnections,
}; };
if (actualAuthType === 'credential') { if (actualAuthType === "credential") {
exportData.credentialId = null; exportData.credentialId = null;
} }
const cleanExportData = Object.fromEntries( const cleanExportData = Object.fromEntries(
Object.entries(exportData).filter(([_, value]) => value !== undefined) Object.entries(exportData).filter(([_, value]) => value !== undefined),
); );
const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], {
const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], {type: 'application/json'}); type: "application/json",
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `${host.name || host.username + '@' + host.ip}-host-config.json`; a.download = `${host.name || host.username + "@" + host.ip}-host-config.json`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
toast.success(`Exported host configuration for ${host.name || host.username}@${host.ip}`); toast.success(
`Exported host configuration for ${host.name || host.username}@${host.ip}`,
);
}; };
const handleEdit = (host: SSHHost) => { const handleEdit = (host: SSHHost) => {
if (onEditHost) { if (onEditHost) {
onEditHost(host); onEditHost(host);
@@ -184,41 +208,53 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const handleRemoveFromFolder = async (host: SSHHost) => { const handleRemoveFromFolder = async (host: SSHHost) => {
confirmWithToast( confirmWithToast(
t('hosts.confirmRemoveFromFolder', {name: host.name || `${host.username}@${host.ip}`, folder: host.folder}), t("hosts.confirmRemoveFromFolder", {
name: host.name || `${host.username}@${host.ip}`,
folder: host.folder,
}),
async () => { async () => {
try { try {
setOperationLoading(true); setOperationLoading(true);
const updatedHost = {...host, folder: ''}; const updatedHost = { ...host, folder: "" };
await updateSSHHost(host.id, updatedHost); await updateSSHHost(host.id, updatedHost);
toast.success(t('hosts.removedFromFolder', {name: host.name || `${host.username}@${host.ip}`})); toast.success(
t("hosts.removedFromFolder", {
name: host.name || `${host.username}@${host.ip}`,
}),
);
await fetchHosts(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
} catch (err) { } catch (err) {
toast.error(t('hosts.failedToRemoveFromFolder')); toast.error(t("hosts.failedToRemoveFromFolder"));
} finally { } finally {
setOperationLoading(false); setOperationLoading(false);
} }
} },
); );
}; };
const handleFolderRename = async (oldName: string) => { const handleFolderRename = async (oldName: string) => {
if (!editingFolderName.trim() || editingFolderName === oldName) { if (!editingFolderName.trim() || editingFolderName === oldName) {
setEditingFolder(null); setEditingFolder(null);
setEditingFolderName(''); setEditingFolderName("");
return; return;
} }
try { try {
setOperationLoading(true); setOperationLoading(true);
await renameFolder(oldName, editingFolderName.trim()); await renameFolder(oldName, editingFolderName.trim());
toast.success(t('hosts.folderRenamed', {oldName, newName: editingFolderName.trim()})); toast.success(
t("hosts.folderRenamed", {
oldName,
newName: editingFolderName.trim(),
}),
);
await fetchHosts(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
setEditingFolder(null); setEditingFolder(null);
setEditingFolderName(''); setEditingFolderName("");
} catch (err) { } catch (err) {
toast.error(t('hosts.failedToRenameFolder')); toast.error(t("hosts.failedToRenameFolder"));
} finally { } finally {
setOperationLoading(false); setOperationLoading(false);
} }
@@ -231,13 +267,13 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const cancelFolderEdit = () => { const cancelFolderEdit = () => {
setEditingFolder(null); setEditingFolder(null);
setEditingFolderName(''); setEditingFolderName("");
}; };
const handleDragStart = (e: React.DragEvent, host: SSHHost) => { const handleDragStart = (e: React.DragEvent, host: SSHHost) => {
setDraggedHost(host); setDraggedHost(host);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData('text/plain', ''); e.dataTransfer.setData("text/plain", "");
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
@@ -248,7 +284,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = "move";
}; };
const handleDragEnter = (e: React.DragEvent, folderName: string) => { const handleDragEnter = (e: React.DragEvent, folderName: string) => {
@@ -271,7 +307,8 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
if (!draggedHost) return; if (!draggedHost) return;
const newFolder = targetFolder === t('hosts.uncategorized') ? '' : targetFolder; const newFolder =
targetFolder === t("hosts.uncategorized") ? "" : targetFolder;
if (draggedHost.folder === newFolder) { if (draggedHost.folder === newFolder) {
setDraggedHost(null); setDraggedHost(null);
@@ -282,21 +319,25 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
setOperationLoading(true); setOperationLoading(true);
const updatedHost = { ...draggedHost, folder: newFolder }; const updatedHost = { ...draggedHost, folder: newFolder };
await updateSSHHost(draggedHost.id, updatedHost); await updateSSHHost(draggedHost.id, updatedHost);
toast.success(t('hosts.movedToFolder', { toast.success(
t("hosts.movedToFolder", {
name: draggedHost.name || `${draggedHost.username}@${draggedHost.ip}`, name: draggedHost.name || `${draggedHost.username}@${draggedHost.ip}`,
folder: targetFolder folder: targetFolder,
})); }),
);
await fetchHosts(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
} catch (err) { } catch (err) {
toast.error(t('hosts.failedToMoveToFolder')); toast.error(t("hosts.failedToMoveToFolder"));
} finally { } finally {
setOperationLoading(false); setOperationLoading(false);
setDraggedHost(null); setDraggedHost(null);
} }
}; };
const handleJsonImport = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleJsonImport = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
@@ -306,38 +347,43 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const data = JSON.parse(text); const data = JSON.parse(text);
if (!Array.isArray(data.hosts) && !Array.isArray(data)) { if (!Array.isArray(data.hosts) && !Array.isArray(data)) {
throw new Error(t('hosts.jsonMustContainHosts')); throw new Error(t("hosts.jsonMustContainHosts"));
} }
const hostsArray = Array.isArray(data.hosts) ? data.hosts : data; const hostsArray = Array.isArray(data.hosts) ? data.hosts : data;
if (hostsArray.length === 0) { if (hostsArray.length === 0) {
throw new Error(t('hosts.noHostsInJson')); throw new Error(t("hosts.noHostsInJson"));
} }
if (hostsArray.length > 100) { if (hostsArray.length > 100) {
throw new Error(t('hosts.maxHostsAllowed')); throw new Error(t("hosts.maxHostsAllowed"));
} }
const result = await bulkImportSSHHosts(hostsArray); const result = await bulkImportSSHHosts(hostsArray);
if (result.success > 0) { if (result.success > 0) {
toast.success(t('hosts.importCompleted', {success: result.success, failed: result.failed})); toast.success(
t("hosts.importCompleted", {
success: result.success,
failed: result.failed,
}),
);
if (result.errors.length > 0) { if (result.errors.length > 0) {
toast.error(`Import errors: ${result.errors.join(', ')}`); toast.error(`Import errors: ${result.errors.join(", ")}`);
} }
await fetchHosts(); await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
} else { } else {
toast.error(t('hosts.importFailed') + `: ${result.errors.join(', ')}`); toast.error(t("hosts.importFailed") + `: ${result.errors.join(", ")}`);
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : t('hosts.failedToImportJson'); const errorMessage =
toast.error(t('hosts.importError') + `: ${errorMessage}`); err instanceof Error ? err.message : t("hosts.failedToImportJson");
toast.error(t("hosts.importError") + `: ${errorMessage}`);
} finally { } finally {
setImporting(false); setImporting(false);
event.target.value = ''; event.target.value = "";
} }
}; };
@@ -346,16 +392,18 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
if (searchQuery.trim()) { if (searchQuery.trim()) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
filtered = hosts.filter(host => { filtered = hosts.filter((host) => {
const searchableText = [ const searchableText = [
host.name || '', host.name || "",
host.username, host.username,
host.ip, host.ip,
host.folder || '', host.folder || "",
...(host.tags || []), ...(host.tags || []),
host.authType, host.authType,
host.defaultPath || '' host.defaultPath || "",
].join(' ').toLowerCase(); ]
.join(" ")
.toLowerCase();
return searchableText.includes(query); return searchableText.includes(query);
}); });
} }
@@ -373,8 +421,8 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const hostsByFolder = useMemo(() => { const hostsByFolder = useMemo(() => {
const grouped: { [key: string]: SSHHost[] } = {}; const grouped: { [key: string]: SSHHost[] } = {};
filteredAndSortedHosts.forEach(host => { filteredAndSortedHosts.forEach((host) => {
const folder = host.folder || t('hosts.uncategorized'); const folder = host.folder || t("hosts.uncategorized");
if (!grouped[folder]) { if (!grouped[folder]) {
grouped[folder] = []; grouped[folder] = [];
} }
@@ -382,13 +430,13 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
}); });
const sortedFolders = Object.keys(grouped).sort((a, b) => { const sortedFolders = Object.keys(grouped).sort((a, b) => {
if (a === t('hosts.uncategorized')) return -1; if (a === t("hosts.uncategorized")) return -1;
if (b === t('hosts.uncategorized')) return 1; if (b === t("hosts.uncategorized")) return 1;
return a.localeCompare(b); return a.localeCompare(b);
}); });
const sortedGrouped: { [key: string]: SSHHost[] } = {}; const sortedGrouped: { [key: string]: SSHHost[] } = {};
sortedFolders.forEach(folder => { sortedFolders.forEach((folder) => {
sortedGrouped[folder] = grouped[folder]; sortedGrouped[folder] = grouped[folder];
}); });
@@ -400,7 +448,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">{t('hosts.loadingHosts')}</p> <p className="text-muted-foreground">{t("hosts.loadingHosts")}</p>
</div> </div>
</div> </div>
); );
@@ -412,7 +460,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="text-center"> <div className="text-center">
<p className="text-red-500 mb-4">{error}</p> <p className="text-red-500 mb-4">{error}</p>
<Button onClick={fetchHosts} variant="outline"> <Button onClick={fetchHosts} variant="outline">
{t('hosts.retry')} {t("hosts.retry")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -424,9 +472,9 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2> <h2 className="text-xl font-semibold">{t("hosts.sshHosts")}</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('hosts.hostsCount', {count: 0})} {t("hosts.hostsCount", { count: 0 })}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -437,18 +485,24 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
variant="outline" variant="outline"
size="sm" size="sm"
className="relative" className="relative"
onClick={() => document.getElementById('json-import-input')?.click()} onClick={() =>
document.getElementById("json-import-input")?.click()
}
disabled={importing} disabled={importing}
> >
{importing ? t('hosts.importing') : t('hosts.importJson')} {importing ? t("hosts.importing") : t("hosts.importJson")}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" <TooltipContent
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg"> side="bottom"
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg"
>
<div className="space-y-2"> <div className="space-y-2">
<p className="font-semibold text-sm">{t('hosts.importJsonTitle')}</p> <p className="font-semibold text-sm">
{t("hosts.importJsonTitle")}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('hosts.importJsonDesc')} {t("hosts.importJsonDesc")}
</p> </p>
</div> </div>
</TooltipContent> </TooltipContent>
@@ -474,7 +528,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
enableTerminal: true, enableTerminal: true,
enableTunnel: false, enableTunnel: false,
enableFileManager: true, enableFileManager: true,
defaultPath: "/var/www" defaultPath: "/var/www",
}, },
{ {
name: "Database Server", name: "Database Server",
@@ -498,9 +552,9 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
endpointHost: "Web Server - Production", endpointHost: "Web Server - Production",
maxRetries: 3, maxRetries: 3,
retryInterval: 10, retryInterval: 10,
autoStart: true autoStart: true,
} },
] ],
}, },
{ {
name: "Development Server", name: "Development Server",
@@ -515,44 +569,45 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
enableTerminal: true, enableTerminal: true,
enableTunnel: false, enableTunnel: false,
enableFileManager: true, enableFileManager: true,
defaultPath: "/home/developer" defaultPath: "/home/developer",
} },
] ],
}; };
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'}); const blob = new Blob([JSON.stringify(sampleData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = 'sample-ssh-hosts.json'; a.download = "sample-ssh-hosts.json";
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}} }}
> >
{t('hosts.downloadSample')} {t("hosts.downloadSample")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
window.open('https://docs.termix.site/json-import', '_blank'); window.open("https://docs.termix.site/json-import", "_blank");
}} }}
> >
{t('hosts.formatGuide')} {t("hosts.formatGuide")}
</Button> </Button>
<div className="w-px h-6 bg-border mx-2" /> <div className="w-px h-6 bg-border mx-2" />
<Button onClick={fetchHosts} variant="outline" size="sm"> <Button onClick={fetchHosts} variant="outline" size="sm">
{t('hosts.refresh')} {t("hosts.refresh")}
</Button> </Button>
</div> </div>
</div> </div>
<input <input
id="json-import-input" id="json-import-input"
type="file" type="file"
@@ -564,9 +619,9 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="flex items-center justify-center flex-1"> <div className="flex items-center justify-center flex-1">
<div className="text-center"> <div className="text-center">
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> <Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">{t('hosts.noHosts')}</h3> <h3 className="text-lg font-semibold mb-2">{t("hosts.noHosts")}</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
{t('hosts.noHostsMessage')} {t("hosts.noHostsMessage")}
</p> </p>
</div> </div>
</div> </div>
@@ -578,9 +633,9 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div> <div>
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2> <h2 className="text-xl font-semibold">{t("hosts.sshHosts")}</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('hosts.hostsCount', {count: filteredAndSortedHosts.length})} {t("hosts.hostsCount", { count: filteredAndSortedHosts.length })}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -591,18 +646,24 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
variant="outline" variant="outline"
size="sm" size="sm"
className="relative" className="relative"
onClick={() => document.getElementById('json-import-input')?.click()} onClick={() =>
document.getElementById("json-import-input")?.click()
}
disabled={importing} disabled={importing}
> >
{importing ? t('hosts.importing') : t('hosts.importJson')} {importing ? t("hosts.importing") : t("hosts.importJson")}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" <TooltipContent
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg"> side="bottom"
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg"
>
<div className="space-y-2"> <div className="space-y-2">
<p className="font-semibold text-sm">{t('hosts.importJsonTitle')}</p> <p className="font-semibold text-sm">
{t("hosts.importJsonTitle")}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('hosts.importJsonDesc')} {t("hosts.importJsonDesc")}
</p> </p>
</div> </div>
</TooltipContent> </TooltipContent>
@@ -628,7 +689,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
enableTerminal: true, enableTerminal: true,
enableTunnel: false, enableTunnel: false,
enableFileManager: true, enableFileManager: true,
defaultPath: "/var/www" defaultPath: "/var/www",
}, },
{ {
name: "Database Server", name: "Database Server",
@@ -652,9 +713,9 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
endpointHost: "Web Server - Production", endpointHost: "Web Server - Production",
maxRetries: 3, maxRetries: 3,
retryInterval: 10, retryInterval: 10,
autoStart: true autoStart: true,
} },
] ],
}, },
{ {
name: "Development Server", name: "Development Server",
@@ -669,44 +730,45 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
enableTerminal: true, enableTerminal: true,
enableTunnel: false, enableTunnel: false,
enableFileManager: true, enableFileManager: true,
defaultPath: "/home/developer" defaultPath: "/home/developer",
} },
] ],
}; };
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'}); const blob = new Blob([JSON.stringify(sampleData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = 'sample-ssh-hosts.json'; a.download = "sample-ssh-hosts.json";
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}} }}
> >
{t('hosts.downloadSample')} {t("hosts.downloadSample")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
window.open('https://docs.termix.site/json-import', '_blank'); window.open("https://docs.termix.site/json-import", "_blank");
}} }}
> >
{t('hosts.formatGuide')} {t("hosts.formatGuide")}
</Button> </Button>
<div className="w-px h-6 bg-border mx-2" /> <div className="w-px h-6 bg-border mx-2" />
<Button onClick={fetchHosts} variant="outline" size="sm"> <Button onClick={fetchHosts} variant="outline" size="sm">
{t('hosts.refresh')} {t("hosts.refresh")}
</Button> </Button>
</div> </div>
</div> </div>
<input <input
id="json-import-input" id="json-import-input"
type="file" type="file"
@@ -718,7 +780,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="relative mb-3"> <div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder={t('placeholders.searchHosts')} placeholder={t("placeholders.searchHosts")}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10" className="pl-10"
@@ -731,28 +793,36 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<div <div
key={folder} key={folder}
className={`border rounded-md transition-all duration-200 ${ className={`border rounded-md transition-all duration-200 ${
dragOverFolder === folder ? 'border-blue-500 bg-blue-500/10' : '' dragOverFolder === folder
? "border-blue-500 bg-blue-500/10"
: ""
}`} }`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnter={(e) => handleDragEnter(e, folder)} onDragEnter={(e) => handleDragEnter(e, folder)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, folder)} onDrop={(e) => handleDrop(e, folder)}
> >
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}> <Accordion
type="multiple"
defaultValue={Object.keys(hostsByFolder)}
>
<AccordionItem value={folder} className="border-none"> <AccordionItem value={folder} className="border-none">
<AccordionTrigger <AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
<Folder className="h-4 w-4" /> <Folder className="h-4 w-4" />
{editingFolder === folder ? ( {editingFolder === folder ? (
<div className="flex items-center gap-2" <div
onClick={(e) => e.stopPropagation()}> className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Input <Input
value={editingFolderName} value={editingFolderName}
onChange={(e) => setEditingFolderName(e.target.value)} onChange={(e) =>
setEditingFolderName(e.target.value)
}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') handleFolderRename(folder); if (e.key === "Enter") handleFolderRename(folder);
if (e.key === 'Escape') cancelFolderEdit(); if (e.key === "Escape") cancelFolderEdit();
}} }}
className="h-6 text-sm px-2 flex-1" className="h-6 text-sm px-2 flex-1"
autoFocus autoFocus
@@ -789,15 +859,19 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
className="font-medium cursor-pointer hover:text-blue-400 transition-colors" className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (folder !== t('hosts.uncategorized')) { if (folder !== t("hosts.uncategorized")) {
startFolderEdit(folder); startFolderEdit(folder);
} }
}} }}
title={folder !== t('hosts.uncategorized') ? 'Click to rename folder' : ''} title={
folder !== t("hosts.uncategorized")
? "Click to rename folder"
: ""
}
> >
{folder} {folder}
</span> </span>
{folder !== t('hosts.uncategorized') && ( {folder !== t("hosts.uncategorized") && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -829,17 +903,21 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
onDragStart={(e) => handleDragStart(e, host)} onDragStart={(e) => handleDragStart(e, host)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
className={`bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group relative ${ className={`bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group relative ${
draggedHost?.id === host.id ? 'opacity-50 scale-95' : '' draggedHost?.id === host.id
? "opacity-50 scale-95"
: ""
}`} }`}
onClick={() => handleEdit(host)} onClick={() => handleEdit(host)}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{host.pin && <Pin {host.pin && (
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>} <Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
)}
<h3 className="font-medium truncate text-sm"> <h3 className="font-medium truncate text-sm">
{host.name || `${host.username}@${host.ip}`} {host.name ||
`${host.username}@${host.ip}`}
</h3> </h3>
</div> </div>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
@@ -850,7 +928,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
</p> </p>
</div> </div>
<div className="flex gap-1 flex-shrink-0 ml-1"> <div className="flex gap-1 flex-shrink-0 ml-1">
{host.folder && host.folder !== '' && ( {host.folder && host.folder !== "" && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -863,13 +941,13 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10" className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
disabled={operationLoading} disabled={operationLoading}
> >
<FolderMinus <FolderMinus className="h-3 w-3" />
className="h-3 w-3"/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Remove from folder <p>
"{host.folder}"</p> Remove from folder "{host.folder}"
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
@@ -898,7 +976,11 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
variant="ghost" variant="ghost"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDelete(host.id, host.name || `${host.username}@${host.ip}`); handleDelete(
host.id,
host.name ||
`${host.username}@${host.ip}`,
);
}} }}
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10" className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
> >
@@ -927,23 +1009,29 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<p>Export host</p> <p>Export host</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{host.tags && host.tags.length > 0 && ( {host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{host.tags.slice(0, 6).map((tag, index) => ( {host.tags
<Badge key={index} variant="outline" .slice(0, 6)
className="text-xs px-1 py-0"> .map((tag, index) => (
<Badge
key={index}
variant="outline"
className="text-xs px-1 py-0"
>
<Tag className="h-2 w-2 mr-0.5" /> <Tag className="h-2 w-2 mr-0.5" />
{tag} {tag}
</Badge> </Badge>
))} ))}
{host.tags.length > 6 && ( {host.tags.length > 6 && (
<Badge variant="outline" <Badge
className="text-xs px-1 py-0"> variant="outline"
className="text-xs px-1 py-0"
>
+{host.tags.length - 6} +{host.tags.length - 6}
</Badge> </Badge>
)} )}
@@ -952,28 +1040,36 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{host.enableTerminal && ( {host.enableTerminal && (
<Badge variant="outline" <Badge
className="text-xs px-1 py-0"> variant="outline"
className="text-xs px-1 py-0"
>
<Terminal className="h-2 w-2 mr-0.5" /> <Terminal className="h-2 w-2 mr-0.5" />
{t('hosts.terminalBadge')} {t("hosts.terminalBadge")}
</Badge> </Badge>
)} )}
{host.enableTunnel && ( {host.enableTunnel && (
<Badge variant="outline" <Badge
className="text-xs px-1 py-0"> variant="outline"
className="text-xs px-1 py-0"
>
<Network className="h-2 w-2 mr-0.5" /> <Network className="h-2 w-2 mr-0.5" />
{t('hosts.tunnelBadge')} {t("hosts.tunnelBadge")}
{host.tunnelConnections && host.tunnelConnections.length > 0 && ( {host.tunnelConnections &&
<span host.tunnelConnections.length > 0 && (
className="ml-0.5">({host.tunnelConnections.length})</span> <span className="ml-0.5">
({host.tunnelConnections.length})
</span>
)} )}
</Badge> </Badge>
)} )}
{host.enableFileManager && ( {host.enableFileManager && (
<Badge variant="outline" <Badge
className="text-xs px-1 py-0"> variant="outline"
className="text-xs px-1 py-0"
>
<FileEdit className="h-2 w-2 mr-0.5" /> <FileEdit className="h-2 w-2 mr-0.5" />
{t('hosts.fileManagerBadge')} {t("hosts.fileManagerBadge")}
</Badge> </Badge>
)} )}
</div> </div>
@@ -982,9 +1078,12 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<div className="text-center"> <div className="text-center">
<p className="font-medium">Click to edit host</p> <p className="font-medium">
<p className="text-xs text-muted-foreground">Drag to Click to edit host
move between folders</p> </p>
<p className="text-xs text-muted-foreground">
Drag to move between folders
</p>
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
+145 -85
View File
@@ -3,13 +3,17 @@ import {useSidebar} from "@/components/ui/sidebar.tsx";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status"; import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {Progress} from "@/components/ui/progress.tsx" import { Progress } from "@/components/ui/progress.tsx";
import { Cpu, HardDrive, MemoryStick } from "lucide-react"; import { Cpu, HardDrive, MemoryStick } from "lucide-react";
import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx"; import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts"; import {
getServerStatusById,
getServerMetricsById,
type ServerMetrics,
} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import {toast} from 'sonner'; import { toast } from "sonner";
interface ServerProps { interface ServerProps {
hostConfig?: any; hostConfig?: any;
@@ -24,12 +28,14 @@ export function Server({
title, title,
isVisible = true, isVisible = true,
isTopbarOpen = true, isTopbarOpen = true,
embedded = false embedded = false,
}: ServerProps): React.ReactElement { }: ServerProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
const { addTab, tabs } = useTabs() as any; const { addTab, tabs } = useTabs() as any;
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline'); const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
"offline",
);
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null); const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false); const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
@@ -43,14 +49,14 @@ export function Server({
const fetchLatestHostConfig = async () => { const fetchLatestHostConfig = async () => {
if (hostConfig?.id) { if (hostConfig?.id) {
try { try {
const {getSSHHosts} = await import('@/ui/main-axios.ts'); const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts(); const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id); const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) { if (updatedHost) {
setCurrentHostConfig(updatedHost); setCurrentHostConfig(updatedHost);
} }
} catch (error) { } catch (error) {
toast.error(t('serverStats.failedToFetchHostConfig')); toast.error(t("serverStats.failedToFetchHostConfig"));
} }
} }
}; };
@@ -60,20 +66,21 @@ export function Server({
const handleHostsChanged = async () => { const handleHostsChanged = async () => {
if (hostConfig?.id) { if (hostConfig?.id) {
try { try {
const {getSSHHosts} = await import('@/ui/main-axios.ts'); const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts(); const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id); const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) { if (updatedHost) {
setCurrentHostConfig(updatedHost); setCurrentHostConfig(updatedHost);
} }
} catch (error) { } catch (error) {
toast.error(t('serverStats.failedToFetchHostConfig')); toast.error(t("serverStats.failedToFetchHostConfig"));
} }
} }
}; };
window.addEventListener('ssh-hosts:changed', handleHostsChanged); window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged); return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]); }, [hostConfig?.id]);
React.useEffect(() => { React.useEffect(() => {
@@ -84,20 +91,20 @@ export function Server({
try { try {
const res = await getServerStatusById(currentHostConfig?.id); const res = await getServerStatusById(currentHostConfig?.id);
if (!cancelled) { if (!cancelled) {
setServerStatus(res?.status === 'online' ? 'online' : 'offline'); setServerStatus(res?.status === "online" ? "online" : "offline");
} }
} catch (error: any) { } catch (error: any) {
if (!cancelled) { if (!cancelled) {
if (error?.response?.status === 503) { if (error?.response?.status === 503) {
setServerStatus('offline'); setServerStatus("offline");
} else if (error?.response?.status === 504) { } else if (error?.response?.status === 504) {
setServerStatus('offline'); setServerStatus("offline");
} else if (error?.response?.status === 404) { } else if (error?.response?.status === 404) {
setServerStatus('offline'); setServerStatus("offline");
} else { } else {
setServerStatus('offline'); setServerStatus("offline");
} }
toast.error(t('serverStats.failedToFetchStatus')); toast.error(t("serverStats.failedToFetchStatus"));
} }
} }
}; };
@@ -113,7 +120,7 @@ export function Server({
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
setMetrics(null); setMetrics(null);
toast.error(t('serverStats.failedToFetchMetrics')); toast.error(t("serverStats.failedToFetchMetrics"));
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -140,19 +147,20 @@ export function Server({
}, [currentHostConfig?.id, isVisible]); }, [currentHostConfig?.id, isVisible]);
const topMarginPx = isTopbarOpen ? 74 : 16; const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8; const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
const bottomMarginPx = 8; const bottomMarginPx = 8;
const isFileManagerAlreadyOpen = React.useMemo(() => { const isFileManagerAlreadyOpen = React.useMemo(() => {
if (!currentHostConfig) return false; if (!currentHostConfig) return false;
return tabs.some((tab: any) => return tabs.some(
tab.type === 'file_manager' && (tab: any) =>
tab.hostConfig?.id === currentHostConfig.id tab.type === "file_manager" &&
tab.hostConfig?.id === currentHostConfig.id,
); );
}, [tabs, currentHostConfig]); }, [tabs, currentHostConfig]);
const wrapperStyle: React.CSSProperties = embedded const wrapperStyle: React.CSSProperties = embedded
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'} ? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: { : {
opacity: isVisible ? 1 : 0, opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
@@ -169,7 +177,6 @@ export function Server({
return ( return (
<div style={wrapperStyle} className={containerClass}> <div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col"> <div className="h-full w-full flex flex-col">
{/* Top Header */} {/* Top Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3"> <div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0"> <div className="flex items-center gap-4 min-w-0">
@@ -178,7 +185,10 @@ export function Server({
{currentHostConfig?.folder} / {title} {currentHostConfig?.folder} / {title}
</h1> </h1>
</div> </div>
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0"> <Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator /> <StatusIndicator />
</Status> </Status>
</div> </div>
@@ -191,18 +201,22 @@ export function Server({
try { try {
setIsRefreshing(true); setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id); const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(res?.status === 'online' ? 'online' : 'offline'); setServerStatus(
const data = await getServerMetricsById(currentHostConfig.id); res?.status === "online" ? "online" : "offline",
);
const data = await getServerMetricsById(
currentHostConfig.id,
);
setMetrics(data); setMetrics(data);
} catch (error: any) { } catch (error: any) {
if (error?.response?.status === 503) { if (error?.response?.status === 503) {
setServerStatus('offline'); setServerStatus("offline");
} else if (error?.response?.status === 504) { } else if (error?.response?.status === 504) {
setServerStatus('offline'); setServerStatus("offline");
} else if (error?.response?.status === 404) { } else if (error?.response?.status === 404) {
setServerStatus('offline'); setServerStatus("offline");
} else { } else {
setServerStatus('offline'); setServerStatus("offline");
} }
setMetrics(null); setMetrics(null);
} finally { } finally {
@@ -210,16 +224,15 @@ export function Server({
} }
} }
}} }}
title={t('serverStats.refreshStatusAndMetrics')} title={t("serverStats.refreshStatusAndMetrics")}
> >
{isRefreshing ? ( {isRefreshing ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div> {t("serverStats.refreshing")}
{t('serverStats.refreshing')}
</div> </div>
) : ( ) : (
t('serverStats.refreshStatus') t("serverStats.refreshStatus")
)} )}
</Button> </Button>
{currentHostConfig?.enableFileManager && ( {currentHostConfig?.enableFileManager && (
@@ -227,20 +240,26 @@ export function Server({
variant="outline" variant="outline"
className="font-semibold" className="font-semibold"
disabled={isFileManagerAlreadyOpen} disabled={isFileManagerAlreadyOpen}
title={isFileManagerAlreadyOpen ? t('serverStats.fileManagerAlreadyOpen') : t('serverStats.openFileManager')} title={
isFileManagerAlreadyOpen
? t("serverStats.fileManagerAlreadyOpen")
: t("serverStats.openFileManager")
}
onClick={() => { onClick={() => {
if (!currentHostConfig || isFileManagerAlreadyOpen) return; if (!currentHostConfig || isFileManagerAlreadyOpen) return;
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== '' const titleBase =
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name.trim() ? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`; : `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({ addTab({
type: 'file_manager', type: "file_manager",
title: titleBase, title: titleBase,
hostConfig: currentHostConfig, hostConfig: currentHostConfig,
}); });
}} }}
> >
{t('nav.fileManager')} {t("nav.fileManager")}
</Button> </Button>
)} )}
</div> </div>
@@ -252,30 +271,35 @@ export function Server({
{isLoadingMetrics && !metrics ? ( {isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div> <span className="text-gray-300">
<span className="text-gray-300">{t('serverStats.loadingMetrics')}</span> {t("serverStats.loadingMetrics")}
</span>
</div> </div>
</div> </div>
) : !metrics && serverStatus === 'offline' ? ( ) : !metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="text-center"> <div className="text-center">
<div <div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div> <div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div> </div>
<p className="text-gray-300 mb-1">{t('serverStats.serverOffline')}</p> <p className="text-gray-300 mb-1">
<p className="text-sm text-gray-500">{t('serverStats.cannotFetchMetrics')}</p> {t("serverStats.serverOffline")}
</p>
<p className="text-sm text-gray-500">
{t("serverStats.cannotFetchMetrics")}
</p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
{/* CPU Stats */} {/* CPU Stats */}
<div <div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Cpu className="h-5 w-5 text-blue-400" /> <Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">{t('serverStats.cpuUsage')}</h3> <h3 className="font-semibold text-lg text-white">
{t("serverStats.cpuUsage")}
</h3>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -284,35 +308,43 @@ export function Server({
{(() => { {(() => {
const pct = metrics?.cpu?.percent; const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores; const cores = metrics?.cpu?.cores;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; const pctText =
const coresText = (typeof cores === 'number') ? t('serverStats.cpuCores', {count: cores}) : t('serverStats.naCpus'); typeof pct === "number" ? `${pct}%` : "N/A";
return `${pctText} ${t('serverStats.of')} ${coresText}`; const coresText =
typeof cores === "number"
? t("serverStats.cpuCores", { count: cores })
: t("serverStats.naCpus");
return `${pctText} ${t("serverStats.of")} ${coresText}`;
})()} })()}
</span> </span>
</div> </div>
<div className="relative"> <div className="relative">
<Progress <Progress
value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0} value={
typeof metrics?.cpu?.percent === "number"
? metrics!.cpu!.percent!
: 0
}
className="h-2" className="h-2"
/> />
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{metrics?.cpu?.load ? {metrics?.cpu?.load
`Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}` : ? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
'Load: N/A' : "Load: N/A"}
}
</div> </div>
</div> </div>
</div> </div>
{/* Memory Stats */} {/* Memory Stats */}
<div <div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" /> <MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">{t('serverStats.memoryUsage')}</h3> <h3 className="font-semibold text-lg text-white">
{t("serverStats.memoryUsage")}
</h3>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -322,17 +354,28 @@ export function Server({
const pct = metrics?.memory?.percent; const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB; const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB; const total = metrics?.memory?.totalGiB;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; const pctText =
const usedText = (typeof used === 'number') ? `${used.toFixed(1)} GiB` : 'N/A'; typeof pct === "number" ? `${pct}%` : "N/A";
const totalText = (typeof total === 'number') ? `${total.toFixed(1)} GiB` : 'N/A'; const usedText =
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`; typeof used === "number"
? `${used.toFixed(1)} GiB`
: "N/A";
const totalText =
typeof total === "number"
? `${total.toFixed(1)} GiB`
: "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()} })()}
</span> </span>
</div> </div>
<div className="relative"> <div className="relative">
<Progress <Progress
value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0} value={
typeof metrics?.memory?.percent === "number"
? metrics!.memory!.percent!
: 0
}
className="h-2" className="h-2"
/> />
</div> </div>
@@ -341,7 +384,10 @@ export function Server({
{(() => { {(() => {
const used = metrics?.memory?.usedGiB; const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB; const total = metrics?.memory?.totalGiB;
const free = (typeof used === 'number' && typeof total === 'number') ? (total - used).toFixed(1) : 'N/A'; const free =
typeof used === "number" && typeof total === "number"
? (total - used).toFixed(1)
: "N/A";
return `Free: ${free} GiB`; return `Free: ${free} GiB`;
})()} })()}
</div> </div>
@@ -349,11 +395,12 @@ export function Server({
</div> </div>
{/* Disk Stats */} {/* Disk Stats */}
<div <div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" /> <HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">{t('serverStats.rootStorageSpace')}</h3> <h3 className="font-semibold text-lg text-white">
{t("serverStats.rootStorageSpace")}
</h3>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -363,17 +410,22 @@ export function Server({
const pct = metrics?.disk?.percent; const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman; const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman; const total = metrics?.disk?.totalHuman;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; const pctText =
const usedText = used ?? 'N/A'; typeof pct === "number" ? `${pct}%` : "N/A";
const totalText = total ?? 'N/A'; const usedText = used ?? "N/A";
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`; const totalText = total ?? "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()} })()}
</span> </span>
</div> </div>
<div className="relative"> <div className="relative">
<Progress <Progress
value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0} value={
typeof metrics?.disk?.percent === "number"
? metrics!.disk!.percent!
: 0
}
className="h-2" className="h-2"
/> />
</div> </div>
@@ -382,7 +434,9 @@ export function Server({
{(() => { {(() => {
const used = metrics?.disk?.usedHuman; const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman; const total = metrics?.disk?.totalHuman;
return used && total ? `Available: ${total}` : 'Available: N/A'; return used && total
? `Available: ${total}`
: "Available: N/A";
})()} })()}
</div> </div>
</div> </div>
@@ -392,16 +446,22 @@ export function Server({
</div> </div>
{/* SSH Tunnels */} {/* SSH Tunnels */}
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && ( {currentHostConfig?.tunnelConnections &&
<div currentHostConfig.tunnelConnections.length > 0 && (
className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0"> <div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel <Tunnel
filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/> filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div> </div>
)} )}
<p className="px-4 pt-2 pb-2 text-sm text-gray-500"> <p className="px-4 pt-2 pb-2 text-sm text-gray-500">
{t('serverStats.feedbackMessage')}{" "} {t("serverStats.feedbackMessage")}{" "}
<a <a
href="https://github.com/LukeGus/Termix/issues/new" href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" target="_blank"
+130 -90
View File
@@ -1,12 +1,18 @@
import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react'; import {
import {useXTerm} from 'react-xtermjs'; useEffect,
import {FitAddon} from '@xterm/addon-fit'; useRef,
import {ClipboardAddon} from '@xterm/addon-clipboard'; useState,
import {Unicode11Addon} from '@xterm/addon-unicode11'; useImperativeHandle,
import {WebLinksAddon} from '@xterm/addon-web-links'; forwardRef,
import {useTranslation} from 'react-i18next'; } from "react";
import {toast} from 'sonner'; import { useXTerm } from "react-xtermjs";
import {getCookie, isElectron} from '@/ui/main-axios.ts'; import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getCookie, isElectron } from "@/ui/main-axios.ts";
interface SSHTerminalProps { interface SSHTerminalProps {
hostConfig: any; hostConfig: any;
@@ -19,7 +25,7 @@ interface SSHTerminalProps {
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal( export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{ hostConfig, isVisible, splitScreen = false, onClose }, { hostConfig, isVisible, splitScreen = false, onClose },
ref ref,
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm(); const { instance: terminal, ref: xtermRef } = useXTerm();
@@ -52,11 +58,10 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
function hardRefresh() { function hardRefresh() {
try { try {
if (terminal && typeof (terminal as any).refresh === 'function') { if (terminal && typeof (terminal as any).refresh === "function") {
(terminal as any).refresh(0, terminal.rows - 1); (terminal as any).refresh(0, terminal.rows - 1);
} }
} catch (_) { } catch (_) {}
}
} }
function scheduleNotify(cols: number, rows: number) { function scheduleNotify(cols: number, rows: number) {
@@ -69,13 +74,17 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
if (!next) return; if (!next) return;
if (last && last.cols === next.cols && last.rows === next.rows) return; if (last && last.cols === next.cols && last.rows === next.rows) return;
if (webSocketRef.current?.readyState === WebSocket.OPEN) { if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({type: 'resize', data: next})); webSocketRef.current.send(
JSON.stringify({ type: "resize", data: next }),
);
lastSentSizeRef.current = next; lastSentSizeRef.current = next;
} }
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
} }
useImperativeHandle(ref, () => ({ useImperativeHandle(
ref,
() => ({
disconnect: () => { disconnect: () => {
isUnmountingRef.current = true; isUnmountingRef.current = true;
shouldNotReconnectRef.current = true; shouldNotReconnectRef.current = true;
@@ -103,26 +112,27 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}, },
sendInput: (data: string) => { sendInput: (data: string) => {
if (webSocketRef.current?.readyState === 1) { if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(JSON.stringify({type: 'input', data})); webSocketRef.current.send(JSON.stringify({ type: "input", data }));
} }
}, },
notifyResize: () => { notifyResize: () => {
try { try {
const cols = terminal?.cols ?? undefined; const cols = terminal?.cols ?? undefined;
const rows = terminal?.rows ?? undefined; const rows = terminal?.rows ?? undefined;
if (typeof cols === 'number' && typeof rows === 'number') { if (typeof cols === "number" && typeof rows === "number") {
scheduleNotify(cols, rows); scheduleNotify(cols, rows);
hardRefresh(); hardRefresh();
} }
} catch (_) { } catch (_) {}
}
}, },
refresh: () => hardRefresh(), refresh: () => hardRefresh(),
}), [terminal]); }),
[terminal],
);
useEffect(() => { useEffect(() => {
window.addEventListener('resize', handleWindowResize); window.addEventListener("resize", handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize); return () => window.removeEventListener("resize", handleWindowResize);
}, []); }, []);
function handleWindowResize() { function handleWindowResize() {
@@ -132,18 +142,21 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
hardRefresh(); hardRefresh();
} }
function getUseRightClickCopyPaste() { function getUseRightClickCopyPaste() {
return getCookie("rightClickCopyPaste") === "true" return getCookie("rightClickCopyPaste") === "true";
} }
function attemptReconnection() { function attemptReconnection() {
if (isUnmountingRef.current || shouldNotReconnectRef.current || isReconnectingRef.current) { if (
isUnmountingRef.current ||
shouldNotReconnectRef.current ||
isReconnectingRef.current
) {
return; return;
} }
if (reconnectAttempts.current >= maxReconnectAttempts) { if (reconnectAttempts.current >= maxReconnectAttempts) {
toast.error(t('terminal.maxReconnectAttemptsReached')); toast.error(t("terminal.maxReconnectAttemptsReached"));
if (onClose) { if (onClose) {
onClose(); onClose();
} }
@@ -158,7 +171,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
reconnectAttempts.current++; reconnectAttempts.current++;
toast.info(t('terminal.reconnecting', {attempt: reconnectAttempts.current, max: maxReconnectAttempts})); toast.info(
t("terminal.reconnecting", {
attempt: reconnectAttempts.current,
max: maxReconnectAttempts,
}),
);
reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = setTimeout(() => {
if (isUnmountingRef.current || shouldNotReconnectRef.current) { if (isUnmountingRef.current || shouldNotReconnectRef.current) {
@@ -183,19 +201,25 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} }
function connectToHost(cols: number, rows: number) { function connectToHost(cols: number, rows: number) {
const isDev = process.env.NODE_ENV === 'development' && const isDev =
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === ''); process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
const wsUrl = isDev const wsUrl = isDev
? 'ws://localhost:8082' ? "ws://localhost:8082"
: isElectron : isElectron
? (() => { ? (() => {
const baseUrl = (window as any).configuredServerUrl || 'http://127.0.0.1:8081'; const baseUrl =
const wsProtocol = baseUrl.startsWith('https://') ? 'wss://' : 'ws://'; (window as any).configuredServerUrl || "http://127.0.0.1:8081";
const wsHost = baseUrl.replace(/^https?:\/\//, ''); const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/ssh/websocket/`; return `${wsProtocol}${wsHost}/ssh/websocket/`;
})() })()
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
webSocketRef.current = ws; webSocketRef.current = ws;
@@ -208,15 +232,14 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
setupWebSocketListeners(ws, cols, rows); setupWebSocketListeners(ws, cols, rows);
} }
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) { function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
ws.addEventListener('open', () => { ws.addEventListener("open", () => {
connectionTimeoutRef.current = setTimeout(() => { connectionTimeoutRef.current = setTimeout(() => {
if (!isConnected) { if (!isConnected) {
if (terminal) { if (terminal) {
terminal.clear(); terminal.clear();
} }
toast.error(t('terminal.connectionTimeout')); toast.error(t("terminal.connectionTimeout"));
if (webSocketRef.current) { if (webSocketRef.current) {
webSocketRef.current.close(); webSocketRef.current.close();
} }
@@ -226,34 +249,41 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} }
}, 10000); }, 10000);
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}})); ws.send(
JSON.stringify({
type: "connectToHost",
data: { cols, rows, hostConfig },
}),
);
terminal.onData((data) => { terminal.onData((data) => {
ws.send(JSON.stringify({type: 'input', data})); ws.send(JSON.stringify({ type: "input", data }));
}); });
pingIntervalRef.current = setInterval(() => { pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'})); ws.send(JSON.stringify({ type: "ping" }));
} }
}, 30000); }, 30000);
}); });
ws.addEventListener('message', (event) => { ws.addEventListener("message", (event) => {
try { try {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === 'data') { if (msg.type === "data") {
terminal.write(msg.data); terminal.write(msg.data);
} else if (msg.type === 'error') { } else if (msg.type === "error") {
const errorMessage = msg.message || t('terminal.unknownError'); const errorMessage = msg.message || t("terminal.unknownError");
if (errorMessage.toLowerCase().includes('auth') || if (
errorMessage.toLowerCase().includes('password') || errorMessage.toLowerCase().includes("auth") ||
errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes("password") ||
errorMessage.toLowerCase().includes('denied') || errorMessage.toLowerCase().includes("permission") ||
errorMessage.toLowerCase().includes('invalid') || errorMessage.toLowerCase().includes("denied") ||
errorMessage.toLowerCase().includes('failed') || errorMessage.toLowerCase().includes("invalid") ||
errorMessage.toLowerCase().includes('incorrect')) { errorMessage.toLowerCase().includes("failed") ||
toast.error(t('terminal.authError', {message: errorMessage})); errorMessage.toLowerCase().includes("incorrect")
) {
toast.error(t("terminal.authError", { message: errorMessage }));
shouldNotReconnectRef.current = true; shouldNotReconnectRef.current = true;
if (webSocketRef.current) { if (webSocketRef.current) {
webSocketRef.current.close(); webSocketRef.current.close();
@@ -264,10 +294,14 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
return; return;
} }
if (errorMessage.toLowerCase().includes('connection') || if (
errorMessage.toLowerCase().includes('timeout') || errorMessage.toLowerCase().includes("connection") ||
errorMessage.toLowerCase().includes('network')) { errorMessage.toLowerCase().includes("timeout") ||
toast.error(t('terminal.connectionError', {message: errorMessage})); errorMessage.toLowerCase().includes("network")
) {
toast.error(
t("terminal.connectionError", { message: errorMessage }),
);
setIsConnected(false); setIsConnected(false);
if (terminal) { if (terminal) {
terminal.clear(); terminal.clear();
@@ -277,8 +311,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
return; return;
} }
toast.error(t('terminal.error', {message: errorMessage})); toast.error(t("terminal.error", { message: errorMessage }));
} else if (msg.type === 'connected') { } else if (msg.type === "connected") {
setIsConnected(true); setIsConnected(true);
setIsConnecting(false); setIsConnecting(false);
if (connectionTimeoutRef.current) { if (connectionTimeoutRef.current) {
@@ -286,11 +320,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
connectionTimeoutRef.current = null; connectionTimeoutRef.current = null;
} }
if (reconnectAttempts.current > 0) { if (reconnectAttempts.current > 0) {
toast.success(t('terminal.reconnected')); toast.success(t("terminal.reconnected"));
} }
reconnectAttempts.current = 0; reconnectAttempts.current = 0;
isReconnectingRef.current = false; isReconnectingRef.current = false;
} else if (msg.type === 'disconnected') { } else if (msg.type === "disconnected") {
wasDisconnectedBySSH.current = true; wasDisconnectedBySSH.current = true;
setIsConnected(false); setIsConnected(false);
if (terminal) { if (terminal) {
@@ -302,24 +336,28 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} }
} }
} catch (error) { } catch (error) {
toast.error(t('terminal.messageParseError')); toast.error(t("terminal.messageParseError"));
} }
}); });
ws.addEventListener('close', (event) => { ws.addEventListener("close", (event) => {
setIsConnected(false); setIsConnected(false);
if (terminal) { if (terminal) {
terminal.clear(); terminal.clear();
} }
setIsConnecting(true); setIsConnecting(true);
if (!wasDisconnectedBySSH.current && !isUnmountingRef.current && !shouldNotReconnectRef.current) { if (
!wasDisconnectedBySSH.current &&
!isUnmountingRef.current &&
!shouldNotReconnectRef.current
) {
attemptReconnection(); attemptReconnection();
} }
}); });
ws.addEventListener('error', (event) => { ws.addEventListener("error", (event) => {
setIsConnected(false); setIsConnected(false);
setConnectionError(t('terminal.websocketError')); setConnectionError(t("terminal.websocketError"));
if (terminal) { if (terminal) {
terminal.clear(); terminal.clear();
} }
@@ -336,17 +374,16 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
return; return;
} }
} catch (_) { } catch (_) {}
} const textarea = document.createElement("textarea");
const textarea = document.createElement('textarea');
textarea.value = text; textarea.value = text;
textarea.style.position = 'fixed'; textarea.style.position = "fixed";
textarea.style.left = '-9999px'; textarea.style.left = "-9999px";
document.body.appendChild(textarea); document.body.appendChild(textarea);
textarea.focus(); textarea.focus();
textarea.select(); textarea.select();
try { try {
document.execCommand('copy'); document.execCommand("copy");
} finally { } finally {
document.body.removeChild(textarea); document.body.removeChild(textarea);
} }
@@ -357,9 +394,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
if (navigator.clipboard && navigator.clipboard.readText) { if (navigator.clipboard && navigator.clipboard.readText) {
return await navigator.clipboard.readText(); return await navigator.clipboard.readText();
} }
} catch (_) { } catch (_) {}
} return "";
return '';
} }
useEffect(() => { useEffect(() => {
@@ -367,18 +403,19 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
terminal.options = { terminal.options = {
cursorBlink: true, cursorBlink: true,
cursorStyle: 'bar', cursorStyle: "bar",
scrollback: 10000, scrollback: 10000,
fontSize: 14, fontSize: 14,
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace', fontFamily:
theme: {background: '#18181b', foreground: '#f7f7f7'}, '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
theme: { background: "#18181b", foreground: "#f7f7f7" },
allowTransparency: true, allowTransparency: true,
convertEol: true, convertEol: true,
windowsMode: false, windowsMode: false,
macOptionIsMeta: false, macOptionIsMeta: false,
macOptionClickForcesSelection: false, macOptionClickForcesSelection: false,
rightClickSelectsWord: false, rightClickSelectsWord: false,
fastScrollModifier: 'alt', fastScrollModifier: "alt",
fastScrollSensitivity: 5, fastScrollSensitivity: 5,
allowProposedApi: true, allowProposedApi: true,
}; };
@@ -411,10 +448,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const pasteText = await readTextFromClipboard(); const pasteText = await readTextFromClipboard();
if (pasteText) terminal.paste(pasteText); if (pasteText) terminal.paste(pasteText);
} }
} catch (_) { } catch (_) {}
}
}; };
element?.addEventListener('contextmenu', handleContextMenu); element?.addEventListener("contextmenu", handleContextMenu);
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
@@ -428,7 +464,10 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
resizeObserver.observe(xtermRef.current); resizeObserver.observe(xtermRef.current);
const readyFonts = (document as any).fonts?.ready instanceof Promise ? (document as any).fonts.ready : Promise.resolve(); const readyFonts =
(document as any).fonts?.ready instanceof Promise
? (document as any).fonts.ready
: Promise.resolve();
readyFonts.then(() => { readyFonts.then(() => {
setTimeout(() => { setTimeout(() => {
fitAddon.fit(); fitAddon.fit();
@@ -455,11 +494,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
isReconnectingRef.current = false; isReconnectingRef.current = false;
setIsConnecting(false); setIsConnecting(false);
resizeObserver.disconnect(); resizeObserver.disconnect();
element?.removeEventListener('contextmenu', handleContextMenu); element?.removeEventListener("contextmenu", handleContextMenu);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); if (reconnectTimeoutRef.current)
if (connectionTimeoutRef.current) clearTimeout(connectionTimeoutRef.current); clearTimeout(reconnectTimeoutRef.current);
if (connectionTimeoutRef.current)
clearTimeout(connectionTimeoutRef.current);
if (pingIntervalRef.current) { if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current); clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null; pingIntervalRef.current = null;
@@ -504,7 +545,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{/* Terminal */} {/* Terminal */}
<div <div
ref={xtermRef} ref={xtermRef}
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? 'opacity-100' : 'opacity-0'} overflow-hidden`} className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"} overflow-hidden`}
onClick={() => { onClick={() => {
if (terminal && !splitScreen) { if (terminal && !splitScreen) {
terminal.focus(); terminal.focus();
@@ -516,9 +557,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{isConnecting && ( {isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-dark-bg"> <div className="absolute inset-0 flex items-center justify-center bg-dark-bg">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div> <span className="text-gray-300">{t("terminal.connecting")}</span>
<span className="text-gray-300">{t('terminal.connecting')}</span>
</div> </div>
</div> </div>
)} )}
@@ -526,7 +566,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
); );
}); });
const style = document.createElement('style'); const style = document.createElement("style");
style.innerHTML = ` style.innerHTML = `
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
+71 -28
View File
@@ -1,17 +1,35 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { TunnelViewer } from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx"; import { TunnelViewer } from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts"; import {
import type {SSHHost, TunnelConnection, TunnelStatus, SSHTunnelProps} from '../../../types/index.js'; getSSHHosts,
getTunnelStatuses,
connectTunnel,
disconnectTunnel,
cancelTunnel,
} from "@/ui/main-axios.ts";
import type {
SSHHost,
TunnelConnection,
TunnelStatus,
SSHTunnelProps,
} from "../../../types/index.js";
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
const [allHosts, setAllHosts] = useState<SSHHost[]>([]); const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]); const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({}); const [tunnelStatuses, setTunnelStatuses] = useState<
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({}); Record<string, TunnelStatus>
>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
{},
);
const prevVisibleHostRef = React.useRef<SSHHost | null>(null); const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
const haveTunnelConnectionsChanged = (a: TunnelConnection[] = [], b: TunnelConnection[] = []): boolean => { const haveTunnelConnectionsChanged = (
a: TunnelConnection[] = [],
b: TunnelConnection[] = [],
): boolean => {
if (a.length !== b.length) return true; if (a.length !== b.length) return true;
for (let i = 0; i < a.length; i++) { for (let i = 0; i < a.length; i++) {
const x = a[i]; const x = a[i];
@@ -34,8 +52,9 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
const hostsData = await getSSHHosts(); const hostsData = await getSSHHosts();
setAllHosts(hostsData); setAllHosts(hostsData);
const nextVisible = filterHostKey const nextVisible = filterHostKey
? hostsData.filter(h => { ? hostsData.filter((h) => {
const key = (h.name && h.name.trim() !== '') ? h.name : `${h.username}@${h.ip}`; const key =
h.name && h.name.trim() !== "" ? h.name : `${h.username}@${h.ip}`;
return key === filterHostKey; return key === filterHostKey;
}) })
: hostsData; : hostsData;
@@ -52,7 +71,10 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
prev.ip !== curr.ip || prev.ip !== curr.ip ||
prev.port !== curr.port || prev.port !== curr.port ||
prev.username !== curr.username || prev.username !== curr.username ||
haveTunnelConnectionsChanged(prev.tunnelConnections, curr.tunnelConnections) haveTunnelConnectionsChanged(
prev.tunnelConnections,
curr.tunnelConnections,
)
) { ) {
changed = true; changed = true;
} }
@@ -76,11 +98,17 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
const handleHostsChanged = () => { const handleHostsChanged = () => {
fetchHosts(); fetchHosts();
}; };
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener); window.addEventListener(
"ssh-hosts:changed",
handleHostsChanged as EventListener,
);
return () => { return () => {
clearInterval(interval); clearInterval(interval);
window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener); window.removeEventListener(
"ssh-hosts:changed",
handleHostsChanged as EventListener,
);
}; };
}, [fetchHosts]); }, [fetchHosts]);
@@ -90,21 +118,26 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchTunnelStatuses]); }, [fetchTunnelStatuses]);
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => { const handleTunnelAction = async (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => {
const tunnel = host.tunnelConnections[tunnelIndex]; const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`; const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
setTunnelActions(prev => ({...prev, [tunnelName]: true})); setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
try { try {
if (action === 'connect') { if (action === "connect") {
const endpointHost = allHosts.find(h => const endpointHost = allHosts.find(
(h) =>
h.name === tunnel.endpointHost || h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost `${h.username}@${h.ip}` === tunnel.endpointHost,
); );
if (!endpointHost) { if (!endpointHost) {
throw new Error('Endpoint host not found'); throw new Error("Endpoint host not found");
} }
const tunnelConfig = { const tunnelConfig = {
@@ -113,21 +146,31 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
sourceIP: host.ip, sourceIP: host.ip,
sourceSSHPort: host.port, sourceSSHPort: host.port,
sourceUsername: host.username, sourceUsername: host.username,
sourcePassword: host.authType === 'password' ? host.password : undefined, sourcePassword:
host.authType === "password" ? host.password : undefined,
sourceAuthMethod: host.authType, sourceAuthMethod: host.authType,
sourceSSHKey: host.authType === 'key' ? host.key : undefined, sourceSSHKey: host.authType === "key" ? host.key : undefined,
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined, sourceKeyPassword:
sourceKeyType: host.authType === 'key' ? host.keyType : undefined, host.authType === "key" ? host.keyPassword : undefined,
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
sourceCredentialId: host.credentialId, sourceCredentialId: host.credentialId,
sourceUserId: host.userId, sourceUserId: host.userId,
endpointIP: endpointHost.ip, endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port, endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username, endpointUsername: endpointHost.username,
endpointPassword: endpointHost.authType === 'password' ? endpointHost.password : undefined, endpointPassword:
endpointHost.authType === "password"
? endpointHost.password
: undefined,
endpointAuthMethod: endpointHost.authType, endpointAuthMethod: endpointHost.authType,
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined, endpointSSHKey:
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined, endpointHost.authType === "key" ? endpointHost.key : undefined,
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined, endpointKeyPassword:
endpointHost.authType === "key"
? endpointHost.keyPassword
: undefined,
endpointKeyType:
endpointHost.authType === "key" ? endpointHost.keyType : undefined,
endpointCredentialId: endpointHost.credentialId, endpointCredentialId: endpointHost.credentialId,
endpointUserId: endpointHost.userId, endpointUserId: endpointHost.userId,
sourcePort: tunnel.sourcePort, sourcePort: tunnel.sourcePort,
@@ -135,20 +178,20 @@ export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
maxRetries: tunnel.maxRetries, maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000, retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart, autoStart: tunnel.autoStart,
isPinned: host.pin isPinned: host.pin,
}; };
await connectTunnel(tunnelConfig); await connectTunnel(tunnelConfig);
} else if (action === 'disconnect') { } else if (action === "disconnect") {
await disconnectTunnel(tunnelName); await disconnectTunnel(tunnelName);
} else if (action === 'cancel') { } else if (action === "cancel") {
await cancelTunnel(tunnelName); await cancelTunnel(tunnelName);
} }
await fetchTunnelStatuses(); await fetchTunnelStatuses();
} catch (err) { } catch (err) {
} finally { } finally {
setTunnelActions(prev => ({...prev, [tunnelName]: false})); setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
} }
}; };
+224 -126
View File
@@ -2,7 +2,7 @@ import React from "react";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Card } from "@/components/ui/card.tsx"; import { Card } from "@/components/ui/card.tsx";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import { import {
Loader2, Loader2,
Pin, Pin,
@@ -14,10 +14,13 @@ import {
Clock, Clock,
Wifi, Wifi,
WifiOff, WifiOff,
X X,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "@/components/ui/badge.tsx"; import { Badge } from "@/components/ui/badge.tsx";
import type {TunnelStatus, SSHTunnelObjectProps} from '../../../types/index.js'; import type {
TunnelStatus,
SSHTunnelObjectProps,
} from "../../../types/index.js";
export function TunnelObject({ export function TunnelObject({
host, host,
@@ -25,7 +28,7 @@ export function TunnelObject({
tunnelActions, tunnelActions,
onTunnelAction, onTunnelAction,
compact = false, compact = false,
bare = false bare = false,
}: SSHTunnelObjectProps): React.ReactElement { }: SSHTunnelObjectProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -36,72 +39,73 @@ export function TunnelObject({
}; };
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => { const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
if (!status) return { if (!status)
return {
icon: <WifiOff className="h-4 w-4" />, icon: <WifiOff className="h-4 w-4" />,
text: t('tunnels.unknown'), text: t("tunnels.unknown"),
color: 'text-muted-foreground', color: "text-muted-foreground",
bgColor: 'bg-muted/50', bgColor: "bg-muted/50",
borderColor: 'border-border' borderColor: "border-border",
}; };
const statusValue = status.status || 'DISCONNECTED'; const statusValue = status.status || "DISCONNECTED";
switch (statusValue.toUpperCase()) { switch (statusValue.toUpperCase()) {
case 'CONNECTED': case "CONNECTED":
return { return {
icon: <Wifi className="h-4 w-4" />, icon: <Wifi className="h-4 w-4" />,
text: t('tunnels.connected'), text: t("tunnels.connected"),
color: 'text-green-600 dark:text-green-400', color: "text-green-600 dark:text-green-400",
bgColor: 'bg-green-500/10 dark:bg-green-400/10', bgColor: "bg-green-500/10 dark:bg-green-400/10",
borderColor: 'border-green-500/20 dark:border-green-400/20' borderColor: "border-green-500/20 dark:border-green-400/20",
}; };
case 'CONNECTING': case "CONNECTING":
return { return {
icon: <Loader2 className="h-4 w-4 animate-spin" />, icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: t('tunnels.connecting'), text: t("tunnels.connecting"),
color: 'text-blue-600 dark:text-blue-400', color: "text-blue-600 dark:text-blue-400",
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10', bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
borderColor: 'border-blue-500/20 dark:border-blue-400/20' borderColor: "border-blue-500/20 dark:border-blue-400/20",
}; };
case 'DISCONNECTING': case "DISCONNECTING":
return { return {
icon: <Loader2 className="h-4 w-4 animate-spin" />, icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: t('tunnels.disconnecting'), text: t("tunnels.disconnecting"),
color: 'text-orange-600 dark:text-orange-400', color: "text-orange-600 dark:text-orange-400",
bgColor: 'bg-orange-500/10 dark:bg-orange-400/10', bgColor: "bg-orange-500/10 dark:bg-orange-400/10",
borderColor: 'border-orange-500/20 dark:border-orange-400/20' borderColor: "border-orange-500/20 dark:border-orange-400/20",
}; };
case 'DISCONNECTED': case "DISCONNECTED":
return { return {
icon: <WifiOff className="h-4 w-4" />, icon: <WifiOff className="h-4 w-4" />,
text: t('tunnels.disconnected'), text: t("tunnels.disconnected"),
color: 'text-muted-foreground', color: "text-muted-foreground",
bgColor: 'bg-muted/30', bgColor: "bg-muted/30",
borderColor: 'border-border' borderColor: "border-border",
}; };
case 'WAITING': case "WAITING":
return { return {
icon: <Clock className="h-4 w-4" />, icon: <Clock className="h-4 w-4" />,
color: 'text-blue-600 dark:text-blue-400', color: "text-blue-600 dark:text-blue-400",
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10', bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
borderColor: 'border-blue-500/20 dark:border-blue-400/20' borderColor: "border-blue-500/20 dark:border-blue-400/20",
}; };
case 'ERROR': case "ERROR":
case 'FAILED': case "FAILED":
return { return {
icon: <AlertCircle className="h-4 w-4" />, icon: <AlertCircle className="h-4 w-4" />,
text: status.reason || t('tunnels.error'), text: status.reason || t("tunnels.error"),
color: 'text-red-600 dark:text-red-400', color: "text-red-600 dark:text-red-400",
bgColor: 'bg-red-500/10 dark:bg-red-400/10', bgColor: "bg-red-500/10 dark:bg-red-400/10",
borderColor: 'border-red-500/20 dark:border-red-400/20' borderColor: "border-red-500/20 dark:border-red-400/20",
}; };
default: default:
return { return {
icon: <WifiOff className="h-4 w-4" />, icon: <WifiOff className="h-4 w-4" />,
text: statusValue, text: statusValue,
color: 'text-muted-foreground', color: "text-muted-foreground",
bgColor: 'bg-muted/30', bgColor: "bg-muted/30",
borderColor: 'border-border' borderColor: "border-border",
}; };
} }
}; };
@@ -117,26 +121,34 @@ export function TunnelObject({
const statusDisplay = getTunnelStatusDisplay(status); const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`; const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName]; const isActionLoading = tunnelActions[tunnelName];
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED'; const statusValue =
const isConnected = statusValue === 'CONNECTED'; status?.status?.toUpperCase() || "DISCONNECTED";
const isConnecting = statusValue === 'CONNECTING'; const isConnected = statusValue === "CONNECTED";
const isDisconnecting = statusValue === 'DISCONNECTING'; const isConnecting = statusValue === "CONNECTING";
const isRetrying = statusValue === 'RETRYING'; const isDisconnecting = statusValue === "DISCONNECTING";
const isWaiting = statusValue === 'WAITING'; const isRetrying = statusValue === "RETRYING";
const isWaiting = statusValue === "WAITING";
return ( return (
<div key={tunnelIndex} <div
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}> key={tunnelIndex}
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0"> <div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}> <span
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
>
{statusDisplay.icon} {statusDisplay.icon}
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words"> <div className="text-sm font-medium break-words">
{t('tunnels.port')} {tunnel.sourcePort} {tunnel.endpointHost}:{tunnel.endpointPort} {t("tunnels.port")} {tunnel.sourcePort} {" "}
{tunnel.endpointHost}:{tunnel.endpointPort}
</div> </div>
<div className={`text-xs ${statusDisplay.color} font-medium`}> <div
className={`text-xs ${statusDisplay.color} font-medium`}
>
{statusDisplay.text} {statusDisplay.text}
</div> </div>
</div> </div>
@@ -149,33 +161,43 @@ export function TunnelObject({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)} onClick={() =>
onTunnelAction(
"disconnect",
host,
tunnelIndex,
)
}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs" className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
> >
<Square className="h-3 w-3 mr-1" /> <Square className="h-3 w-3 mr-1" />
{t('tunnels.disconnect')} {t("tunnels.disconnect")}
</Button> </Button>
</> </>
) : isRetrying || isWaiting ? ( ) : isRetrying || isWaiting ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onTunnelAction('cancel', host, tunnelIndex)} onClick={() =>
onTunnelAction("cancel", host, tunnelIndex)
}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs" className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
> >
<X className="h-3 w-3 mr-1" /> <X className="h-3 w-3 mr-1" />
{t('tunnels.cancel')} {t("tunnels.cancel")}
</Button> </Button>
) : ( ) : (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onTunnelAction('connect', host, tunnelIndex)} onClick={() =>
onTunnelAction("connect", host, tunnelIndex)
}
disabled={isConnecting || isDisconnecting} disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs" className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
> >
<Play className="h-3 w-3 mr-1" /> <Play className="h-3 w-3 mr-1" />
{t('tunnels.connect')} {t("tunnels.connect")}
</Button> </Button>
)} )}
</div> </div>
@@ -187,49 +209,75 @@ export function TunnelObject({
className="h-7 px-2 text-muted-foreground border-border text-xs" className="h-7 px-2 text-muted-foreground border-border text-xs"
> >
<Loader2 className="h-3 w-3 mr-1 animate-spin" /> <Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')} {isConnected
? t("tunnels.disconnecting")
: isRetrying || isWaiting
? t("tunnels.canceling")
: t("tunnels.connecting")}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( {(statusValue === "ERROR" || statusValue === "FAILED") &&
<div status?.reason && (
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20"> <div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">{t('tunnels.error')}:</div> <div className="font-medium mb-1">
{t("tunnels.error")}:
</div>
{status.reason} {status.reason}
{status.reason && status.reason.includes('Max retries exhausted') && ( {status.reason &&
status.reason.includes("Max retries exhausted") && (
<> <>
<div <div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20"> {t("tunnels.checkDockerLogs")}{" "}
{t('tunnels.checkDockerLogs')} <a <a
href="https://discord.com/invite/jVQGdvHDrf" target="_blank" href="https://discord.com/invite/jVQGdvHDrf"
target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">Discord</a> or className="underline text-blue-600 dark:text-blue-400"
create a <a >
Discord
</a>{" "}
or create a{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new" href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" rel="noopener noreferrer" target="_blank"
className="underline text-blue-600 dark:text-blue-400">GitHub rel="noopener noreferrer"
issue</a> for help. className="underline text-blue-600 dark:text-blue-400"
>
GitHub issue
</a>{" "}
for help.
</div> </div>
</> </>
)} )}
</div> </div>
)} )}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && ( {(statusValue === "RETRYING" ||
<div statusValue === "WAITING") &&
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20"> status?.retryCount &&
status?.maxRetries && (
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1"> <div className="font-medium mb-1">
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')} {statusValue === "WAITING"
? t("tunnels.waitingForRetry")
: t("tunnels.retryingConnection")}
</div> </div>
<div> <div>
{t('tunnels.attempt', { {t("tunnels.attempt", {
current: status.retryCount, current: status.retryCount,
max: status.maxRetries max: status.maxRetries,
})} })}
{status.nextRetryIn && ( {status.nextRetryIn && (
<span> {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span> <span>
{" "}
{" "}
{t("tunnels.nextRetryIn", {
seconds: status.nextRetryIn,
})}
</span>
)} )}
</div> </div>
</div> </div>
@@ -241,7 +289,7 @@ export function TunnelObject({
) : ( ) : (
<div className="text-center py-4 text-muted-foreground"> <div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" /> <Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t('tunnels.noTunnelConnections')}</p> <p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
</div> </div>
)} )}
</div> </div>
@@ -255,7 +303,9 @@ export function TunnelObject({
{!compact && ( {!compact && (
<div className="flex items-center justify-between gap-2 mb-3"> <div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>} {host.pin && (
<Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-card-foreground truncate"> <h3 className="font-semibold text-card-foreground truncate">
{host.name || `${host.username}@${host.ip}`} {host.name || `${host.username}@${host.ip}`}
@@ -271,7 +321,11 @@ export function TunnelObject({
{!compact && host.tags && host.tags.length > 0 && ( {!compact && host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3"> <div className="flex flex-wrap gap-1 mb-3">
{host.tags.slice(0, 3).map((tag, index) => ( {host.tags.slice(0, 3).map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs px-1 py-0"> <Badge
key={index}
variant="secondary"
className="text-xs px-1 py-0"
>
<Tag className="h-2 w-2 mr-0.5" /> <Tag className="h-2 w-2 mr-0.5" />
{tag} {tag}
</Badge> </Badge>
@@ -290,7 +344,7 @@ export function TunnelObject({
{!compact && ( {!compact && (
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2"> <h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4" /> <Network className="h-4 w-4" />
{t('tunnels.tunnelConnections')} ({host.tunnelConnections.length}) {t("tunnels.tunnelConnections")} ({host.tunnelConnections.length})
</h4> </h4>
)} )}
{host.tunnelConnections && host.tunnelConnections.length > 0 ? ( {host.tunnelConnections && host.tunnelConnections.length > 0 ? (
@@ -300,26 +354,34 @@ export function TunnelObject({
const statusDisplay = getTunnelStatusDisplay(status); const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`; const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName]; const isActionLoading = tunnelActions[tunnelName];
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED'; const statusValue =
const isConnected = statusValue === 'CONNECTED'; status?.status?.toUpperCase() || "DISCONNECTED";
const isConnecting = statusValue === 'CONNECTING'; const isConnected = statusValue === "CONNECTED";
const isDisconnecting = statusValue === 'DISCONNECTING'; const isConnecting = statusValue === "CONNECTING";
const isRetrying = statusValue === 'RETRYING'; const isDisconnecting = statusValue === "DISCONNECTING";
const isWaiting = statusValue === 'WAITING'; const isRetrying = statusValue === "RETRYING";
const isWaiting = statusValue === "WAITING";
return ( return (
<div key={tunnelIndex} <div
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}> key={tunnelIndex}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0"> <div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}> <span
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
>
{statusDisplay.icon} {statusDisplay.icon}
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words"> <div className="text-sm font-medium break-words">
{t('tunnels.port')} {tunnel.sourcePort} {tunnel.endpointHost}:{tunnel.endpointPort} {t("tunnels.port")} {tunnel.sourcePort} {" "}
{tunnel.endpointHost}:{tunnel.endpointPort}
</div> </div>
<div className={`text-xs ${statusDisplay.color} font-medium`}> <div
className={`text-xs ${statusDisplay.color} font-medium`}
>
{statusDisplay.text} {statusDisplay.text}
</div> </div>
</div> </div>
@@ -332,33 +394,43 @@ export function TunnelObject({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)} onClick={() =>
onTunnelAction(
"disconnect",
host,
tunnelIndex,
)
}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs" className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
> >
<Square className="h-3 w-3 mr-1" /> <Square className="h-3 w-3 mr-1" />
{t('tunnels.disconnect')} {t("tunnels.disconnect")}
</Button> </Button>
</> </>
) : isRetrying || isWaiting ? ( ) : isRetrying || isWaiting ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onTunnelAction('cancel', host, tunnelIndex)} onClick={() =>
onTunnelAction("cancel", host, tunnelIndex)
}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs" className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
> >
<X className="h-3 w-3 mr-1" /> <X className="h-3 w-3 mr-1" />
{t('tunnels.cancel')} {t("tunnels.cancel")}
</Button> </Button>
) : ( ) : (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onTunnelAction('connect', host, tunnelIndex)} onClick={() =>
onTunnelAction("connect", host, tunnelIndex)
}
disabled={isConnecting || isDisconnecting} disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs" className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
> >
<Play className="h-3 w-3 mr-1" /> <Play className="h-3 w-3 mr-1" />
{t('tunnels.connect')} {t("tunnels.connect")}
</Button> </Button>
)} )}
</div> </div>
@@ -371,49 +443,75 @@ export function TunnelObject({
className="h-7 px-2 text-muted-foreground border-border text-xs" className="h-7 px-2 text-muted-foreground border-border text-xs"
> >
<Loader2 className="h-3 w-3 mr-1 animate-spin" /> <Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')} {isConnected
? t("tunnels.disconnecting")
: isRetrying || isWaiting
? t("tunnels.canceling")
: t("tunnels.connecting")}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( {(statusValue === "ERROR" || statusValue === "FAILED") &&
<div status?.reason && (
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20"> <div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">{t('tunnels.error')}:</div> <div className="font-medium mb-1">
{t("tunnels.error")}:
</div>
{status.reason} {status.reason}
{status.reason && status.reason.includes('Max retries exhausted') && ( {status.reason &&
status.reason.includes("Max retries exhausted") && (
<> <>
<div <div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20"> {t("tunnels.checkDockerLogs")}{" "}
{t('tunnels.checkDockerLogs')} <a <a
href="https://discord.com/invite/jVQGdvHDrf" target="_blank" href="https://discord.com/invite/jVQGdvHDrf"
target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">Discord</a> or className="underline text-blue-600 dark:text-blue-400"
create a <a >
Discord
</a>{" "}
or create a{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new" href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" rel="noopener noreferrer" target="_blank"
className="underline text-blue-600 dark:text-blue-400">GitHub rel="noopener noreferrer"
issue</a> for help. className="underline text-blue-600 dark:text-blue-400"
>
GitHub issue
</a>{" "}
for help.
</div> </div>
</> </>
)} )}
</div> </div>
)} )}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && ( {(statusValue === "RETRYING" ||
<div statusValue === "WAITING") &&
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20"> status?.retryCount &&
status?.maxRetries && (
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1"> <div className="font-medium mb-1">
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')} {statusValue === "WAITING"
? t("tunnels.waitingForRetry")
: t("tunnels.retryingConnection")}
</div> </div>
<div> <div>
{t('tunnels.attempt', { {t("tunnels.attempt", {
current: status.retryCount, current: status.retryCount,
max: status.maxRetries max: status.maxRetries,
})} })}
{status.nextRetryIn && ( {status.nextRetryIn && (
<span> {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span> <span>
{" "}
{" "}
{t("tunnels.nextRetryIn", {
seconds: status.nextRetryIn,
})}
</span>
)} )}
</div> </div>
</div> </div>
@@ -425,7 +523,7 @@ export function TunnelObject({
) : ( ) : (
<div className="text-center py-4 text-muted-foreground"> <div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" /> <Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t('tunnels.noTunnelConnections')}</p> <p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
</div> </div>
)} )}
</div> </div>
+34 -13
View File
@@ -1,30 +1,45 @@
import React from "react"; import React from "react";
import { TunnelObject } from "./TunnelObject.tsx"; import { TunnelObject } from "./TunnelObject.tsx";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import type {SSHHost, TunnelConnection, TunnelStatus} from '../../../types/index.js'; import type {
SSHHost,
TunnelConnection,
TunnelStatus,
} from "../../../types/index.js";
interface SSHTunnelViewerProps { interface SSHTunnelViewerProps {
hosts: SSHHost[]; hosts: SSHHost[];
tunnelStatuses: Record<string, TunnelStatus>; tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>; tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>; onTunnelAction: (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>;
} }
export function TunnelViewer({ export function TunnelViewer({
hosts = [], hosts = [],
tunnelStatuses = {}, tunnelStatuses = {},
tunnelActions = {}, tunnelActions = {},
onTunnelAction onTunnelAction,
}: SSHTunnelViewerProps): React.ReactElement { }: SSHTunnelViewerProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined; const activeHost: SSHHost | undefined =
Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) { if (
!activeHost ||
!activeHost.tunnelConnections ||
activeHost.tunnelConnections.length === 0
) {
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3"> <div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
<h3 className="text-lg font-semibold text-foreground mb-2">{t('tunnels.noSshTunnels')}</h3> <h3 className="text-lg font-semibold text-foreground mb-2">
{t("tunnels.noSshTunnels")}
</h3>
<p className="text-muted-foreground max-w-md"> <p className="text-muted-foreground max-w-md">
{t('tunnels.createFirstTunnelMessage')} {t("tunnels.createFirstTunnelMessage")}
</p> </p>
</div> </div>
); );
@@ -33,18 +48,24 @@ export function TunnelViewer({
return ( return (
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0"> <div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
<div className="w-full flex-shrink-0 mb-2"> <div className="w-full flex-shrink-0 mb-2">
<h1 className="text-xl font-semibold text-foreground">{t('tunnels.title')}</h1> <h1 className="text-xl font-semibold text-foreground">
{t("tunnels.title")}
</h1>
</div> </div>
<div className="min-h-0 flex-1 overflow-auto pr-1"> <div className="min-h-0 flex-1 overflow-auto pr-1">
<div <div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => ( {activeHost.tunnelConnections.map((t, idx) => (
<TunnelObject <TunnelObject
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`} key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
host={{...activeHost, tunnelConnections: [activeHost.tunnelConnections[idx]]}} host={{
...activeHost,
tunnelConnections: [activeHost.tunnelConnections[idx]],
}}
tunnelStatuses={tunnelStatuses} tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions} tunnelActions={tunnelActions}
onTunnelAction={(action, _host, _index) => onTunnelAction(action, activeHost, idx)} onTunnelAction={(action, _host, _index) =>
onTunnelAction(action, activeHost, idx)
}
compact compact
bare bare
/> />
+67 -45
View File
@@ -1,9 +1,12 @@
import React, {useState, useEffect} from "react" import React, { useState, useEffect } from "react";
import {LeftSidebar} from "@/ui/Desktop/Navigation/LeftSidebar.tsx" import { LeftSidebar } from "@/ui/Desktop/Navigation/LeftSidebar.tsx";
import {Homepage} from "@/ui/Desktop/Homepage/Homepage.tsx" import { Homepage } from "@/ui/Desktop/Homepage/Homepage.tsx";
import {AppView} from "@/ui/Desktop/Navigation/AppView.tsx" import { AppView } from "@/ui/Desktop/Navigation/AppView.tsx";
import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx" import { HostManager } from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx";
import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx" import {
TabProvider,
useTabs,
} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { TopNavbar } from "@/ui/Desktop/Navigation/TopNavbar.tsx"; import { TopNavbar } from "@/ui/Desktop/Navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx"; import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx"; import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
@@ -11,13 +14,15 @@ import {Toaster} from "@/components/ui/sonner.tsx";
import { getUserInfo, getCookie } from "@/ui/main-axios.ts"; import { getUserInfo, getCookie } from "@/ui/main-axios.ts";
function AppContent() { function AppContent() {
const [view, setView] = useState<string>("homepage") const [view, setView] = useState<string>("homepage");
const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"])) const [mountedViews, setMountedViews] = useState<Set<string>>(
const [isAuthenticated, setIsAuthenticated] = useState(false) new Set(["homepage"]),
const [username, setUsername] = useState<string | null>(null) );
const [isAdmin, setIsAdmin] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false);
const [authLoading, setAuthLoading] = useState(true) const [username, setUsername] = useState<string | null>(null);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true) const [isAdmin, setIsAdmin] = useState(false);
const [authLoading, setAuthLoading] = useState(true);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
const { currentTab, tabs } = useTabs(); const { currentTab, tabs } = useTabs();
useEffect(() => { useEffect(() => {
@@ -35,7 +40,8 @@ function AppContent() {
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; document.cookie =
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
}) })
.finally(() => setAuthLoading(false)); .finally(() => setAuthLoading(false));
} else { } else {
@@ -44,44 +50,53 @@ function AppContent() {
setUsername(null); setUsername(null);
setAuthLoading(false); setAuthLoading(false);
} }
} };
checkAuth() checkAuth();
const handleStorageChange = () => checkAuth() const handleStorageChange = () => checkAuth();
window.addEventListener('storage', handleStorageChange) window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange) return () => window.removeEventListener("storage", handleStorageChange);
}, []) }, []);
const handleSelectView = (nextView: string) => { const handleSelectView = (nextView: string) => {
setMountedViews((prev) => { setMountedViews((prev) => {
if (prev.has(nextView)) return prev if (prev.has(nextView)) return prev;
const next = new Set(prev) const next = new Set(prev);
next.add(nextView) next.add(nextView);
return next return next;
}) });
setView(nextView) setView(nextView);
} };
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => { const handleAuthSuccess = (authData: {
setIsAuthenticated(true) isAdmin: boolean;
setIsAdmin(authData.isAdmin) username: string | null;
setUsername(authData.username) userId: string | null;
} }) => {
setIsAuthenticated(true);
setIsAdmin(authData.isAdmin);
setUsername(authData.username);
};
const currentTabData = tabs.find(tab => tab.id === currentTab); const currentTabData = tabs.find((tab) => tab.id === currentTab);
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager'; const showTerminalView =
const showHome = currentTabData?.type === 'home'; currentTabData?.type === "terminal" ||
const showSshManager = currentTabData?.type === 'ssh_manager'; currentTabData?.type === "server" ||
const showAdmin = currentTabData?.type === 'admin'; currentTabData?.type === "file_manager";
const showProfile = currentTabData?.type === 'user_profile'; const showHome = currentTabData?.type === "home";
const showSshManager = currentTabData?.type === "ssh_manager";
const showAdmin = currentTabData?.type === "admin";
const showProfile = currentTabData?.type === "user_profile";
return ( return (
<div> <div>
{!isAuthenticated && !authLoading && ( {!isAuthenticated && !authLoading && (
<div> <div>
<div className="absolute inset-0" style={{ <div
className="absolute inset-0"
style={{
backgroundImage: `linear-gradient( backgroundImage: `linear-gradient(
135deg, 135deg,
transparent 0%, transparent 0%,
@@ -91,8 +106,9 @@ function AppContent() {
transparent 51%, transparent 51%,
transparent 100% transparent 100%
)`, )`,
backgroundSize: '80px 80px' backgroundSize: "80px 80px",
}}/> }}
/>
</div> </div>
)} )}
@@ -135,7 +151,10 @@ function AppContent() {
{showSshManager && ( {showSshManager && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden"> <div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen}/> <HostManager
onSelectView={handleSelectView}
isTopbarOpen={isTopbarOpen}
/>
</div> </div>
)} )}
@@ -151,7 +170,10 @@ function AppContent() {
</div> </div>
)} )}
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/> <TopNavbar
isTopbarOpen={isTopbarOpen}
setIsTopbarOpen={setIsTopbarOpen}
/>
</LeftSidebar> </LeftSidebar>
)} )}
<Toaster <Toaster
@@ -162,7 +184,7 @@ function AppContent() {
offset={20} offset={20}
/> />
</div> </div>
) );
} }
function DesktopApp() { function DesktopApp() {
@@ -173,4 +195,4 @@ function DesktopApp() {
); );
} }
export default DesktopApp export default DesktopApp;
+63 -46
View File
@@ -1,11 +1,16 @@
import React, {useState, useEffect} from 'react'; import React, { useState, useEffect } from "react";
import {Button} from '@/components/ui/button.tsx'; import { Button } from "@/components/ui/button.tsx";
import {Input} from '@/components/ui/input.tsx'; import { Input } from "@/components/ui/input.tsx";
import {Label} from '@/components/ui/label.tsx'; import { Label } from "@/components/ui/label.tsx";
import {Alert, AlertTitle, AlertDescription} from '@/components/ui/alert.tsx'; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import {getServerConfig, saveServerConfig, testServerConnection, type ServerConfig} from '@/ui/main-axios.ts'; import {
import {CheckCircle, XCircle, Server, Wifi} from 'lucide-react'; getServerConfig,
saveServerConfig,
testServerConnection,
type ServerConfig,
} from "@/ui/main-axios.ts";
import { CheckCircle, XCircle, Server, Wifi } from "lucide-react";
interface ServerConfigProps { interface ServerConfigProps {
onServerConfigured: (serverUrl: string) => void; onServerConfigured: (serverUrl: string) => void;
@@ -13,13 +18,19 @@ interface ServerConfigProps {
isFirstTime?: boolean; isFirstTime?: boolean;
} }
export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}: ServerConfigProps) { export function ServerConfig({
onServerConfigured,
onCancel,
isFirstTime = false,
}: ServerConfigProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [serverUrl, setServerUrl] = useState(''); const [serverUrl, setServerUrl] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown'); const [connectionStatus, setConnectionStatus] = useState<
"unknown" | "success" | "error"
>("unknown");
useEffect(() => { useEffect(() => {
loadServerConfig(); loadServerConfig();
@@ -30,15 +41,14 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
const config = await getServerConfig(); const config = await getServerConfig();
if (config?.serverUrl) { if (config?.serverUrl) {
setServerUrl(config.serverUrl); setServerUrl(config.serverUrl);
setConnectionStatus('success'); setConnectionStatus("success");
}
} catch (error) {
} }
} catch (error) {}
}; };
const handleTestConnection = async () => { const handleTestConnection = async () => {
if (!serverUrl.trim()) { if (!serverUrl.trim()) {
setError(t('serverConfig.enterServerUrl')); setError(t("serverConfig.enterServerUrl"));
return; return;
} }
@@ -47,21 +57,24 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
try { try {
let normalizedUrl = serverUrl.trim(); let normalizedUrl = serverUrl.trim();
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) { if (
!normalizedUrl.startsWith("http://") &&
!normalizedUrl.startsWith("https://")
) {
normalizedUrl = `http://${normalizedUrl}`; normalizedUrl = `http://${normalizedUrl}`;
} }
const result = await testServerConnection(normalizedUrl); const result = await testServerConnection(normalizedUrl);
if (result.success) { if (result.success) {
setConnectionStatus('success'); setConnectionStatus("success");
} else { } else {
setConnectionStatus('error'); setConnectionStatus("error");
setError(result.error || t('serverConfig.connectionFailed')); setError(result.error || t("serverConfig.connectionFailed"));
} }
} catch (error) { } catch (error) {
setConnectionStatus('error'); setConnectionStatus("error");
setError(t('serverConfig.connectionError')); setError(t("serverConfig.connectionError"));
} finally { } finally {
setTesting(false); setTesting(false);
} }
@@ -69,12 +82,12 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
const handleSaveConfig = async () => { const handleSaveConfig = async () => {
if (!serverUrl.trim()) { if (!serverUrl.trim()) {
setError(t('serverConfig.enterServerUrl')); setError(t("serverConfig.enterServerUrl"));
return; return;
} }
if (connectionStatus !== 'success') { if (connectionStatus !== "success") {
setError(t('serverConfig.testConnectionFirst')); setError(t("serverConfig.testConnectionFirst"));
return; return;
} }
@@ -83,13 +96,16 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
try { try {
let normalizedUrl = serverUrl.trim(); let normalizedUrl = serverUrl.trim();
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) { if (
!normalizedUrl.startsWith("http://") &&
!normalizedUrl.startsWith("https://")
) {
normalizedUrl = `http://${normalizedUrl}`; normalizedUrl = `http://${normalizedUrl}`;
} }
const config: ServerConfig = { const config: ServerConfig = {
serverUrl: normalizedUrl, serverUrl: normalizedUrl,
lastUpdated: new Date().toISOString() lastUpdated: new Date().toISOString(),
}; };
const success = await saveServerConfig(config); const success = await saveServerConfig(config);
@@ -97,10 +113,10 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
if (success) { if (success) {
onServerConfigured(normalizedUrl); onServerConfigured(normalizedUrl);
} else { } else {
setError(t('serverConfig.saveFailed')); setError(t("serverConfig.saveFailed"));
} }
} catch (error) { } catch (error) {
setError(t('serverConfig.saveError')); setError(t("serverConfig.saveError"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -108,7 +124,7 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
const handleUrlChange = (value: string) => { const handleUrlChange = (value: string) => {
setServerUrl(value); setServerUrl(value);
setConnectionStatus('unknown'); setConnectionStatus("unknown");
setError(null); setError(null);
}; };
@@ -118,14 +134,14 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center"> <div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
<Server className="w-6 h-6 text-primary" /> <Server className="w-6 h-6 text-primary" />
</div> </div>
<h2 className="text-xl font-semibold">{t('serverConfig.title')}</h2> <h2 className="text-xl font-semibold">{t("serverConfig.title")}</h2>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2">
{t('serverConfig.description')} {t("serverConfig.description")}
</p> </p>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="server-url">{t('serverConfig.serverUrl')}</Label> <Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Input <Input
id="server-url" id="server-url"
@@ -144,8 +160,7 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
className="w-10 h-10 p-0 flex items-center justify-center" className="w-10 h-10 p-0 flex items-center justify-center"
> >
{testing ? ( {testing ? (
<div <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"/>
) : ( ) : (
<Wifi className="w-4 h-4" /> <Wifi className="w-4 h-4" />
)} )}
@@ -153,17 +168,21 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
</div> </div>
</div> </div>
{connectionStatus !== 'unknown' && ( {connectionStatus !== "unknown" && (
<div className="flex items-center space-x-2 text-sm"> <div className="flex items-center space-x-2 text-sm">
{connectionStatus === 'success' ? ( {connectionStatus === "success" ? (
<> <>
<CheckCircle className="w-4 h-4 text-green-500" /> <CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-green-600">{t('serverConfig.connected')}</span> <span className="text-green-600">
{t("serverConfig.connected")}
</span>
</> </>
) : ( ) : (
<> <>
<XCircle className="w-4 h-4 text-red-500" /> <XCircle className="w-4 h-4 text-red-500" />
<span className="text-red-600">{t('serverConfig.disconnected')}</span> <span className="text-red-600">
{t("serverConfig.disconnected")}
</span>
</> </>
)} )}
</div> </div>
@@ -171,12 +190,11 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle> <AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
)} )}
<div className="flex space-x-2"> <div className="flex space-x-2">
{onCancel && !isFirstTime && ( {onCancel && !isFirstTime && (
<Button <Button
@@ -193,22 +211,21 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
type="button" type="button"
className={onCancel && !isFirstTime ? "flex-1" : "w-full"} className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
onClick={handleSaveConfig} onClick={handleSaveConfig}
disabled={loading || testing || connectionStatus !== 'success'} disabled={loading || testing || connectionStatus !== "success"}
> >
{loading ? ( {loading ? (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"/> <span>{t("serverConfig.saving")}</span>
<span>{t('serverConfig.saving')}</span>
</div> </div>
) : ( ) : (
t('serverConfig.saveConfig') t("serverConfig.saveConfig")
)} )}
</Button> </Button>
</div> </div>
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-center">
{t('serverConfig.helpText')} {t("serverConfig.helpText")}
</div> </div>
</div> </div>
</div> </div>
+29 -15
View File
@@ -9,7 +9,11 @@ interface HomepageProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
isAuthenticated: boolean; isAuthenticated: boolean;
authLoading: boolean; authLoading: boolean;
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void; onAuthSuccess: (authData: {
isAdmin: boolean;
username: string | null;
userId: string | null;
}) => void;
isTopbarOpen: boolean; isTopbarOpen: boolean;
} }
@@ -17,7 +21,7 @@ export function Homepage({
isAuthenticated, isAuthenticated,
authLoading, authLoading,
onAuthSuccess, onAuthSuccess,
isTopbarOpen isTopbarOpen,
}: HomepageProps): React.ReactElement { }: HomepageProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
@@ -37,10 +41,7 @@ export function Homepage({
if (isAuthenticated) { if (isAuthenticated) {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
if (jwt) { if (jwt) {
Promise.all([ Promise.all([getUserInfo(), getDatabaseHealth()])
getUserInfo(),
getDatabaseHealth()
])
.then(([meRes]) => { .then(([meRes]) => {
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null); setUsername(meRes.username || null);
@@ -52,7 +53,9 @@ export function Homepage({
setUsername(null); setUsername(null);
setUserId(null); setUserId(null);
if (err?.response?.data?.error?.includes("Database")) { if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later."); setDbError(
"Could not connect to the database. Please try again later.",
);
} else { } else {
setDbError(null); setDbError(null);
} }
@@ -61,7 +64,6 @@ export function Homepage({
} }
}, [isAuthenticated]); }, [isAuthenticated]);
return ( return (
<> <>
{!loggedIn ? ( {!loggedIn ? (
@@ -91,16 +93,16 @@ export function Homepage({
> >
<div className="flex flex-row items-center justify-center gap-8 relative z-10"> <div className="flex flex-row items-center justify-center gap-8 relative z-10">
<div className="flex flex-col items-center gap-6 w-[400px]"> <div className="flex flex-col items-center gap-6 w-[400px]">
<HomepageUpdateLog <HomepageUpdateLog loggedIn={loggedIn} />
loggedIn={loggedIn}
/>
<div className="flex flex-row items-center gap-3"> <div className="flex flex-row items-center gap-3">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')} onClick={() =>
window.open("https://github.com/LukeGus/Termix", "_blank")
}
> >
GitHub GitHub
</Button> </Button>
@@ -109,7 +111,12 @@ export function Homepage({
variant="outline" variant="outline"
size="sm" size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')} onClick={() =>
window.open(
"https://github.com/LukeGus/Termix/issues/new",
"_blank",
)
}
> >
Feedback Feedback
</Button> </Button>
@@ -118,7 +125,12 @@ export function Homepage({
variant="outline" variant="outline"
size="sm" size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')} onClick={() =>
window.open(
"https://discord.com/invite/jVQGdvHDrf",
"_blank",
)
}
> >
Discord Discord
</Button> </Button>
@@ -127,7 +139,9 @@ export function Homepage({
variant="outline" variant="outline"
size="sm" size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')} onClick={() =>
window.open("https://github.com/sponsors/LukeGus", "_blank")
}
> >
Donate Donate
</Button> </Button>
+50 -36
View File
@@ -1,10 +1,23 @@
import React from "react"; import React from "react";
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx"; import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Badge } from "@/components/ui/badge.tsx"; import { Badge } from "@/components/ui/badge.tsx";
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; import {
X,
ExternalLink,
AlertTriangle,
Info,
CheckCircle,
AlertCircle,
} from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type {TermixAlert} from '../../../types/index.js'; import type { TermixAlert } from "../../../types/index.js";
interface AlertCardProps { interface AlertCardProps {
alert: TermixAlert; alert: TermixAlert;
@@ -14,13 +27,13 @@ interface AlertCardProps {
const getAlertIcon = (type?: string) => { const getAlertIcon = (type?: string) => {
switch (type) { switch (type) {
case 'warning': case "warning":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />; return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
case 'error': case "error":
return <AlertCircle className="h-5 w-5 text-red-500" />; return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'success': case "success":
return <CheckCircle className="h-5 w-5 text-green-500" />; return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'info': case "info":
default: default:
return <Info className="h-5 w-5 text-blue-500" />; return <Info className="h-5 w-5 text-blue-500" />;
} }
@@ -28,33 +41,37 @@ const getAlertIcon = (type?: string) => {
const getPriorityBadgeVariant = (priority?: string) => { const getPriorityBadgeVariant = (priority?: string) => {
switch (priority) { switch (priority) {
case 'critical': case "critical":
return 'destructive'; return "destructive";
case 'high': case "high":
return 'destructive'; return "destructive";
case 'medium': case "medium":
return 'secondary'; return "secondary";
case 'low': case "low":
default: default:
return 'outline'; return "outline";
} }
}; };
const getTypeBadgeVariant = (type?: string) => { const getTypeBadgeVariant = (type?: string) => {
switch (type) { switch (type) {
case 'warning': case "warning":
return 'secondary'; return "secondary";
case 'error': case "error":
return 'destructive'; return "destructive";
case 'success': case "success":
return 'default'; return "default";
case 'info': case "info":
default: default:
return 'outline'; return "outline";
} }
}; };
export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement { export function HomepageAlertCard({
alert,
onDismiss,
onClose,
}: AlertCardProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
if (!alert) { if (!alert) {
@@ -72,10 +89,10 @@ export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps):
const diffTime = expiryDate.getTime() - now.getTime(); const diffTime = expiryDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) return t('common.expired'); if (diffDays < 0) return t("common.expired");
if (diffDays === 0) return t('common.expiresToday'); if (diffDays === 0) return t("common.expiresToday");
if (diffDays === 1) return t('common.expiresTomorrow'); if (diffDays === 1) return t("common.expiresTomorrow");
return t('common.expiresInDays', {days: diffDays}); return t("common.expiresInDays", { days: diffDays });
}; };
return ( return (
@@ -84,9 +101,7 @@ export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps):
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{getAlertIcon(alert.type)} {getAlertIcon(alert.type)}
<CardTitle className="text-xl font-bold"> <CardTitle className="text-xl font-bold">{alert.title}</CardTitle>
{alert.title}
</CardTitle>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -120,16 +135,15 @@ export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps):
</CardContent> </CardContent>
<CardFooter className="flex items-center justify-between pt-0"> <CardFooter className="flex items-center justify-between pt-0">
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button variant="outline" onClick={handleDismiss}>
variant="outline"
onClick={handleDismiss}
>
Dismiss Dismiss
</Button> </Button>
{alert.actionUrl && alert.actionText && ( {alert.actionUrl && alert.actionText && (
<Button <Button
variant="default" variant="default"
onClick={() => window.open(alert.actionUrl, '_blank', 'noopener,noreferrer')} onClick={() =>
window.open(alert.actionUrl, "_blank", "noopener,noreferrer")
}
className="gap-2" className="gap-2"
> >
{alert.actionText} {alert.actionText}
@@ -3,14 +3,17 @@ import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts"; import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type {TermixAlert} from '../../../types/index.js'; import type { TermixAlert } from "../../../types/index.js";
interface AlertManagerProps { interface AlertManagerProps {
userId: string | null; userId: string | null;
loggedIn: boolean; loggedIn: boolean;
} }
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement { export function HomepageAlertManager({
userId,
loggedIn,
}: AlertManagerProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const [alerts, setAlerts] = useState<TermixAlert[]>([]); const [alerts, setAlerts] = useState<TermixAlert[]>([]);
const [currentAlertIndex, setCurrentAlertIndex] = useState(0); const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
@@ -36,22 +39,26 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => { const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 }; const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0; const aPriority =
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0; priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
const bPriority =
priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
if (aPriority !== bPriority) { if (aPriority !== bPriority) {
return bPriority - aPriority; return bPriority - aPriority;
} }
return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime(); return (
new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()
);
}); });
setAlerts(sortedAlerts); setAlerts(sortedAlerts);
setCurrentAlertIndex(0); setCurrentAlertIndex(0);
} catch (err) { } catch (err) {
const {toast} = await import('sonner'); const { toast } = await import("sonner");
toast.error(t('homepage.failedToLoadAlerts')); toast.error(t("homepage.failedToLoadAlerts"));
setError(t('homepage.failedToLoadAlerts')); setError(t("homepage.failedToLoadAlerts"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -63,19 +70,20 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
try { try {
await dismissAlert(userId, alertId); await dismissAlert(userId, alertId);
setAlerts(prev => { setAlerts((prev) => {
const newAlerts = prev.filter(alert => alert.id !== alertId); const newAlerts = prev.filter((alert) => alert.id !== alertId);
return newAlerts; return newAlerts;
}); });
setCurrentAlertIndex(prevIndex => { setCurrentAlertIndex((prevIndex) => {
const newAlertsLength = alerts.length - 1; const newAlertsLength = alerts.length - 1;
if (newAlertsLength === 0) return 0; if (newAlertsLength === 0) return 0;
if (prevIndex >= newAlertsLength) return Math.max(0, newAlertsLength - 1); if (prevIndex >= newAlertsLength)
return Math.max(0, newAlertsLength - 1);
return prevIndex; return prevIndex;
}); });
} catch (err) { } catch (err) {
setError(t('homepage.failedToDismissAlert')); setError(t("homepage.failedToDismissAlert"));
} }
}; };
@@ -117,8 +125,8 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
} }
const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 }; const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
alerts.forEach(alert => { alerts.forEach((alert) => {
const priority = alert.priority || 'low'; const priority = alert.priority || "low";
priorityCounts[priority as keyof typeof priorityCounts]++; priorityCounts[priority as keyof typeof priorityCounts]++;
}); });
const hasMultipleAlerts = alerts.length > 1; const hasMultipleAlerts = alerts.length > 1;
+217 -115
View File
@@ -35,7 +35,11 @@ interface HomepageAuthProps extends React.ComponentProps<"div"> {
authLoading: boolean; authLoading: boolean;
dbError: string | null; dbError: string | null;
setDbError: (error: string | null) => void; setDbError: (error: string | null) => void;
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void; onAuthSuccess: (authData: {
isAdmin: boolean;
username: string | null;
userId: string | null;
}) => void;
} }
export function HomepageAuth({ export function HomepageAuth({
@@ -52,7 +56,9 @@ export function HomepageAuth({
...props ...props
}: HomepageAuthProps) { }: HomepageAuthProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login"); const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">(
"login",
);
const [localUsername, setLocalUsername] = useState(""); const [localUsername, setLocalUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [signupConfirmPassword, setSignupConfirmPassword] = useState(""); const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
@@ -62,10 +68,10 @@ export function HomepageAuth({
password: false, password: false,
signupConfirm: false, signupConfirm: false,
resetNew: false, resetNew: false,
resetConfirm: false resetConfirm: false,
}); });
const toggleVisibility = (field: keyof typeof visibility) => { const toggleVisibility = (field: keyof typeof visibility) => {
setVisibility(prev => ({...prev, [field]: !prev[field]})); setVisibility((prev) => ({ ...prev, [field]: !prev[field] }));
}; };
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -74,7 +80,9 @@ export function HomepageAuth({
const [registrationAllowed, setRegistrationAllowed] = useState(true); const [registrationAllowed, setRegistrationAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false); const [oidcConfigured, setOidcConfigured] = useState(false);
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate"); const [resetStep, setResetStep] = useState<
"initiate" | "verify" | "newPassword"
>("initiate");
const [resetCode, setResetCode] = useState(""); const [resetCode, setResetCode] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
@@ -92,19 +100,21 @@ export function HomepageAuth({
}, [loggedIn]); }, [loggedIn]);
useEffect(() => { useEffect(() => {
getRegistrationAllowed().then(res => { getRegistrationAllowed().then((res) => {
setRegistrationAllowed(res.allowed); setRegistrationAllowed(res.allowed);
}); });
}, []); }, []);
useEffect(() => { useEffect(() => {
getOIDCConfig().then((response) => { getOIDCConfig()
.then((response) => {
if (response) { if (response) {
setOidcConfigured(true); setOidcConfigured(true);
} else { } else {
setOidcConfigured(false); setOidcConfigured(false);
} }
}).catch((error) => { })
.catch((error) => {
if (error.response?.status === 404) { if (error.response?.status === 404) {
setOidcConfigured(false); setOidcConfigured(false);
} else { } else {
@@ -114,7 +124,8 @@ export function HomepageAuth({
}, []); }, []);
useEffect(() => { useEffect(() => {
getUserCount().then(res => { getUserCount()
.then((res) => {
if (res.count === 0) { if (res.count === 0) {
setFirstUser(true); setFirstUser(true);
setTab("signup"); setTab("signup");
@@ -122,8 +133,9 @@ export function HomepageAuth({
setFirstUser(false); setFirstUser(false);
} }
setDbError(null); setDbError(null);
}).catch(() => { })
setDbError(t('errors.databaseConnection')); .catch(() => {
setDbError(t("errors.databaseConnection"));
}); });
}, [setDbError]); }, [setDbError]);
@@ -133,7 +145,7 @@ export function HomepageAuth({
setLoading(true); setLoading(true);
if (!localUsername.trim()) { if (!localUsername.trim()) {
setError(t('errors.requiredField')); setError(t("errors.requiredField"));
setLoading(false); setLoading(false);
return; return;
} }
@@ -144,12 +156,12 @@ export function HomepageAuth({
res = await loginUser(localUsername, password); res = await loginUser(localUsername, password);
} else { } else {
if (password !== signupConfirmPassword) { if (password !== signupConfirmPassword) {
setError(t('errors.passwordMismatch')); setError(t("errors.passwordMismatch"));
setLoading(false); setLoading(false);
return; return;
} }
if (password.length < 6) { if (password.length < 6) {
setError(t('errors.minLength', {min: 6})); setError(t("errors.minLength", { min: 6 }));
setLoading(false); setLoading(false);
return; return;
} }
@@ -166,13 +178,11 @@ export function HomepageAuth({
} }
if (!res || !res.token) { if (!res || !res.token) {
throw new Error(t('errors.noTokenReceived')); throw new Error(t("errors.noTokenReceived"));
} }
setCookie("jwt", res.token); setCookie("jwt", res.token);
[meRes] = await Promise.all([ [meRes] = await Promise.all([getUserInfo()]);
getUserInfo(),
]);
setInternalLoggedIn(true); setInternalLoggedIn(true);
setLoggedIn(true); setLoggedIn(true);
@@ -183,7 +193,7 @@ export function HomepageAuth({
onAuthSuccess({ onAuthSuccess({
isAdmin: !!meRes.is_admin, isAdmin: !!meRes.is_admin,
username: meRes.username || null, username: meRes.username || null,
userId: meRes.id || null userId: meRes.id || null,
}); });
setInternalLoggedIn(true); setInternalLoggedIn(true);
if (tab === "signup") { if (tab === "signup") {
@@ -193,7 +203,9 @@ export function HomepageAuth({
setTotpCode(""); setTotpCode("");
setTotpTempToken(""); setTotpTempToken("");
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error || err?.message || t('errors.unknownError')); setError(
err?.response?.data?.error || err?.message || t("errors.unknownError"),
);
setInternalLoggedIn(false); setInternalLoggedIn(false);
setLoggedIn(false); setLoggedIn(false);
setIsAdmin(false); setIsAdmin(false);
@@ -201,7 +213,7 @@ export function HomepageAuth({
setUserId(null); setUserId(null);
setCookie("jwt", "", -1); setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) { if (err?.response?.data?.error?.includes("Database")) {
setDbError(t('errors.databaseConnection')); setDbError(t("errors.databaseConnection"));
} else { } else {
setDbError(null); setDbError(null);
} }
@@ -218,7 +230,11 @@ export function HomepageAuth({
setResetStep("verify"); setResetStep("verify");
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error || err?.message || t('errors.failedPasswordReset')); setError(
err?.response?.data?.error ||
err?.message ||
t("errors.failedPasswordReset"),
);
} finally { } finally {
setResetLoading(false); setResetLoading(false);
} }
@@ -233,7 +249,7 @@ export function HomepageAuth({
setResetStep("newPassword"); setResetStep("newPassword");
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error || t('errors.failedVerifyCode')); setError(err?.response?.data?.error || t("errors.failedVerifyCode"));
} finally { } finally {
setResetLoading(false); setResetLoading(false);
} }
@@ -244,13 +260,13 @@ export function HomepageAuth({
setResetLoading(true); setResetLoading(true);
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError(t('errors.passwordMismatch')); setError(t("errors.passwordMismatch"));
setResetLoading(false); setResetLoading(false);
return; return;
} }
if (newPassword.length < 6) { if (newPassword.length < 6) {
setError(t('errors.minLength', {min: 6})); setError(t("errors.minLength", { min: 6 }));
setResetLoading(false); setResetLoading(false);
return; return;
} }
@@ -267,7 +283,7 @@ export function HomepageAuth({
setResetSuccess(true); setResetSuccess(true);
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error || t('errors.failedCompleteReset')); setError(err?.response?.data?.error || t("errors.failedCompleteReset"));
} finally { } finally {
setResetLoading(false); setResetLoading(false);
} }
@@ -292,7 +308,7 @@ export function HomepageAuth({
async function handleTOTPVerification() { async function handleTOTPVerification() {
if (totpCode.length !== 6) { if (totpCode.length !== 6) {
setError(t('auth.enterCode')); setError(t("auth.enterCode"));
return; return;
} }
@@ -303,7 +319,7 @@ export function HomepageAuth({
const res = await verifyTOTPLogin(totpTempToken, totpCode); const res = await verifyTOTPLogin(totpTempToken, totpCode);
if (!res || !res.token) { if (!res || !res.token) {
throw new Error(t('errors.noTokenReceived')); throw new Error(t("errors.noTokenReceived"));
} }
setCookie("jwt", res.token); setCookie("jwt", res.token);
@@ -318,14 +334,18 @@ export function HomepageAuth({
onAuthSuccess({ onAuthSuccess({
isAdmin: !!meRes.is_admin, isAdmin: !!meRes.is_admin,
username: meRes.username || null, username: meRes.username || null,
userId: meRes.id || null userId: meRes.id || null,
}); });
setInternalLoggedIn(true); setInternalLoggedIn(true);
setTotpRequired(false); setTotpRequired(false);
setTotpCode(""); setTotpCode("");
setTotpTempToken(""); setTotpTempToken("");
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error || err?.message || t('errors.invalidTotpCode')); setError(
err?.response?.data?.error ||
err?.message ||
t("errors.invalidTotpCode"),
);
} finally { } finally {
setTotpLoading(false); setTotpLoading(false);
} }
@@ -338,25 +358,29 @@ export function HomepageAuth({
const authResponse = await getOIDCAuthorizeUrl(); const authResponse = await getOIDCAuthorizeUrl();
const { auth_url: authUrl } = authResponse; const { auth_url: authUrl } = authResponse;
if (!authUrl || authUrl === 'undefined') { if (!authUrl || authUrl === "undefined") {
throw new Error(t('errors.invalidAuthUrl')); throw new Error(t("errors.invalidAuthUrl"));
} }
window.location.replace(authUrl); window.location.replace(authUrl);
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error || err?.message || t('errors.failedOidcLogin')); setError(
err?.response?.data?.error ||
err?.message ||
t("errors.failedOidcLogin"),
);
setOidcLoading(false); setOidcLoading(false);
} }
} }
useEffect(() => { useEffect(() => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const success = urlParams.get('success'); const success = urlParams.get("success");
const token = urlParams.get('token'); const token = urlParams.get("token");
const error = urlParams.get('error'); const error = urlParams.get("error");
if (error) { if (error) {
setError(`${t('errors.oidcAuthFailed')}: ${error}`); setError(`${t("errors.oidcAuthFailed")}: ${error}`);
setOidcLoading(false); setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
return; return;
@@ -368,7 +392,7 @@ export function HomepageAuth({
setCookie("jwt", token); setCookie("jwt", token);
getUserInfo() getUserInfo()
.then(meRes => { .then((meRes) => {
setInternalLoggedIn(true); setInternalLoggedIn(true);
setLoggedIn(true); setLoggedIn(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
@@ -378,20 +402,28 @@ export function HomepageAuth({
onAuthSuccess({ onAuthSuccess({
isAdmin: !!meRes.is_admin, isAdmin: !!meRes.is_admin,
username: meRes.username || null, username: meRes.username || null,
userId: meRes.id || null userId: meRes.id || null,
}); });
setInternalLoggedIn(true); setInternalLoggedIn(true);
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState(
{},
document.title,
window.location.pathname,
);
}) })
.catch(err => { .catch((err) => {
setError(t('errors.failedUserInfo')); setError(t("errors.failedUserInfo"));
setInternalLoggedIn(false); setInternalLoggedIn(false);
setLoggedIn(false); setLoggedIn(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
setUserId(null); setUserId(null);
setCookie("jwt", "", -1); setCookie("jwt", "", -1);
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState(
{},
document.title,
window.location.pathname,
);
}) })
.finally(() => { .finally(() => {
setOidcLoading(false); setOidcLoading(false);
@@ -400,21 +432,38 @@ export function HomepageAuth({
}, []); }, []);
const Spinner = ( const Spinner = (
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/> className="animate-spin mr-2 h-4 w-4 text-white inline-block"
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/> viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
</svg> </svg>
); );
const [showServerConfig, setShowServerConfig] = useState<boolean | null>(null); const [showServerConfig, setShowServerConfig] = useState<boolean | null>(
const [currentServerUrl, setCurrentServerUrl] = useState<string>(''); null,
);
const [currentServerUrl, setCurrentServerUrl] = useState<string>("");
useEffect(() => { useEffect(() => {
const checkServerConfig = async () => { const checkServerConfig = async () => {
if (isElectron()) { if (isElectron()) {
try { try {
const config = await getServerConfig(); const config = await getServerConfig();
setCurrentServerUrl(config?.serverUrl || ''); setCurrentServerUrl(config?.serverUrl || "");
setShowServerConfig(!config || !config.serverUrl); setShowServerConfig(!config || !config.serverUrl);
} catch (error) { } catch (error) {
setShowServerConfig(true); setShowServerConfig(true);
@@ -430,7 +479,7 @@ export function HomepageAuth({
if (showServerConfig === null) { if (showServerConfig === null) {
return ( return (
<div <div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ''}`} className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
{...props} {...props}
> >
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
@@ -443,7 +492,7 @@ export function HomepageAuth({
if (showServerConfig) { if (showServerConfig) {
return ( return (
<div <div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ''}`} className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
{...props} {...props}
> >
<ServerConfigComponent <ServerConfigComponent
@@ -461,7 +510,7 @@ export function HomepageAuth({
return ( return (
<div <div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ''}`} className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
{...props} {...props}
> >
{dbError && ( {dbError && (
@@ -472,9 +521,9 @@ export function HomepageAuth({
)} )}
{firstUser && !dbError && !internalLoggedIn && ( {firstUser && !dbError && !internalLoggedIn && (
<Alert variant="default" className="mb-4"> <Alert variant="default" className="mb-4">
<AlertTitle>{t('auth.firstUser')}</AlertTitle> <AlertTitle>{t("auth.firstUser")}</AlertTitle>
<AlertDescription className="inline"> <AlertDescription className="inline">
{t('auth.firstUserMessage')}{" "} {t("auth.firstUserMessage")}{" "}
<a <a
href="https://github.com/LukeGus/Termix/issues/new" href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" target="_blank"
@@ -482,40 +531,43 @@ export function HomepageAuth({
className="text-blue-600 underline hover:text-blue-800 inline" className="text-blue-600 underline hover:text-blue-800 inline"
> >
GitHub Issue GitHub Issue
</a>. </a>
.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{!registrationAllowed && !internalLoggedIn && ( {!registrationAllowed && !internalLoggedIn && (
<Alert variant="destructive" className="mb-4"> <Alert variant="destructive" className="mb-4">
<AlertTitle>{t('auth.registerTitle')}</AlertTitle> <AlertTitle>{t("auth.registerTitle")}</AlertTitle>
<AlertDescription> <AlertDescription>
{t('messages.registrationDisabled')} {t("messages.registrationDisabled")}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{totpRequired && ( {totpRequired && (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="mb-6 text-center"> <div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">{t('auth.twoFactorAuth')}</h2> <h2 className="text-xl font-bold mb-1">
<p className="text-muted-foreground">{t('auth.enterCode')}</p> {t("auth.twoFactorAuth")}
</h2>
<p className="text-muted-foreground">{t("auth.enterCode")}</p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="totp-code">{t('auth.verifyCode')}</Label> <Label htmlFor="totp-code">{t("auth.verifyCode")}</Label>
<Input <Input
id="totp-code" id="totp-code"
type="text" type="text"
placeholder="000000" placeholder="000000"
maxLength={6} maxLength={6}
value={totpCode} value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))} onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
disabled={totpLoading} disabled={totpLoading}
className="text-center text-2xl tracking-widest font-mono" className="text-center text-2xl tracking-widest font-mono"
autoComplete="one-time-code" autoComplete="one-time-code"
/> />
<p className="text-xs text-muted-foreground text-center"> <p className="text-xs text-muted-foreground text-center">
{t('auth.backupCode')} {t("auth.backupCode")}
</p> </p>
</div> </div>
@@ -525,7 +577,7 @@ export function HomepageAuth({
disabled={totpLoading || totpCode.length < 6} disabled={totpLoading || totpCode.length < 6}
onClick={handleTOTPVerification} onClick={handleTOTPVerification}
> >
{totpLoading ? Spinner : t('auth.verifyCode')} {totpLoading ? Spinner : t("auth.verifyCode")}
</Button> </Button>
<Button <Button
@@ -540,12 +592,14 @@ export function HomepageAuth({
setError(null); setError(null);
}} }}
> >
{t('common.cancel')} {t("common.cancel")}
</Button> </Button>
</div> </div>
)} )}
{(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && ( {!internalLoggedIn &&
(!authLoading || !getCookie("jwt")) &&
!totpRequired && (
<> <>
<div className="flex gap-2 mb-6"> <div className="flex gap-2 mb-6">
<button <button
@@ -554,7 +608,7 @@ export function HomepageAuth({
"flex-1 py-2 text-base font-medium rounded-md transition-all", "flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login" tab === "login"
? "bg-primary text-primary-foreground shadow" ? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent" : "bg-muted text-muted-foreground hover:bg-accent",
)} )}
onClick={() => { onClick={() => {
setTab("login"); setTab("login");
@@ -564,7 +618,7 @@ export function HomepageAuth({
aria-selected={tab === "login"} aria-selected={tab === "login"}
disabled={loading || firstUser} disabled={loading || firstUser}
> >
{t('common.login')} {t("common.login")}
</button> </button>
<button <button
type="button" type="button"
@@ -572,7 +626,7 @@ export function HomepageAuth({
"flex-1 py-2 text-base font-medium rounded-md transition-all", "flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup" tab === "signup"
? "bg-primary text-primary-foreground shadow" ? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent" : "bg-muted text-muted-foreground hover:bg-accent",
)} )}
onClick={() => { onClick={() => {
setTab("signup"); setTab("signup");
@@ -582,7 +636,7 @@ export function HomepageAuth({
aria-selected={tab === "signup"} aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed} disabled={loading || !registrationAllowed}
> >
{t('common.register')} {t("common.register")}
</button> </button>
{oidcConfigured && ( {oidcConfigured && (
<button <button
@@ -591,7 +645,7 @@ export function HomepageAuth({
"flex-1 py-2 text-base font-medium rounded-md transition-all", "flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external" tab === "external"
? "bg-primary text-primary-foreground shadow" ? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent" : "bg-muted text-muted-foreground hover:bg-accent",
)} )}
onClick={() => { onClick={() => {
setTab("external"); setTab("external");
@@ -601,16 +655,19 @@ export function HomepageAuth({
aria-selected={tab === "external"} aria-selected={tab === "external"}
disabled={oidcLoading} disabled={oidcLoading}
> >
{t('auth.external')} {t("auth.external")}
</button> </button>
)} )}
</div> </div>
<div className="mb-6 text-center"> <div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1"> <h2 className="text-xl font-bold mb-1">
{tab === "login" ? t('auth.loginTitle') : {tab === "login"
tab === "signup" ? t('auth.registerTitle') : ? t("auth.loginTitle")
tab === "external" ? t('auth.loginWithExternal') : : tab === "signup"
t('auth.forgotPassword')} ? t("auth.registerTitle")
: tab === "external"
? t("auth.loginWithExternal")
: t("auth.forgotPassword")}
</h2> </h2>
</div> </div>
@@ -619,14 +676,14 @@ export function HomepageAuth({
{tab === "external" && ( {tab === "external" && (
<> <>
<div className="text-center text-muted-foreground mb-4"> <div className="text-center text-muted-foreground mb-4">
<p>{t('auth.loginWithExternalDesc')}</p> <p>{t("auth.loginWithExternalDesc")}</p>
</div> </div>
{(() => { {(() => {
if (isElectron()) { if (isElectron()) {
return ( return (
<div className="text-center p-4 bg-muted/50 rounded-lg border"> <div className="text-center p-4 bg-muted/50 rounded-lg border">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{t('auth.externalNotSupportedInElectron')} {t("auth.externalNotSupportedInElectron")}
</p> </p>
</div> </div>
); );
@@ -638,7 +695,9 @@ export function HomepageAuth({
disabled={oidcLoading} disabled={oidcLoading}
onClick={handleOIDCLogin} onClick={handleOIDCLogin}
> >
{oidcLoading ? Spinner : t('auth.loginWithExternal')} {oidcLoading
? Spinner
: t("auth.loginWithExternal")}
</Button> </Button>
); );
} }
@@ -650,18 +709,20 @@ export function HomepageAuth({
{resetStep === "initiate" && ( {resetStep === "initiate" && (
<> <>
<div className="text-center text-muted-foreground mb-4"> <div className="text-center text-muted-foreground mb-4">
<p>{t('auth.resetCodeDesc')}</p> <p>{t("auth.resetCodeDesc")}</p>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="reset-username">{t('common.username')}</Label> <Label htmlFor="reset-username">
{t("common.username")}
</Label>
<Input <Input
id="reset-username" id="reset-username"
type="text" type="text"
required required
className="h-11 text-base" className="h-11 text-base"
value={localUsername} value={localUsername}
onChange={e => setLocalUsername(e.target.value)} onChange={(e) => setLocalUsername(e.target.value)}
disabled={resetLoading} disabled={resetLoading}
/> />
</div> </div>
@@ -671,20 +732,26 @@ export function HomepageAuth({
disabled={resetLoading || !localUsername.trim()} disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset} onClick={handleInitiatePasswordReset}
> >
{resetLoading ? Spinner : t('auth.sendResetCode')} {resetLoading ? Spinner : t("auth.sendResetCode")}
</Button> </Button>
</div> </div>
</> </>
)} )}
{resetStep === "verify" && ( {resetStep === "verify" && (
<>o <>
o
<div className="text-center text-muted-foreground mb-4"> <div className="text-center text-muted-foreground mb-4">
<p>{t('auth.enterResetCode')} <strong>{localUsername}</strong></p> <p>
{t("auth.enterResetCode")}{" "}
<strong>{localUsername}</strong>
</p>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="reset-code">{t('auth.resetCode')}</Label> <Label htmlFor="reset-code">
{t("auth.resetCode")}
</Label>
<Input <Input
id="reset-code" id="reset-code"
type="text" type="text"
@@ -692,7 +759,9 @@ export function HomepageAuth({
maxLength={6} maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest" className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode} value={resetCode}
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))} onChange={(e) =>
setResetCode(e.target.value.replace(/\D/g, ""))
}
disabled={resetLoading} disabled={resetLoading}
placeholder="000000" placeholder="000000"
/> />
@@ -703,7 +772,9 @@ export function HomepageAuth({
disabled={resetLoading || resetCode.length !== 6} disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode} onClick={handleVerifyResetCode}
> >
{resetLoading ? Spinner : t('auth.verifyCodeButton')} {resetLoading
? Spinner
: t("auth.verifyCodeButton")}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -715,7 +786,7 @@ export function HomepageAuth({
setResetCode(""); setResetCode("");
}} }}
> >
{t('common.back')} {t("common.back")}
</Button> </Button>
</div> </div>
</> </>
@@ -724,9 +795,11 @@ export function HomepageAuth({
{resetSuccess && ( {resetSuccess && (
<> <>
<Alert className="mb-4"> <Alert className="mb-4">
<AlertTitle>{t('auth.passwordResetSuccess')}</AlertTitle> <AlertTitle>
{t("auth.passwordResetSuccess")}
</AlertTitle>
<AlertDescription> <AlertDescription>
{t('auth.passwordResetSuccessDesc')} {t("auth.passwordResetSuccessDesc")}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Button <Button
@@ -737,7 +810,7 @@ export function HomepageAuth({
resetPasswordState(); resetPasswordState();
}} }}
> >
{t('auth.goToLogin')} {t("auth.goToLogin")}
</Button> </Button>
</> </>
)} )}
@@ -745,30 +818,38 @@ export function HomepageAuth({
{resetStep === "newPassword" && !resetSuccess && ( {resetStep === "newPassword" && !resetSuccess && (
<> <>
<div className="text-center text-muted-foreground mb-4"> <div className="text-center text-muted-foreground mb-4">
<p>{t('auth.enterNewPassword')} <strong>{localUsername}</strong></p> <p>
{t("auth.enterNewPassword")}{" "}
<strong>{localUsername}</strong>
</p>
</div> </div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="new-password">{t('auth.newPassword')}</Label> <Label htmlFor="new-password">
{t("auth.newPassword")}
</Label>
<PasswordInput <PasswordInput
id="new-password" id="new-password"
required required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200" className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword} value={newPassword}
onChange={e => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
disabled={resetLoading} disabled={resetLoading}
autoComplete="new-password" autoComplete="new-password"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label <Label htmlFor="confirm-password">
htmlFor="confirm-password">{t('auth.confirmNewPassword')}</Label> {t("auth.confirmNewPassword")}
</Label>
<PasswordInput <PasswordInput
id="confirm-password" id="confirm-password"
required required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200" className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword} value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)} onChange={(e) =>
setConfirmPassword(e.target.value)
}
disabled={resetLoading} disabled={resetLoading}
autoComplete="new-password" autoComplete="new-password"
/> />
@@ -776,10 +857,14 @@ export function HomepageAuth({
<Button <Button
type="button" type="button"
className="w-full h-11 text-base font-semibold" className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !newPassword || !confirmPassword} disabled={
resetLoading || !newPassword || !confirmPassword
}
onClick={handleCompletePasswordReset} onClick={handleCompletePasswordReset}
> >
{resetLoading ? Spinner : t('auth.resetPasswordButton')} {resetLoading
? Spinner
: t("auth.resetPasswordButton")}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -792,7 +877,7 @@ export function HomepageAuth({
setConfirmPassword(""); setConfirmPassword("");
}} }}
> >
{t('common.back')} {t("common.back")}
</Button> </Button>
</div> </div>
</> </>
@@ -803,45 +888,58 @@ export function HomepageAuth({
) : ( ) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}> <form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="username">{t('common.username')}</Label> <Label htmlFor="username">{t("common.username")}</Label>
<Input <Input
id="username" id="username"
type="text" type="text"
required required
className="h-11 text-base" className="h-11 text-base"
value={localUsername} value={localUsername}
onChange={e => setLocalUsername(e.target.value)} onChange={(e) => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn} disabled={loading || internalLoggedIn}
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="password">{t('common.password')}</Label> <Label htmlFor="password">{t("common.password")}</Label>
<PasswordInput <PasswordInput
id="password" id="password"
required required
className="h-11 text-base" className="h-11 text-base"
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/> disabled={loading || internalLoggedIn}
/>
</div> </div>
{tab === "signup" && ( {tab === "signup" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label> <Label htmlFor="signup-confirm-password">
{t("common.confirmPassword")}
</Label>
<PasswordInput <PasswordInput
id="signup-confirm-password" id="signup-confirm-password"
required required
className="h-11 text-base" className="h-11 text-base"
value={signupConfirmPassword} value={signupConfirmPassword}
onChange={e => setSignupConfirmPassword(e.target.value)} onChange={(e) => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}/> disabled={loading || internalLoggedIn}
/>
</div> </div>
)} )}
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold" <Button
disabled={loading || internalLoggedIn}> type="submit"
{loading ? Spinner : (tab === "login" ? t('common.login') : t('auth.signUp'))} className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}
>
{loading
? Spinner
: tab === "login"
? t("common.login")
: t("auth.signUp")}
</Button> </Button>
{tab === "login" && ( {tab === "login" && (
<Button type="button" variant="outline" <Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold" className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn} disabled={loading || internalLoggedIn}
onClick={() => { onClick={() => {
@@ -850,7 +948,7 @@ export function HomepageAuth({
clearFormFields(); clearFormFields();
}} }}
> >
{t('auth.resetPasswordButton')} {t("auth.resetPasswordButton")}
</Button> </Button>
)} )}
</form> </form>
@@ -859,14 +957,18 @@ export function HomepageAuth({
<div className="mt-6 pt-4 border-t border-dark-border space-y-4"> <div className="mt-6 pt-4 border-t border-dark-border space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Label className="text-sm text-muted-foreground">{t('common.language')}</Label> <Label className="text-sm text-muted-foreground">
{t("common.language")}
</Label>
</div> </div>
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
{isElectron() && currentServerUrl && ( {isElectron() && currentServerUrl && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<Label className="text-sm text-muted-foreground">Server</Label> <Label className="text-sm text-muted-foreground">
Server
</Label>
<div className="text-xs text-muted-foreground truncate max-w-[200px]"> <div className="text-xs text-muted-foreground truncate max-w-[200px]">
{currentServerUrl} {currentServerUrl}
</div> </div>
+39 -29
View File
@@ -39,7 +39,7 @@ interface RSSResponse {
} }
interface VersionResponse { interface VersionResponse {
status: 'up_to_date' | 'requires_update'; status: "up_to_date" | "requires_update";
version: string; version: string;
latest_release: { latest_release: {
name: string; name: string;
@@ -60,17 +60,14 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
useEffect(() => { useEffect(() => {
if (loggedIn) { if (loggedIn) {
setLoading(true); setLoading(true);
Promise.all([ Promise.all([getReleasesRSS(100), getVersionInfo()])
getReleasesRSS(100),
getVersionInfo()
])
.then(([releasesRes, versionRes]) => { .then(([releasesRes, versionRes]) => {
setReleases(releasesRes); setReleases(releasesRes);
setVersionInfo(versionRes); setVersionInfo(versionRes);
setError(null); setError(null);
}) })
.catch(err => { .catch((err) => {
setError(t('common.failedToFetchUpdateInfo')); setError(t("common.failedToFetchUpdateInfo"));
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} }
@@ -81,32 +78,34 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
} }
const formatDescription = (description: string) => { const formatDescription = (description: string) => {
const firstLine = description.split('\n')[0]; const firstLine = description.split("\n")[0];
return firstLine return firstLine.replace(/[#*`]/g, "").replace(/\s+/g, " ").trim();
.replace(/[#*`]/g, '')
.replace(/\s+/g, ' ')
.trim();
}; };
return ( return (
<div <div className="w-[400px] h-[600px] flex flex-col border-2 border-dark-border rounded-lg bg-dark-bg p-4 shadow-lg">
className="w-[400px] h-[600px] flex flex-col border-2 border-dark-border rounded-lg bg-dark-bg p-4 shadow-lg">
<div> <div>
<h3 className="text-lg font-bold mb-3 text-white">{t('common.updatesAndReleases')}</h3> <h3 className="text-lg font-bold mb-3 text-white">
{t("common.updatesAndReleases")}
</h3>
<Separator className="p-0.25 mt-3 mb-3 bg-dark-border" /> <Separator className="p-0.25 mt-3 mb-3 bg-dark-border" />
{versionInfo && versionInfo.status === 'requires_update' && ( {versionInfo && versionInfo.status === "requires_update" && (
<Alert className="bg-dark-bg-darker border-dark-border text-white"> <Alert className="bg-dark-bg-darker border-dark-border text-white">
<AlertTitle className="text-white">{t('common.updateAvailable')}</AlertTitle> <AlertTitle className="text-white">
{t("common.updateAvailable")}
</AlertTitle>
<AlertDescription className="text-gray-300"> <AlertDescription className="text-gray-300">
{t('common.newVersionAvailable', {version: versionInfo.version})} {t("common.newVersionAvailable", {
version: versionInfo.version,
})}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
</div> </div>
{versionInfo && versionInfo.status === 'requires_update' && ( {versionInfo && versionInfo.status === "requires_update" && (
<Separator className="p-0.25 mt-3 mb-3 bg-dark-border" /> <Separator className="p-0.25 mt-3 mb-3 bg-dark-border" />
)} )}
@@ -118,9 +117,16 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
)} )}
{error && ( {error && (
<Alert variant="destructive" className="bg-red-900/20 border-red-500 text-red-300"> <Alert
<AlertTitle className="text-red-300">{t('common.error')}</AlertTitle> variant="destructive"
<AlertDescription className="text-red-300">{error}</AlertDescription> className="bg-red-900/20 border-red-500 text-red-300"
>
<AlertTitle className="text-red-300">
{t("common.error")}
</AlertTitle>
<AlertDescription className="text-red-300">
{error}
</AlertDescription>
</Alert> </Alert>
)} )}
@@ -128,16 +134,15 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
<div <div
key={release.id} key={release.id}
className="border border-dark-border rounded-lg p-3 hover:bg-dark-bg-darker transition-colors cursor-pointer bg-dark-bg-darker/50" className="border border-dark-border rounded-lg p-3 hover:bg-dark-bg-darker transition-colors cursor-pointer bg-dark-bg-darker/50"
onClick={() => window.open(release.link, '_blank')} onClick={() => window.open(release.link, "_blank")}
> >
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-sm leading-tight flex-1 text-white"> <h4 className="font-semibold text-sm leading-tight flex-1 text-white">
{release.title} {release.title}
</h4> </h4>
{release.isPrerelease && ( {release.isPrerelease && (
<span <span className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium"> {t("common.preRelease")}
{t('common.preRelease')}
</span> </span>
)} )}
</div> </div>
@@ -151,7 +156,10 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
{release.assets.length > 0 && ( {release.assets.length > 0 && (
<> <>
<span className="mx-2"></span> <span className="mx-2"></span>
<span>{release.assets.length} asset{release.assets.length !== 1 ? 's' : ''}</span> <span>
{release.assets.length} asset
{release.assets.length !== 1 ? "s" : ""}
</span>
</> </>
)} )}
</div> </div>
@@ -160,9 +168,11 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
{releases && releases.items.length === 0 && !loading && ( {releases && releases.items.length === 0 && !loading && (
<Alert className="bg-dark-bg-darker border-dark-border text-gray-300"> <Alert className="bg-dark-bg-darker border-dark-border text-gray-300">
<AlertTitle className="text-gray-300">{t('common.noReleases')}</AlertTitle> <AlertTitle className="text-gray-300">
{t("common.noReleases")}
</AlertTitle>
<AlertDescription className="text-gray-400"> <AlertDescription className="text-gray-400">
{t('common.noReleasesFound')} {t("common.noReleasesFound")}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
+277 -111
View File
@@ -3,25 +3,43 @@ import {Terminal} from "@/ui/Desktop/Apps/Terminal/Terminal.tsx";
import { Server as ServerView } from "@/ui/Desktop/Apps/Server/Server.tsx"; import { Server as ServerView } from "@/ui/Desktop/Apps/Server/Server.tsx";
import { FileManager } from "@/ui/Desktop/Apps/File Manager/FileManager.tsx"; import { FileManager } from "@/ui/Desktop/Apps/File Manager/FileManager.tsx";
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx'; import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable.tsx";
import * as ResizablePrimitive from "react-resizable-panels"; import * as ResizablePrimitive from "react-resizable-panels";
import { useSidebar } from "@/components/ui/sidebar.tsx"; import { useSidebar } from "@/components/ui/sidebar.tsx";
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react"; import {
LucideRefreshCcw,
LucideRefreshCw,
RefreshCcw,
RefreshCcwDot,
} from "lucide-react";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
interface TerminalViewProps { interface TerminalViewProps {
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
} }
export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactElement { export function AppView({
isTopbarOpen = true,
}: TerminalViewProps): React.ReactElement {
const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as any; const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as any;
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'file_manager'); const terminalTabs = tabs.filter(
(tab: any) =>
tab.type === "terminal" ||
tab.type === "server" ||
tab.type === "file_manager",
);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({}); const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({}); const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>(
{},
);
const [ready, setReady] = useState<boolean>(true); const [ready, setReady] = useState<boolean>(true);
const [resetKey, setResetKey] = useState<number>(0); const [resetKey, setResetKey] = useState<number>(0);
@@ -53,7 +71,8 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
const layoutScheduleRef = useRef<number | null>(null); const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => { const scheduleMeasureAndFit = () => {
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current); if (layoutScheduleRef.current)
cancelAnimationFrame(layoutScheduleRef.current);
layoutScheduleRef.current = requestAnimationFrame(() => { layoutScheduleRef.current = requestAnimationFrame(() => {
updatePanelRects(); updatePanelRects();
layoutScheduleRef.current = requestAnimationFrame(() => { layoutScheduleRef.current = requestAnimationFrame(() => {
@@ -75,18 +94,21 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
useEffect(() => { useEffect(() => {
hideThenFit(); hideThenFit();
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]); }, [currentTab, terminalTabs.length, allSplitScreenTab.join(",")]);
useEffect(() => { useEffect(() => {
scheduleMeasureAndFit(); scheduleMeasureAndFit();
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]); }, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
useEffect(() => { useEffect(() => {
const roContainer = containerRef.current ? new ResizeObserver(() => { const roContainer = containerRef.current
? new ResizeObserver(() => {
updatePanelRects(); updatePanelRects();
fitActiveAndNotify(); fitActiveAndNotify();
}) : null; })
if (containerRef.current && roContainer) roContainer.observe(containerRef.current); : null;
if (containerRef.current && roContainer)
roContainer.observe(containerRef.current);
return () => roContainer?.disconnect(); return () => roContainer?.disconnect();
}, []); }, []);
@@ -95,30 +117,37 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
updatePanelRects(); updatePanelRects();
fitActiveAndNotify(); fitActiveAndNotify();
}; };
window.addEventListener('resize', onWinResize); window.addEventListener("resize", onWinResize);
return () => window.removeEventListener('resize', onWinResize); return () => window.removeEventListener("resize", onWinResize);
}, []); }, []);
const HEADER_H = 28; const HEADER_H = 28;
const renderTerminalsLayer = () => { const renderTerminalsLayer = () => {
const styles: Record<number, React.CSSProperties> = {}; const styles: Record<number, React.CSSProperties> = {};
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id)); const splitTabs = terminalTabs.filter((tab: any) =>
allSplitScreenTab.includes(tab.id),
);
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab); const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[]; const layoutTabs = [
mainTab,
...splitTabs.filter(
(t: any) => t && t.id !== (mainTab && (mainTab as any).id),
),
].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0 && mainTab) { if (allSplitScreenTab.length === 0 && mainTab) {
const isFileManagerTab = mainTab.type === 'file_manager'; const isFileManagerTab = mainTab.type === "file_manager";
styles[mainTab.id] = { styles[mainTab.id] = {
position: 'absolute', position: "absolute",
top: isFileManagerTab ? 0 : 2, top: isFileManagerTab ? 0 : 2,
left: isFileManagerTab ? 0 : 2, left: isFileManagerTab ? 0 : 2,
right: isFileManagerTab ? 0 : 2, right: isFileManagerTab ? 0 : 2,
bottom: isFileManagerTab ? 0 : 2, bottom: isFileManagerTab ? 0 : 2,
zIndex: 20, zIndex: 20,
display: 'block', display: "block",
pointerEvents: 'auto', pointerEvents: "auto",
opacity: ready ? 1 : 0 opacity: ready ? 1 : 0,
}; };
} else { } else {
layoutTabs.forEach((t: any) => { layoutTabs.forEach((t: any) => {
@@ -126,14 +155,14 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
const parentRect = containerRef.current?.getBoundingClientRect(); const parentRect = containerRef.current?.getBoundingClientRect();
if (rect && parentRect) { if (rect && parentRect) {
styles[t.id] = { styles[t.id] = {
position: 'absolute', position: "absolute",
top: (rect.top - parentRect.top) + HEADER_H + 2, top: rect.top - parentRect.top + HEADER_H + 2,
left: (rect.left - parentRect.left) + 2, left: rect.left - parentRect.left + 2,
width: rect.width - 4, width: rect.width - 4,
height: rect.height - HEADER_H - 4, height: rect.height - HEADER_H - 4,
zIndex: 20, zIndex: 20,
display: 'block', display: "block",
pointerEvents: 'auto', pointerEvents: "auto",
opacity: ready ? 1 : 0, opacity: ready ? 1 : 0,
}; };
} }
@@ -144,19 +173,24 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
<div className="absolute inset-0 z-[1]"> <div className="absolute inset-0 z-[1]">
{terminalTabs.map((t: any) => { {terminalTabs.map((t: any) => {
const hasStyle = !!styles[t.id]; const hasStyle = !!styles[t.id];
const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab); const isVisible =
hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
const finalStyle: React.CSSProperties = hasStyle const finalStyle: React.CSSProperties = hasStyle
? {...styles[t.id], overflow: 'hidden'} ? { ...styles[t.id], overflow: "hidden" }
: { : ({
position: 'absolute', inset: 0, visibility: 'hidden', pointerEvents: 'none', zIndex: 0, position: "absolute",
} as React.CSSProperties; inset: 0,
visibility: "hidden",
pointerEvents: "none",
zIndex: 0,
} as React.CSSProperties);
const effectiveVisible = isVisible && ready; const effectiveVisible = isVisible && ready;
return ( return (
<div key={t.id} style={finalStyle}> <div key={t.id} style={finalStyle}>
<div className="absolute inset-0 rounded-md bg-dark-bg"> <div className="absolute inset-0 rounded-md bg-dark-bg">
{t.type === 'terminal' ? ( {t.type === "terminal" ? (
<Terminal <Terminal
ref={t.terminalRef} ref={t.terminalRef}
hostConfig={t.hostConfig} hostConfig={t.hostConfig}
@@ -166,7 +200,7 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
splitScreen={allSplitScreenTab.length > 0} splitScreen={allSplitScreenTab.length > 0}
onClose={() => removeTab(t.id)} onClose={() => removeTab(t.id)}
/> />
) : t.type === 'server' ? ( ) : t.type === "server" ? (
<ServerView <ServerView
hostConfig={t.hostConfig} hostConfig={t.hostConfig}
title={t.title} title={t.title}
@@ -207,41 +241,71 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
}; };
const renderSplitOverlays = () => { const renderSplitOverlays = () => {
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id)); const splitTabs = terminalTabs.filter((tab: any) =>
allSplitScreenTab.includes(tab.id),
);
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab); const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[]; const layoutTabs = [
mainTab,
...splitTabs.filter(
(t: any) => t && t.id !== (mainTab && (mainTab as any).id),
),
].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0) return null; if (allSplitScreenTab.length === 0) return null;
const handleStyle = { const handleStyle = {
pointerEvents: 'auto', pointerEvents: "auto",
zIndex: 12, zIndex: 12,
background: 'var(--color-dark-border)' background: "var(--color-dark-border)",
} as React.CSSProperties; } as React.CSSProperties;
const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any; const commonGroupProps = {
onLayout: scheduleMeasureAndFit,
onResize: scheduleMeasureAndFit,
} as any;
if (layoutTabs.length === 2) { if (layoutTabs.length === 2) {
const [a, b] = layoutTabs as any[]; const [a, b] = layoutTabs as any[];
return ( return (
<div className="absolute inset-0 z-[10] pointer-events-none"> <div className="absolute inset-0 z-[10] pointer-events-none">
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal" <ResizablePrimitive.PanelGroup
className="h-full w-full" {...commonGroupProps}> key={resetKey}
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" direction="horizontal"
id={`panel-${a.id}`} order={1}> className="h-full w-full"
<div ref={el => { {...commonGroupProps}
panelRefs.current[String(a.id)] = el; >
}} className="h-full w-full flex flex-col bg-transparent relative"> <ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`}
order={1}
>
<div <div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div> ref={(el) => {
panelRefs.current[String(a.id)] = el;
}}
className="h-full w-full flex flex-col bg-transparent relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{a.title}
</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle} /> <ResizableHandle style={handleStyle} />
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" <ResizablePanel
id={`panel-${b.id}`} order={2}> defaultSize={50}
<div ref={el => { minSize={20}
panelRefs.current[String(b.id)] = el; className="!overflow-hidden h-full w-full"
}} className="h-full w-full flex flex-col bg-transparent relative"> id={`panel-${b.id}`}
order={2}
>
<div <div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative"> ref={(el) => {
panelRefs.current[String(b.id)] = el;
}}
className="h-full w-full flex flex-col bg-transparent relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{b.title} {b.title}
<ResetButton onClick={handleReset} /> <ResetButton onClick={handleReset} />
</div> </div>
@@ -255,29 +319,60 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
const [a, b, c] = layoutTabs as any[]; const [a, b, c] = layoutTabs as any[];
return ( return (
<div className="absolute inset-0 z-[10] pointer-events-none"> <div className="absolute inset-0 z-[10] pointer-events-none">
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" <ResizablePrimitive.PanelGroup
id="main-vertical" {...commonGroupProps}> key={resetKey}
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" direction="vertical"
id="top-panel" order={1}> className="h-full w-full"
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" id="main-vertical"
className="h-full w-full" id="top-horizontal" {...commonGroupProps}> {...commonGroupProps}
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" >
id={`panel-${a.id}`} order={1}> <ResizablePanel
<div ref={el => { defaultSize={50}
panelRefs.current[String(a.id)] = el; minSize={20}
}} className="h-full w-full flex flex-col relative"> className="!overflow-hidden h-full w-full"
id="top-panel"
order={1}
>
<ResizablePanelGroup
key={`top-${resetKey}`}
direction="horizontal"
className="h-full w-full"
id="top-horizontal"
{...commonGroupProps}
>
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`}
order={1}
>
<div <div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div> ref={(el) => {
panelRefs.current[String(a.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{a.title}
</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle} /> <ResizableHandle style={handleStyle} />
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" <ResizablePanel
id={`panel-${b.id}`} order={2}> defaultSize={50}
<div ref={el => { minSize={20}
panelRefs.current[String(b.id)] = el; className="!overflow-hidden h-full w-full"
}} className="h-full w-full flex flex-col relative"> id={`panel-${b.id}`}
order={2}
>
<div <div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative"> ref={(el) => {
panelRefs.current[String(b.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{b.title} {b.title}
<ResetButton onClick={handleReset} /> <ResetButton onClick={handleReset} />
</div> </div>
@@ -286,13 +381,22 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle} /> <ResizableHandle style={handleStyle} />
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" <ResizablePanel
id="bottom-panel" order={2}> defaultSize={50}
<div ref={el => { minSize={20}
panelRefs.current[String(c.id)] = el; className="!overflow-hidden h-full w-full"
}} className="h-full w-full flex flex-col relative"> id="bottom-panel"
order={2}
>
<div <div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{c.title}</div> ref={(el) => {
panelRefs.current[String(c.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{c.title}
</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePrimitive.PanelGroup> </ResizablePrimitive.PanelGroup>
@@ -303,29 +407,60 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
const [a, b, c, d] = layoutTabs as any[]; const [a, b, c, d] = layoutTabs as any[];
return ( return (
<div className="absolute inset-0 z-[10] pointer-events-none"> <div className="absolute inset-0 z-[10] pointer-events-none">
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full" <ResizablePrimitive.PanelGroup
id="main-vertical" {...commonGroupProps}> key={resetKey}
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" direction="vertical"
id="top-panel" order={1}> className="h-full w-full"
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal" id="main-vertical"
className="h-full w-full" id="top-horizontal" {...commonGroupProps}> {...commonGroupProps}
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" >
id={`panel-${a.id}`} order={1}> <ResizablePanel
<div ref={el => { defaultSize={50}
panelRefs.current[String(a.id)] = el; minSize={20}
}} className="h-full w-full flex flex-col relative"> className="!overflow-hidden h-full w-full"
id="top-panel"
order={1}
>
<ResizablePanelGroup
key={`top-${resetKey}`}
direction="horizontal"
className="h-full w-full"
id="top-horizontal"
{...commonGroupProps}
>
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`}
order={1}
>
<div <div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div> ref={(el) => {
panelRefs.current[String(a.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{a.title}
</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle} /> <ResizableHandle style={handleStyle} />
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" <ResizablePanel
id={`panel-${b.id}`} order={2}> defaultSize={50}
<div ref={el => { minSize={20}
panelRefs.current[String(b.id)] = el; className="!overflow-hidden h-full w-full"
}} className="h-full w-full flex flex-col relative"> id={`panel-${b.id}`}
order={2}
>
<div <div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative"> ref={(el) => {
panelRefs.current[String(b.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{b.title} {b.title}
<ResetButton onClick={handleReset} /> <ResetButton onClick={handleReset} />
</div> </div>
@@ -334,27 +469,55 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle} /> <ResizableHandle style={handleStyle} />
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" <ResizablePanel
id="bottom-panel" order={2}> defaultSize={50}
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal" minSize={20}
className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}> className="!overflow-hidden h-full w-full"
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel"
id={`panel-${c.id}`} order={1}> order={2}
<div ref={el => { >
panelRefs.current[String(c.id)] = el; <ResizablePanelGroup
}} className="h-full w-full flex flex-col relative"> key={`bottom-${resetKey}`}
direction="horizontal"
className="h-full w-full"
id="bottom-horizontal"
{...commonGroupProps}
>
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${c.id}`}
order={1}
>
<div <div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{c.title}</div> ref={(el) => {
panelRefs.current[String(c.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{c.title}
</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle style={handleStyle} /> <ResizableHandle style={handleStyle} />
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" <ResizablePanel
id={`panel-${d.id}`} order={2}> defaultSize={50}
<div ref={el => { minSize={20}
panelRefs.current[String(d.id)] = el; className="!overflow-hidden h-full w-full"
}} className="h-full w-full flex flex-col relative"> id={`panel-${d.id}`}
order={2}
>
<div <div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{d.title}</div> ref={(el) => {
panelRefs.current[String(d.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{d.title}
</div>
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
@@ -367,11 +530,11 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
}; };
const currentTabData = tabs.find((tab: any) => tab.id === currentTab); const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
const isFileManager = currentTabData?.type === 'file_manager'; const isFileManager = currentTabData?.type === "file_manager";
const isSplitScreen = allSplitScreenTab.length > 0; const isSplitScreen = allSplitScreenTab.length > 0;
const topMarginPx = isTopbarOpen ? 74 : 26; const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8; const bottomMarginPx = 8;
return ( return (
@@ -379,7 +542,10 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
ref={containerRef} ref={containerRef}
className="border-2 border-dark-border rounded-lg overflow-hidden overflow-x-hidden relative" className="border-2 border-dark-border rounded-lg overflow-hidden overflow-x-hidden relative"
style={{ style={{
background: (isFileManager && !isSplitScreen) ? 'var(--color-dark-bg-darkest)' : 'var(--color-dark-bg)', background:
isFileManager && !isSplitScreen
? "var(--color-dark-bg-darkest)"
: "var(--color-dark-bg)",
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
marginRight: 17, marginRight: 17,
marginTop: topMarginPx, marginTop: topMarginPx,
+17 -6
View File
@@ -35,7 +35,10 @@ interface FolderCardProps {
isLast: boolean; isLast: boolean;
} }
export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactElement { export function FolderCard({
folderName,
hosts,
}: FolderCardProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true); const [isExpanded, setIsExpanded] = useState(true);
const toggleExpanded = () => { const toggleExpanded = () => {
@@ -44,13 +47,17 @@ export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactEle
return ( return (
<div className="bg-dark-bg-darker border-2 border-dark-border rounded-lg overflow-hidden p-0 m-0"> <div className="bg-dark-bg-darker border-2 border-dark-border rounded-lg overflow-hidden p-0 m-0">
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-dark-bg-header`}> <div
className={`px-4 py-3 relative ${isExpanded ? "border-b-2" : ""} bg-dark-bg-header`}
>
<div className="flex gap-2 pr-10"> <div className="flex gap-2 pr-10">
<div className="flex-shrink-0 flex items-center"> <div className="flex-shrink-0 flex items-center">
<Folder size={16} strokeWidth={3} /> <Folder size={16} strokeWidth={3} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle> <CardTitle className="mb-0 leading-tight break-words text-md">
{folderName}
</CardTitle>
</div> </div>
</div> </div>
<Button <Button
@@ -58,13 +65,17 @@ export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactEle
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0" className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
onClick={toggleExpanded} onClick={toggleExpanded}
> >
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/> <ChevronDown
className={`h-4 w-4 transition-transform ${isExpanded ? "" : "rotate-180"}`}
/>
</Button> </Button>
</div> </div>
{isExpanded && ( {isExpanded && (
<div className="flex flex-col p-2 gap-y-3"> <div className="flex flex-col p-2 gap-y-3">
{hosts.map((host, index) => ( {hosts.map((host, index) => (
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}> <React.Fragment
key={`${folderName}-host-${host.id}-${host.name || host.ip}`}
>
<Host host={host} /> <Host host={host} />
{index < hosts.length - 1 && ( {index < hosts.length - 1 && (
<div className="relative -mx-2"> <div className="relative -mx-2">
@@ -76,5 +87,5 @@ export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactEle
</div> </div>
)} )}
</div> </div>
) );
} }
+28 -14
View File
@@ -5,15 +5,19 @@ import {ButtonGroup} from "@/components/ui/button-group.tsx";
import { Server, Terminal } from "lucide-react"; import { Server, Terminal } from "lucide-react";
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { getServerStatusById } from "@/ui/main-axios.ts"; import { getServerStatusById } from "@/ui/main-axios.ts";
import type {HostProps} from '../../../../types/index.js'; import type { HostProps } from "../../../../types/index.js";
export function Host({ host }: HostProps): React.ReactElement { export function Host({ host }: HostProps): React.ReactElement {
const { addTab } = useTabs(); const { addTab } = useTabs();
const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'degraded'>('degraded'); const [serverStatus, setServerStatus] = useState<
"online" | "offline" | "degraded"
>("degraded");
const tags = Array.isArray(host.tags) ? host.tags : []; const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0; const hasTags = tags.length > 0;
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`; const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
useEffect(() => { useEffect(() => {
let intervalId: number | undefined; let intervalId: number | undefined;
@@ -23,18 +27,18 @@ export function Host({host}: HostProps): React.ReactElement {
try { try {
const res = await getServerStatusById(host.id); const res = await getServerStatusById(host.id);
if (!cancelled) { if (!cancelled) {
setServerStatus(res?.status === 'online' ? 'online' : 'offline'); setServerStatus(res?.status === "online" ? "online" : "offline");
} }
} catch (error: any) { } catch (error: any) {
if (!cancelled) { if (!cancelled) {
if (error?.response?.status === 503) { if (error?.response?.status === 503) {
setServerStatus('offline'); setServerStatus("offline");
} else if (error?.response?.status === 504) { } else if (error?.response?.status === 504) {
setServerStatus('degraded'); setServerStatus("degraded");
} else if (error?.response?.status === 404) { } else if (error?.response?.status === 404) {
setServerStatus('offline'); setServerStatus("offline");
} else { } else {
setServerStatus('offline'); setServerStatus("offline");
} }
} }
} }
@@ -51,24 +55,31 @@ export function Host({host}: HostProps): React.ReactElement {
}, [host.id]); }, [host.id]);
const handleTerminalClick = () => { const handleTerminalClick = () => {
addTab({type: 'terminal', title, hostConfig: host}); addTab({ type: "terminal", title, hostConfig: host });
}; };
const handleServerClick = () => { const handleServerClick = () => {
addTab({type: 'server', title, hostConfig: host}); addTab({ type: "server", title, hostConfig: host });
}; };
return ( return (
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0"> <Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator /> <StatusIndicator />
</Status> </Status>
<p className="font-semibold flex-1 min-w-0 break-words text-sm"> <p className="font-semibold flex-1 min-w-0 break-words text-sm">
{host.name || host.ip} {host.name || host.ip}
</p> </p>
<ButtonGroup className="flex-shrink-0"> <ButtonGroup className="flex-shrink-0">
<Button variant="outline" className="!px-2 border-1 border-dark-border" onClick={handleServerClick}> <Button
variant="outline"
className="!px-2 border-1 border-dark-border"
onClick={handleServerClick}
>
<Server /> <Server />
</Button> </Button>
{host.enableTerminal && ( {host.enableTerminal && (
@@ -85,12 +96,15 @@ export function Host({host}: HostProps): React.ReactElement {
{hasTags && ( {hasTags && (
<div className="flex flex-wrap items-center gap-2 mt-1"> <div className="flex flex-wrap items-center gap-2 mt-1">
{tags.map((tag: string) => ( {tags.map((tag: string) => (
<div key={tag} className="bg-dark-bg border-1 border-dark-border pl-2 pr-2 rounded-[10px]"> <div
key={tag}
className="bg-dark-bg border-1 border-dark-border pl-2 pr-2 rounded-[10px]"
>
<p className="text-sm">{tag}</p> <p className="text-sm">{tag}</p>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
) );
} }
+133 -88
View File
@@ -1,24 +1,29 @@
import React, {useState} from 'react'; import React, { useState } from "react";
import { import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react";
ChevronUp, User2, HardDrive, Menu, ChevronRight import { useTranslation } from "react-i18next";
} from "lucide-react";
import {useTranslation} from 'react-i18next';
import { getCookie, setCookie, isElectron } from "@/ui/main-axios.ts"; import { getCookie, setCookie, isElectron } from "@/ui/main-axios.ts";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarFooter, SidebarContent,
SidebarFooter,
SidebarGroup, SidebarGroup,
SidebarGroupLabel, SidebarGroupLabel,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarProvider, SidebarInset, SidebarHeader, SidebarMenuItem,
} from "@/components/ui/sidebar.tsx" SidebarProvider,
SidebarInset,
SidebarHeader,
} from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { import {
Separator, DropdownMenu,
} from "@/components/ui/separator.tsx" DropdownMenuContent,
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu"; DropdownMenuItem,
DropdownMenuTrigger,
} from "@radix-ui/react-dropdown-menu";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Label } from "@/components/ui/label.tsx"; import { Label } from "@/components/ui/label.tsx";
@@ -61,18 +66,16 @@ interface SidebarProps {
children?: React.ReactNode; children?: React.ReactNode;
} }
function handleLogout() { function handleLogout() {
if (isElectron()) { if (isElectron()) {
localStorage.removeItem('jwt'); localStorage.removeItem("jwt");
} else { } else {
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
} }
window.location.reload(); window.location.reload();
} }
export function LeftSidebar({ export function LeftSidebar({
onSelectView, onSelectView,
getView, getView,
@@ -90,32 +93,39 @@ export function LeftSidebar({
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true); const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab, updateHostConfig} = useTabs() as any; const {
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0; tabs: tabList,
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager'); addTab,
setCurrentTab,
allSplitScreenTab,
updateHostConfig,
} = useTabs() as any;
const isSplitScreenActive =
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
const openSshManagerTab = () => { const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return; if (sshManagerTab || isSplitScreenActive) return;
const id = addTab({type: 'ssh_manager'} as any); const id = addTab({ type: "ssh_manager" } as any);
setCurrentTab(id); setCurrentTab(id);
}; };
const adminTab = tabList.find((t) => t.type === 'admin'); const adminTab = tabList.find((t) => t.type === "admin");
const openAdminTab = () => { const openAdminTab = () => {
if (isSplitScreenActive) return; if (isSplitScreenActive) return;
if (adminTab) { if (adminTab) {
setCurrentTab(adminTab.id); setCurrentTab(adminTab.id);
return; return;
} }
const id = addTab({type: 'admin'} as any); const id = addTab({ type: "admin" } as any);
setCurrentTab(id); setCurrentTab(id);
}; };
const userProfileTab = tabList.find((t) => t.type === 'user_profile'); const userProfileTab = tabList.find((t) => t.type === "user_profile");
const openUserProfileTab = () => { const openUserProfileTab = () => {
if (isSplitScreenActive) return; if (isSplitScreenActive) return;
if (userProfileTab) { if (userProfileTab) {
setCurrentTab(userProfileTab.id); setCurrentTab(userProfileTab.id);
return; return;
} }
const id = addTab({type: 'user_profile'} as any); const id = addTab({ type: "user_profile" } as any);
setCurrentTab(id); setCurrentTab(id);
}; };
@@ -126,14 +136,13 @@ export function LeftSidebar({
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const fetchHosts = React.useCallback(async () => { const fetchHosts = React.useCallback(async () => {
try { try {
const newHosts = await getSSHHosts(); const newHosts = await getSSHHosts();
const prevHosts = prevHostsRef.current; const prevHosts = prevHostsRef.current;
const existingHostsMap = new Map(prevHosts.map(h => [h.id, h])); const existingHostsMap = new Map(prevHosts.map((h) => [h.id, h]));
const newHostsMap = new Map(newHosts.map(h => [h.id, h])); const newHostsMap = new Map(newHosts.map((h) => [h.id, h]));
let hasChanges = false; let hasChanges = false;
@@ -164,8 +173,10 @@ export function LeftSidebar({
newHost.keyType !== existingHost.keyType || newHost.keyType !== existingHost.keyType ||
newHost.credentialId !== existingHost.credentialId || newHost.credentialId !== existingHost.credentialId ||
newHost.defaultPath !== existingHost.defaultPath || newHost.defaultPath !== existingHost.defaultPath ||
JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags) || JSON.stringify(newHost.tags) !==
JSON.stringify(newHost.tunnelConnections) !== JSON.stringify(existingHost.tunnelConnections) JSON.stringify(existingHost.tags) ||
JSON.stringify(newHost.tunnelConnections) !==
JSON.stringify(existingHost.tunnelConnections)
) { ) {
hasChanges = true; hasChanges = true;
break; break;
@@ -178,13 +189,13 @@ export function LeftSidebar({
setHosts(newHosts); setHosts(newHosts);
prevHostsRef.current = newHosts; prevHostsRef.current = newHosts;
newHosts.forEach(newHost => { newHosts.forEach((newHost) => {
updateHostConfig(newHost.id, newHost); updateHostConfig(newHost.id, newHost);
}); });
}, 50); }, 50);
} }
} catch (err: any) { } catch (err: any) {
setHostsError(t('leftSidebar.failedToLoadHosts')); setHostsError(t("leftSidebar.failedToLoadHosts"));
} }
}, [updateHostConfig]); }, [updateHostConfig]);
@@ -201,11 +212,23 @@ export function LeftSidebar({
const handleCredentialsChanged = () => { const handleCredentialsChanged = () => {
fetchHosts(); fetchHosts();
}; };
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener); window.addEventListener(
window.addEventListener('credentials:changed', handleCredentialsChanged as EventListener); "ssh-hosts:changed",
handleHostsChanged as EventListener,
);
window.addEventListener(
"credentials:changed",
handleCredentialsChanged as EventListener,
);
return () => { return () => {
window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener); window.removeEventListener(
window.removeEventListener('credentials:changed', handleCredentialsChanged as EventListener); "ssh-hosts:changed",
handleHostsChanged as EventListener,
);
window.removeEventListener(
"credentials:changed",
handleCredentialsChanged as EventListener,
);
}; };
}, [fetchHosts]); }, [fetchHosts]);
@@ -217,24 +240,27 @@ export function LeftSidebar({
const filteredHosts = React.useMemo(() => { const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts; if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase(); const q = debouncedSearch.trim().toLowerCase();
return hosts.filter(h => { return hosts.filter((h) => {
const searchableText = [ const searchableText = [
h.name || '', h.name || "",
h.username, h.username,
h.ip, h.ip,
h.folder || '', h.folder || "",
...(h.tags || []), ...(h.tags || []),
h.authType, h.authType,
h.defaultPath || '' h.defaultPath || "",
].join(' ').toLowerCase(); ]
.join(" ")
.toLowerCase();
return searchableText.includes(q); return searchableText.includes(q);
}); });
}, [hosts, debouncedSearch]); }, [hosts, debouncedSearch]);
const hostsByFolder = React.useMemo(() => { const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {}; const map: Record<string, SSHHost[]> = {};
filteredHosts.forEach(h => { filteredHosts.forEach((h) => {
const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder'); const folder =
h.folder && h.folder.trim() ? h.folder : t("leftSidebar.noFolder");
if (!map[folder]) map[folder] = []; if (!map[folder]) map[folder] = [];
map[folder].push(h); map[folder].push(h);
}); });
@@ -244,16 +270,20 @@ export function LeftSidebar({
const sortedFolders = React.useMemo(() => { const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder); const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => { folders.sort((a, b) => {
if (a === t('leftSidebar.noFolder')) return -1; if (a === t("leftSidebar.noFolder")) return -1;
if (b === t('leftSidebar.noFolder')) return 1; if (b === t("leftSidebar.noFolder")) return 1;
return a.localeCompare(b); return a.localeCompare(b);
}); });
return folders; return folders;
}, [hostsByFolder]); }, [hostsByFolder]);
const getSortedHosts = React.useCallback((arr: SSHHost[]) => { const getSortedHosts = React.useCallback((arr: SSHHost[]) => {
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); const pinned = arr
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); .filter((h) => h.pin)
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
const rest = arr
.filter((h) => !h.pin)
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
return [...pinned, ...rest]; return [...pinned, ...rest];
}, []); }, []);
@@ -263,7 +293,7 @@ export function LeftSidebar({
setDeleteError(null); setDeleteError(null);
if (!deletePassword.trim()) { if (!deletePassword.trim()) {
setDeleteError(t('leftSidebar.passwordRequired')); setDeleteError(t("leftSidebar.passwordRequired"));
setDeleteLoading(false); setDeleteLoading(false);
return; return;
} }
@@ -274,7 +304,9 @@ export function LeftSidebar({
handleLogout(); handleLogout();
} catch (err: any) { } catch (err: any) {
setDeleteError(err?.response?.data?.error || t('leftSidebar.failedToDeleteAccount')); setDeleteError(
err?.response?.data?.error || t("leftSidebar.failedToDeleteAccount"),
);
setDeleteLoading(false); setDeleteLoading(false);
} }
}; };
@@ -290,7 +322,7 @@ export function LeftSidebar({
variant="outline" variant="outline"
onClick={() => setIsSidebarOpen(!isSidebarOpen)} onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-[28px] h-[28px] absolute right-5" className="w-[28px] h-[28px] absolute right-5"
title={t('common.toggleSidebar')} title={t("common.toggleSidebar")}
> >
<Menu className="h-4 w-4" /> <Menu className="h-4 w-4" />
</Button> </Button>
@@ -299,12 +331,21 @@ export function LeftSidebar({
<Separator className="p-0.25" /> <Separator className="p-0.25" />
<SidebarContent> <SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2"> <SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button className="m-2 flex flex-row font-semibold border-2 !border-dark-border" <Button
className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
variant="outline" variant="outline"
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive} onClick={openSshManagerTab}
title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}> disabled={!!sshManagerTab || isSplitScreenActive}
title={
sshManagerTab
? t("interface.sshManagerAlreadyOpen")
: isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
}
>
<HardDrive strokeWidth="2.5" /> <HardDrive strokeWidth="2.5" />
{t('nav.hostManager')} {t("nav.hostManager")}
</Button> </Button>
</SidebarGroup> </SidebarGroup>
<Separator className="p-0.25" /> <Separator className="p-0.25" />
@@ -312,8 +353,8 @@ export function LeftSidebar({
<div className="!bg-dark-bg-input rounded-lg"> <div className="!bg-dark-bg-input rounded-lg">
<Input <Input
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder={t('placeholders.searchHostsAny')} placeholder={t("placeholders.searchHostsAny")}
className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md" className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md"
autoComplete="off" autoComplete="off"
/> />
@@ -321,9 +362,8 @@ export function LeftSidebar({
{hostsError && ( {hostsError && (
<div className="px-1"> <div className="px-1">
<div <div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full"> {t("leftSidebar.failedToLoadHosts")}
{t('leftSidebar.failedToLoadHosts')}
</div> </div>
</div> </div>
)} )}
@@ -331,7 +371,7 @@ export function LeftSidebar({
{hostsLoading && ( {hostsLoading && (
<div className="px-4 pb-2"> <div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-center">
{t('hosts.loadingHosts')} {t("hosts.loadingHosts")}
</div> </div>
</div> </div>
)} )}
@@ -357,7 +397,7 @@ export function LeftSidebar({
className="data-[state=open]:opacity-90 w-full" className="data-[state=open]:opacity-90 w-full"
disabled={disabled} disabled={disabled}
> >
<User2/> {username ? username : t('common.logout')} <User2 /> {username ? username : t("common.logout")}
<ChevronUp className="ml-auto" /> <ChevronUp className="ml-auto" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -371,30 +411,32 @@ export function LeftSidebar({
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => { onClick={() => {
openUserProfileTab(); openUserProfileTab();
}}> }}
<span>{t('profile.title')}</span> >
<span>{t("profile.title")}</span>
</DropdownMenuItem> </DropdownMenuItem>
{isAdmin && !isElectron() && ( {isAdmin && !isElectron() && (
<DropdownMenuItem <DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => { onClick={() => {
if (isAdmin) openAdminTab(); if (isAdmin) openAdminTab();
}}> }}
<span>{t('admin.title')}</span> >
<span>{t("admin.title")}</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem <DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={handleLogout}> onClick={handleLogout}
>
<span>{t('common.logout')}</span> <span>{t("common.logout")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => setDeleteAccountOpen(true)} onClick={() => setDeleteAccountOpen(true)}
> >
<span className="text-red-400"> <span className="text-red-400">
{t('leftSidebar.deleteAccount')} {t("leftSidebar.deleteAccount")}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -402,18 +444,15 @@ export function LeftSidebar({
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
<SidebarInset> <SidebarInset>{children}</SidebarInset>
{children}
</SidebarInset>
</SidebarProvider> </SidebarProvider>
{!isSidebarOpen && ( {!isSidebarOpen && (
<div <div
onClick={() => setIsSidebarOpen(true)} onClick={() => setIsSidebarOpen(true)}
className="absolute top-0 left-0 w-[10px] h-full bg-dark-bg cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md"> className="absolute top-0 left-0 w-[10px] h-full bg-dark-bg cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md"
>
<ChevronRight size={10} /> <ChevronRight size={10} />
</div> </div>
)} )}
@@ -422,20 +461,22 @@ export function LeftSidebar({
<div <div
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] pointer-events-auto isolate" className="fixed top-0 left-0 right-0 bottom-0 z-[999999] pointer-events-auto isolate"
style={{ style={{
transform: 'translateZ(0)', transform: "translateZ(0)",
willChange: 'z-index' willChange: "z-index",
}} }}
> >
<div <div
className="w-[400px] h-full bg-dark-bg border-r-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[9999999]" className="w-[400px] h-full bg-dark-bg border-r-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[9999999]"
style={{ style={{
boxShadow: '4px 0 20px rgba(0, 0, 0, 0.5)', boxShadow: "4px 0 20px rgba(0, 0, 0, 0.5)",
transform: 'translateZ(0)' transform: "translateZ(0)",
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between p-4 border-b border-dark-border"> <div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold text-white">{t('leftSidebar.deleteAccount')}</h2> <h2 className="text-lg font-semibold text-white">
{t("leftSidebar.deleteAccount")}
</h2>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -445,7 +486,7 @@ export function LeftSidebar({
setDeleteError(null); setDeleteError(null);
}} }}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center" className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t('leftSidebar.closeDeleteAccount')} title={t("leftSidebar.closeDeleteAccount")}
> >
<span className="text-lg font-bold leading-none">×</span> <span className="text-lg font-bold leading-none">×</span>
</Button> </Button>
@@ -454,31 +495,33 @@ export function LeftSidebar({
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="text-sm text-gray-300"> <div className="text-sm text-gray-300">
{t('leftSidebar.deleteAccountWarning')} {t("leftSidebar.deleteAccountWarning")}
</div> </div>
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{t('common.warning')}</AlertTitle> <AlertTitle>{t("common.warning")}</AlertTitle>
<AlertDescription> <AlertDescription>
{t('leftSidebar.deleteAccountWarningDetails')} {t("leftSidebar.deleteAccountWarningDetails")}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{deleteError && ( {deleteError && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle> <AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{deleteError}</AlertDescription> <AlertDescription>{deleteError}</AlertDescription>
</Alert> </Alert>
)} )}
<form onSubmit={handleDeleteAccount} className="space-y-4"> <form onSubmit={handleDeleteAccount} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="delete-password">{t('leftSidebar.confirmPassword')}</Label> <Label htmlFor="delete-password">
{t("leftSidebar.confirmPassword")}
</Label>
<PasswordInput <PasswordInput
id="delete-password" id="delete-password"
value={deletePassword} value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)} onChange={(e) => setDeletePassword(e.target.value)}
placeholder={t('placeholders.confirmPassword')} placeholder={t("placeholders.confirmPassword")}
required required
/> />
</div> </div>
@@ -490,7 +533,9 @@ export function LeftSidebar({
className="flex-1" className="flex-1"
disabled={deleteLoading || !deletePassword.trim()} disabled={deleteLoading || !deletePassword.trim()}
> >
{deleteLoading ? t('leftSidebar.deleting') : t('leftSidebar.deleteAccount')} {deleteLoading
? t("leftSidebar.deleting")
: t("leftSidebar.deleteAccount")}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -501,7 +546,7 @@ export function LeftSidebar({
setDeleteError(null); setDeleteError(null);
}} }}
> >
{t('leftSidebar.cancel')} {t("leftSidebar.cancel")}
</Button> </Button>
</div> </div>
</form> </form>
@@ -520,5 +565,5 @@ export function LeftSidebar({
</div> </div>
)} )}
</div> </div>
) );
} }
+38 -18
View File
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { ButtonGroup } from "@/components/ui/button-group.tsx"; import { ButtonGroup } from "@/components/ui/button-group.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import { import {
Home, Home,
SeparatorVertical, SeparatorVertical,
@@ -9,7 +9,7 @@ import {
Terminal as TerminalIcon, Terminal as TerminalIcon,
Server as ServerIcon, Server as ServerIcon,
Folder as FolderIcon, Folder as FolderIcon,
User as UserIcon User as UserIcon,
} from "lucide-react"; } from "lucide-react";
interface TabProps { interface TabProps {
@@ -37,14 +37,14 @@ export function Tab({
canClose = false, canClose = false,
disableActivate = false, disableActivate = false,
disableSplit = false, disableSplit = false,
disableClose = false disableClose = false,
}: TabProps): React.ReactElement { }: TabProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
if (tabType === "home") { if (tabType === "home") {
return ( return (
<Button <Button
variant="outline" variant="outline"
className={`!px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active' : ''}`} className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
onClick={onActivate} onClick={onActivate}
disabled={disableActivate} disabled={disableActivate}
> >
@@ -53,22 +53,40 @@ export function Tab({
); );
} }
if (tabType === "terminal" || tabType === "server" || tabType === "file_manager" || tabType === "user_profile") { if (
const isServer = tabType === 'server'; tabType === "terminal" ||
const isFileManager = tabType === 'file_manager'; tabType === "server" ||
const isUserProfile = tabType === 'user_profile'; tabType === "file_manager" ||
tabType === "user_profile"
) {
const isServer = tabType === "server";
const isFileManager = tabType === "file_manager";
const isUserProfile = tabType === "user_profile";
return ( return (
<ButtonGroup> <ButtonGroup>
<Button <Button
variant="outline" variant="outline"
className={`!px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active' : ''}`} className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
onClick={onActivate} onClick={onActivate}
disabled={disableActivate} disabled={disableActivate}
> >
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ? {isServer ? (
<FolderIcon className="mr-1 h-4 w-4"/> : isUserProfile ? <ServerIcon className="mr-1 h-4 w-4" />
<UserIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>} ) : isFileManager ? (
{title || (isServer ? t('nav.serverStats') : isFileManager ? t('nav.fileManager') : isUserProfile ? t('nav.userProfile') : t('nav.terminal'))} <FolderIcon className="mr-1 h-4 w-4" />
) : isUserProfile ? (
<UserIcon className="mr-1 h-4 w-4" />
) : (
<TerminalIcon className="mr-1 h-4 w-4" />
)}
{title ||
(isServer
? t("nav.serverStats")
: isFileManager
? t("nav.fileManager")
: isUserProfile
? t("nav.userProfile")
: t("nav.terminal"))}
</Button> </Button>
{canSplit && ( {canSplit && (
<Button <Button
@@ -76,7 +94,9 @@ export function Tab({
className="!px-2 border-1 border-dark-border" className="!px-2 border-1 border-dark-border"
onClick={onSplit} onClick={onSplit}
disabled={disableSplit} disabled={disableSplit}
title={disableSplit ? t('nav.cannotSplitTab') : t('nav.splitScreen')} title={
disableSplit ? t("nav.cannotSplitTab") : t("nav.splitScreen")
}
> >
<SeparatorVertical className="w-[28px] h-[28px]" /> <SeparatorVertical className="w-[28px] h-[28px]" />
</Button> </Button>
@@ -100,11 +120,11 @@ export function Tab({
<ButtonGroup> <ButtonGroup>
<Button <Button
variant="outline" variant="outline"
className={`!px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active' : ''}`} className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
onClick={onActivate} onClick={onActivate}
disabled={disableActivate} disabled={disableActivate}
> >
{title || t('nav.sshManager')} {title || t("nav.sshManager")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -123,11 +143,11 @@ export function Tab({
<ButtonGroup> <ButtonGroup>
<Button <Button
variant="outline" variant="outline"
className={`!px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active' : ''}`} className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
onClick={onActivate} onClick={onActivate}
disabled={disableActivate} disabled={disableActivate}
> >
{title || t('nav.admin')} {title || t("nav.admin")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
+60 -32
View File
@@ -1,6 +1,12 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react'; import React, {
import {useTranslation} from 'react-i18next'; createContext,
import type {TabContextTab} from '../../../types/index.js'; useContext,
useState,
useRef,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import type { TabContextTab } from "../../../types/index.js";
export type Tab = TabContextTab; export type Tab = TabContextTab;
@@ -8,7 +14,7 @@ interface TabContextType {
tabs: Tab[]; tabs: Tab[];
currentTab: number | null; currentTab: number | null;
allSplitScreenTab: number[]; allSplitScreenTab: number[];
addTab: (tab: Omit<Tab, 'id'>) => number; addTab: (tab: Omit<Tab, "id">) => number;
removeTab: (tabId: number) => void; removeTab: (tabId: number) => void;
setCurrentTab: (tabId: number) => void; setCurrentTab: (tabId: number) => void;
setSplitScreenTab: (tabId: number) => void; setSplitScreenTab: (tabId: number) => void;
@@ -21,7 +27,7 @@ const TabContext = createContext<TabContextType | undefined>(undefined);
export function useTabs() { export function useTabs() {
const context = useContext(TabContext); const context = useContext(TabContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useTabs must be used within a TabProvider'); throw new Error("useTabs must be used within a TabProvider");
} }
return context; return context;
} }
@@ -33,27 +39,39 @@ interface TabProviderProps {
export function TabProvider({ children }: TabProviderProps) { export function TabProvider({ children }: TabProviderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [tabs, setTabs] = useState<Tab[]>([ const [tabs, setTabs] = useState<Tab[]>([
{id: 1, type: 'home', title: t('nav.home')} { id: 1, type: "home", title: t("nav.home") },
]); ]);
const [currentTab, setCurrentTab] = useState<number>(1); const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]); const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(2); const nextTabId = useRef(2);
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string { function computeUniqueTitle(
const defaultTitle = tabType === 'server' ? t('nav.serverStats') : (tabType === 'file_manager' ? t('nav.fileManager') : t('nav.terminal')); tabType: Tab["type"],
desiredTitle: string | undefined,
): string {
const defaultTitle =
tabType === "server"
? t("nav.serverStats")
: tabType === "file_manager"
? t("nav.fileManager")
: t("nav.terminal");
const baseTitle = (desiredTitle || defaultTitle).trim(); const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/); const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle; const root = match ? match[1] : baseTitle;
const usedNumbers = new Set<number>(); const usedNumbers = new Set<number>();
let rootUsed = false; let rootUsed = false;
tabs.forEach(t => { tabs.forEach((t) => {
if (!t.title) return; if (!t.title) return;
if (t.title === root) { if (t.title === root) {
rootUsed = true; rootUsed = true;
return; return;
} }
const m = t.title.match(new RegExp(`^${root.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`)); const m = t.title.match(
new RegExp(
`^${root.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`,
),
);
if (m) { if (m) {
const n = parseInt(m[1], 10); const n = parseInt(m[1], 10);
if (!isNaN(n)) usedNumbers.add(n); if (!isNaN(n)) usedNumbers.add(n);
@@ -66,41 +84,51 @@ export function TabProvider({children}: TabProviderProps) {
return `${root} (${n})`; return `${root} (${n})`;
} }
const addTab = (tabData: Omit<Tab, 'id'>): number => { const addTab = (tabData: Omit<Tab, "id">): number => {
const id = nextTabId.current++; const id = nextTabId.current++;
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager'; const needsUniqueTitle =
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || ''); tabData.type === "terminal" ||
tabData.type === "server" ||
tabData.type === "file_manager";
const effectiveTitle = needsUniqueTitle
? computeUniqueTitle(tabData.type, tabData.title)
: tabData.title || "";
const newTab: Tab = { const newTab: Tab = {
...tabData, ...tabData,
id, id,
title: effectiveTitle, title: effectiveTitle,
terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined terminalRef:
tabData.type === "terminal" ? React.createRef<any>() : undefined,
}; };
setTabs(prev => [...prev, newTab]); setTabs((prev) => [...prev, newTab]);
setCurrentTab(id); setCurrentTab(id);
setAllSplitScreenTab(prev => prev.filter(tid => tid !== id)); setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
return id; return id;
}; };
const removeTab = (tabId: number) => { const removeTab = (tabId: number) => {
const tab = tabs.find(t => t.id === tabId); const tab = tabs.find((t) => t.id === tabId);
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") { if (
tab &&
tab.terminalRef?.current &&
typeof tab.terminalRef.current.disconnect === "function"
) {
tab.terminalRef.current.disconnect(); tab.terminalRef.current.disconnect();
} }
setTabs(prev => prev.filter(tab => tab.id !== tabId)); setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
setAllSplitScreenTab(prev => prev.filter(id => id !== tabId)); setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId));
if (currentTab === tabId) { if (currentTab === tabId) {
const remainingTabs = tabs.filter(tab => tab.id !== tabId); const remainingTabs = tabs.filter((tab) => tab.id !== tabId);
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1); setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
} }
}; };
const setSplitScreenTab = (tabId: number) => { const setSplitScreenTab = (tabId: number) => {
setAllSplitScreenTab(prev => { setAllSplitScreenTab((prev) => {
if (prev.includes(tabId)) { if (prev.includes(tabId)) {
return prev.filter(id => id !== tabId); return prev.filter((id) => id !== tabId);
} else if (prev.length < 3) { } else if (prev.length < 3) {
return [...prev, tabId]; return [...prev, tabId];
} }
@@ -109,20 +137,24 @@ export function TabProvider({children}: TabProviderProps) {
}; };
const getTab = (tabId: number) => { const getTab = (tabId: number) => {
return tabs.find(tab => tab.id === tabId); return tabs.find((tab) => tab.id === tabId);
}; };
const updateHostConfig = (hostId: number, newHostConfig: any) => { const updateHostConfig = (hostId: number, newHostConfig: any) => {
setTabs(prev => prev.map(tab => { setTabs((prev) =>
prev.map((tab) => {
if (tab.hostConfig && tab.hostConfig.id === hostId) { if (tab.hostConfig && tab.hostConfig.id === hostId) {
return { return {
...tab, ...tab,
hostConfig: newHostConfig, hostConfig: newHostConfig,
title: newHostConfig.name?.trim() ? newHostConfig.name : `${newHostConfig.username}@${newHostConfig.ip}:${newHostConfig.port}` title: newHostConfig.name?.trim()
? newHostConfig.name
: `${newHostConfig.username}@${newHostConfig.ip}:${newHostConfig.port}`,
}; };
} }
return tab; return tab;
})); }),
);
}; };
const value: TabContextType = { const value: TabContextType = {
@@ -137,9 +169,5 @@ export function TabProvider({children}: TabProviderProps) {
updateHostConfig, updateHostConfig,
}; };
return ( return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
<TabContext.Provider value={value}>
{children}
</TabContext.Provider>
);
} }
+27 -29
View File
@@ -14,7 +14,7 @@ import {
Folder as FolderIcon, Folder as FolderIcon,
Shield as AdminIcon, Shield as AdminIcon,
Network as SshManagerIcon, Network as SshManagerIcon,
User as UserIcon User as UserIcon,
} from "lucide-react"; } from "lucide-react";
import { useTabs, type Tab } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import { useTabs, type Tab } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -23,21 +23,21 @@ export function TabDropdown(): React.ReactElement {
const { tabs, currentTab, setCurrentTab } = useTabs(); const { tabs, currentTab, setCurrentTab } = useTabs();
const { t } = useTranslation(); const { t } = useTranslation();
const getTabIcon = (tabType: Tab['type']) => { const getTabIcon = (tabType: Tab["type"]) => {
switch (tabType) { switch (tabType) {
case 'home': case "home":
return <Home className="h-4 w-4" />; return <Home className="h-4 w-4" />;
case 'terminal': case "terminal":
return <TerminalIcon className="h-4 w-4" />; return <TerminalIcon className="h-4 w-4" />;
case 'server': case "server":
return <ServerIcon className="h-4 w-4" />; return <ServerIcon className="h-4 w-4" />;
case 'file_manager': case "file_manager":
return <FolderIcon className="h-4 w-4" />; return <FolderIcon className="h-4 w-4" />;
case 'user_profile': case "user_profile":
return <UserIcon className="h-4 w-4" />; return <UserIcon className="h-4 w-4" />;
case 'ssh_manager': case "ssh_manager":
return <SshManagerIcon className="h-4 w-4" />; return <SshManagerIcon className="h-4 w-4" />;
case 'admin': case "admin":
return <AdminIcon className="h-4 w-4" />; return <AdminIcon className="h-4 w-4" />;
default: default:
return <TerminalIcon className="h-4 w-4" />; return <TerminalIcon className="h-4 w-4" />;
@@ -46,21 +46,21 @@ export function TabDropdown(): React.ReactElement {
const getTabDisplayTitle = (tab: Tab) => { const getTabDisplayTitle = (tab: Tab) => {
switch (tab.type) { switch (tab.type) {
case 'home': case "home":
return t('nav.home'); return t("nav.home");
case 'server': case "server":
return tab.title || t('nav.serverStats'); return tab.title || t("nav.serverStats");
case 'file_manager': case "file_manager":
return tab.title || t('nav.fileManager'); return tab.title || t("nav.fileManager");
case 'user_profile': case "user_profile":
return tab.title || t('nav.userProfile'); return tab.title || t("nav.userProfile");
case 'ssh_manager': case "ssh_manager":
return tab.title || t('nav.sshManager'); return tab.title || t("nav.sshManager");
case 'admin': case "admin":
return tab.title || t('nav.admin'); return tab.title || t("nav.admin");
case 'terminal': case "terminal":
default: default:
return tab.title || t('nav.terminal'); return tab.title || t("nav.terminal");
} }
}; };
@@ -78,7 +78,7 @@ export function TabDropdown(): React.ReactElement {
<Button <Button
variant="outline" variant="outline"
className="w-[30px] h-[30px] border-dark-border" className="w-[30px] h-[30px] border-dark-border"
title={t('nav.tabNavigation', {defaultValue: 'Tab Navigation'})} title={t("nav.tabNavigation", { defaultValue: "Tab Navigation" })}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</Button> </Button>
@@ -95,14 +95,12 @@ export function TabDropdown(): React.ReactElement {
onClick={() => handleTabSwitch(tab.id)} onClick={() => handleTabSwitch(tab.id)}
className={`flex items-center gap-2 cursor-pointer px-3 py-2 ${ className={`flex items-center gap-2 cursor-pointer px-3 py-2 ${
isActive isActive
? 'bg-dark-bg-active text-white' ? "bg-dark-bg-active text-white"
: 'hover:bg-dark-hover text-gray-300' : "hover:bg-dark-hover text-gray-300"
}`} }`}
> >
{getTabIcon(tab.type)} {getTabIcon(tab.type)}
<span className="flex-1 truncate"> <span className="flex-1 truncate">{getTabDisplayTitle(tab)}</span>
{getTabDisplayTitle(tab)}
</span>
{isActive && ( {isActive && (
<div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" /> <div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
)} )}

Some files were not shown because too many files have changed in this diff Show More