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

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
github: [ LukeGus ] github: [LukeGus]

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,8 +24,9 @@ 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]
- Version [e.g. 1.6.0] - Browser [e.g. chrome, safari]
- Version [e.g. 1.6.0]
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.

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.**

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:

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
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

1
.prettierrc Normal file
View File

@@ -0,0 +1 @@
{}

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 |

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)

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"
}, },

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,141 +28,152 @@ 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 });
} }
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,106 +184,132 @@ 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 };
} }
}); });
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);
}); });

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");

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,
}, },
}, },
]) ]);

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" }
} }
@@ -2258,5 +2302,4 @@
} }
} }
} }
} }

17
package-lock.json generated
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",

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",

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",
],
}); });
}); });

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`, {
fs.mkdirSync(dbDir, {recursive: true}); operation: "db_init",
path: dbDir,
});
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", {
export const db = drizzle(sqlite, {schema}); operation: "db_init",
path: dbPath,
});
export const db = drizzle(sqlite, { schema });

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`),
}); });

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,140 +109,152 @@ 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();
const dismissedAlertRecords = await db const dismissedAlertRecords = await db
.select({alertId: dismissedAlerts.alertId}) .select({ alertId: dismissedAlerts.alertId })
.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" });
} }
}); });

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,66 +175,70 @@ 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 {
const result = await db const result = await db
.select({folder: sshCredentials.folder}) .select({ folder: sshCredentials.folder })
.from(sshCredentials) .from(sshCredentials)
.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
.set({folder: newName}) .update(sshCredentials)
.where(and( .set({ folder: newName })
.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

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,41 +494,52 @@ 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;
}> { }> {
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) => {
resolve({stdout, stderr, code: exitCode}); exitCode = typeof code === "number" ? code : null;
}).on('data', (data: Buffer) => { resolve({ stdout, stderr, code: exitCode });
stdout += data.toString('utf8'); })
}).stderr.on('data', (data: Buffer) => { .on("data", (data: Buffer) => {
stderr += data.toString('utf8'); stdout += 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);
return {total, idle}; return { total, idle };
} }
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,25 +689,35 @@ 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;
} }
const result = { const result = {
cpu: {percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet}, cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
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 },
}; };
metricsCache.set(host.id, result); metricsCache.set(host.id, result);
@@ -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",
});
} }
}); });

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,
});
} }
}); });
@@ -94,30 +104,55 @@ wss.on('connection', (ws: WebSocket) => {
userId?: string; userId?: string;
}; };
}) { }) {
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,28 +160,32 @@ 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);
let resolvedCredentials = {password, key, keyPassword, keyType, authType}; let resolvedCredentials = { password, key, keyPassword, keyType, authType };
if (credentialId && id && hostConfig.userId) { if (credentialId && id && hostConfig.userId) {
try { try {
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();
} }
} }

File diff suppressed because it is too large Load Diff

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);
} }
})(); })();

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;

View File

@@ -1,73 +1,73 @@
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,
defaultTheme = "system", defaultTheme = "system",
storageKey = "vite-ui-theme", storageKey = "vite-ui-theme",
...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;
} };

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 };

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 };

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 };

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,
), ),
}); });
})} })}

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 };

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,
} };

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 };

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,
} };

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,
} };

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 };

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 };

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";

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 };

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 };

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 };

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 };

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,
} };

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 };

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>

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,
} };

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,
} };

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 };

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 };

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 };

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,
} };

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 };

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 };

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 };

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,
}; };
} }

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;
} }

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: {

View File

@@ -145,7 +145,8 @@
} }
@layer base { @layer base {
html, body { html,
body {
height: 100%; height: 100%;
} }

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;

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));
} }

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;
@@ -54,16 +57,16 @@ function RootApp() {
const width = useWindowWidth(); const width = useWindowWidth();
const isMobile = width < 768; const isMobile = width < 768;
if (isElectron()) { if (isElectron()) {
return <DesktopApp/>; return <DesktopApp />;
} }
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>,
) );

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;
} }

View File

@@ -1,13 +1,18 @@
import React from "react"; import React from "react";
import {useSidebar} from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
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 {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import {Checkbox} from "@/components/ui/checkbox.tsx"; 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,
@@ -16,10 +21,10 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table.tsx"; } from "@/components/ui/table.tsx";
import {Shield, Trash2, Users} from "lucide-react"; import { Shield, Trash2, Users } from "lucide-react";
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";
import { import {
getOIDCConfig, getOIDCConfig,
getRegistrationAllowed, getRegistrationAllowed,
@@ -31,45 +36,51 @@ 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({
const {t} = useTranslation(); isTopbarOpen = true,
const {confirmWithToast} = useConfirmation(); }: AdminSettingsProps): React.ReactElement {
const {state: sidebarState} = useSidebar(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const { state: sidebarState } = useSidebar();
const [allowRegistration, setAllowRegistration] = React.useState(true); const [allowRegistration, setAllowRegistration] = React.useState(true);
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,206 +209,306 @@ 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
<Users className="h-4 w-4"/> value="registration"
{t('admin.general')} className="flex items-center gap-2"
>
<Users className="h-4 w-4" />
{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" />
OIDC OIDC
</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,18 +555,25 @@ 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>
</TableRow> </TableRow>
@@ -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")}
<Shield className="h-4 w-4"/> </TableCell>
{t('admin.removeAdminButton')} <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" />
{t("admin.removeAdminButton")}
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -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({
const {t} = useTranslation(); editingCredential,
onFormSubmit,
}: CredentialEditorProps) {
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>
)} )}
@@ -336,26 +382,34 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
<FormField <FormField
control={form.control} control={form.control}
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>
)} )}
@@ -364,18 +418,18 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
<FormField <FormField
control={form.control} control={form.control}
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);
}} }}
@@ -409,15 +463,17 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
<FormField <FormField
control={form.control} control={form.control}
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,39 +542,47 @@ 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
control={form.control} control={form.control}
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,27 +592,32 @@ 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
control={form.control} control={form.control}
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>
@@ -563,12 +660,14 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
<FormField <FormField
control={form.control} control={form.control}
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>
@@ -578,9 +677,11 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
<FormField <FormField
control={form.control} control={form.control}
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}
@@ -628,15 +732,25 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
<Controller <Controller
control={form.control} control={form.control}
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>
@@ -646,12 +760,14 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
<FormField <FormField
control={form.control} control={form.control}
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>
@@ -661,9 +777,11 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
<FormField <FormField
control={form.control} control={form.control}
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}
@@ -714,13 +835,11 @@ export function CredentialEditor({editingCredential, onFormSubmit}: CredentialEd
</Tabs> </Tabs>
</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>

View File

@@ -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({
const {t} = useTranslation(); value,
onValueChange,
onCredentialSelect,
}: CredentialSelectorProps) {
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>

View File

@@ -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 {
import {Badge} from "@/components/ui/badge"; Card,
import {Separator} from "@/components/ui/separator"; CardContent,
import {ScrollArea} from "@/components/ui/scroll-area"; CardDescription,
import {Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet"; CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
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> = ({
const {t} = useTranslation(); credential,
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(null); onClose,
onEdit,
}) => {
const { t } = useTranslation();
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,10 +107,10 @@ 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,26 +136,33 @@ 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"
size="sm" size="sm"
onClick={() => copyToClipboard(value, label)} onClick={() => copyToClipboard(value, label)}
> >
<Copy className="h-4 w-4"/> <Copy className="h-4 w-4" />
</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,78 +219,88 @@ 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">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800"> <div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
<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>
{credentialDetails.folder && ( {credentialDetails.folder && (
<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>
)} )}
{credentialDetails.tags.length > 0 && ( {credentialDetails.tags.length > 0 && (
<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>
))} ))}
@@ -260,23 +309,29 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose,
</div> </div>
)} )}
<Separator/> <Separator />
<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,20 +467,20 @@ 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>
<CardContent> <CardContent>
{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>

View File

@@ -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({
const {t} = useTranslation(); onEditCredential,
const {confirmWithToast} = useConfirmation(); }: CredentialsManagerProps) {
const { t } = useTranslation();
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);
@@ -191,16 +228,18 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
try { try {
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,24 +329,28 @@ 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>
<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,22 +362,26 @@ 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>
<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
@@ -380,7 +441,7 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
disabled={operationLoading} disabled={operationLoading}
> >
<Check className="h-3 w-3"/> <Check className="h-3 w-3" />
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -392,7 +453,7 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
disabled={operationLoading} disabled={operationLoading}
> >
<X className="h-3 w-3"/> <X className="h-3 w-3" />
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -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"
@@ -420,7 +485,7 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity" className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity"
title="Rename folder" title="Rename folder"
> >
<Pencil className="h-3 w-3"/> <Pencil className="h-3 w-3" />
</Button> </Button>
)} )}
</> </>
@@ -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>
)} )}
@@ -494,7 +570,7 @@ export function CredentialsManager({onEditCredential}: CredentialsManagerProps)
}} }}
className="h-5 w-5 p-0 hover:bg-blue-500/10" className="h-5 w-5 p-0 hover:bg-blue-500/10"
> >
<Edit className="h-3 w-3"/> <Edit className="h-3 w-3" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -508,11 +584,15 @@ 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"
> >
<Trash2 className="h-3 w-3"/> <Trash2 className="h-3 w-3" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -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) => (
<Tag className="h-2 w-2 mr-0.5"/> <Badge
key={index}
variant="outline"
className="text-xs px-1 py-0"
>
<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"
<Key className="h-2 w-2 mr-0.5"/> >
{credential.authType === "password" ? (
<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>

View File

@@ -1,16 +1,18 @@
import React from "react"; 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(
const {tabs, activeTab, setActiveTab, closeTab, onHomeClick} = props; props: FileManagerTopNavbarProps,
): React.ReactElement {
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
return ( return (
<FileManagerTabList <FileManagerTabList

View File

@@ -1,14 +1,14 @@
import React, {useState, useEffect, useRef} from "react"; import React, { useState, useEffect, useRef } from "react";
import {FileManagerLeftSidebar} from "@/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx"; import { FileManagerLeftSidebar } from "@/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx";
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,36 +581,47 @@ 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>
<div className="p-0.25 w-px h-[30px] bg-dark-border"></div> <div className="p-0.25 w-px h-[30px] bg-dark-border"></div>
<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>
@@ -597,15 +675,18 @@ export function FileManager({onSelectView, initialHost = null, onClose}: {
<div className="relative h-full flex items-center justify-center"> <div className="relative h-full flex items-center justify-center">
<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>

View File

@@ -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,322 +11,325 @@ 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}
height="100%" height="100%"
basicSetup={{lineNumbers: true}} basicSetup={{ lineNumbers: true }}
className="min-h-full min-w-full flex-1" className="min-h-full min-w-full flex-1"
/> />
</div> </div>

View File

@@ -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 && (
@@ -73,7 +86,7 @@ export function FileManagerHomeView({
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md" className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={onRemove} onClick={onRemove}
> >
<Trash2 className="w-3 h-3 text-red-500"/> <Trash2 className="w-3 h-3 text-red-500" />
</Button> </Button>
)} )}
</div> </div>
@@ -81,13 +94,15 @@ 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)}
> >
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> <Folder className="w-4 h-4 text-blue-400 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">
{shortcut.path} {shortcut.path}
@@ -101,7 +116,7 @@ export function FileManagerHomeView({
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md" className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={() => onRemoveShortcut(shortcut)} onClick={() => onRemoveShortcut(shortcut)}
> >
<Trash2 className="w-3 h-3 text-red-500"/> <Trash2 className="w-3 h-3 text-red-500" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -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>

View File

@@ -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,12 +322,12 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
visible: true, visible: true,
x, x,
y, y,
item item,
}); });
}; };
const closeContextMenu = () => { const closeContextMenu = () => {
setContextMenu({visible: false, x: 0, y: 0, item: null}); setContextMenu({ visible: false, x: 0, y: 0, item: null });
}; };
const handleRename = async (item: any, newName: string) => { const handleRename = async (item: any, newName: string) => {
@@ -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,12 +348,14 @@ 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"),
);
} }
}; };
const startRename = (item: any) => { const startRename = (item: any) => {
setRenamingItem({item, newName: item.name}); setRenamingItem({ item, newName: item.name });
closeContextMenu(); closeContextMenu();
}; };
@@ -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 && (
@@ -504,7 +580,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
handleContextMenu(e, item); handleContextMenu(e, item);
}} }}
> >
<MoreVertical className="w-4 h-4"/> <MoreVertical className="w-4 h-4" />
</Button> </Button>
)} )}
</div> </div>
@@ -535,14 +611,14 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-dark-hover flex items-center gap-2" className="w-full px-3 py-2 text-left text-sm text-white hover:bg-dark-hover flex items-center gap-2"
onClick={() => startRename(contextMenu.item)} onClick={() => startRename(contextMenu.item)}
> >
<Edit3 className="w-4 h-4"/> <Edit3 className="w-4 h-4" />
Rename Rename
</button> </button>
<button <button
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-dark-hover flex items-center gap-2" className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-dark-hover flex items-center gap-2"
onClick={() => startDelete(contextMenu.item)} onClick={() => startDelete(contextMenu.item)}
> >
<Trash2 className="w-4 h-4"/> <Trash2 className="w-4 h-4" />
Delete Delete
</button> </button>
</div> </div>
@@ -551,4 +627,4 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
); );
}); });
export {FileManagerLeftSidebar}; export { FileManagerLeftSidebar };

View File

@@ -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;
} }
@@ -51,47 +51,75 @@ export function FileManagerLeftSidebarFileViewer({
isLoading, isLoading,
error, error,
isSSHMode, isSSHMode,
}: FileManagerLeftSidebarVileViewerProps) { }: FileManagerLeftSidebarVileViewerProps) {
const {t} = useTranslation(); const { t } = useTranslation();
return ( return (
<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"
<Trash2 className="w-4 h-4 text-red-500"/> variant="ghost"
className="h-7 w-7"
onClick={() => onDeleteFile(item)}
>
<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>

View File

@@ -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,20 +12,20 @@ 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);
const [showCreateFile, setShowCreateFile] = useState(false); const [showCreateFile, setShowCreateFile] = useState(false);
const [showCreateFolder, setShowCreateFolder] = useState(false); const [showCreateFolder, setShowCreateFolder] = 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,75 +350,91 @@ 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>
<div className="bg-dark-bg-light border-2 border-dark-border-medium rounded-md p-3"> <div className="bg-dark-bg-light border-2 border-dark-border-medium rounded-md p-3">
<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>
<Separator className="p-0.25 bg-dark-border"/> <Separator className="p-0.25 bg-dark-border" />
{showUpload && ( {showUpload && (
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4"> <Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<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
@@ -358,7 +443,7 @@ export function FileManagerOperations({
onClick={() => setShowUpload(false)} onClick={() => setShowUpload(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2" className="h-8 w-8 p-0 flex-shrink-0 ml-2"
> >
<X className="w-4 h-4"/> <X className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -366,8 +451,10 @@ export function FileManagerOperations({
<div className="border-2 border-dashed border-dark-border-hover rounded-lg p-4 text-center"> <div className="border-2 border-dashed border-dark-border-hover rounded-lg p-4 text-center">
{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>
@@ -430,8 +521,10 @@ export function FileManagerOperations({
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<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
@@ -440,21 +533,21 @@ export function FileManagerOperations({
onClick={() => setShowCreateFile(false)} onClick={() => setShowCreateFile(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2" className="h-8 w-8 p-0 flex-shrink-0 ml-2"
> >
<X className="w-4 h-4"/> <X className="w-4 h-4" />
</Button> </Button>
</div> </div>
<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>
@@ -484,8 +579,10 @@ export function FileManagerOperations({
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<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
@@ -494,21 +591,21 @@ export function FileManagerOperations({
onClick={() => setShowCreateFolder(false)} onClick={() => setShowCreateFolder(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2" className="h-8 w-8 p-0 flex-shrink-0 ml-2"
> >
<X className="w-4 h-4"/> <X className="w-4 h-4" />
</Button> </Button>
</div> </div>
<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>
@@ -538,8 +637,10 @@ export function FileManagerOperations({
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<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
@@ -548,27 +649,28 @@ export function FileManagerOperations({
onClick={() => setShowDelete(false)} onClick={() => setShowDelete(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2" className="h-8 w-8 p-0 flex-shrink-0 ml-2"
> >
<X className="w-4 h-4"/> <X className="w-4 h-4" />
</Button> </Button>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<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>
@@ -613,8 +720,10 @@ export function FileManagerOperations({
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<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
@@ -623,33 +732,33 @@ export function FileManagerOperations({
onClick={() => setShowRename(false)} onClick={() => setShowRename(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2" className="h-8 w-8 p-0 flex-shrink-0 ml-2"
> >
<X className="w-4 h-4"/> <X className="w-4 h-4" />
</Button> </Button>
</div> </div>
<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>

View File

@@ -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>
@@ -42,7 +52,7 @@ export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onH
variant="outline" variant="outline"
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border" className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
> >
<X className="!w-4 !h-4" strokeWidth={2}/> <X className="!w-4 !h-4" strokeWidth={2} />
</Button> </Button>
</div> </div>
); );

View File

@@ -1,21 +1,29 @@
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 {
import {Separator} from "@/components/ui/separator.tsx"; Tabs,
import {HostManagerEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx"; TabsContent,
import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx"; TabsList,
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx"; TabsTrigger,
import {useSidebar} from "@/components/ui/sidebar.tsx"; } from "@/components/ui/tabs.tsx";
import {useTranslation} from "react-i18next"; import { Separator } from "@/components/ui/separator.tsx";
import type {SSHHost, HostManagerProps} from '../../../types/index'; import { HostManagerEditor } from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx";
import { CredentialsManager } from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
import { CredentialEditor } from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { useTranslation } from "react-i18next";
import type { SSHHost, HostManagerProps } from "../../../types/index";
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement { export function HostManager({
const {t} = useTranslation(); onSelectView,
isTopbarOpen,
}: HostManagerProps): React.ReactElement {
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);
const [editingCredential, setEditingCredential] = useState<any | null>(null); const [editingCredential, setEditingCredential] = useState<any | null>(null);
const {state: sidebarState} = useSidebar(); const { state: sidebarState } = useSidebar();
const handleEditHost = (host: SSHHost) => { const handleEditHost = (host: SSHHost) => {
setEditingHost(host); setEditingHost(host);
@@ -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,28 +69,43 @@ 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
<Separator className="p-0.25 -mt-0.5 mb-1"/> value="host_viewer"
<HostManagerViewer onEditHost={handleEditHost}/> className="flex-1 flex flex-col h-full min-h-0"
>
<Separator className="p-0.25 -mt-0.5 mb-1" />
<HostManagerViewer onEditHost={handleEditHost} />
</TabsContent> </TabsContent>
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0"> <TabsContent
<Separator className="p-0.25 -mt-0.5 mb-1"/> value="add_host"
className="flex-1 flex flex-col h-full min-h-0"
>
<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
editingHost={editingHost} editingHost={editingHost}
@@ -91,14 +113,20 @@ 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
<Separator className="p-0.25 -mt-0.5 mb-1"/> value="credentials"
className="flex-1 flex flex-col h-full min-h-0"
>
<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
<Separator className="p-0.25 -mt-0.5 mb-1"/> value="add_credential"
className="flex-1 flex flex-col h-full min-h-0"
>
<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
editingCredential={editingCredential} editingCredential={editingCredential}
@@ -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

View File

@@ -1,14 +1,30 @@
import React, {useState, useEffect, useMemo, useRef} from "react"; import React, { useState, useEffect, useMemo, useRef } from "react";
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 {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,
import {toast} from "sonner"; AccordionItem,
import {useTranslation} from "react-i18next"; AccordionTrigger,
import {useConfirmation} from "@/hooks/use-confirmation.ts"; } 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 { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { import {
Edit, Edit,
Trash2, Trash2,
@@ -24,13 +40,16 @@ 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();
const {confirmWithToast} = useConfirmation(); const { confirmWithToast } = useConfirmation();
const [hosts, setHosts] = useState<SSHHost[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -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);
@@ -280,23 +317,27 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
try { try {
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"
@@ -563,10 +618,10 @@ 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"
@@ -716,9 +778,9 @@ 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
@@ -768,7 +838,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
disabled={operationLoading} disabled={operationLoading}
> >
<Check className="h-3 w-3"/> <Check className="h-3 w-3" />
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -780,7 +850,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
disabled={operationLoading} disabled={operationLoading}
> >
<X className="h-3 w-3"/> <X className="h-3 w-3" />
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -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"
@@ -808,7 +882,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity" className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity"
title="Rename folder" title="Rename folder"
> >
<Pencil className="h-3 w-3"/> <Pencil className="h-3 w-3" />
</Button> </Button>
)} )}
</> </>
@@ -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>
)} )}
@@ -884,7 +962,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
}} }}
className="h-5 w-5 p-0 hover:bg-blue-500/10" className="h-5 w-5 p-0 hover:bg-blue-500/10"
> >
<Edit className="h-3 w-3"/> <Edit className="h-3 w-3" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -898,11 +976,15 @@ 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"
> >
<Trash2 className="h-3 w-3"/> <Trash2 className="h-3 w-3" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -920,30 +1002,36 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
}} }}
className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-500/10" className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-500/10"
> >
<Upload className="h-3 w-3"/> <Upload className="h-3 w-3" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<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) => (
<Tag className="h-2 w-2 mr-0.5"/> <Badge
key={index}
variant="outline"
className="text-xs px-1 py-0"
>
<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"
<Terminal className="h-2 w-2 mr-0.5"/> className="text-xs px-1 py-0"
{t('hosts.terminalBadge')} >
<Terminal className="h-2 w-2 mr-0.5" />
{t("hosts.terminalBadge")}
</Badge> </Badge>
)} )}
{host.enableTunnel && ( {host.enableTunnel && (
<Badge variant="outline" <Badge
className="text-xs px-1 py-0"> variant="outline"
<Network className="h-2 w-2 mr-0.5"/> className="text-xs px-1 py-0"
{t('hosts.tunnelBadge')} >
{host.tunnelConnections && host.tunnelConnections.length > 0 && ( <Network className="h-2 w-2 mr-0.5" />
<span {t("hosts.tunnelBadge")}
className="ml-0.5">({host.tunnelConnections.length})</span> {host.tunnelConnections &&
host.tunnelConnections.length > 0 && (
<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"
<FileEdit className="h-2 w-2 mr-0.5"/> className="text-xs px-1 py-0"
{t('hosts.fileManagerBadge')} >
<FileEdit className="h-2 w-2 mr-0.5" />
{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>

View File

@@ -1,15 +1,19 @@
import React from "react"; import React from "react";
import {useSidebar} from "@/components/ui/sidebar.tsx"; 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 {
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; getServerStatusById,
import {useTranslation} from 'react-i18next'; getServerMetricsById,
import {toast} from 'sonner'; type ServerMetrics,
} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { useTranslation } from "react-i18next";
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,8 +185,11 @@ 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
<StatusIndicator/> status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status> </Status>
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -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,55 +240,66 @@ 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>
</div> </div>
<Separator className="p-0.25 w-full"/> <Separator className="p-0.25 w-full" />
{/* Stats */} {/* Stats */}
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4"> <div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
{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"

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;
@@ -18,11 +24,11 @@ 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();
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null); const webSocketRef = useRef<WebSocket | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null); const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
@@ -52,16 +58,15 @@ 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) {
if (!(cols > 0 && rows > 0)) return; if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = {cols, rows}; pendingSizeRef.current = { cols, rows };
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
notifyTimerRef.current = setTimeout(() => { notifyTimerRef.current = setTimeout(() => {
const next = pendingSizeRef.current; const next = pendingSizeRef.current;
@@ -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');

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 }));
} }
}; };

View File

@@ -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 {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,9 +28,9 @@ 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();
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => { const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[tunnelIndex]; const tunnel = host.tunnelConnections[tunnelIndex];
@@ -36,72 +39,73 @@ export function TunnelObject({
}; };
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => { const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
if (!status) return { if (!status)
icon: <WifiOff className="h-4 w-4"/>, return {
text: t('tunnels.unknown'), icon: <WifiOff className="h-4 w-4" />,
color: 'text-muted-foreground', text: t("tunnels.unknown"),
bgColor: 'bg-muted/50', color: "text-muted-foreground",
borderColor: 'border-border' bgColor: "bg-muted/50",
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>
@@ -186,50 +208,76 @@ export function TunnelObject({
disabled disabled
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>
@@ -240,8 +288,8 @@ export function TunnelObject({
</div> </div>
) : ( ) : (
<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,8 +321,12 @@ 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
<Tag className="h-2 w-2 mr-0.5"/> key={index}
variant="secondary"
className="text-xs px-1 py-0"
>
<Tag className="h-2 w-2 mr-0.5" />
{tag} {tag}
</Badge> </Badge>
))} ))}
@@ -284,13 +338,13 @@ export function TunnelObject({
</div> </div>
)} )}
{!compact && <Separator className="mb-3"/>} {!compact && <Separator className="mb-3" />}
<div className="space-y-3"> <div className="space-y-3">
{!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>
@@ -370,50 +442,76 @@ export function TunnelObject({
disabled disabled
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>
@@ -424,8 +522,8 @@ export function TunnelObject({
</div> </div>
) : ( ) : (
<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>

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
/> />

View File

@@ -1,24 +1,29 @@
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 {
import {TopNavbar} from "@/ui/Desktop/Navigation/TopNavbar.tsx"; TabProvider,
import {AdminSettings} from "@/ui/Desktop/Admin/AdminSettings.tsx"; useTabs,
import {UserProfile} from "@/ui/Desktop/User/UserProfile.tsx"; } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {Toaster} from "@/components/ui/sonner.tsx"; import { TopNavbar } from "@/ui/Desktop/Navigation/TopNavbar.tsx";
import {getUserInfo, getCookie} from "@/ui/main-axios.ts"; import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx";
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 {currentTab, tabs} = useTabs(); const [authLoading, setAuthLoading] = useState(true);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
const { currentTab, tabs } = useTabs();
useEffect(() => { useEffect(() => {
const checkAuth = () => { const checkAuth = () => {
@@ -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>
)} )}
@@ -117,7 +133,7 @@ function AppContent() {
> >
{showTerminalView && ( {showTerminalView && (
<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">
<AppView isTopbarOpen={isTopbarOpen}/> <AppView isTopbarOpen={isTopbarOpen} />
</div> </div>
)} )}
@@ -135,23 +151,29 @@ 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>
)} )}
{showAdmin && ( {showAdmin && (
<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">
<AdminSettings isTopbarOpen={isTopbarOpen}/> <AdminSettings isTopbarOpen={isTopbarOpen} />
</div> </div>
)} )}
{showProfile && ( {showProfile && (
<div className="h-screen w-full visible pointer-events-auto static overflow-auto"> <div className="h-screen w-full visible pointer-events-auto static overflow-auto">
<UserProfile isTopbarOpen={isTopbarOpen}/> <UserProfile isTopbarOpen={isTopbarOpen} />
</div> </div>
)} )}
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/> <TopNavbar
isTopbarOpen={isTopbarOpen}
setIsTopbarOpen={setIsTopbarOpen}
/>
</LeftSidebar> </LeftSidebar>
)} )}
<Toaster <Toaster
@@ -162,15 +184,15 @@ function AppContent() {
offset={20} offset={20}
/> />
</div> </div>
) );
} }
function DesktopApp() { function DesktopApp() {
return ( return (
<TabProvider> <TabProvider>
<AppContent/> <AppContent />
</TabProvider> </TabProvider>
); );
} }
export default DesktopApp export default DesktopApp;

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({
const {t} = useTranslation(); onServerConfigured,
const [serverUrl, setServerUrl] = useState(''); onCancel,
isFirstTime = false,
}: ServerConfigProps) {
const { t } = useTranslation();
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);
}; };
@@ -116,16 +132,16 @@ export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center"> <div className="text-center">
<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,26 +160,29 @@ 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" />
)} )}
</Button> </Button>
</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>

View File

@@ -1,15 +1,19 @@
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import {HomepageAuth} from "@/ui/Desktop/Homepage/HomepageAuth.tsx"; import { HomepageAuth } from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
import {HomepageUpdateLog} from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx"; import { HomepageUpdateLog } from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
import {Button} from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {getUserInfo, getDatabaseHealth, getCookie} from "@/ui/main-axios.ts"; import { getUserInfo, getDatabaseHealth, getCookie } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
interface HomepageProps { 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,8 +21,8 @@ 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);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
@@ -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>

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 {
import {Button} from "@/components/ui/button.tsx"; Card,
import {Badge} from "@/components/ui/badge.tsx"; CardContent,
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; CardFooter,
import {useTranslation} from "react-i18next"; CardHeader,
import type {TermixAlert} from '../../../types/index.js'; CardTitle,
} from "@/components/ui/card.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import {
X,
ExternalLink,
AlertTriangle,
Info,
CheckCircle,
AlertCircle,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TermixAlert } from "../../../types/index.js";
interface AlertCardProps { interface AlertCardProps {
alert: TermixAlert; alert: TermixAlert;
@@ -14,48 +27,52 @@ 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" />;
} }
}; };
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({
const {t} = useTranslation(); alert,
onDismiss,
onClose,
}: AlertCardProps): React.ReactElement {
const { t } = useTranslation();
if (!alert) { if (!alert) {
return null; return null;
@@ -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"
@@ -94,7 +109,7 @@ export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps):
onClick={onClose} onClick={onClose}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
> >
<X className="h-4 w-4"/> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
@@ -120,20 +135,19 @@ 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}
<ExternalLink className="h-4 w-4"/> <ExternalLink className="h-4 w-4" />
</Button> </Button>
)} )}
</div> </div>

View File

@@ -1,17 +1,20 @@
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import {HomepageAlertCard} from "./HomepageAlertCard.tsx"; 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({
const {t} = useTranslation(); userId,
loggedIn,
}: AlertManagerProps): React.ReactElement {
const { t } = useTranslation();
const [alerts, setAlerts] = useState<TermixAlert[]>([]); const [alerts, setAlerts] = useState<TermixAlert[]>([]);
const [currentAlertIndex, setCurrentAlertIndex] = useState(0); const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -35,23 +38,27 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
const userAlerts = response.alerts || []; const userAlerts = response.alerts || [];
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"));
} }
}; };
@@ -116,9 +124,9 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
return null; return null;
} }
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;

View File

@@ -1,12 +1,12 @@
import React, {useState, useEffect} from "react"; import React, { useState, useEffect } from "react";
import {cn} from "@/lib/utils.ts"; import { cn } from "@/lib/utils.ts";
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 {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 {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 {LanguageSwitcher} from "@/ui/Desktop/User/LanguageSwitcher.tsx"; import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
import { import {
registerUser, registerUser,
loginUser, loginUser,
@@ -24,7 +24,7 @@ import {
getServerConfig, getServerConfig,
isElectron, isElectron,
} from "../../main-axios.ts"; } from "../../main-axios.ts";
import {ServerConfig as ServerConfigComponent} from "@/ui/Desktop/Electron Only/ServerConfig.tsx"; import { ServerConfig as ServerConfigComponent } from "@/ui/Desktop/Electron Only/ServerConfig.tsx";
interface HomepageAuthProps extends React.ComponentProps<"div"> { interface HomepageAuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void; setLoggedIn: (loggedIn: boolean) => void;
@@ -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({
@@ -50,9 +54,11 @@ export function HomepageAuth({
setDbError, setDbError,
onAuthSuccess, onAuthSuccess,
...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);
} }
@@ -336,27 +356,31 @@ export function HomepageAuth({
setOidcLoading(true); setOidcLoading(true);
try { try {
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,11 +479,11 @@ 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">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin"/> <div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div> </div>
</div> </div>
); );
@@ -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>

View File

@@ -1,8 +1,8 @@
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import {Separator} from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import {getReleasesRSS, getVersionInfo} from "@/ui/main-axios.ts"; import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> { interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
loggedIn: boolean; loggedIn: boolean;
@@ -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;
@@ -50,8 +50,8 @@ interface VersionResponse {
cache_age?: number; cache_age?: number;
} }
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { export function HomepageUpdateLog({ loggedIn }: HomepageUpdateLogProps) {
const {t} = useTranslation(); const { t } = useTranslation();
const [releases, setReleases] = useState<RSSResponse | null>(null); const [releases, setReleases] = useState<RSSResponse | null>(null);
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null); const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -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,33 +78,35 @@ 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" />
)} )}
<div className="flex-1 overflow-y-auto space-y-3 pr-2"> <div className="flex-1 overflow-y-auto space-y-3 pr-2">
@@ -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>
)} )}

View File

@@ -1,27 +1,45 @@
import React, {useEffect, useRef, useState} from "react"; import React, { useEffect, useRef, useState } from "react";
import {Terminal} from "@/ui/Desktop/Apps/Terminal/Terminal.tsx"; 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 {
import {Button} from "@/components/ui/button.tsx"; LucideRefreshCcw,
LucideRefreshCw,
RefreshCcw,
RefreshCcwDot,
} from "lucide-react";
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({
const {tabs, currentTab, allSplitScreenTab, removeTab} = useTabs() as any; isTopbarOpen = true,
const {state: sidebarState} = useSidebar(); }: TerminalViewProps): React.ReactElement {
const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as any;
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}
@@ -189,7 +223,7 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
); );
}; };
const ResetButton = ({onClick}: { onClick: () => void }) => ( const ResetButton = ({ onClick }: { onClick: () => void }) => (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -197,7 +231,7 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
aria-label="Reset split sizes" aria-label="Reset split sizes"
className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-dark-border-panel bg-dark-bg-panel hover:bg-dark-bg-panel-hover text-white flex items-center justify-center p-0" className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-dark-border-panel bg-dark-bg-panel hover:bg-dark-bg-panel-hover text-white flex items-center justify-center p-0"
> >
<RefreshCcw className="h-4 w-4"/> <RefreshCcw className="h-4 w-4" />
</Button> </Button>
); );
@@ -207,43 +241,73 @@ 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>
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -255,44 +319,84 @@ 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>
</div> </div>
</ResizablePanel> </ResizablePanel>
</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,58 +407,117 @@ 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>
</div> </div>
</ResizablePanel> </ResizablePanel>
</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,

View File

@@ -1,9 +1,9 @@
import React, {useState} from "react"; import React, { useState } from "react";
import {CardTitle} from "@/components/ui/card.tsx"; import { CardTitle } from "@/components/ui/card.tsx";
import {ChevronDown, Folder} from "lucide-react"; import { ChevronDown, Folder } from "lucide-react";
import {Button} from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {Host} from "@/ui/Desktop/Navigation/Hosts/Host.tsx"; import { Host } from "@/ui/Desktop/Navigation/Hosts/Host.tsx";
import {Separator} from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -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,17 +65,21 @@ 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
<Host host={host}/> key={`${folderName}-host-${host.id}-${host.name || host.ip}`}
>
<Host host={host} />
{index < hosts.length - 1 && ( {index < hosts.length - 1 && (
<div className="relative -mx-2"> <div className="relative -mx-2">
<Separator className="p-0.25 absolute inset-x-0"/> <Separator className="p-0.25 absolute inset-x-0" />
</div> </div>
)} )}
</React.Fragment> </React.Fragment>
@@ -76,5 +87,5 @@ export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactEle
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@@ -1,19 +1,23 @@
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status"; import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import {Button} from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {ButtonGroup} from "@/components/ui/button-group.tsx"; import { ButtonGroup } from "@/components/ui/button-group.tsx";
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,25 +55,32 @@ 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
<StatusIndicator/> status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<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
<Server/> variant="outline"
className="!px-2 border-1 border-dark-border"
onClick={handleServerClick}
>
<Server />
</Button> </Button>
{host.enableTerminal && ( {host.enableTerminal && (
<Button <Button
@@ -77,7 +88,7 @@ export function Host({host}: HostProps): React.ReactElement {
className="!px-2 border-1 border-dark-border" className="!px-2 border-1 border-dark-border"
onClick={handleTerminalClick} onClick={handleTerminalClick}
> >
<Terminal/> <Terminal />
</Button> </Button>
)} )}
</ButtonGroup> </ButtonGroup>
@@ -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>
) );
} }

View File

@@ -1,33 +1,38 @@
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 { getCookie, setCookie, isElectron } from "@/ui/main-axios.ts";
import {useTranslation} from 'react-i18next';
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,
import {Input} from "@/components/ui/input.tsx"; DropdownMenuTrigger,
import {PasswordInput} from "@/components/ui/password-input.tsx"; } from "@radix-ui/react-dropdown-menu";
import {Label} from "@/components/ui/label.tsx"; import { Input } from "@/components/ui/input.tsx";
import {Button} from "@/components/ui/button.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx";
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx"; import { Label } from "@/components/ui/label.tsx";
import {FolderCard} from "@/ui/Desktop/Navigation/Hosts/FolderCard.tsx"; import { Button } from "@/components/ui/button.tsx";
import {getSSHHosts} from "@/ui/main-axios.ts"; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import { FolderCard } from "@/ui/Desktop/Navigation/Hosts/FolderCard.tsx";
import {deleteAccount} from "@/ui/main-axios.ts"; import { getSSHHosts } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { deleteAccount } from "@/ui/main-axios.ts";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -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,
@@ -80,8 +83,8 @@ export function LeftSidebar({
isAdmin, isAdmin,
username, username,
children, children,
}: SidebarProps): React.ReactElement { }: SidebarProps): React.ReactElement {
const {t} = useTranslation(); const { t } = useTranslation();
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false); const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
const [deletePassword, setDeletePassword] = React.useState(""); const [deletePassword, setDeletePassword] = React.useState("");
@@ -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,30 +322,39 @@ 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>
</SidebarGroupLabel> </SidebarGroupLabel>
</SidebarHeader> </SidebarHeader>
<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}
<HardDrive strokeWidth="2.5"/> title={
{t('nav.hostManager')} sshManagerTab
? t("interface.sshManagerAlreadyOpen")
: isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
}
>
<HardDrive strokeWidth="2.5" />
{t("nav.hostManager")}
</Button> </Button>
</SidebarGroup> </SidebarGroup>
<Separator className="p-0.25"/> <Separator className="p-0.25" />
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2"> <SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
<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>
)} )}
@@ -347,7 +387,7 @@ export function LeftSidebar({
))} ))}
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<Separator className="p-0.25 mt-1 mb-1"/> <Separator className="p-0.25 mt-1 mb-1" />
<SidebarFooter> <SidebarFooter>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
@@ -357,8 +397,8 @@ 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>
<DropdownMenuContent <DropdownMenuContent
@@ -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,19 +444,16 @@ 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>
) );
} }

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,38 +37,56 @@ 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}
> >
<Home/> <Home />
</Button> </Button>
); );
} }
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,9 +94,11 @@ 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>
)} )}
{canClose && ( {canClose && (
@@ -88,7 +108,7 @@ export function Tab({
onClick={onClose} onClick={onClose}
disabled={disableClose} disabled={disableClose}
> >
<X/> <X />
</Button> </Button>
)} )}
</ButtonGroup> </ButtonGroup>
@@ -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"
@@ -112,7 +132,7 @@ export function Tab({
onClick={onClose} onClick={onClose}
disabled={disableClose} disabled={disableClose}
> >
<X/> <X />
</Button> </Button>
</ButtonGroup> </ButtonGroup>
); );
@@ -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"
@@ -135,7 +155,7 @@ export function Tab({
onClick={onClose} onClick={onClose}
disabled={disableClose} disabled={disableClose}
> >
<X/> <X />
</Button> </Button>
</ButtonGroup> </ButtonGroup>
); );

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;
} }
@@ -30,30 +36,42 @@ interface TabProviderProps {
children: ReactNode; children: ReactNode;
} }
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>
);
} }

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