v1.6.0 (#221)
* Add documentation in Chinese language (#160) * Update file naming and structure for mobile support * Add conditional desktop/mobile rendering * Mobile terminal * Fix overwritten i18n (#161) * Add comprehensive Chinese internationalization support - Implemented i18n framework with react-i18next for multi-language support - Added Chinese (zh) and English (en) translation files with comprehensive coverage - Localized Admin interface, authentication flows, and error messages - Translated FileManager operations and UI elements - Updated HomepageAuth component with localized authentication messages - Localized LeftSidebar navigation and host management - Added language switcher component (shown after login only) - Configured default language as English with Chinese as secondary option - Localized TOTPSetup two-factor authentication interface - Updated Docker build to include translation files - Achieved 95%+ UI localization coverage across core components Co-Authored-By: Claude <noreply@anthropic.com> * Extend Chinese localization coverage to Host Manager components - Added comprehensive translations for HostManagerHostViewer component - Localized all host management UI text including import/export features - Translated error messages and confirmation dialogs for host operations - Added translations for HostManagerHostEditor validation messages - Localized connection details, organization settings, and form labels - Fixed syntax error in FileManagerOperations component - Achieved near-complete localization of SSH host management interface - Updated placeholders and tooltips for better user guidance Co-Authored-By: Claude <noreply@anthropic.com> * Complete comprehensive Chinese localization for Termix - Added full localization support for Tunnel components (connected/disconnected states, retry messages) - Localized all tunnel status messages and connection errors - Added translations for port forwarding UI elements - Verified Server, TopNavbar, and Tab components already have complete i18n support - Achieved 99%+ localization coverage across entire application - All core UI components now fully support Chinese and English languages This completes the comprehensive internationalization effort for the Termix SSH management platform. Co-Authored-By: Claude <noreply@anthropic.com> * Localize additional Host Manager components and authentication settings - Added translations for all authentication options (Password, Key, SSH Private Key) - Localized form labels in HostManagerHostEditor (Pin Connection, Enable Terminal/Tunnel/FileManager) - Translated Upload/Update Key button states - Localized Host Viewer and Add/Edit Host tab labels - Added Chinese translations for all host management settings - Fixed duplicate translation keys in JSON files Co-Authored-By: Claude <noreply@anthropic.com> * Extend localization coverage to UI components and common strings - Added comprehensive common translations (online/offline, success/error, etc.) - Localized status indicator component with all status states - Updated FileManagerLeftSidebar toast messages for rename/delete operations - Added translations for UI elements (close, toggle sidebar, etc.) - Expanded placeholder translations for form inputs - Added Chinese translations for all new common strings - Improved consistency across component status messages Co-Authored-By: Claude <noreply@anthropic.com> * Complete Chinese localization for remaining UI components - Add comprehensive Chinese translations for Host Manager component - Translate all form labels, buttons, and descriptions - Add translations for SSH configuration warnings and instructions - Localize tunnel connection settings and port forwarding options - Localize SSH Tools panel - Translate key recording functionality - Add translations for settings and configuration options - Translate homepage welcome messages and navigation elements - Add Chinese translations for login success messages - Localize "Updates & Releases" section title - Translate sidebar "Host Manager" button - Fix translation key display issues - Remove duplicate translation keys in both language files - Ensure all components properly reference translation keys - Fix hosts.tunnelConnections key mapping This completes the full Chinese localization of the Termix application, achieving near 100% UI translation coverage while maintaining English as the default language. * Complete final Chinese localization for Host Manager tunnel configuration - Add Chinese translations for authentication UI elements - Translate "Authentication", "Password", and "Key" tab labels - Localize SSH private key and key password fields - Add translations for key type selector - Localize tunnel connection configuration descriptions - Translate retry attempts and retry interval descriptions - Add dynamic tunnel forwarding description with port parameters - Localize endpoint SSH configuration labels - Fix missing translation keys - Add "upload" translation for file upload button - Ensure all FormLabel and FormDescription elements use translation keys This completes the comprehensive Chinese localization of the entire Termix application, achieving 100% UI translation coverage. * Fix PR feedback: Improve Profile section translations and UX - Fixed password reset translations in Profile section - Moved language selector from TopNavbar to Profile page - Added profile.selectPreferredLanguage translation key - Improved user experience for language preferences * Apply critical OIDC and notification system fixes while preserving i18n - Merge OIDC authentication fixes from3877e90: * Enhanced JWKS discovery mechanism with multiple backup URLs * Better support for non-standard OIDC providers (Authentik, etc.) * Improved error handling for "Failed to get user information" - Migrate to unified Sonner toast notification system: * Replace custom success/error state management * Remove redundant alert state variables * Consistent user feedback across all components - Improve code quality and function naming conventions - PRESERVE all existing i18n functionality and Chinese translations 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * Fix OIDC errors for "Failed to get user information" * Fix OIDC errors for "Failed to get user information" * Fix spelling error * Migrate everything to alert system, update user.ts for OIDC updates. * Fix OIDC errors for "Failed to get user information" * Fix OIDC errors for "Failed to get user information" * Fix spelling error * Migrate everything to alert system, update user.ts for OIDC updates. * Update env * Fix users.ts and schema for override * Convert web app to Electron desktop application - Add Electron main process with developer tools support - Create preload script for secure context bridge - Configure electron-builder for packaging - Update Vite config for Electron compatibility (base: './') - Add environment variable support for API host configuration - Fix i18n to use relative paths for Electron file protocol - Restore multi-port backend architecture (8081-8085) - Add enhanced backend startup script with port checking - Update package.json with Electron dependencies and build scripts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Complete Electron desktop application implementation - Add backend auto-start functionality in main process - Fix authentication token storage for Electron environment - Implement localStorage-based token management in Electron - Add proper Electron environment detection via preload script - Fix WebSocket connections for terminal functionality - Resolve font file loading issues in packaged application - Update API endpoints to work with backend auto-start - Streamline build scripts with unified electron:package command - Fix better-sqlite3 native module compatibility issues - Ensure all services start automatically in production mode 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove releases folder from git and force Desktop UI. * Improve mobile support with half-baked custom keyboard * Fix API routing * Upgrade mobile keyboard with more keys. * Add cross-platform support and clean up obsolete files - Add electron-packager scripts for Windows, macOS, and Linux - Include universal architecture support for macOS - Add electron:package:all for building all platforms - Remove obsolete start-backend.sh script (replaced by Electron auto-start) - Improve ignore patterns to exclude repo-images folder - Add platform-specific icon configurations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix build system by removing electron-builder dependency - Remove electron-builder and @electron/rebuild packages to resolve build errors - Clean up package.json scripts that depend on electron-builder - Fix merge conflict markers in AdminSettings.tsx and PasswordReset.tsx - All build commands now work correctly: - npm run build (frontend + backend) - npm run build:frontend - npm run build:backend - npm run electron:package (using electron-packager) The build system is now stable and functional without signing requirements. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Mobile UI improvement * Electron dev (#185) * Add comprehensive Chinese internationalization support - Implemented i18n framework with react-i18next for multi-language support - Added Chinese (zh) and English (en) translation files with comprehensive coverage - Localized Admin interface, authentication flows, and error messages - Translated FileManager operations and UI elements - Updated HomepageAuth component with localized authentication messages - Localized LeftSidebar navigation and host management - Added language switcher component (shown after login only) - Configured default language as English with Chinese as secondary option - Localized TOTPSetup two-factor authentication interface - Updated Docker build to include translation files - Achieved 95%+ UI localization coverage across core components Co-Authored-By: Claude <noreply@anthropic.com> * Extend Chinese localization coverage to Host Manager components - Added comprehensive translations for HostManagerHostViewer component - Localized all host management UI text including import/export features - Translated error messages and confirmation dialogs for host operations - Added translations for HostManagerHostEditor validation messages - Localized connection details, organization settings, and form labels - Fixed syntax error in FileManagerOperations component - Achieved near-complete localization of SSH host management interface - Updated placeholders and tooltips for better user guidance Co-Authored-By: Claude <noreply@anthropic.com> * Complete comprehensive Chinese localization for Termix - Added full localization support for Tunnel components (connected/disconnected states, retry messages) - Localized all tunnel status messages and connection errors - Added translations for port forwarding UI elements - Verified Server, TopNavbar, and Tab components already have complete i18n support - Achieved 99%+ localization coverage across entire application - All core UI components now fully support Chinese and English languages This completes the comprehensive internationalization effort for the Termix SSH management platform. Co-Authored-By: Claude <noreply@anthropic.com> * Localize additional Host Manager components and authentication settings - Added translations for all authentication options (Password, Key, SSH Private Key) - Localized form labels in HostManagerHostEditor (Pin Connection, Enable Terminal/Tunnel/FileManager) - Translated Upload/Update Key button states - Localized Host Viewer and Add/Edit Host tab labels - Added Chinese translations for all host management settings - Fixed duplicate translation keys in JSON files Co-Authored-By: Claude <noreply@anthropic.com> * Extend localization coverage to UI components and common strings - Added comprehensive common translations (online/offline, success/error, etc.) - Localized status indicator component with all status states - Updated FileManagerLeftSidebar toast messages for rename/delete operations - Added translations for UI elements (close, toggle sidebar, etc.) - Expanded placeholder translations for form inputs - Added Chinese translations for all new common strings - Improved consistency across component status messages Co-Authored-By: Claude <noreply@anthropic.com> * Complete Chinese localization for remaining UI components - Add comprehensive Chinese translations for Host Manager component - Translate all form labels, buttons, and descriptions - Add translations for SSH configuration warnings and instructions - Localize tunnel connection settings and port forwarding options - Localize SSH Tools panel - Translate key recording functionality - Add translations for settings and configuration options - Translate homepage welcome messages and navigation elements - Add Chinese translations for login success messages - Localize "Updates & Releases" section title - Translate sidebar "Host Manager" button - Fix translation key display issues - Remove duplicate translation keys in both language files - Ensure all components properly reference translation keys - Fix hosts.tunnelConnections key mapping This completes the full Chinese localization of the Termix application, achieving near 100% UI translation coverage while maintaining English as the default language. * Complete final Chinese localization for Host Manager tunnel configuration - Add Chinese translations for authentication UI elements - Translate "Authentication", "Password", and "Key" tab labels - Localize SSH private key and key password fields - Add translations for key type selector - Localize tunnel connection configuration descriptions - Translate retry attempts and retry interval descriptions - Add dynamic tunnel forwarding description with port parameters - Localize endpoint SSH configuration labels - Fix missing translation keys - Add "upload" translation for file upload button - Ensure all FormLabel and FormDescription elements use translation keys This completes the comprehensive Chinese localization of the entire Termix application, achieving 100% UI translation coverage. * Fix PR feedback: Improve Profile section translations and UX - Fixed password reset translations in Profile section - Moved language selector from TopNavbar to Profile page - Added profile.selectPreferredLanguage translation key - Improved user experience for language preferences * Apply critical OIDC and notification system fixes while preserving i18n - Merge OIDC authentication fixes from3877e90: * Enhanced JWKS discovery mechanism with multiple backup URLs * Better support for non-standard OIDC providers (Authentik, etc.) * Improved error handling for "Failed to get user information" - Migrate to unified Sonner toast notification system: * Replace custom success/error state management * Remove redundant alert state variables * Consistent user feedback across all components - Improve code quality and function naming conventions - PRESERVE all existing i18n functionality and Chinese translations 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * Fix OIDC errors for "Failed to get user information" * Fix OIDC errors for "Failed to get user information" * Fix spelling error * Migrate everything to alert system, update user.ts for OIDC updates. * Fix OIDC errors for "Failed to get user information" * Fix OIDC errors for "Failed to get user information" * Fix spelling error * Migrate everything to alert system, update user.ts for OIDC updates. * Update env * Fix users.ts and schema for override * Convert web app to Electron desktop application - Add Electron main process with developer tools support - Create preload script for secure context bridge - Configure electron-builder for packaging - Update Vite config for Electron compatibility (base: './') - Add environment variable support for API host configuration - Fix i18n to use relative paths for Electron file protocol - Restore multi-port backend architecture (8081-8085) - Add enhanced backend startup script with port checking - Update package.json with Electron dependencies and build scripts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Complete Electron desktop application implementation - Add backend auto-start functionality in main process - Fix authentication token storage for Electron environment - Implement localStorage-based token management in Electron - Add proper Electron environment detection via preload script - Fix WebSocket connections for terminal functionality - Resolve font file loading issues in packaged application - Update API endpoints to work with backend auto-start - Streamline build scripts with unified electron:package command - Fix better-sqlite3 native module compatibility issues - Ensure all services start automatically in production mode 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove releases folder from git and force Desktop UI. * Improve mobile support with half-baked custom keyboard * Fix API routing * Upgrade mobile keyboard with more keys. * Add cross-platform support and clean up obsolete files - Add electron-packager scripts for Windows, macOS, and Linux - Include universal architecture support for macOS - Add electron:package:all for building all platforms - Remove obsolete start-backend.sh script (replaced by Electron auto-start) - Improve ignore patterns to exclude repo-images folder - Add platform-specific icon configurations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix build system by removing electron-builder dependency - Remove electron-builder and @electron/rebuild packages to resolve build errors - Clean up package.json scripts that depend on electron-builder - Fix merge conflict markers in AdminSettings.tsx and PasswordReset.tsx - All build commands now work correctly: - npm run build (frontend + backend) - npm run build:frontend - npm run build:backend - npm run electron:package (using electron-packager) The build system is now stable and functional without signing requirements. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> * Add navigation and hardcoded hosts * Update mobile sidebar to use API, add auth and tab system to mobile. * Update sidebar state * Mobile support (#190) * Add vibration to keyboard * Fix keyboard keys * Fix keyboard keys * Fix keyboard keys * Rename files, improve keyboard usability * Improve keyboard view and fix various issues with it * Add mobile chinese translation * Disable OS keyboard from appearing * Fix fit addon not resizing with "more" on keyboard * Disable OS keyboard on terminal load * Merge Luke and Zac * feat: add export option for ssh hosts (#173) (#187) * Update issue templates * feat: add export JSON option for SSH hosts (#173) --------- Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * feat(profile): display version number from .env in profile menu (#182) * feat(profile): display version number from .env in profile menu * Update version checking process --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Add pretier * feat(auth): Add password visibility toggle to auth forms (#166) * added hide and unhide password button * Undo admin settings changes --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Re-added password input * Remove encrpytion, improve logging and merge interfaces. * Improve logging (backend and frontend) and added dedicde OIDC clear * feat: Added option to paste private key (#203) * Improve logging frontend/backend, fix host form being reversed. * Improve logging more, fix credentials sync issues, migrate more to be toasts * Improve logging more, fix credentials sync issues, migrate more to be toasts * More error to toast migration * Remove more inline styles and run npm updates * Update homepage appearing over everything and terminal incorrect bg * Improved server stat generation and UI by caching and supporting more platforms * Update mobile app with the same stat changes and remove rate limiting * Put user profle in its own tab, add code rabbit support * Improve code rabbit yaml * Update chinese translation and fix z indexs causing delay to hide * Bump vite from 7.1.3 to 7.1.5 (#204) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.3 to 7.1.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.1.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.1.5 dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update read me * Update electron builder and fix mobile terminal background * Update logo, move translations, update electron building. * Remove backend from electon, switching to server manager * Add electron server configurator * Fix backend builder on Dockerfile * Fix langauge file for Dockerfile * Fix architecture issues in Dockerfile * Fix architecture issues in Dockerfile * Fix architecture issues in Dockerfile * Fix backend building for docker image * Add electron builder * Fix node starting in entrypoint and remove release from electron build * Remove double packaing in electron build * Fix folder nesting for electron gbuilder * Fix native module docker build (better-sql and bcrypt) * Fix api routes and missing translations and improve reconnection for terminals * Update read me for new installation method * Update CONTRIBUTING.md with color scheme * Fix terrminal not closing afer 3 tries * Fix electronm api routing, fikx ssh not connecting, and OIDC redirect errors * Fix more electron API issues (ssh/oidc), make server manager force API check, and login saving. * Add electron API routes * Fix more electron APi routes and issues * Hide admin settings on electron and fix server manager URl verification * Hide admin settings on electron and fix server manager URl verification * Fix admin setting visiblity on electron * Add links to docs in respective places * Migrate all getCookies to use main-axios. * Migrate all isElectron to use main-axios. * Clean up backend files * Clean up frontend files and read me translations * Run prettier * Fix terminal in web, and update translations and prep for release. * Update API to work on devs and remove random letter * Run prettier * Update read me for release * Update read me for release * Fixed delete issue (ready for release) * Ensure retention days for artifact upload are set --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: starry <115192496+sky22333@users.noreply.github.com> Co-authored-by: ZacharyZcR <PayasoNorahC@protonmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Shivam Kumar <155747305+maishivamhoo123@users.noreply.github.com> Co-authored-by: Abhilash Gandhamalla <150357125+AbhilashG12@users.noreply.github.com> Co-authored-by: jedi04 <78037206+jedi04@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
578
.coderabbit.yaml
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
language: "en"
|
||||||
|
early_access: false
|
||||||
|
reviews:
|
||||||
|
request_changes_workflow: false
|
||||||
|
high_level_summary: true
|
||||||
|
poem: false
|
||||||
|
review_status: true
|
||||||
|
collapse_walkthrough: false
|
||||||
|
path_filters:
|
||||||
|
- "!**/.xml"
|
||||||
|
- "!**/__generated__/**"
|
||||||
|
- "!**/generated/**"
|
||||||
|
- "!**/*.json"
|
||||||
|
- "!**/*.svg"
|
||||||
|
- "!**/*.png"
|
||||||
|
- "!**/*.jpg"
|
||||||
|
- "!**/*.gif"
|
||||||
|
- "!**/*.lock"
|
||||||
|
- "!**/node_modules/**"
|
||||||
|
- "!**/dist/**"
|
||||||
|
- "!**/public/locales/**"
|
||||||
|
- "!**/repo-images/**"
|
||||||
|
path_instructions:
|
||||||
|
- path: "**/*.{ts,tsx}"
|
||||||
|
instructions: |
|
||||||
|
Review TypeScript and React code for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
|
**Architecture & Patterns:**
|
||||||
|
- Follow the established multi-port backend architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
||||||
|
- Use proper separation between Desktop and Mobile UI components
|
||||||
|
- Maintain consistent state management patterns with React hooks and context
|
||||||
|
- Follow the established tab-based navigation system
|
||||||
|
|
||||||
|
**Database & Backend:**
|
||||||
|
- Use Drizzle ORM with SQLite for database operations
|
||||||
|
- Implement proper JWT authentication middleware patterns
|
||||||
|
- Follow the established API error handling patterns in main-axios.ts
|
||||||
|
- Use proper logging with the structured logger system (apiLogger, authLogger, sshLogger, etc.)
|
||||||
|
- Maintain proper input validation and sanitization
|
||||||
|
|
||||||
|
**UI/UX Guidelines:**
|
||||||
|
- Use Shadcn/UI components with Tailwind CSS for consistent styling
|
||||||
|
- Follow the established theme system with dark/light mode support
|
||||||
|
- Use proper responsive design patterns for Desktop/Mobile views
|
||||||
|
- Implement proper loading states and error handling
|
||||||
|
- Use the established confirmation patterns with useConfirmation hook
|
||||||
|
- Use CSS variables and classes from index.css instead of hardcoding colors
|
||||||
|
- Follow the established color token system (--primary, --secondary, --background, etc.)
|
||||||
|
- Use proper Tailwind CSS classes instead of inline styles
|
||||||
|
- Implement proper focus states and accessibility indicators
|
||||||
|
|
||||||
|
**SSH & Security:**
|
||||||
|
- Implement proper SSH connection management with session handling
|
||||||
|
- Use secure credential storage and management patterns
|
||||||
|
- Follow the established authentication flow (password, key, credential-based)
|
||||||
|
- Implement proper file operation security and validation
|
||||||
|
|
||||||
|
**Code Quality:**
|
||||||
|
- Use proper TypeScript types from the centralized types/index.ts
|
||||||
|
- Follow the established API patterns in main-axios.ts
|
||||||
|
- Implement proper error boundaries and fallback UI
|
||||||
|
- Use proper React patterns (hooks, context, refs)
|
||||||
|
- Maintain consistent naming conventions and file organization
|
||||||
|
- All API interactions should go through main-axios.ts functions, not direct axios calls
|
||||||
|
- Use proper component interaction patterns through props and callbacks
|
||||||
|
- Follow the established state management patterns with useState and useEffect
|
||||||
|
- Use proper event handling and form submission patterns
|
||||||
|
|
||||||
|
**Bug Detection & Fixes:**
|
||||||
|
- Identify and fix memory leaks in useEffect cleanup functions
|
||||||
|
- Fix missing dependency arrays in useEffect hooks
|
||||||
|
- Resolve infinite re-render loops caused by object/array dependencies
|
||||||
|
- Fix race conditions in async operations and API calls
|
||||||
|
- Identify and fix potential null/undefined access errors
|
||||||
|
- Fix improper state updates that cause stale closures
|
||||||
|
- Resolve event handler memory leaks and proper cleanup
|
||||||
|
- Fix improper error handling that could crash the application
|
||||||
|
- Identify and fix accessibility issues and keyboard navigation problems
|
||||||
|
- Fix responsive design issues and mobile compatibility problems
|
||||||
|
- Resolve TypeScript type errors and missing type definitions
|
||||||
|
- Fix improper form validation and submission handling
|
||||||
|
- Identify and fix performance issues and unnecessary re-renders
|
||||||
|
- Fix improper API error handling and user feedback
|
||||||
|
- Resolve authentication state inconsistencies and token management issues
|
||||||
|
|
||||||
|
**Internationalization:**
|
||||||
|
- Use the i18next translation system with proper t() function calls
|
||||||
|
- Support both English and Chinese locales
|
||||||
|
- Use proper translation keys and fallbacks
|
||||||
|
|
||||||
|
**Performance:**
|
||||||
|
- Implement proper cleanup in useEffect hooks
|
||||||
|
- Use proper memoization where appropriate
|
||||||
|
- Follow the established polling and refresh patterns
|
||||||
|
- Implement proper connection pooling and resource management
|
||||||
|
|
||||||
|
**Specific to Termix:**
|
||||||
|
- Maintain compatibility with Electron and web versions
|
||||||
|
- Follow the established terminal integration patterns with xterm.js
|
||||||
|
- Use proper file manager operations and SSH session management
|
||||||
|
- Implement proper tunnel management and status tracking
|
||||||
|
- Follow the established alert and notification system patterns
|
||||||
|
|
||||||
|
Highlight any deviations from these patterns and suggest improvements for maintainability, security, and user experience.
|
||||||
|
|
||||||
|
**General Bug Detection & Fixes:**
|
||||||
|
- Identify and fix common React bugs (missing keys, improper state updates, memory leaks)
|
||||||
|
- Fix TypeScript errors and type safety issues
|
||||||
|
- Resolve accessibility violations and keyboard navigation problems
|
||||||
|
- Fix responsive design issues and mobile compatibility problems
|
||||||
|
- Identify and fix performance bottlenecks and unnecessary re-renders
|
||||||
|
- Fix improper error handling that could crash the application
|
||||||
|
- Resolve security vulnerabilities and improper data handling
|
||||||
|
- Fix improper form validation and user input handling
|
||||||
|
- Identify and fix race conditions and async operation issues
|
||||||
|
- Fix improper cleanup and resource management
|
||||||
|
- Resolve improper authentication and authorization issues
|
||||||
|
- Fix improper API error handling and user feedback
|
||||||
|
- Identify and fix potential null/undefined access errors
|
||||||
|
- Fix improper event handling and memory leaks
|
||||||
|
- Resolve improper state management and data flow issues
|
||||||
|
|
||||||
|
- path: "**/backend/**/*.{ts,js}"
|
||||||
|
instructions: |
|
||||||
|
Review backend code for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
|
**Backend Architecture:**
|
||||||
|
- Follow the multi-port microservice architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
||||||
|
- Use Express.js with proper middleware patterns
|
||||||
|
- Implement proper CORS and security headers
|
||||||
|
- Use proper request/response logging with structured logging
|
||||||
|
|
||||||
|
**Database Operations:**
|
||||||
|
- Use Drizzle ORM with proper schema definitions
|
||||||
|
- Implement proper database migrations and schema updates
|
||||||
|
- Use proper transaction handling for critical operations
|
||||||
|
- Follow the established database connection patterns
|
||||||
|
|
||||||
|
**Authentication & Security:**
|
||||||
|
- Implement proper JWT token validation and refresh
|
||||||
|
- Use bcryptjs for password hashing with proper salt rounds
|
||||||
|
- Implement proper input validation and sanitization
|
||||||
|
- Use proper CORS configuration for security
|
||||||
|
- Implement proper rate limiting and security headers
|
||||||
|
|
||||||
|
**SSH Operations:**
|
||||||
|
- Use ssh2 library with proper connection management
|
||||||
|
- Implement proper SSH key handling and validation
|
||||||
|
- Use proper session management and cleanup
|
||||||
|
- Implement proper error handling for SSH operations
|
||||||
|
- Use proper file operation security and validation
|
||||||
|
|
||||||
|
**API Design:**
|
||||||
|
- Follow RESTful API patterns with proper HTTP status codes
|
||||||
|
- Implement proper error response formatting
|
||||||
|
- Use proper request/response validation
|
||||||
|
- Implement proper API versioning and backward compatibility
|
||||||
|
- All API routes should be defined in main-axios.ts, not scattered across components
|
||||||
|
- Use the established multi-port API architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
||||||
|
- Follow the established error handling patterns with handleApiError function
|
||||||
|
- Use proper structured logging with service-specific loggers (apiLogger, authLogger, sshLogger, etc.)
|
||||||
|
|
||||||
|
**Logging & Monitoring:**
|
||||||
|
- Use the structured logging system with proper context
|
||||||
|
- Implement proper error tracking and reporting
|
||||||
|
- Use proper performance monitoring and metrics
|
||||||
|
- Implement proper health checks and status endpoints
|
||||||
|
|
||||||
|
Highlight any security vulnerabilities, performance issues, or architectural deviations.
|
||||||
|
|
||||||
|
- path: "**/components/**/*.{ts,tsx}"
|
||||||
|
instructions: |
|
||||||
|
Review UI components for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
|
**Component Design:**
|
||||||
|
- Use Shadcn/UI components as the foundation
|
||||||
|
- Implement proper component composition and reusability
|
||||||
|
- Use proper TypeScript interfaces and prop types
|
||||||
|
- Follow the established component naming conventions
|
||||||
|
|
||||||
|
**Styling & Theming:**
|
||||||
|
- Use Tailwind CSS with proper responsive design
|
||||||
|
- Implement proper dark/light theme support
|
||||||
|
- Use proper color tokens and design system consistency
|
||||||
|
- Implement proper accessibility features (ARIA labels, keyboard navigation)
|
||||||
|
- Use CSS variables from index.css instead of hardcoding colors (--primary, --secondary, --background, etc.)
|
||||||
|
- Follow the established color scheme and design tokens
|
||||||
|
- Use proper Tailwind CSS utility classes instead of custom CSS
|
||||||
|
- Implement proper focus states and hover effects
|
||||||
|
|
||||||
|
**State Management:**
|
||||||
|
- Use proper React hooks and context patterns
|
||||||
|
- Implement proper state lifting and prop drilling avoidance
|
||||||
|
- Use proper memoization with useMemo and useCallback
|
||||||
|
- Implement proper cleanup in useEffect hooks
|
||||||
|
|
||||||
|
**Form Handling:**
|
||||||
|
- Use react-hook-form with proper validation
|
||||||
|
- Implement proper form state management
|
||||||
|
- Use proper error handling and user feedback
|
||||||
|
- Implement proper accessibility for form elements
|
||||||
|
|
||||||
|
**SSH Integration:**
|
||||||
|
- Implement proper SSH connection status indicators
|
||||||
|
- Use proper terminal integration with xterm.js
|
||||||
|
- Implement proper file manager operations
|
||||||
|
- Use proper tunnel status and management UI
|
||||||
|
|
||||||
|
Highlight any UI/UX issues, accessibility problems, or performance concerns.
|
||||||
|
|
||||||
|
- path: "**/types/**/*.{ts,js}"
|
||||||
|
instructions: |
|
||||||
|
Review type definitions for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
|
**Type Design:**
|
||||||
|
- Use proper TypeScript interfaces and type definitions
|
||||||
|
- Implement proper type safety and validation
|
||||||
|
- Use proper generic types and utility types
|
||||||
|
- Follow the established type naming conventions
|
||||||
|
|
||||||
|
**API Types:**
|
||||||
|
- Define proper request/response types for all API endpoints
|
||||||
|
- Use proper error types and status codes
|
||||||
|
- Implement proper validation types and schemas
|
||||||
|
- Use proper pagination and filtering types
|
||||||
|
|
||||||
|
**SSH Types:**
|
||||||
|
- Define proper SSH connection and configuration types
|
||||||
|
- Use proper tunnel and credential types
|
||||||
|
- Implement proper file operation types
|
||||||
|
- Use proper authentication and security types
|
||||||
|
|
||||||
|
**Type Safety:**
|
||||||
|
- Ensure proper type coverage and completeness
|
||||||
|
- Use proper strict type checking
|
||||||
|
- Implement proper type narrowing and guards
|
||||||
|
- Use proper type assertions and casting
|
||||||
|
|
||||||
|
Highlight any type safety issues, missing types, or type inconsistencies.
|
||||||
|
|
||||||
|
- path: "**/hooks/**/*.{ts,tsx}"
|
||||||
|
instructions: |
|
||||||
|
Review custom hooks for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
|
**Hook Design:**
|
||||||
|
- Use proper React hooks patterns and conventions
|
||||||
|
- Implement proper hook composition and reusability
|
||||||
|
- Use proper TypeScript types for hook parameters and return values
|
||||||
|
- Follow the established hook naming conventions
|
||||||
|
|
||||||
|
**State Management:**
|
||||||
|
- Implement proper state management with useState and useReducer
|
||||||
|
- Use proper context and provider patterns
|
||||||
|
- Implement proper state persistence and synchronization
|
||||||
|
- Use proper state cleanup and memory management
|
||||||
|
|
||||||
|
**Side Effects:**
|
||||||
|
- Use proper useEffect patterns with proper dependencies
|
||||||
|
- Implement proper cleanup functions and resource management
|
||||||
|
- Use proper async operations and error handling
|
||||||
|
- Implement proper polling and refresh patterns
|
||||||
|
|
||||||
|
**Performance:**
|
||||||
|
- Use proper memoization with useMemo and useCallback
|
||||||
|
- Implement proper debouncing and throttling
|
||||||
|
- Use proper lazy loading and code splitting
|
||||||
|
- Implement proper optimization patterns
|
||||||
|
|
||||||
|
**SSH Integration:**
|
||||||
|
- Implement proper SSH connection management hooks
|
||||||
|
- Use proper terminal integration hooks
|
||||||
|
- Implement proper file manager operation hooks
|
||||||
|
- Use proper tunnel management hooks
|
||||||
|
|
||||||
|
**Hook-Specific Bug Detection:**
|
||||||
|
- Fix missing cleanup functions in useEffect hooks that cause memory leaks
|
||||||
|
- Resolve infinite loops caused by incorrect dependency arrays
|
||||||
|
- Fix stale closure issues in event handlers and async operations
|
||||||
|
- Identify and fix improper state updates that cause unnecessary re-renders
|
||||||
|
- Fix race conditions in async hooks and API calls
|
||||||
|
- Resolve improper ref usage and null reference errors
|
||||||
|
- Fix improper context usage and provider nesting issues
|
||||||
|
- Identify and fix custom hook dependency issues
|
||||||
|
- Resolve improper memoization that causes stale data
|
||||||
|
- Fix improper error handling in custom hooks
|
||||||
|
|
||||||
|
Highlight any hook design issues, performance problems, or reusability concerns.
|
||||||
|
|
||||||
|
- path: "**/lib/**/*.{ts,js}"
|
||||||
|
instructions: |
|
||||||
|
Review utility libraries and helper functions for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
|
**Utility Functions:**
|
||||||
|
- Implement proper utility functions with clear purposes
|
||||||
|
- Use proper TypeScript types and JSDoc documentation
|
||||||
|
- Implement proper error handling and validation
|
||||||
|
- Follow the established utility naming conventions
|
||||||
|
|
||||||
|
**Logging System:**
|
||||||
|
- Use proper structured logging with context and metadata
|
||||||
|
- Implement proper log levels and filtering
|
||||||
|
- Use proper log formatting and output
|
||||||
|
- Implement proper log rotation and cleanup
|
||||||
|
|
||||||
|
**API Utilities:**
|
||||||
|
- Implement proper API client configuration and management
|
||||||
|
- Use proper request/response interceptors
|
||||||
|
- Implement proper error handling and retry logic
|
||||||
|
- Use proper authentication and authorization handling
|
||||||
|
- All API functions should be centralized in main-axios.ts
|
||||||
|
- Use proper service-specific API instances (sshHostApi, tunnelApi, fileManagerApi, statsApi, authApi)
|
||||||
|
- Follow the established error handling patterns with handleApiError function
|
||||||
|
- Use proper structured logging with service-specific loggers
|
||||||
|
|
||||||
|
**Security Utilities:**
|
||||||
|
- Implement proper input validation and sanitization
|
||||||
|
- Use proper encryption and decryption functions
|
||||||
|
- Implement proper secure random generation
|
||||||
|
- Use proper security headers and CORS handling
|
||||||
|
|
||||||
|
**SSH Utilities:**
|
||||||
|
- Implement proper SSH connection utilities
|
||||||
|
- Use proper SSH key handling and validation
|
||||||
|
- Implement proper SSH command execution
|
||||||
|
- Use proper SSH file operation utilities
|
||||||
|
|
||||||
|
**Utility Bug Detection:**
|
||||||
|
- Fix improper error handling in utility functions that could crash the application
|
||||||
|
- Resolve null/undefined access errors in utility functions
|
||||||
|
- Fix improper input validation that could cause security vulnerabilities
|
||||||
|
- Identify and fix memory leaks in utility functions
|
||||||
|
- Fix improper async/await usage and promise handling
|
||||||
|
- Resolve improper type checking and validation errors
|
||||||
|
- Fix improper logging that could expose sensitive information
|
||||||
|
- Identify and fix performance bottlenecks in utility functions
|
||||||
|
- Fix improper data transformation and serialization issues
|
||||||
|
- Resolve improper configuration and environment variable handling
|
||||||
|
|
||||||
|
Highlight any utility design issues, performance problems, or security concerns.
|
||||||
|
|
||||||
|
- path: "**/main-axios.ts"
|
||||||
|
instructions: |
|
||||||
|
Review main-axios.ts API client configuration for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
|
**API Client Architecture:**
|
||||||
|
- Maintain the multi-port API architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
||||||
|
- Use proper service-specific API instances (sshHostApi, tunnelApi, fileManagerApi, statsApi, authApi)
|
||||||
|
- Implement proper API instance creation with createApiInstance function
|
||||||
|
- Use proper base URL configuration for different environments (dev, production, Electron)
|
||||||
|
|
||||||
|
**Error Handling:**
|
||||||
|
- Use the centralized handleApiError function for consistent error handling
|
||||||
|
- Implement proper error classification (auth, network, validation, server errors)
|
||||||
|
- Use proper error logging with service-specific loggers
|
||||||
|
- Implement proper error response formatting and user-friendly messages
|
||||||
|
|
||||||
|
**Request/Response Interceptors:**
|
||||||
|
- Implement proper JWT token handling in request interceptors
|
||||||
|
- Use proper request timing and performance logging
|
||||||
|
- Implement proper response logging and error tracking
|
||||||
|
- Use proper authentication token refresh and cleanup
|
||||||
|
|
||||||
|
**API Function Organization:**
|
||||||
|
- Group API functions by service (SSH Host Management, Tunnel Management, File Manager, etc.)
|
||||||
|
- Use proper TypeScript types for all API functions
|
||||||
|
- Implement proper parameter validation and sanitization
|
||||||
|
- Use proper return type definitions and error handling
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
- Implement proper JWT token management and refresh
|
||||||
|
- Use proper cookie handling for web and Electron environments
|
||||||
|
- Implement proper authentication state management
|
||||||
|
- Use proper token expiration and cleanup
|
||||||
|
|
||||||
|
**Logging:**
|
||||||
|
- Use proper structured logging with context and metadata
|
||||||
|
- Implement proper request/response logging with performance metrics
|
||||||
|
- Use proper error logging with appropriate log levels
|
||||||
|
- Implement proper service-specific logger selection
|
||||||
|
|
||||||
|
**Performance:**
|
||||||
|
- Implement proper request timeout and retry logic
|
||||||
|
- Use proper connection pooling and resource management
|
||||||
|
- Implement proper request deduplication and caching
|
||||||
|
- Use proper performance monitoring and metrics
|
||||||
|
|
||||||
|
**Security:**
|
||||||
|
- Implement proper input validation and sanitization
|
||||||
|
- Use proper CORS and security header handling
|
||||||
|
- Implement proper authentication and authorization
|
||||||
|
- Use proper secure communication and data handling
|
||||||
|
|
||||||
|
**API Bug Detection:**
|
||||||
|
- Fix improper error handling that could expose sensitive information
|
||||||
|
- Resolve race conditions in concurrent API calls
|
||||||
|
- Fix improper token management and authentication state issues
|
||||||
|
- Identify and fix memory leaks in API interceptors
|
||||||
|
- Fix improper request/response validation that could cause crashes
|
||||||
|
- Resolve improper timeout handling and retry logic
|
||||||
|
- Fix improper error response formatting and user feedback
|
||||||
|
- Identify and fix performance issues in API calls
|
||||||
|
- Fix improper request deduplication and caching issues
|
||||||
|
- Resolve improper authentication token refresh and cleanup
|
||||||
|
- Fix improper CORS and security header configuration
|
||||||
|
- Identify and fix potential security vulnerabilities in API handling
|
||||||
|
|
||||||
|
Highlight any API design issues, error handling problems, or security concerns.
|
||||||
|
|
||||||
|
- path: "**/electron/**/*.{ts,js,cjs}"
|
||||||
|
instructions: |
|
||||||
|
Review Electron application code for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
|
**Electron Architecture:**
|
||||||
|
- Use proper Electron main and renderer process separation
|
||||||
|
- Implement proper IPC (Inter-Process Communication) patterns
|
||||||
|
- Use proper security and sandboxing configurations
|
||||||
|
- Follow the established Electron best practices
|
||||||
|
|
||||||
|
**Security:**
|
||||||
|
- Implement proper security policies and configurations
|
||||||
|
- Use proper context isolation and node integration
|
||||||
|
- Implement proper CSP and security headers
|
||||||
|
- Use proper authentication and authorization handling
|
||||||
|
|
||||||
|
**Performance:**
|
||||||
|
- Implement proper memory management and cleanup
|
||||||
|
- Use proper resource optimization and caching
|
||||||
|
- Implement proper background processing and threading
|
||||||
|
- Use proper performance monitoring and profiling
|
||||||
|
|
||||||
|
**Electron Bug Detection:**
|
||||||
|
- Fix improper IPC communication that could cause crashes
|
||||||
|
- Resolve memory leaks in Electron main and renderer processes
|
||||||
|
- Fix improper window management and lifecycle issues
|
||||||
|
- Identify and fix security vulnerabilities in Electron configuration
|
||||||
|
- Fix improper context isolation and node integration issues
|
||||||
|
- Resolve improper event handling and cleanup in Electron
|
||||||
|
- Fix improper file system access and permission issues
|
||||||
|
- Identify and fix performance issues in Electron processes
|
||||||
|
- Fix improper auto-updater and version management
|
||||||
|
- Resolve improper tray and menu functionality issues
|
||||||
|
- Fix improper security policies and CSP configuration
|
||||||
|
- Identify and fix potential security vulnerabilities in Electron setup
|
||||||
|
|
||||||
|
Highlight any Electron-specific issues, security vulnerabilities, or performance problems.
|
||||||
|
|
||||||
|
- path: "**/docker/**/*"
|
||||||
|
instructions: |
|
||||||
|
Review Docker configuration files for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
|
**Dockerfile Design:**
|
||||||
|
- Use proper multi-stage builds for optimization
|
||||||
|
- Implement proper layer caching and optimization
|
||||||
|
- Use proper security and minimal base images
|
||||||
|
- Follow the established Docker best practices
|
||||||
|
|
||||||
|
**Security:**
|
||||||
|
- Implement proper user and permission management
|
||||||
|
- Use proper security scanning and vulnerability assessment
|
||||||
|
- Implement proper secrets and credential management
|
||||||
|
- Use proper network security and isolation
|
||||||
|
|
||||||
|
**Performance:**
|
||||||
|
- Implement proper resource optimization and allocation
|
||||||
|
- Use proper caching and build optimization
|
||||||
|
- Implement proper monitoring and logging
|
||||||
|
- Use proper health checks and status monitoring
|
||||||
|
|
||||||
|
**Docker Bug Detection:**
|
||||||
|
- Fix improper multi-stage build optimization that causes large images
|
||||||
|
- Resolve security vulnerabilities in base images and dependencies
|
||||||
|
- Fix improper volume and data persistence configuration
|
||||||
|
- Identify and fix resource limit and constraint issues
|
||||||
|
- Fix improper networking and port configuration
|
||||||
|
- Resolve improper environment variable and secret management
|
||||||
|
- Fix improper health check and status monitoring configuration
|
||||||
|
- Identify and fix performance issues in container startup
|
||||||
|
- Fix improper logging and monitoring configuration
|
||||||
|
- Resolve improper backup and recovery procedures
|
||||||
|
- Fix improper scaling and load balancing configuration
|
||||||
|
- Identify and fix potential security vulnerabilities in Docker setup
|
||||||
|
|
||||||
|
Highlight any Docker configuration issues, security vulnerabilities, or performance problems.
|
||||||
|
|
||||||
|
- path: "**/*.md"
|
||||||
|
instructions: |
|
||||||
|
Review documentation files for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
|
**Documentation Quality:**
|
||||||
|
- Ensure proper grammar, spelling, and clarity
|
||||||
|
- Use proper formatting and structure
|
||||||
|
- Implement proper code examples and snippets
|
||||||
|
- Follow the established documentation standards
|
||||||
|
|
||||||
|
**Content Accuracy:**
|
||||||
|
- Ensure proper technical accuracy and completeness
|
||||||
|
- Use proper up-to-date information and examples
|
||||||
|
- Implement proper cross-references and links
|
||||||
|
- Use proper version and compatibility information
|
||||||
|
|
||||||
|
**User Experience:**
|
||||||
|
- Ensure proper user-friendly language and explanations
|
||||||
|
- Use proper step-by-step instructions and guides
|
||||||
|
- Implement proper troubleshooting and FAQ sections
|
||||||
|
- Use proper visual aids and diagrams where appropriate
|
||||||
|
|
||||||
|
Highlight any documentation issues, inaccuracies, or missing information.
|
||||||
|
|
||||||
|
- path: "**/index.css"
|
||||||
|
instructions: |
|
||||||
|
Review index.css styling configuration for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
|
**CSS Variable System:**
|
||||||
|
- Define proper CSS custom properties for colors, spacing, and typography
|
||||||
|
- Use consistent naming conventions for CSS variables (--primary, --secondary, --background, etc.)
|
||||||
|
- Implement proper dark/light theme variable definitions
|
||||||
|
- Use proper semantic color naming (--destructive, --muted, --accent, etc.)
|
||||||
|
|
||||||
|
**Design System:**
|
||||||
|
- Follow the established design token system
|
||||||
|
- Use proper color palette definitions with proper contrast ratios
|
||||||
|
- Implement proper typography scale and font family definitions
|
||||||
|
- Use proper spacing and sizing scale definitions
|
||||||
|
|
||||||
|
**Theme Support:**
|
||||||
|
- Implement proper dark and light theme variable definitions
|
||||||
|
- Use proper CSS custom property fallbacks
|
||||||
|
- Implement proper theme switching support
|
||||||
|
- Use proper color scheme media queries
|
||||||
|
|
||||||
|
**Component Styling:**
|
||||||
|
- Define proper base styles for common components
|
||||||
|
- Use proper utility classes and helper styles
|
||||||
|
- Implement proper responsive design utilities
|
||||||
|
- Use proper accessibility-focused styling
|
||||||
|
|
||||||
|
**Color Management:**
|
||||||
|
- Avoid hardcoded color values, use CSS variables instead
|
||||||
|
- Implement proper color contrast and accessibility
|
||||||
|
- Use proper semantic color definitions
|
||||||
|
- Implement proper color state variations (hover, focus, active)
|
||||||
|
|
||||||
|
**Typography:**
|
||||||
|
- Define proper font family and weight definitions
|
||||||
|
- Use proper line height and letter spacing
|
||||||
|
- Implement proper text size and hierarchy
|
||||||
|
- Use proper font loading and fallback strategies
|
||||||
|
|
||||||
|
**Layout Utilities:**
|
||||||
|
- Define proper spacing and margin utilities
|
||||||
|
- Use proper flexbox and grid utilities
|
||||||
|
- Implement proper responsive breakpoint utilities
|
||||||
|
- Use proper container and layout helpers
|
||||||
|
|
||||||
|
**Accessibility:**
|
||||||
|
- Implement proper focus indicators and states
|
||||||
|
- Use proper color contrast ratios
|
||||||
|
- Implement proper reduced motion support
|
||||||
|
- Use proper screen reader friendly styling
|
||||||
|
|
||||||
|
**Performance:**
|
||||||
|
- Use efficient CSS selectors and properties
|
||||||
|
- Implement proper CSS organization and structure
|
||||||
|
- Use proper CSS custom property optimization
|
||||||
|
- Implement proper critical CSS and loading strategies
|
||||||
|
|
||||||
|
Highlight any styling issues, accessibility problems, or design system inconsistencies.
|
||||||
|
auto_review:
|
||||||
|
enabled: true
|
||||||
|
ignore_title_keywords:
|
||||||
|
- "WIP"
|
||||||
|
- "DO NOT MERGE"
|
||||||
|
- "DRAFT"
|
||||||
|
- "EXPERIMENTAL"
|
||||||
|
- "TEST"
|
||||||
|
drafts: false
|
||||||
|
chat:
|
||||||
|
auto_reply: true
|
||||||
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help Termix improve
|
||||||
|
title: "[BUG]"
|
||||||
|
labels: bug
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots/Logs**
|
||||||
|
If applicable, add screenshots or console/Docker logs to help explain your problem.
|
||||||
|
|
||||||
|
**Environment (please complete the following information):**
|
||||||
|
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 1.6.0]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for Termix
|
||||||
|
title: "[FEATURE]"
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
4
.github/workflows/docker-image.yml
vendored
@@ -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:
|
||||||
|
|||||||
6
.github/workflows/electron-build.yml
vendored
@@ -88,11 +88,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Create Linux Portable zip
|
- name: Create Linux Portable zip
|
||||||
run: |
|
run: |
|
||||||
zip -r Termix-Linux-Portable.zip release/linux-unpacked/*
|
cd release/linux-unpacked
|
||||||
|
zip -r ../../Termix-Linux-Portable.zip *
|
||||||
|
cd ../..
|
||||||
|
|
||||||
- name: Upload Linux Portable Artifact
|
- name: Upload Linux Portable Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: Termix-Linux-Portable
|
name: Termix-Linux-Portable
|
||||||
path: Termix-Linux-Portable.zip
|
path: Termix-Linux-Portable.zip
|
||||||
retention-days: 3
|
retention-days: 30
|
||||||
|
|||||||
2
.gitignore
vendored
@@ -23,3 +23,5 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
/db/
|
/db/
|
||||||
|
/release/
|
||||||
|
/.claude/
|
||||||
|
|||||||
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Ignore artifacts:
|
||||||
|
build
|
||||||
|
coverage
|
||||||
1
.prettierrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Contributing
|
\_# Contributing
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -9,13 +9,13 @@
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/LukeGus/Termix
|
git clone https://github.com/LukeGus/Termix
|
||||||
```
|
```
|
||||||
2. Install the dependencies:
|
2. Install the dependencies:
|
||||||
```sh
|
```sh
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running the development server
|
## Running the development server
|
||||||
|
|
||||||
@@ -23,10 +23,10 @@ Run the following commands:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run dev
|
npm run dev
|
||||||
npx tsc -p tsconfig.node.json
|
npm run dev:backend
|
||||||
node ./dist/backend/starter.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
a
|
||||||
This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
|
This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
@@ -34,23 +34,74 @@ This will start the backend and the frontend Vite server. You can access Termix
|
|||||||
1. **Fork the repository**: Click the "Fork" button at the top right of
|
1. **Fork the repository**: Click the "Fork" button at the top right of
|
||||||
the [repository page](https://github.com/LukeGus/Termix).
|
the [repository page](https://github.com/LukeGus/Termix).
|
||||||
2. **Create a new branch**:
|
2. **Create a new branch**:
|
||||||
```sh
|
```sh
|
||||||
git checkout -b feature/my-new-feature
|
git checkout -b feature/my-new-feature
|
||||||
```
|
```
|
||||||
3. **Make your changes**: Implement your feature, fix, or improvement.
|
3. **Make your changes**: Implement your feature, fix, or improvement.
|
||||||
4. **Commit your changes**:
|
4. **Commit your changes**:
|
||||||
```sh
|
```sh
|
||||||
git commit -m "Add feature: my new feature"
|
git commit -m "Feature request my new feature"
|
||||||
```
|
```
|
||||||
5. **Push to your fork**:
|
5. **Push to your fork**:
|
||||||
```sh
|
```sh
|
||||||
git push origin feature/my-new-feature
|
git push origin feature/my-feature-request
|
||||||
```
|
```
|
||||||
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
|
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
|
||||||
|
|
||||||
## 📝 Guidelines
|
## 📝 Guidelines
|
||||||
|
|
||||||
- Follow the existing code style. Use Tailwind CSS with shadcn components.
|
- Follow the existing code style. Use Tailwind CSS with shadcn components.
|
||||||
|
- Use the below color scheme with the respective CSS variable placed in the `className` of a div/component.
|
||||||
- Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded.
|
- Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded.
|
||||||
- Include meaningful commit messages.
|
- Include meaningful commit messages.
|
||||||
- Link related issues when applicable.
|
- Link related issues when applicable.
|
||||||
|
- `MobileApp.tsx` renders when the users screen width is less than 768px, otherwise it loads the usual `DesktopApp.tsx`.
|
||||||
|
|
||||||
|
## Color Scheme
|
||||||
|
|
||||||
|
### Background Colors
|
||||||
|
|
||||||
|
| CSS Variable | Color Value | Usage | Description |
|
||||||
|
| ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
|
||||||
|
| `--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-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
|
||||||
|
| `--color-dark-bg-light` | `#141416` | Light dark backgrounds | Lighter variant of dark background |
|
||||||
|
| `--color-dark-bg-very-light` | `#101014` | Very light dark backgrounds | Very light variant of dark background |
|
||||||
|
| `--color-dark-bg-panel` | `#1b1b1e` | Panel backgrounds | Background for panels and cards |
|
||||||
|
| `--color-dark-bg-panel-hover` | `#232327` | Panel hover states | Background for panels on hover |
|
||||||
|
|
||||||
|
### Element-Specific Backgrounds
|
||||||
|
|
||||||
|
| CSS Variable | Color Value | Usage | Description |
|
||||||
|
| ------------------------ | ----------- | ------------------ | --------------------------------------------- |
|
||||||
|
| `--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-active` | `#1d1d1f` | Active states | Background for active/selected elements |
|
||||||
|
| `--color-dark-bg-header` | `#131316` | Header backgrounds | Background for headers and navigation bars |
|
||||||
|
|
||||||
|
### Border Colors
|
||||||
|
|
||||||
|
| CSS Variable | Color Value | Usage | Description |
|
||||||
|
| ---------------------------- | ----------- | --------------- | ---------------------------------------- |
|
||||||
|
| `--color-dark-border` | `#303032` | Default borders | Standard border color |
|
||||||
|
| `--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-light` | `#5a5a5d` | Light borders | Lighter border color for subtle elements |
|
||||||
|
| `--color-dark-border-medium` | `#373739` | Medium borders | Medium weight border color |
|
||||||
|
| `--color-dark-border-panel` | `#222224` | Panel borders | Border color for panels and cards |
|
||||||
|
|
||||||
|
### Interactive States
|
||||||
|
|
||||||
|
| CSS Variable | Color Value | Usage | Description |
|
||||||
|
| ------------------------ | ----------- | ----------------- | --------------------------------------------- |
|
||||||
|
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
|
||||||
|
| `--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-hover-alt` | `#2a2a2d` | Alternative hover | Alternative hover state color |
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||||
|
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||||
|
repo.
|
||||||
|
|||||||
109
README-CN.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# 仓库统计
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> 英文</a> |
|
||||||
|
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文
|
||||||
|
</p>
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||||
|
|
||||||
|
#### 核心技术
|
||||||
|
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
[](#)
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/LukeGus/Termix">
|
||||||
|
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
如果你愿意,可以在这里支持这个项目!\
|
||||||
|
[](https://github.com/sponsors/LukeGus)
|
||||||
|
|
||||||
|
# 概览
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/LukeGus/Termix">
|
||||||
|
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix
|
||||||
|
提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。
|
||||||
|
|
||||||
|
# 功能
|
||||||
|
|
||||||
|
- **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统
|
||||||
|
- **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重连和健康监控
|
||||||
|
- **远程文件编辑器** - 直接在远程服务器编辑文件,支持语法高亮和文件管理功能(上传、删除、重命名等)
|
||||||
|
- **SSH 主机管理器** - 保存、组织和管理 SSH 连接,支持标签和文件夹
|
||||||
|
- **服务器统计** - 查看任意 SSH 服务器的 CPU、内存和硬盘使用情况
|
||||||
|
- **用户认证** - 安全的用户管理,支持管理员控制、OIDC 和双因素认证(TOTP)
|
||||||
|
- **现代化界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁界面
|
||||||
|
- **语言支持** - 内置中英文支持
|
||||||
|
|
||||||
|
# 计划功能
|
||||||
|
|
||||||
|
- **增强管理员控制** - 提供更精细的用户和管理员权限控制、共享主机等功能
|
||||||
|
- **主题定制** - 修改所有工具的主题风格
|
||||||
|
- **增强终端支持** - 添加更多终端协议,如 VNC 和 RDP(有类似 Apache Guacamole 的 RDP 集成经验者请通过创建 issue 联系我)
|
||||||
|
- **移动端支持** - 支持移动应用或 Termix 网站移动版,让你在手机上管理服务器
|
||||||
|
|
||||||
|
# 安装
|
||||||
|
|
||||||
|
访问 Termix [文档](https://docs.termix.site/install) 获取安装信息。或者可以参考以下示例 docker-compose 文件:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
termix:
|
||||||
|
image: ghcr.io/lukegus/termix:latest
|
||||||
|
container_name: termix
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- termix-data:/app/data
|
||||||
|
environment:
|
||||||
|
PORT: "8080"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
termix-data:
|
||||||
|
driver: local
|
||||||
|
```
|
||||||
|
|
||||||
|
# 支持
|
||||||
|
|
||||||
|
如果你需要 Termix 的帮助,可以加入 [Discord](https://discord.gg/jVQGdvHDrf)
|
||||||
|
服务器并访问支持频道。你也可以在 [GitHub](https://github.com/LukeGus/Termix/issues) 仓库提交 issue 或 pull request。
|
||||||
|
|
||||||
|
# 展示
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
<img src="./repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./repo-images/Image 3.png" width="250" alt="Termix Demo 3"/>
|
||||||
|
<img src="./repo-images/Image 4.png" width="250" alt="Termix Demo 4"/>
|
||||||
|
<img src="./repo-images/Image 5.png" width="250" alt="Termix Demo 5"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
|
||||||
|
你的浏览器不支持 video 标签。
|
||||||
|
</video>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# 许可证
|
||||||
|
|
||||||
|
根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。
|
||||||
42
README.md
@@ -1,9 +1,17 @@
|
|||||||
# Repo Stats
|
# Repo Stats
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English |
|
||||||
|
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a>
|
||||||
|
</p>
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||||
|
|
||||||
#### Top Technologies
|
#### Top Technologies
|
||||||
|
|
||||||
[](#)
|
[](#)
|
||||||
[](#)
|
[](#)
|
||||||
[](#)
|
[](#)
|
||||||
@@ -29,26 +37,34 @@ If you would like, you can support the project here!\
|
|||||||
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, SSH tunneling capabilities, and remote file editing, with many more tools to come.
|
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based
|
||||||
|
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
|
||||||
|
access, SSH tunneling capabilities, and remote file editing, with many more tools to come.
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
|
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
|
||||||
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
|
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
|
||||||
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (uploading, removing, renaming, deleting files)
|
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (
|
||||||
|
uploading, removing, renaming, deleting files)
|
||||||
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders
|
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders
|
||||||
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
|
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
|
||||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
|
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
|
||||||
- **Modern UI** - Clean interface built with React, Tailwind CSS, and Shadcn
|
- **Modern UI** - Clean desktop/mobile friendly (in progress) interface built with React, Tailwind CSS, and Shadcn
|
||||||
- **Languages** - Built-in support for English and Chinese
|
- **Languages** - Built-in support for English and Chinese
|
||||||
|
- **Improved Platform Support** - Now includes an installable Electron app (in progress) for desktop, with a dedicated
|
||||||
|
mobile app also planned.
|
||||||
|
|
||||||
# Planned Features
|
# Planned Features
|
||||||
- **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc
|
|
||||||
- **Theming** - Modify theming for all tools
|
See [Projects](https://github.com/users/LukeGus/projects/3). If you are looking to contribute,
|
||||||
- **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue)
|
see [Contributing](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md),
|
||||||
- **Mobile Support** - Support a mobile app or version of the Termix website to manage servers from your phone
|
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
|
|
||||||
|
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix. Otherwise, view
|
||||||
|
a sample docker-compose file here:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
termix:
|
termix:
|
||||||
@@ -67,8 +83,15 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Pre-built binaries are now available for download, including a Windows installer/portable app and a Linux portable app (
|
||||||
|
built with Electron). See [Docs](http://localhost:5174/install#pre-built-binaries) for details. A native iOS/Android app
|
||||||
|
is planned.
|
||||||
|
|
||||||
# Support
|
# Support
|
||||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo.
|
|
||||||
|
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||||
|
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||||
|
repo.
|
||||||
|
|
||||||
# Show-off
|
# Show-off
|
||||||
|
|
||||||
@@ -90,4 +113,5 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
Distributed under the Apache License Version 2.0. See LICENSE for more information.
|
Distributed under the Apache License Version 2.0. See LICENSE for more information.
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ RUN apk add --no-cache python3 make g++
|
|||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN npm ci --force && \
|
ENV npm_config_target_platform=linux
|
||||||
|
ENV npm_config_target_arch=x64
|
||||||
|
ENV npm_config_target_libc=glibc
|
||||||
|
|
||||||
|
RUN npm ci --force --ignore-scripts && \
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 2: Build frontend
|
# Stage 2: Build frontend
|
||||||
@@ -23,6 +27,12 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
ENV npm_config_target_platform=linux
|
||||||
|
ENV npm_config_target_arch=x64
|
||||||
|
ENV npm_config_target_libc=glibc
|
||||||
|
|
||||||
|
RUN npm rebuild better-sqlite3 --force
|
||||||
|
|
||||||
RUN npm run build:backend
|
RUN npm run build:backend
|
||||||
|
|
||||||
# Stage 4: Production dependencies
|
# Stage 4: Production dependencies
|
||||||
@@ -31,6 +41,10 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
ENV npm_config_target_platform=linux
|
||||||
|
ENV npm_config_target_arch=x64
|
||||||
|
ENV npm_config_target_libc=glibc
|
||||||
|
|
||||||
RUN npm ci --only=production --ignore-scripts --force && \
|
RUN npm ci --only=production --ignore-scripts --force && \
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
@@ -42,7 +56,13 @@ RUN apk add --no-cache python3 make g++
|
|||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
|
ENV npm_config_target_platform=linux
|
||||||
|
ENV npm_config_target_arch=x64
|
||||||
|
ENV npm_config_target_libc=glibc
|
||||||
|
|
||||||
|
# Install native modules and compile them properly
|
||||||
|
RUN npm ci --only=production --force && \
|
||||||
|
npm rebuild better-sqlite3 bcryptjs --force && \
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 6: Final image
|
# Stage 6: Final image
|
||||||
@@ -57,14 +77,12 @@ RUN apk add --no-cache nginx gettext su-exec && \
|
|||||||
|
|
||||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
|
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
|
||||||
COPY --from=frontend-builder /app/public/locales /usr/share/nginx/html/locales
|
COPY --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales
|
||||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
RUN chown -R nginx:nginx /usr/share/nginx/html
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=production-deps /app/node_modules /app/node_modules
|
COPY --from=native-builder /app/node_modules /app/node_modules
|
||||||
COPY --from=native-builder /app/node_modules/bcryptjs /app/node_modules/bcryptjs
|
|
||||||
COPY --from=native-builder /app/node_modules/better-sqlite3 /app/node_modules/better-sqlite3
|
|
||||||
COPY --from=backend-builder /app/dist/backend ./dist/backend
|
COPY --from=backend-builder /app/dist/backend ./dist/backend
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ cd /app
|
|||||||
export NODE_ENV=production
|
export NODE_ENV=production
|
||||||
|
|
||||||
if command -v su-exec > /dev/null 2>&1; then
|
if command -v su-exec > /dev/null 2>&1; then
|
||||||
su-exec node node dist/backend/starter.js
|
su-exec node node dist/backend/backend/starter.js
|
||||||
else
|
else
|
||||||
su -s /bin/sh node -c "node dist/backend/starter.js"
|
su -s /bin/sh node -c "node dist/backend/backend/starter.js"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "All services started"
|
echo "All services started"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ http {
|
|||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /users/ {
|
location ~ ^/users(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -27,7 +27,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /version/ {
|
location ~ ^/version(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -36,7 +36,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /releases/ {
|
location ~ ^/releases(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -45,7 +45,16 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /alerts/ {
|
location ~ ^/alerts(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/credentials(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -129,7 +138,16 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /status/ {
|
location /health {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/status(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8085;
|
proxy_pass http://127.0.0.1:8085;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -138,7 +156,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /metrics/ {
|
location ~ ^/metrics(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:8085;
|
proxy_pass http://127.0.0.1:8085;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
44
electron-builder.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"appId": "com.termix.app",
|
||||||
|
"productName": "Termix",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"electron/**/*",
|
||||||
|
"public/**/*",
|
||||||
|
"!**/node_modules/**/*",
|
||||||
|
"!src/**/*",
|
||||||
|
"!*.md",
|
||||||
|
"!tsconfig*.json",
|
||||||
|
"!vite.config.ts",
|
||||||
|
"!eslint.config.js"
|
||||||
|
],
|
||||||
|
"asarUnpack": ["node_modules/node-fetch/**/*"],
|
||||||
|
"extraMetadata": {
|
||||||
|
"main": "electron/main.cjs"
|
||||||
|
},
|
||||||
|
"buildDependenciesFromSource": false,
|
||||||
|
"nodeGypRebuild": false,
|
||||||
|
"npmRebuild": false,
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"icon": "public/icon.ico",
|
||||||
|
"executableName": "Termix"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"artifactName": "${productName}-Setup-${version}.${ext}",
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true,
|
||||||
|
"shortcutName": "Termix",
|
||||||
|
"uninstallDisplayName": "Termix"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage",
|
||||||
|
"icon": "public/icon.png",
|
||||||
|
"category": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
334
electron/main.cjs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
const { app, BrowserWindow, shell, ipcMain } = require("electron");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
let mainWindow = null;
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||||
|
|
||||||
|
const gotTheLock = app.requestSingleInstanceLock();
|
||||||
|
if (!gotTheLock) {
|
||||||
|
console.log("Another instance is already running, quitting...");
|
||||||
|
app.quit();
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
app.on("second-instance", (event, commandLine, workingDirectory) => {
|
||||||
|
console.log("Second instance detected, focusing existing window...");
|
||||||
|
if (mainWindow) {
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.focus();
|
||||||
|
mainWindow.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
title: "Termix",
|
||||||
|
icon: isDev
|
||||||
|
? path.join(__dirname, "..", "public", "icon.png")
|
||||||
|
: path.join(process.resourcesPath, "public", "icon.png"),
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
webSecurity: !isDev,
|
||||||
|
preload: path.join(__dirname, "preload.js"),
|
||||||
|
},
|
||||||
|
show: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
mainWindow.setMenuBarVisibility(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
mainWindow.loadURL("http://localhost:5173");
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
} else {
|
||||||
|
const indexPath = path.join(__dirname, "..", "dist", "index.html");
|
||||||
|
console.log("Loading frontend from:", indexPath);
|
||||||
|
mainWindow.loadFile(indexPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.once("ready-to-show", () => {
|
||||||
|
console.log("Window ready to show");
|
||||||
|
mainWindow.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on(
|
||||||
|
"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.on("close", (event) => {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
event.preventDefault();
|
||||||
|
mainWindow.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on("closed", () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle("get-app-version", () => {
|
||||||
|
return app.getVersion();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("get-platform", () => {
|
||||||
|
return process.platform;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("get-server-config", () => {
|
||||||
|
try {
|
||||||
|
const userDataPath = app.getPath("userData");
|
||||||
|
const configPath = path.join(userDataPath, "server-config.json");
|
||||||
|
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
const configData = fs.readFileSync(configPath, "utf8");
|
||||||
|
return JSON.parse(configData);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reading server config:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("save-server-config", (event, config) => {
|
||||||
|
try {
|
||||||
|
const userDataPath = app.getPath("userData");
|
||||||
|
const configPath = path.join(userDataPath, "server-config.json");
|
||||||
|
|
||||||
|
if (!fs.existsSync(userDataPath)) {
|
||||||
|
fs.mkdirSync(userDataPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving server config:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||||
|
try {
|
||||||
|
let fetch;
|
||||||
|
try {
|
||||||
|
fetch = globalThis.fetch || require("node:fetch");
|
||||||
|
} catch (e) {
|
||||||
|
const https = require("https");
|
||||||
|
const http = require("http");
|
||||||
|
const { URL } = require("url");
|
||||||
|
|
||||||
|
fetch = (url, options = {}) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const isHttps = urlObj.protocol === "https:";
|
||||||
|
const client = isHttps ? https : http;
|
||||||
|
|
||||||
|
const req = client.request(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: options.method || "GET",
|
||||||
|
headers: options.headers || {},
|
||||||
|
timeout: options.timeout || 5000,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk) => (data += chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
resolve({
|
||||||
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||||
|
status: res.statusCode,
|
||||||
|
text: () => Promise.resolve(data),
|
||||||
|
json: () => Promise.resolve(JSON.parse(data)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on("error", reject);
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("Request timeout"));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.body) {
|
||||||
|
req.write(options.body);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
||||||
|
|
||||||
|
const healthUrl = `${normalizedServerUrl}/health`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(healthUrl, {
|
||||||
|
method: "GET",
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.text();
|
||||||
|
|
||||||
|
if (
|
||||||
|
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 {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const healthData = JSON.parse(data);
|
||||||
|
if (
|
||||||
|
healthData &&
|
||||||
|
(healthData.status === "ok" ||
|
||||||
|
healthData.status === "healthy" ||
|
||||||
|
healthData.healthy === true ||
|
||||||
|
healthData.database === "connected")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: response.status,
|
||||||
|
testedUrl: healthUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.log("Health endpoint did not return valid JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (urlError) {
|
||||||
|
console.error("Health check failed:", urlError);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const versionUrl = `${normalizedServerUrl}/version`;
|
||||||
|
const response = await fetch(versionUrl, {
|
||||||
|
method: "GET",
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.text();
|
||||||
|
|
||||||
|
if (
|
||||||
|
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 {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const versionData = JSON.parse(data);
|
||||||
|
if (
|
||||||
|
versionData &&
|
||||||
|
(versionData.status === "up_to_date" ||
|
||||||
|
versionData.status === "requires_update" ||
|
||||||
|
(versionData.localVersion &&
|
||||||
|
versionData.version &&
|
||||||
|
versionData.latest_release))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: response.status,
|
||||||
|
testedUrl: versionUrl,
|
||||||
|
warning:
|
||||||
|
"Health endpoint not available, but server appears to be running",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.log("Version endpoint did not return valid JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (versionError) {
|
||||||
|
console.error("Version check failed:", versionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
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.",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createWindow();
|
||||||
|
console.log("Termix started successfully");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow();
|
||||||
|
} else if (mainWindow) {
|
||||||
|
mainWindow.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("before-quit", () => {
|
||||||
|
console.log("App is quitting...");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("will-quit", () => {
|
||||||
|
console.log("App will quit...");
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
console.error("Uncaught Exception:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason, promise) => {
|
||||||
|
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||||
|
});
|
||||||
29
electron/preload.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
|
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||||
|
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||||
|
|
||||||
|
getServerConfig: () => ipcRenderer.invoke("get-server-config"),
|
||||||
|
saveServerConfig: (config) =>
|
||||||
|
ipcRenderer.invoke("save-server-config", config),
|
||||||
|
testServerConnection: (serverUrl) =>
|
||||||
|
ipcRenderer.invoke("test-server-connection", serverUrl),
|
||||||
|
|
||||||
|
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
||||||
|
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
||||||
|
|
||||||
|
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
|
||||||
|
onUpdateDownloaded: (callback) =>
|
||||||
|
ipcRenderer.on("update-downloaded", callback),
|
||||||
|
|
||||||
|
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
|
||||||
|
isElectron: true,
|
||||||
|
isDev: process.env.NODE_ENV === "development",
|
||||||
|
|
||||||
|
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.IS_ELECTRON = true;
|
||||||
|
|
||||||
|
console.log("electronAPI exposed to window");
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
|
|||||||
4065
openapi.json
9507
package-lock.json
generated
27
package.json
@@ -1,15 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.6.0",
|
||||||
|
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||||
|
"author": "Karmaa",
|
||||||
|
"main": "electron/main.cjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"clean": "npx prettier . --write",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build && tsc -p tsconfig.node.json",
|
||||||
"build:backend": "tsc -p tsconfig.node.json",
|
"build:backend": "tsc -p tsconfig.node.json",
|
||||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/starter.js",
|
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"electron": "electron .",
|
||||||
|
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron .\"",
|
||||||
|
"build:win-portable": "npm run build && electron-builder --win --dir",
|
||||||
|
"build:win-installer": "npm run build && electron-builder --win --publish=never",
|
||||||
|
"build:linux-portable": "npm run build && electron-builder --linux --dir"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
@@ -73,6 +82,7 @@
|
|||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
|
"react-simple-keyboard": "^3.8.120",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
@@ -96,14 +106,21 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"electron": "^38.0.0",
|
||||||
|
"electron-builder": "^26.0.12",
|
||||||
|
"electron-icon-builder": "^2.0.1",
|
||||||
|
"electron-packager": "^17.1.2",
|
||||||
"eslint": "^9.34.0",
|
"eslint": "^9.34.0",
|
||||||
"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",
|
||||||
"typescript-eslint": "^8.40.0",
|
"typescript-eslint": "^8.40.0",
|
||||||
"vite": "^7.1.3"
|
"vite": "^7.1.5",
|
||||||
|
"wait-on": "^8.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 168 KiB |
BIN
public/icon.icns
Normal file
BIN
public/icon.ico
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
public/icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.1 KiB |
BIN
public/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 483 B |
BIN
public/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
public/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 986 B |
BIN
public/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/icons/icon.icns
Normal file
BIN
public/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 353 KiB |
228
src/App.tsx
@@ -1,228 +0,0 @@
|
|||||||
import React, {useState, useEffect} from "react"
|
|
||||||
import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx"
|
|
||||||
import {Homepage} from "@/ui/Homepage/Homepage.tsx"
|
|
||||||
import {AppView} from "@/ui/Navigation/AppView.tsx"
|
|
||||||
import {HostManager} from "@/ui/Apps/Host Manager/HostManager.tsx"
|
|
||||||
import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"
|
|
||||||
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
|
|
||||||
import { AdminSettings } from "@/ui/Admin/AdminSettings";
|
|
||||||
import { UserProfile } from "@/ui/User/UserProfile.tsx";
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
|
||||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
|
||||||
|
|
||||||
function getCookie(name: string) {
|
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
|
||||||
const parts = v.split('=');
|
|
||||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
|
||||||
}, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCookie(name: string, value: string, days = 7) {
|
|
||||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppContent() {
|
|
||||||
const [view, setView] = useState<string>("homepage")
|
|
||||||
const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"]))
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
|
||||||
const [username, setUsername] = useState<string | null>(null)
|
|
||||||
const [isAdmin, setIsAdmin] = useState(false)
|
|
||||||
const [authLoading, setAuthLoading] = useState(true)
|
|
||||||
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true)
|
|
||||||
const {currentTab, tabs} = useTabs();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkAuth = () => {
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
if (jwt) {
|
|
||||||
setAuthLoading(true);
|
|
||||||
getUserInfo()
|
|
||||||
.then((meRes) => {
|
|
||||||
setIsAuthenticated(true);
|
|
||||||
setIsAdmin(!!meRes.is_admin);
|
|
||||||
setUsername(meRes.username || null);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
setIsAdmin(false);
|
|
||||||
setUsername(null);
|
|
||||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
|
||||||
})
|
|
||||||
.finally(() => setAuthLoading(false));
|
|
||||||
} else {
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
setIsAdmin(false);
|
|
||||||
setUsername(null);
|
|
||||||
setAuthLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAuth()
|
|
||||||
|
|
||||||
const handleStorageChange = () => checkAuth()
|
|
||||||
window.addEventListener('storage', handleStorageChange)
|
|
||||||
|
|
||||||
return () => window.removeEventListener('storage', handleStorageChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSelectView = (nextView: string) => {
|
|
||||||
setMountedViews((prev) => {
|
|
||||||
if (prev.has(nextView)) return prev
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.add(nextView)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setView(nextView)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => {
|
|
||||||
setIsAuthenticated(true)
|
|
||||||
setIsAdmin(authData.isAdmin)
|
|
||||||
setUsername(authData.username)
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTabData = tabs.find(tab => tab.id === currentTab);
|
|
||||||
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
|
|
||||||
const showHome = currentTabData?.type === 'home';
|
|
||||||
const showSshManager = currentTabData?.type === 'ssh_manager';
|
|
||||||
const showAdmin = currentTabData?.type === 'admin';
|
|
||||||
const showProfile = currentTabData?.type === 'profile';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{!isAuthenticated && !authLoading && (
|
|
||||||
<div>
|
|
||||||
<div className="absolute inset-0" style={{
|
|
||||||
backgroundImage: `linear-gradient(
|
|
||||||
135deg,
|
|
||||||
transparent 0%,
|
|
||||||
transparent 49%,
|
|
||||||
rgba(255, 255, 255, 0.03) 49%,
|
|
||||||
rgba(255, 255, 255, 0.03) 51%,
|
|
||||||
transparent 51%,
|
|
||||||
transparent 100%
|
|
||||||
)`,
|
|
||||||
backgroundSize: '80px 80px'
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isAuthenticated && !authLoading && (
|
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
|
||||||
<Homepage
|
|
||||||
onSelectView={handleSelectView}
|
|
||||||
isAuthenticated={isAuthenticated}
|
|
||||||
authLoading={authLoading}
|
|
||||||
onAuthSuccess={handleAuthSuccess}
|
|
||||||
isTopbarOpen={isTopbarOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAuthenticated && (
|
|
||||||
<LeftSidebar
|
|
||||||
onSelectView={handleSelectView}
|
|
||||||
disabled={!isAuthenticated || authLoading}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
username={username}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-screen w-full"
|
|
||||||
style={{
|
|
||||||
visibility: showTerminalView ? "visible" : "hidden",
|
|
||||||
pointerEvents: showTerminalView ? "auto" : "none",
|
|
||||||
height: showTerminalView ? "100vh" : 0,
|
|
||||||
width: showTerminalView ? "100%" : 0,
|
|
||||||
position: showTerminalView ? "static" : "absolute",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AppView isTopbarOpen={isTopbarOpen} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="h-screen w-full"
|
|
||||||
style={{
|
|
||||||
visibility: showHome ? "visible" : "hidden",
|
|
||||||
pointerEvents: showHome ? "auto" : "none",
|
|
||||||
height: showHome ? "100vh" : 0,
|
|
||||||
width: showHome ? "100%" : 0,
|
|
||||||
position: showHome ? "static" : "absolute",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Homepage
|
|
||||||
onSelectView={handleSelectView}
|
|
||||||
isAuthenticated={isAuthenticated}
|
|
||||||
authLoading={authLoading}
|
|
||||||
onAuthSuccess={handleAuthSuccess}
|
|
||||||
isTopbarOpen={isTopbarOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="h-screen w-full"
|
|
||||||
style={{
|
|
||||||
visibility: showSshManager ? "visible" : "hidden",
|
|
||||||
pointerEvents: showSshManager ? "auto" : "none",
|
|
||||||
height: showSshManager ? "100vh" : 0,
|
|
||||||
width: showSshManager ? "100%" : 0,
|
|
||||||
position: showSshManager ? "static" : "absolute",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="h-screen w-full"
|
|
||||||
style={{
|
|
||||||
visibility: showAdmin ? "visible" : "hidden",
|
|
||||||
pointerEvents: showAdmin ? "auto" : "none",
|
|
||||||
height: showAdmin ? "100vh" : 0,
|
|
||||||
width: showAdmin ? "100%" : 0,
|
|
||||||
position: showAdmin ? "static" : "absolute",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="h-screen w-full"
|
|
||||||
style={{
|
|
||||||
visibility: showProfile ? "visible" : "hidden",
|
|
||||||
pointerEvents: showProfile ? "auto" : "none",
|
|
||||||
height: showProfile ? "100vh" : 0,
|
|
||||||
width: showProfile ? "100%" : 0,
|
|
||||||
position: showProfile ? "static" : "absolute",
|
|
||||||
overflow: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UserProfile isTopbarOpen={isTopbarOpen} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
|
||||||
</LeftSidebar>
|
|
||||||
)}
|
|
||||||
<Toaster
|
|
||||||
position="bottom-right"
|
|
||||||
richColors={false}
|
|
||||||
closeButton
|
|
||||||
duration={5000}
|
|
||||||
offset={20}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<TabProvider>
|
|
||||||
<AppContent />
|
|
||||||
</TabProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@@ -1,249 +1,295 @@
|
|||||||
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 chalk from 'chalk';
|
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 'dotenv/config';
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import "dotenv/config";
|
||||||
|
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",
|
||||||
const dbIconSymbol = '🗄️';
|
"Authorization",
|
||||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
"User-Agent",
|
||||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
"X-Electron-App",
|
||||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
|
],
|
||||||
};
|
}),
|
||||||
const logger = {
|
);
|
||||||
info: (msg: string): void => {
|
|
||||||
console.log(formatMessage('info', chalk.cyan, msg));
|
|
||||||
},
|
|
||||||
warn: (msg: string): void => {
|
|
||||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
|
||||||
},
|
|
||||||
error: (msg: string, err?: unknown): void => {
|
|
||||||
console.error(formatMessage('error', chalk.redBright, msg));
|
|
||||||
if (err) console.error(err);
|
|
||||||
},
|
|
||||||
success: (msg: string): void => {
|
|
||||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
|
||||||
},
|
|
||||||
debug: (msg: string): void => {
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
data: any;
|
data: any;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class GitHubCache {
|
class GitHubCache {
|
||||||
private cache: Map<string, CacheEntry> = new Map();
|
private cache: Map<string, CacheEntry> = new Map();
|
||||||
private readonly CACHE_DURATION = 30 * 60 * 1000;
|
private readonly CACHE_DURATION = 30 * 60 * 1000;
|
||||||
|
|
||||||
set(key: string, data: any): void {
|
set(key: string, data: any): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.cache.set(key, {
|
this.cache.set(key, {
|
||||||
data,
|
data,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
expiresAt: now + this.CACHE_DURATION
|
expiresAt: now + this.CACHE_DURATION,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): any | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key: string): any | null {
|
if (Date.now() > entry.expiresAt) {
|
||||||
const entry = this.cache.get(key);
|
this.cache.delete(key);
|
||||||
if (!entry) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
tag_name: string;
|
||||||
|
name: string;
|
||||||
|
body: string;
|
||||||
|
published_at: string;
|
||||||
|
html_url: string;
|
||||||
|
assets: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
tag_name: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
body: string;
|
size: number;
|
||||||
published_at: string;
|
download_count: number;
|
||||||
html_url: string;
|
browser_download_url: string;
|
||||||
assets: Array<{
|
}>;
|
||||||
id: number;
|
prerelease: boolean;
|
||||||
name: string;
|
draft: boolean;
|
||||||
size: number;
|
|
||||||
download_count: number;
|
|
||||||
browser_download_url: string;
|
|
||||||
}>;
|
|
||||||
prerelease: boolean;
|
|
||||||
draft: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
|
async function fetchGitHubAPI(
|
||||||
const cachedData = githubCache.get(cacheKey);
|
endpoint: string,
|
||||||
if (cachedData) {
|
cacheKey: string,
|
||||||
return {
|
): Promise<any> {
|
||||||
data: cachedData,
|
const cachedData = githubCache.get(cacheKey);
|
||||||
cached: true,
|
if (cachedData) {
|
||||||
cache_age: Date.now() - cachedData.timestamp
|
return {
|
||||||
};
|
data: cachedData,
|
||||||
|
cached: true,
|
||||||
|
cache_age: Date.now() - cachedData.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"User-Agent": "TermixUpdateChecker/1.0",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const data = await response.json();
|
||||||
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
|
githubCache.set(cacheKey, data);
|
||||||
headers: {
|
|
||||||
'Accept': 'application/vnd.github+json',
|
|
||||||
'User-Agent': 'TermixUpdateChecker/1.0',
|
|
||||||
'X-GitHub-Api-Version': '2022-11-28'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
return {
|
||||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
data: data,
|
||||||
}
|
cached: false,
|
||||||
|
};
|
||||||
const data = await response.json();
|
} catch (error) {
|
||||||
|
databaseLogger.error(`Failed to fetch from GitHub API`, error, {
|
||||||
githubCache.set(cacheKey, data);
|
operation: "github_api",
|
||||||
|
endpoint,
|
||||||
return {
|
});
|
||||||
data: data,
|
throw error;
|
||||||
cached: false
|
}
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to fetch from GitHub API: ${endpoint}`, 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) => {
|
||||||
const localVersion = process.env.VERSION;
|
let localVersion = process.env.VERSION;
|
||||||
|
|
||||||
if (!localVersion) {
|
|
||||||
return res.status(401).send('Local Version Not Set');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!localVersion) {
|
||||||
try {
|
try {
|
||||||
const cacheKey = 'latest_release';
|
const packagePath = path.resolve(process.cwd(), "package.json");
|
||||||
const releaseData = await fetchGitHubAPI(
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
localVersion = packageJson.version;
|
||||||
cacheKey
|
|
||||||
);
|
|
||||||
|
|
||||||
const rawTag = releaseData.data.tag_name || releaseData.data.name || '';
|
|
||||||
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
|
|
||||||
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
|
||||||
|
|
||||||
if (!remoteVersion) {
|
|
||||||
return res.status(401).send('Remote Version Not Found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
status: localVersion === remoteVersion ? 'up_to_date' : 'requires_update',
|
|
||||||
version: remoteVersion,
|
|
||||||
latest_release: {
|
|
||||||
tag_name: releaseData.data.tag_name,
|
|
||||||
name: releaseData.data.name,
|
|
||||||
published_at: releaseData.data.published_at,
|
|
||||||
html_url: releaseData.data.html_url
|
|
||||||
},
|
|
||||||
cached: releaseData.cached,
|
|
||||||
cache_age: releaseData.cache_age
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Version check failed', err);
|
|
||||||
res.status(500).send('Fetch Error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/releases/rss', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
|
||||||
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
|
|
||||||
const cacheKey = `releases_rss_${page}_${per_page}`;
|
|
||||||
|
|
||||||
const releasesData = await fetchGitHubAPI(
|
|
||||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
|
|
||||||
cacheKey
|
|
||||||
);
|
|
||||||
|
|
||||||
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
|
|
||||||
id: release.id,
|
|
||||||
title: release.name || release.tag_name,
|
|
||||||
description: release.body,
|
|
||||||
link: release.html_url,
|
|
||||||
pubDate: release.published_at,
|
|
||||||
version: release.tag_name,
|
|
||||||
isPrerelease: release.prerelease,
|
|
||||||
isDraft: release.draft,
|
|
||||||
assets: release.assets.map(asset => ({
|
|
||||||
name: asset.name,
|
|
||||||
size: asset.size,
|
|
||||||
download_count: asset.download_count,
|
|
||||||
download_url: asset.browser_download_url
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
feed: {
|
|
||||||
title: `${REPO_NAME} Releases`,
|
|
||||||
description: `Latest releases from ${REPO_NAME} repository`,
|
|
||||||
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
|
|
||||||
updated: new Date().toISOString()
|
|
||||||
},
|
|
||||||
items: rssItems,
|
|
||||||
total_count: rssItems.length,
|
|
||||||
cached: releasesData.cached,
|
|
||||||
cache_age: releasesData.cache_age
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to generate RSS format', error)
|
databaseLogger.error("Failed to read version from package.json", error, {
|
||||||
res.status(500).json({
|
operation: "version_check",
|
||||||
error: 'Failed to generate RSS format',
|
});
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localVersion) {
|
||||||
|
databaseLogger.error("No version information available", undefined, {
|
||||||
|
operation: "version_check",
|
||||||
|
});
|
||||||
|
return res.status(404).send("Local Version Not Set");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheKey = "latest_release";
|
||||||
|
const releaseData = await fetchGitHubAPI(
|
||||||
|
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
||||||
|
cacheKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
|
||||||
|
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
|
||||||
|
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
||||||
|
|
||||||
|
if (!remoteVersion) {
|
||||||
|
databaseLogger.warn("Remote version not found in GitHub response", {
|
||||||
|
operation: "version_check",
|
||||||
|
rawTag,
|
||||||
|
});
|
||||||
|
return res.status(401).send("Remote Version Not Found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUpToDate = localVersion === remoteVersion;
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
status: isUpToDate ? "up_to_date" : "requires_update",
|
||||||
|
localVersion: localVersion,
|
||||||
|
version: remoteVersion,
|
||||||
|
latest_release: {
|
||||||
|
tag_name: releaseData.data.tag_name,
|
||||||
|
name: releaseData.data.name,
|
||||||
|
published_at: releaseData.data.published_at,
|
||||||
|
html_url: releaseData.data.html_url,
|
||||||
|
},
|
||||||
|
cached: releaseData.cached,
|
||||||
|
cache_age: releaseData.cache_age,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (err) {
|
||||||
|
databaseLogger.error("Version check failed", err, {
|
||||||
|
operation: "version_check",
|
||||||
|
});
|
||||||
|
res.status(500).send("Fetch Error");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/users', userRoutes);
|
app.get("/releases/rss", async (req, res) => {
|
||||||
app.use('/ssh', sshRoutes);
|
try {
|
||||||
app.use('/alerts', alertRoutes);
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const per_page = Math.min(
|
||||||
|
parseInt(req.query.per_page as string) || 20,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
const cacheKey = `releases_rss_${page}_${per_page}`;
|
||||||
|
|
||||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
const releasesData = await fetchGitHubAPI(
|
||||||
logger.error('Unhandled error:', err);
|
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
|
||||||
res.status(500).json({error: 'Internal Server Error'});
|
cacheKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
|
||||||
|
id: release.id,
|
||||||
|
title: release.name || release.tag_name,
|
||||||
|
description: release.body,
|
||||||
|
link: release.html_url,
|
||||||
|
pubDate: release.published_at,
|
||||||
|
version: release.tag_name,
|
||||||
|
isPrerelease: release.prerelease,
|
||||||
|
isDraft: release.draft,
|
||||||
|
assets: release.assets.map((asset) => ({
|
||||||
|
name: asset.name,
|
||||||
|
size: asset.size,
|
||||||
|
download_count: asset.download_count,
|
||||||
|
download_url: asset.browser_download_url,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
feed: {
|
||||||
|
title: `${REPO_NAME} Releases`,
|
||||||
|
description: `Latest releases from ${REPO_NAME} repository`,
|
||||||
|
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
items: rssItems,
|
||||||
|
total_count: rssItems.length,
|
||||||
|
cached: releasesData.cached,
|
||||||
|
cache_age: releasesData.cache_age,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to generate RSS format", error, {
|
||||||
|
operation: "rss_releases",
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to generate RSS format",
|
||||||
|
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(
|
||||||
|
(
|
||||||
|
err: unknown,
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response,
|
||||||
|
next: express.NextFunction,
|
||||||
|
) => {
|
||||||
|
apiLogger.error("Unhandled error in request", err, {
|
||||||
|
operation: "error_handler",
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
userAgent: req.get("User-Agent"),
|
||||||
|
});
|
||||||
|
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}`, {
|
||||||
|
operation: "server_start",
|
||||||
|
port: PORT,
|
||||||
|
routes: [
|
||||||
|
"/users",
|
||||||
|
"/ssh",
|
||||||
|
"/alerts",
|
||||||
|
"/credentials",
|
||||||
|
"/health",
|
||||||
|
"/version",
|
||||||
|
"/releases/rss",
|
||||||
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,454 +1,306 @@
|
|||||||
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 chalk from 'chalk';
|
import fs from "fs";
|
||||||
import fs from 'fs';
|
import path from "path";
|
||||||
import path from 'path';
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
const dbIconSymbol = '🗄️';
|
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
|
||||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
|
||||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
|
|
||||||
};
|
|
||||||
const logger = {
|
|
||||||
info: (msg: string): void => {
|
|
||||||
console.log(formatMessage('info', chalk.cyan, msg));
|
|
||||||
},
|
|
||||||
warn: (msg: string): void => {
|
|
||||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
|
||||||
},
|
|
||||||
error: (msg: string, err?: unknown): void => {
|
|
||||||
console.error(formatMessage('error', chalk.redBright, msg));
|
|
||||||
if (err) console.error(err);
|
|
||||||
},
|
|
||||||
success: (msg: string): void => {
|
|
||||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
|
||||||
},
|
|
||||||
debug: (msg: string): void => {
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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)) {
|
||||||
fs.mkdirSync(dbDir, {recursive: true});
|
databaseLogger.info(`Creating database directory`, {
|
||||||
|
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,
|
||||||
|
});
|
||||||
const sqlite = new Database(dbPath);
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
(
|
id TEXT PRIMARY KEY,
|
||||||
id
|
username TEXT NOT NULL,
|
||||||
TEXT
|
password_hash TEXT NOT NULL,
|
||||||
PRIMARY
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
KEY,
|
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||||
username
|
client_id TEXT NOT NULL,
|
||||||
TEXT
|
client_secret TEXT NOT NULL,
|
||||||
NOT
|
issuer_url TEXT NOT NULL,
|
||||||
NULL,
|
authorization_url TEXT NOT NULL,
|
||||||
password_hash
|
token_url TEXT NOT NULL,
|
||||||
TEXT
|
redirect_uri TEXT,
|
||||||
NOT
|
identifier_path TEXT NOT NULL,
|
||||||
NULL,
|
name_path TEXT NOT NULL,
|
||||||
is_admin
|
scopes TEXT NOT NULL
|
||||||
INTEGER
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
DEFAULT
|
|
||||||
0,
|
|
||||||
|
|
||||||
is_oidc
|
|
||||||
INTEGER
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
DEFAULT
|
|
||||||
0,
|
|
||||||
client_id
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
client_secret
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
issuer_url
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
authorization_url
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
token_url
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
redirect_uri
|
|
||||||
TEXT,
|
|
||||||
identifier_path
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
name_path
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
scopes
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
(
|
key TEXT PRIMARY KEY,
|
||||||
key
|
value TEXT NOT NULL
|
||||||
TEXT
|
|
||||||
PRIMARY
|
|
||||||
KEY,
|
|
||||||
value
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ssh_data
|
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||||
(
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id
|
user_id TEXT NOT NULL,
|
||||||
INTEGER
|
name TEXT,
|
||||||
PRIMARY
|
ip TEXT NOT NULL,
|
||||||
KEY
|
port INTEGER NOT NULL,
|
||||||
AUTOINCREMENT,
|
username TEXT NOT NULL,
|
||||||
user_id
|
folder TEXT,
|
||||||
TEXT
|
tags TEXT,
|
||||||
NOT
|
pin INTEGER NOT NULL DEFAULT 0,
|
||||||
NULL,
|
auth_type TEXT NOT NULL,
|
||||||
name
|
password TEXT,
|
||||||
TEXT,
|
key TEXT,
|
||||||
ip
|
key_password TEXT,
|
||||||
TEXT
|
key_type TEXT,
|
||||||
NOT
|
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||||
NULL,
|
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||||
port
|
tunnel_connections TEXT,
|
||||||
INTEGER
|
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||||
NOT
|
default_path TEXT,
|
||||||
NULL,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
username
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
TEXT
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
NOT
|
);
|
||||||
NULL,
|
|
||||||
folder
|
|
||||||
TEXT,
|
|
||||||
tags
|
|
||||||
TEXT,
|
|
||||||
pin
|
|
||||||
INTEGER
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
DEFAULT
|
|
||||||
0,
|
|
||||||
auth_type
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
password
|
|
||||||
TEXT,
|
|
||||||
key
|
|
||||||
TEXT,
|
|
||||||
key_password
|
|
||||||
TEXT,
|
|
||||||
key_type
|
|
||||||
TEXT,
|
|
||||||
enable_terminal
|
|
||||||
INTEGER
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
DEFAULT
|
|
||||||
1,
|
|
||||||
enable_tunnel
|
|
||||||
INTEGER
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
DEFAULT
|
|
||||||
1,
|
|
||||||
tunnel_connections
|
|
||||||
TEXT,
|
|
||||||
enable_file_manager
|
|
||||||
INTEGER
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
DEFAULT
|
|
||||||
1,
|
|
||||||
default_path
|
|
||||||
TEXT,
|
|
||||||
created_at
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
DEFAULT
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
updated_at
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
DEFAULT
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN
|
|
||||||
KEY
|
|
||||||
(
|
|
||||||
user_id
|
|
||||||
) REFERENCES users
|
|
||||||
(
|
|
||||||
id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS file_manager_recent
|
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
||||||
(
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id
|
user_id TEXT NOT NULL,
|
||||||
INTEGER
|
host_id INTEGER NOT NULL,
|
||||||
PRIMARY
|
name TEXT NOT NULL,
|
||||||
KEY
|
path TEXT NOT NULL,
|
||||||
AUTOINCREMENT,
|
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
user_id
|
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
TEXT
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||||
NOT
|
);
|
||||||
NULL,
|
|
||||||
host_id
|
|
||||||
INTEGER
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
name
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
path
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
last_opened
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
DEFAULT
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN
|
|
||||||
KEY
|
|
||||||
(
|
|
||||||
user_id
|
|
||||||
) REFERENCES users
|
|
||||||
(
|
|
||||||
id
|
|
||||||
),
|
|
||||||
FOREIGN KEY
|
|
||||||
(
|
|
||||||
host_id
|
|
||||||
) REFERENCES ssh_data
|
|
||||||
(
|
|
||||||
id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS file_manager_pinned
|
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
||||||
(
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id
|
user_id TEXT NOT NULL,
|
||||||
INTEGER
|
host_id INTEGER NOT NULL,
|
||||||
PRIMARY
|
name TEXT NOT NULL,
|
||||||
KEY
|
path TEXT NOT NULL,
|
||||||
AUTOINCREMENT,
|
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
user_id
|
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
TEXT
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||||
NOT
|
);
|
||||||
NULL,
|
|
||||||
host_id
|
|
||||||
INTEGER
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
name
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
path
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
pinned_at
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
DEFAULT
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN
|
|
||||||
KEY
|
|
||||||
(
|
|
||||||
user_id
|
|
||||||
) REFERENCES users
|
|
||||||
(
|
|
||||||
id
|
|
||||||
),
|
|
||||||
FOREIGN KEY
|
|
||||||
(
|
|
||||||
host_id
|
|
||||||
) REFERENCES ssh_data
|
|
||||||
(
|
|
||||||
id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts
|
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
||||||
(
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id
|
user_id TEXT NOT NULL,
|
||||||
INTEGER
|
host_id INTEGER NOT NULL,
|
||||||
PRIMARY
|
name TEXT NOT NULL,
|
||||||
KEY
|
path TEXT NOT NULL,
|
||||||
AUTOINCREMENT,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
user_id
|
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
TEXT
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||||
NOT
|
);
|
||||||
NULL,
|
|
||||||
host_id
|
|
||||||
INTEGER
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
name
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
path
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL,
|
|
||||||
created_at
|
|
||||||
TEXT
|
|
||||||
NOT
|
|
||||||
NULL
|
|
||||||
DEFAULT
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN
|
|
||||||
KEY
|
|
||||||
(
|
|
||||||
user_id
|
|
||||||
) REFERENCES users
|
|
||||||
(
|
|
||||||
id
|
|
||||||
),
|
|
||||||
FOREIGN KEY
|
|
||||||
(
|
|
||||||
host_id
|
|
||||||
) REFERENCES ssh_data
|
|
||||||
(
|
|
||||||
id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dismissed_alerts
|
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||||
(
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id
|
user_id TEXT NOT NULL,
|
||||||
INTEGER
|
alert_id TEXT NOT NULL,
|
||||||
PRIMARY
|
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
KEY
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
AUTOINCREMENT,
|
);
|
||||||
user_id
|
|
||||||
TEXT
|
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||||
NOT
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
NULL,
|
user_id TEXT NOT NULL,
|
||||||
alert_id
|
name TEXT NOT NULL,
|
||||||
TEXT
|
description TEXT,
|
||||||
NOT
|
folder TEXT,
|
||||||
NULL,
|
tags TEXT,
|
||||||
dismissed_at
|
auth_type TEXT NOT NULL,
|
||||||
TEXT
|
username TEXT NOT NULL,
|
||||||
NOT
|
password TEXT,
|
||||||
NULL
|
key TEXT,
|
||||||
DEFAULT
|
key_password TEXT,
|
||||||
CURRENT_TIMESTAMP,
|
key_type TEXT,
|
||||||
FOREIGN
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
KEY
|
last_used TEXT,
|
||||||
(
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
user_id
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
) REFERENCES users
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
(
|
);
|
||||||
id
|
|
||||||
)
|
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
||||||
);
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
credential_id INTEGER NOT NULL,
|
||||||
|
host_id INTEGER NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id),
|
||||||
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
const addColumnIfNotExists = (
|
||||||
|
table: string,
|
||||||
|
column: string,
|
||||||
|
definition: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${column}
|
||||||
|
FROM ${table} LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
sqlite.prepare(`SELECT ${column}
|
databaseLogger.debug(`Adding column ${column} to ${table}`, {
|
||||||
FROM ${table} LIMIT 1`).get();
|
operation: "schema_migration",
|
||||||
} catch (e) {
|
table,
|
||||||
try {
|
column,
|
||||||
sqlite.exec(`ALTER TABLE ${table}
|
});
|
||||||
|
sqlite.exec(`ALTER TABLE ${table}
|
||||||
ADD COLUMN ${column} ${definition};`);
|
ADD COLUMN ${column} ${definition};`);
|
||||||
} catch (alterError) {
|
databaseLogger.success(`Column ${column} added to ${table}`, {
|
||||||
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
|
operation: "schema_migration",
|
||||||
}
|
table,
|
||||||
|
column,
|
||||||
|
});
|
||||||
|
} catch (alterError) {
|
||||||
|
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||||
|
operation: "schema_migration",
|
||||||
|
table,
|
||||||
|
column,
|
||||||
|
error: alterError,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const migrateSchema = () => {
|
const migrateSchema = () => {
|
||||||
logger.info('Checking for schema updates...');
|
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");
|
||||||
try {
|
|
||||||
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
|
|
||||||
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('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
addColumnIfNotExists(
|
||||||
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
"ssh_data",
|
||||||
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
"credential_id",
|
||||||
|
"INTEGER REFERENCES ssh_credentials(id)",
|
||||||
|
);
|
||||||
|
|
||||||
logger.success('Schema migration completed');
|
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
|
||||||
|
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||||
|
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||||
|
|
||||||
|
databaseLogger.success("Schema migration completed", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
migrateSchema();
|
const initializeDatabase = async () => {
|
||||||
|
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) {
|
||||||
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
|
databaseLogger.info("Initializing default settings", {
|
||||||
|
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 {
|
||||||
|
databaseLogger.debug("Default settings already exist", {
|
||||||
|
operation: "db_init",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('Could not initialize default settings');
|
databaseLogger.warn("Could not initialize default settings", {
|
||||||
}
|
operation: "db_init",
|
||||||
|
error: e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const db = drizzle(sqlite, {schema});
|
initializeDatabase().catch((error) => {
|
||||||
|
databaseLogger.error("Failed to initialize database", error, {
|
||||||
|
operation: "db_init",
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
databaseLogger.success("Database connection established", {
|
||||||
|
operation: "db_init",
|
||||||
|
path: dbPath,
|
||||||
|
});
|
||||||
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|||||||
@@ -1,87 +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"),
|
||||||
password: text('password'),
|
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
||||||
key: text('key', {length: 8192}),
|
authType: text("auth_type").notNull(),
|
||||||
keyPassword: text('key_password'),
|
|
||||||
keyType: text('key_type'),
|
password: text("password"),
|
||||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
key: text("key", { length: 8192 }),
|
||||||
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
keyPassword: text("key_password"),
|
||||||
tunnelConnections: text('tunnel_connections'),
|
keyType: text("key_type"),
|
||||||
enableFileManager: integer('enable_file_manager', {mode: 'boolean'}).notNull().default(true),
|
|
||||||
defaultPath: text('default_path'),
|
credentialId: integer("credential_id").references(() => sshCredentials.id),
|
||||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
.notNull()
|
||||||
|
.default(true),
|
||||||
|
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
|
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", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
folder: text("folder"),
|
||||||
|
tags: text("tags"),
|
||||||
|
authType: text("auth_type").notNull(),
|
||||||
|
username: text("username").notNull(),
|
||||||
|
password: text("password"),
|
||||||
|
key: text("key", { length: 16384 }),
|
||||||
|
keyPassword: text("key_password"),
|
||||||
|
keyType: text("key_type"),
|
||||||
|
usageCount: integer("usage_count").notNull().default(0),
|
||||||
|
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", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id),
|
||||||
|
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`),
|
||||||
});
|
});
|
||||||
@@ -1,270 +1,261 @@
|
|||||||
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 chalk from 'chalk';
|
import fetch from "node-fetch";
|
||||||
import fetch from 'node-fetch';
|
import { authLogger } from "../../utils/logger.js";
|
||||||
import type {Request, Response, NextFunction} from 'express';
|
|
||||||
|
|
||||||
const dbIconSymbol = '🚨';
|
|
||||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
|
||||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
|
||||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#dc2626')(`[${dbIconSymbol}]`)} ${message}`;
|
|
||||||
};
|
|
||||||
const logger = {
|
|
||||||
info: (msg: string): void => {
|
|
||||||
console.log(formatMessage('info', chalk.cyan, msg));
|
|
||||||
},
|
|
||||||
warn: (msg: string): void => {
|
|
||||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
|
||||||
},
|
|
||||||
error: (msg: string, err?: unknown): void => {
|
|
||||||
console.error(formatMessage('error', chalk.redBright, msg));
|
|
||||||
if (err) console.error(err);
|
|
||||||
},
|
|
||||||
success: (msg: string): void => {
|
|
||||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
|
||||||
},
|
|
||||||
debug: (msg: string): void => {
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
data: any;
|
data: any;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AlertCache {
|
class AlertCache {
|
||||||
private cache: Map<string, CacheEntry> = new Map();
|
private cache: Map<string, CacheEntry> = new Map();
|
||||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||||
|
|
||||||
set(key: string, data: any): void {
|
set(key: string, data: any): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.cache.set(key, {
|
this.cache.set(key, {
|
||||||
data,
|
data,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
expiresAt: now + this.CACHE_DURATION
|
expiresAt: now + this.CACHE_DURATION,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): any | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key: string): any | null {
|
if (Date.now() > entry.expiresAt) {
|
||||||
const entry = this.cache.get(key);
|
this.cache.delete(key);
|
||||||
if (!entry) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "TermixAlertChecker/1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
authLogger.warn("GitHub API returned error status", {
|
||||||
|
operation: "alerts_fetch",
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`GitHub raw content error: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
|
||||||
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const now = new Date();
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'User-Agent': 'TermixAlertChecker/1.0'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const validAlerts = alerts.filter((alert) => {
|
||||||
throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`);
|
const expiryDate = new Date(alert.expiresAt);
|
||||||
}
|
const isValid = expiryDate > now;
|
||||||
|
return isValid;
|
||||||
|
});
|
||||||
|
|
||||||
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
|
alertCache.set(cacheKey, validAlerts);
|
||||||
|
return validAlerts;
|
||||||
const now = new Date();
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to fetch alerts from GitHub", {
|
||||||
const validAlerts = alerts.filter(alert => {
|
operation: "alerts_fetch",
|
||||||
const expiryDate = new Date(alert.expiresAt);
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
const isValid = expiryDate > now;
|
});
|
||||||
return isValid;
|
return [];
|
||||||
});
|
}
|
||||||
|
|
||||||
alertCache.set(cacheKey, validAlerts);
|
|
||||||
return validAlerts;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch alerts from GitHub', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = express.Router();
|
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) {
|
||||||
logger.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 dismissedAlertRecords = await db
|
|
||||||
.select({alertId: dismissedAlerts.alertId})
|
|
||||||
.from(dismissedAlerts)
|
|
||||||
.where(eq(dismissedAlerts.userId, userId));
|
|
||||||
|
|
||||||
const dismissedAlertIds = new Set(dismissedAlertRecords.map(record => record.alertId));
|
|
||||||
|
|
||||||
const userAlerts = allAlerts.filter(alert => !dismissedAlertIds.has(alert.id));
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
alerts: userAlerts,
|
|
||||||
total_count: userAlerts.length,
|
|
||||||
dismissed_count: dismissedAlertIds.size
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get user alerts', error);
|
|
||||||
res.status(500).json({error: 'Failed to fetch user alerts'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allAlerts = await fetchAlertsFromGitHub();
|
||||||
|
|
||||||
|
const dismissedAlertRecords = await db
|
||||||
|
.select({ alertId: dismissedAlerts.alertId })
|
||||||
|
.from(dismissedAlerts)
|
||||||
|
.where(eq(dismissedAlerts.userId, userId));
|
||||||
|
|
||||||
|
const dismissedAlertIds = new Set(
|
||||||
|
dismissedAlertRecords.map((record) => record.alertId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const userAlerts = allAlerts.filter(
|
||||||
|
(alert) => !dismissedAlertIds.has(alert.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
alerts: userAlerts,
|
||||||
|
total_count: userAlerts.length,
|
||||||
|
dismissed_count: dismissedAlertIds.size,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to get user alerts", error);
|
||||||
|
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) {
|
||||||
logger.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
|
|
||||||
.select()
|
|
||||||
.from(dismissedAlerts)
|
|
||||||
.where(and(
|
|
||||||
eq(dismissedAlerts.userId, userId),
|
|
||||||
eq(dismissedAlerts.alertId, alertId)
|
|
||||||
));
|
|
||||||
|
|
||||||
if (existingDismissal.length > 0) {
|
|
||||||
logger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
|
|
||||||
return res.status(409).json({error: 'Alert already dismissed'});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await db.insert(dismissedAlerts).values({
|
|
||||||
userId,
|
|
||||||
alertId
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`);
|
|
||||||
res.json({message: 'Alert dismissed successfully'});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to dismiss alert', error);
|
|
||||||
res.status(500).json({error: 'Failed to dismiss alert'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingDismissal = await db
|
||||||
|
.select()
|
||||||
|
.from(dismissedAlerts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dismissedAlerts.userId, userId),
|
||||||
|
eq(dismissedAlerts.alertId, alertId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingDismissal.length > 0) {
|
||||||
|
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
|
||||||
|
return res.status(409).json({ error: "Alert already dismissed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.insert(dismissedAlerts).values({
|
||||||
|
userId,
|
||||||
|
alertId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: "Alert dismissed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to dismiss alert", error);
|
||||||
|
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
|
|
||||||
.select({
|
|
||||||
alertId: dismissedAlerts.alertId,
|
|
||||||
dismissedAt: dismissedAlerts.dismissedAt
|
|
||||||
})
|
|
||||||
.from(dismissedAlerts)
|
|
||||||
.where(eq(dismissedAlerts.userId, userId));
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
dismissed_alerts: dismissedAlertRecords,
|
|
||||||
total_count: dismissedAlertRecords.length
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get dismissed alerts', error);
|
|
||||||
res.status(500).json({error: 'Failed to fetch dismissed alerts'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dismissedAlertRecords = await db
|
||||||
|
.select({
|
||||||
|
alertId: dismissedAlerts.alertId,
|
||||||
|
dismissedAt: dismissedAlerts.dismissedAt,
|
||||||
|
})
|
||||||
|
.from(dismissedAlerts)
|
||||||
|
.where(eq(dismissedAlerts.userId, userId));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
dismissed_alerts: dismissedAlertRecords,
|
||||||
|
total_count: dismissedAlertRecords.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to get dismissed alerts", error);
|
||||||
|
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
|
|
||||||
.delete(dismissedAlerts)
|
|
||||||
.where(and(
|
|
||||||
eq(dismissedAlerts.userId, userId),
|
|
||||||
eq(dismissedAlerts.alertId, alertId)
|
|
||||||
));
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
return res.status(404).json({error: 'Dismissed alert not found'});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.success(`Alert ${alertId} undismissed by user ${userId}`);
|
|
||||||
res.json({message: 'Alert undismissed successfully'});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to undismiss alert', error);
|
|
||||||
res.status(500).json({error: 'Failed to undismiss alert'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.delete(dismissedAlerts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dismissedAlerts.userId, userId),
|
||||||
|
eq(dismissedAlerts.alertId, alertId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: "Dismissed alert not found" });
|
||||||
|
}
|
||||||
|
res.json({ message: "Alert undismissed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to undismiss alert", error);
|
||||||
|
res.status(500).json({ error: "Failed to undismiss alert" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
664
src/backend/database/routes/credentials.ts
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
|
||||||
|
import { eq, and, desc, sql } from "drizzle-orm";
|
||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
interface JWTPayload {
|
||||||
|
userId: string;
|
||||||
|
iat?: number;
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonEmptyString(val: any): val is string {
|
||||||
|
return typeof val === "string" && val.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const authHeader = req.headers["authorization"];
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
authLogger.warn("Missing or invalid Authorization header");
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ error: "Missing or invalid Authorization header" });
|
||||||
|
}
|
||||||
|
const token = authHeader.split(" ")[1];
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || "secret";
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||||
|
(req as any).userId = payload.userId;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.warn("Invalid or expired token");
|
||||||
|
return res.status(401).json({ error: "Invalid or expired token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new credential
|
||||||
|
// POST /credentials
|
||||||
|
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
folder,
|
||||||
|
tags,
|
||||||
|
authType,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
key,
|
||||||
|
keyPassword,
|
||||||
|
keyType,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isNonEmptyString(userId) ||
|
||||||
|
!isNonEmptyString(name) ||
|
||||||
|
!isNonEmptyString(username)
|
||||||
|
) {
|
||||||
|
authLogger.warn("Invalid credential creation data validation failed", {
|
||||||
|
operation: "credential_create",
|
||||||
|
userId,
|
||||||
|
hasName: !!name,
|
||||||
|
hasUsername: !!username,
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: "Name and username are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["password", "key"].includes(authType)) {
|
||||||
|
authLogger.warn("Invalid auth type provided", {
|
||||||
|
operation: "credential_create",
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
authType,
|
||||||
|
});
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Auth type must be "password" or "key"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (authType === "password" && !password) {
|
||||||
|
authLogger.warn("Password required for password authentication", {
|
||||||
|
operation: "credential_create",
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
authType,
|
||||||
|
});
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Password is required for password authentication" });
|
||||||
|
}
|
||||||
|
if (authType === "key" && !key) {
|
||||||
|
authLogger.warn("SSH key required for key authentication", {
|
||||||
|
operation: "credential_create",
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
authType,
|
||||||
|
});
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "SSH key is required for key authentication" });
|
||||||
|
}
|
||||||
|
const plainPassword = authType === "password" && password ? password : null;
|
||||||
|
const plainKey = authType === "key" && key ? key : null;
|
||||||
|
const plainKeyPassword =
|
||||||
|
authType === "key" && keyPassword ? keyPassword : null;
|
||||||
|
|
||||||
|
const credentialData = {
|
||||||
|
userId,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description?.trim() || null,
|
||||||
|
folder: folder?.trim() || null,
|
||||||
|
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||||
|
authType,
|
||||||
|
username: username.trim(),
|
||||||
|
password: plainPassword,
|
||||||
|
key: plainKey,
|
||||||
|
keyPassword: plainKeyPassword,
|
||||||
|
keyType: keyType || null,
|
||||||
|
usageCount: 0,
|
||||||
|
lastUsed: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.insert(sshCredentials)
|
||||||
|
.values(credentialData)
|
||||||
|
.returning();
|
||||||
|
const created = result[0];
|
||||||
|
|
||||||
|
authLogger.success(
|
||||||
|
`SSH credential created: ${name} (${authType}) by user ${userId}`,
|
||||||
|
{
|
||||||
|
operation: "credential_create_success",
|
||||||
|
userId,
|
||||||
|
credentialId: created.id,
|
||||||
|
name,
|
||||||
|
authType,
|
||||||
|
username,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(formatCredentialOutput(created));
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to create credential in database", err, {
|
||||||
|
operation: "credential_create",
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
authType,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : "Failed to create credential",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all credentials for the authenticated user
|
||||||
|
// GET /credentials
|
||||||
|
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
authLogger.warn("Invalid userId for credential fetch");
|
||||||
|
return res.status(400).json({ error: "Invalid userId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentials = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.userId, userId))
|
||||||
|
.orderBy(desc(sshCredentials.updatedAt));
|
||||||
|
|
||||||
|
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to fetch credentials", err);
|
||||||
|
res.status(500).json({ error: "Failed to fetch credentials" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all unique credential folders for the authenticated user
|
||||||
|
// GET /credentials/folders
|
||||||
|
router.get("/folders", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
authLogger.warn("Invalid userId for credential folder fetch");
|
||||||
|
return res.status(400).json({ error: "Invalid userId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db
|
||||||
|
.select({ folder: sshCredentials.folder })
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.userId, userId));
|
||||||
|
|
||||||
|
const folderCounts: Record<string, number> = {};
|
||||||
|
result.forEach((r) => {
|
||||||
|
if (r.folder && r.folder.trim() !== "") {
|
||||||
|
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const folders = Object.keys(folderCounts).filter(
|
||||||
|
(folder) => folderCounts[folder] > 0,
|
||||||
|
);
|
||||||
|
res.json(folders);
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to fetch credential folders", err);
|
||||||
|
res.status(500).json({ error: "Failed to fetch credential folders" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get a specific credential by ID (with plain text secrets)
|
||||||
|
// GET /credentials/:id
|
||||||
|
router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !id) {
|
||||||
|
authLogger.warn("Invalid request for credential fetch");
|
||||||
|
return res.status(400).json({ error: "Invalid request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentials = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, parseInt(id)),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (credentials.length === 0) {
|
||||||
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = credentials[0];
|
||||||
|
const output = formatCredentialOutput(credential);
|
||||||
|
|
||||||
|
if (credential.password) {
|
||||||
|
(output as any).password = credential.password;
|
||||||
|
}
|
||||||
|
if (credential.key) {
|
||||||
|
(output as any).key = credential.key;
|
||||||
|
}
|
||||||
|
if (credential.keyPassword) {
|
||||||
|
(output as any).keyPassword = credential.keyPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(output);
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to fetch credential", err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : "Failed to fetch credential",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a credential
|
||||||
|
// PUT /credentials/:id
|
||||||
|
router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const { id } = req.params;
|
||||||
|
const updateData = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !id) {
|
||||||
|
authLogger.warn("Invalid request for credential update");
|
||||||
|
return res.status(400).json({ error: "Invalid request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, parseInt(id)),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: any = {};
|
||||||
|
|
||||||
|
if (updateData.name !== undefined)
|
||||||
|
updateFields.name = updateData.name.trim();
|
||||||
|
if (updateData.description !== undefined)
|
||||||
|
updateFields.description = updateData.description?.trim() || null;
|
||||||
|
if (updateData.folder !== undefined)
|
||||||
|
updateFields.folder = updateData.folder?.trim() || null;
|
||||||
|
if (updateData.tags !== undefined) {
|
||||||
|
updateFields.tags = Array.isArray(updateData.tags)
|
||||||
|
? updateData.tags.join(",")
|
||||||
|
: updateData.tags || "";
|
||||||
|
}
|
||||||
|
if (updateData.username !== undefined)
|
||||||
|
updateFields.username = updateData.username.trim();
|
||||||
|
if (updateData.authType !== undefined)
|
||||||
|
updateFields.authType = updateData.authType;
|
||||||
|
if (updateData.keyType !== undefined)
|
||||||
|
updateFields.keyType = updateData.keyType;
|
||||||
|
|
||||||
|
if (updateData.password !== undefined) {
|
||||||
|
updateFields.password = updateData.password || null;
|
||||||
|
}
|
||||||
|
if (updateData.key !== undefined) {
|
||||||
|
updateFields.key = updateData.key || null;
|
||||||
|
}
|
||||||
|
if (updateData.keyPassword !== undefined) {
|
||||||
|
updateFields.keyPassword = updateData.keyPassword || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateFields).length === 0) {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.id, parseInt(id)));
|
||||||
|
|
||||||
|
return res.json(formatCredentialOutput(existing[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(sshCredentials)
|
||||||
|
.set(updateFields)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, parseInt(id)),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.id, parseInt(id)));
|
||||||
|
|
||||||
|
const credential = updated[0];
|
||||||
|
authLogger.success(
|
||||||
|
`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
|
||||||
|
{
|
||||||
|
operation: "credential_update_success",
|
||||||
|
userId,
|
||||||
|
credentialId: parseInt(id),
|
||||||
|
name: credential.name,
|
||||||
|
authType: credential.authType,
|
||||||
|
username: credential.username,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(formatCredentialOutput(updated[0]));
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to update credential", err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : "Failed to update credential",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a credential
|
||||||
|
// DELETE /credentials/:id
|
||||||
|
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !id) {
|
||||||
|
authLogger.warn("Invalid request for credential deletion");
|
||||||
|
return res.status(400).json({ error: "Invalid request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentialToDelete = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, parseInt(id)),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (credentialToDelete.length === 0) {
|
||||||
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostsUsingCredential = await db
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(
|
||||||
|
and(eq(sshData.credentialId, parseInt(id)), eq(sshData.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hostsUsingCredential.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(sshData)
|
||||||
|
.set({
|
||||||
|
credentialId: null,
|
||||||
|
password: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
authType: "password",
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshData.credentialId, parseInt(id)),
|
||||||
|
eq(sshData.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(sshCredentialUsage)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentialUsage.credentialId, parseInt(id)),
|
||||||
|
eq(sshCredentialUsage.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, parseInt(id)),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const credential = credentialToDelete[0];
|
||||||
|
authLogger.success(
|
||||||
|
`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`,
|
||||||
|
{
|
||||||
|
operation: "credential_delete_success",
|
||||||
|
userId,
|
||||||
|
credentialId: parseInt(id),
|
||||||
|
name: credential.name,
|
||||||
|
authType: credential.authType,
|
||||||
|
username: credential.username,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: "Credential deleted successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to delete credential", err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: err instanceof Error ? err.message : "Failed to delete credential",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply a credential to an SSH host (for quick application)
|
||||||
|
// POST /credentials/:id/apply-to-host/:hostId
|
||||||
|
router.post(
|
||||||
|
"/:id/apply-to-host/:hostId",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const { id: credentialId, hostId } = req.params;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
|
||||||
|
authLogger.warn("Invalid request for credential application");
|
||||||
|
return res.status(400).json({ error: "Invalid request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentials = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, parseInt(credentialId)),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (credentials.length === 0) {
|
||||||
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = credentials[0];
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(sshData)
|
||||||
|
.set({
|
||||||
|
credentialId: parseInt(credentialId),
|
||||||
|
username: credential.username,
|
||||||
|
authType: credential.authType,
|
||||||
|
password: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.insert(sshCredentialUsage).values({
|
||||||
|
credentialId: parseInt(credentialId),
|
||||||
|
hostId: parseInt(hostId),
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(sshCredentials)
|
||||||
|
.set({
|
||||||
|
usageCount: sql`${sshCredentials.usageCount}
|
||||||
|
+ 1`,
|
||||||
|
lastUsed: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(sshCredentials.id, parseInt(credentialId)));
|
||||||
|
res.json({ message: "Credential applied to host successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to apply credential to host", err);
|
||||||
|
res.status(500).json({
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to apply credential to host",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get hosts using a specific credential
|
||||||
|
// GET /credentials/:id/hosts
|
||||||
|
router.get(
|
||||||
|
"/:id/hosts",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const { id: credentialId } = req.params;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !credentialId) {
|
||||||
|
authLogger.warn("Invalid request for credential hosts fetch");
|
||||||
|
return res.status(400).json({ error: "Invalid request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hosts = await db
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshData.credentialId, parseInt(credentialId)),
|
||||||
|
eq(sshData.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(hosts.map((host) => formatSSHHostOutput(host)));
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to fetch hosts using credential", err);
|
||||||
|
res.status(500).json({
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to fetch hosts using credential",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatCredentialOutput(credential: any): any {
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
name: credential.name,
|
||||||
|
description: credential.description,
|
||||||
|
folder: credential.folder,
|
||||||
|
tags:
|
||||||
|
typeof credential.tags === "string"
|
||||||
|
? credential.tags
|
||||||
|
? credential.tags.split(",").filter(Boolean)
|
||||||
|
: []
|
||||||
|
: [],
|
||||||
|
authType: credential.authType,
|
||||||
|
username: credential.username,
|
||||||
|
keyType: credential.keyType,
|
||||||
|
usageCount: credential.usageCount || 0,
|
||||||
|
lastUsed: credential.lastUsed,
|
||||||
|
createdAt: credential.createdAt,
|
||||||
|
updatedAt: credential.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSSHHostOutput(host: any): any {
|
||||||
|
return {
|
||||||
|
id: host.id,
|
||||||
|
userId: host.userId,
|
||||||
|
name: host.name,
|
||||||
|
ip: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
folder: host.folder,
|
||||||
|
tags:
|
||||||
|
typeof host.tags === "string"
|
||||||
|
? host.tags
|
||||||
|
? host.tags.split(",").filter(Boolean)
|
||||||
|
: []
|
||||||
|
: [],
|
||||||
|
pin: !!host.pin,
|
||||||
|
authType: host.authType,
|
||||||
|
enableTerminal: !!host.enableTerminal,
|
||||||
|
enableTunnel: !!host.enableTunnel,
|
||||||
|
tunnelConnections: host.tunnelConnections
|
||||||
|
? JSON.parse(host.tunnelConnections)
|
||||||
|
: [],
|
||||||
|
enableFileManager: !!host.enableFileManager,
|
||||||
|
defaultPath: host.defaultPath,
|
||||||
|
createdAt: host.createdAt,
|
||||||
|
updatedAt: host.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename a credential folder
|
||||||
|
// PUT /credentials/folders/rename
|
||||||
|
router.put(
|
||||||
|
"/folders/rename",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const { oldName, newName } = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Both oldName and newName are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldName === newName) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Old name and new name cannot be the same" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(sshCredentials)
|
||||||
|
.set({ folder: newName })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
eq(sshCredentials.folder, oldName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, message: "Folder renamed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Error renaming credential folder:", error);
|
||||||
|
res.status(500).json({ error: "Failed to rename folder" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,355 +1,498 @@
|
|||||||
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 chalk from 'chalk';
|
import { db } from "../database/db/index.js";
|
||||||
|
import { sshCredentials } from "../database/db/schema.js";
|
||||||
const wss = new WebSocketServer({port: 8082});
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { sshLogger } from "../utils/logger.js";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const sshIconSymbol = '🖥️';
|
|
||||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
|
||||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
|
||||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`;
|
|
||||||
};
|
|
||||||
const logger = {
|
|
||||||
info: (msg: string): void => {
|
|
||||||
console.log(formatMessage('info', chalk.cyan, msg));
|
|
||||||
},
|
|
||||||
warn: (msg: string): void => {
|
|
||||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
|
||||||
},
|
|
||||||
error: (msg: string, err?: unknown): void => {
|
|
||||||
console.error(formatMessage('error', chalk.redBright, msg));
|
|
||||||
if (err) console.error(err);
|
|
||||||
},
|
|
||||||
success: (msg: string): void => {
|
|
||||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
|
||||||
},
|
|
||||||
debug: (msg: string): void => {
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
wss.on('connection', (ws: WebSocket) => {
|
|
||||||
let sshConn: Client | null = null;
|
|
||||||
let sshStream: ClientChannel | null = null;
|
|
||||||
let pingInterval: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
cleanupSSH();
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('message', (msg: RawData) => {
|
|
||||||
|
|
||||||
|
|
||||||
let parsed: any;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(msg.toString());
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Invalid JSON received: ' + msg.toString());
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {type, data} = parsed;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'connectToHost':
|
|
||||||
handleConnectToHost(data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'resize':
|
|
||||||
handleResize(data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'disconnect':
|
|
||||||
cleanupSSH();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'input':
|
|
||||||
if (sshStream) {
|
|
||||||
if (data === '\t') {
|
|
||||||
sshStream.write(data);
|
|
||||||
} else if (data.startsWith('\x1b')) {
|
|
||||||
sshStream.write(data);
|
|
||||||
} else {
|
|
||||||
sshStream.write(Buffer.from(data, 'utf8'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'ping':
|
|
||||||
ws.send(JSON.stringify({type: 'pong'}));
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
logger.warn('Unknown message type: ' + type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleConnectToHost(data: {
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
hostConfig: {
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
password?: string;
|
|
||||||
key?: string;
|
|
||||||
keyPassword?: string;
|
|
||||||
keyType?: string;
|
|
||||||
authType?: string;
|
|
||||||
};
|
|
||||||
}) {
|
|
||||||
const {cols, rows, hostConfig} = data;
|
|
||||||
const {ip, port, username, password, key, keyPassword, keyType, authType} = hostConfig;
|
|
||||||
|
|
||||||
if (!username || typeof username !== 'string' || username.trim() === '') {
|
|
||||||
logger.error('Invalid username provided');
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
|
|
||||||
logger.error('Invalid IP provided');
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!port || typeof port !== 'number' || port <= 0) {
|
|
||||||
logger.error('Invalid port provided');
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sshConn = new Client();
|
|
||||||
|
|
||||||
const connectionTimeout = setTimeout(() => {
|
|
||||||
if (sshConn) {
|
|
||||||
logger.error('SSH connection timeout');
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
|
|
||||||
cleanupSSH(connectionTimeout);
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
sshConn.on('ready', () => {
|
|
||||||
clearTimeout(connectionTimeout);
|
|
||||||
|
|
||||||
|
|
||||||
sshConn!.shell({
|
|
||||||
rows: data.rows,
|
|
||||||
cols: data.cols,
|
|
||||||
term: 'xterm-256color'
|
|
||||||
} as PseudoTtyOptions, (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
logger.error('Shell error: ' + err.message);
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sshStream = stream;
|
|
||||||
|
|
||||||
stream.on('data', (data: Buffer) => {
|
|
||||||
ws.send(JSON.stringify({type: 'data', data: data.toString()}));
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('close', () => {
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', (err: Error) => {
|
|
||||||
logger.error('SSH stream error: ' + err.message);
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
|
||||||
});
|
|
||||||
|
|
||||||
setupPingInterval();
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'}));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
sshConn.on('error', (err: Error) => {
|
|
||||||
clearTimeout(connectionTimeout);
|
|
||||||
logger.error('SSH connection error: ' + err.message);
|
|
||||||
|
|
||||||
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}));
|
|
||||||
cleanupSSH(connectionTimeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
sshConn.on('close', () => {
|
|
||||||
clearTimeout(connectionTimeout);
|
|
||||||
|
|
||||||
cleanupSSH(connectionTimeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const connectConfig: any = {
|
|
||||||
host: ip,
|
|
||||||
port,
|
|
||||||
username,
|
|
||||||
keepaliveInterval: 30000,
|
|
||||||
keepaliveCountMax: 3,
|
|
||||||
readyTimeout: 60000,
|
|
||||||
tcpKeepAlive: true,
|
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
|
||||||
|
|
||||||
env: {
|
|
||||||
TERM: 'xterm-256color',
|
|
||||||
LANG: 'en_US.UTF-8',
|
|
||||||
LC_ALL: 'en_US.UTF-8',
|
|
||||||
LC_CTYPE: 'en_US.UTF-8',
|
|
||||||
LC_MESSAGES: 'en_US.UTF-8',
|
|
||||||
LC_MONETARY: 'en_US.UTF-8',
|
|
||||||
LC_NUMERIC: 'en_US.UTF-8',
|
|
||||||
LC_TIME: 'en_US.UTF-8',
|
|
||||||
LC_COLLATE: 'en_US.UTF-8',
|
|
||||||
COLORTERM: 'truecolor',
|
|
||||||
},
|
|
||||||
|
|
||||||
algorithms: {
|
|
||||||
kex: [
|
|
||||||
'diffie-hellman-group14-sha256',
|
|
||||||
'diffie-hellman-group14-sha1',
|
|
||||||
'diffie-hellman-group1-sha1',
|
|
||||||
'diffie-hellman-group-exchange-sha256',
|
|
||||||
'diffie-hellman-group-exchange-sha1',
|
|
||||||
'ecdh-sha2-nistp256',
|
|
||||||
'ecdh-sha2-nistp384',
|
|
||||||
'ecdh-sha2-nistp521'
|
|
||||||
],
|
|
||||||
cipher: [
|
|
||||||
'aes128-ctr',
|
|
||||||
'aes192-ctr',
|
|
||||||
'aes256-ctr',
|
|
||||||
'aes128-gcm@openssh.com',
|
|
||||||
'aes256-gcm@openssh.com',
|
|
||||||
'aes128-cbc',
|
|
||||||
'aes192-cbc',
|
|
||||||
'aes256-cbc',
|
|
||||||
'3des-cbc'
|
|
||||||
],
|
|
||||||
hmac: [
|
|
||||||
'hmac-sha2-256',
|
|
||||||
'hmac-sha2-512',
|
|
||||||
'hmac-sha1',
|
|
||||||
'hmac-md5'
|
|
||||||
],
|
|
||||||
compress: [
|
|
||||||
'none',
|
|
||||||
'zlib@openssh.com',
|
|
||||||
'zlib'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (authType === 'key' && key) {
|
|
||||||
try {
|
|
||||||
if (!key.includes('-----BEGIN') || !key.includes('-----END')) {
|
|
||||||
throw new Error('Invalid private key format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
||||||
|
|
||||||
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
|
|
||||||
|
|
||||||
if (keyPassword) {
|
|
||||||
connectConfig.passphrase = keyPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyType && keyType !== 'auto') {
|
|
||||||
connectConfig.privateKeyType = keyType;
|
|
||||||
}
|
|
||||||
} catch (keyError) {
|
|
||||||
logger.error('SSH key format error: ' + keyError.message);
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (authType === 'key') {
|
|
||||||
logger.error('SSH key authentication requested but no key provided');
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
connectConfig.password = password;
|
|
||||||
}
|
|
||||||
|
|
||||||
sshConn.connect(connectConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleResize(data: { cols: number; rows: number }) {
|
|
||||||
if (sshStream && sshStream.setWindow) {
|
|
||||||
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
|
||||||
ws.send(JSON.stringify({type: 'resized', cols: data.cols, rows: data.rows}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pingInterval) {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
pingInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sshStream) {
|
|
||||||
try {
|
|
||||||
sshStream.end();
|
|
||||||
} catch (e: any) {
|
|
||||||
logger.error('Error closing stream: ' + e.message);
|
|
||||||
}
|
|
||||||
sshStream = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sshConn) {
|
|
||||||
try {
|
|
||||||
sshConn.end();
|
|
||||||
} catch (e: any) {
|
|
||||||
logger.error('Error closing connection: ' + e.message);
|
|
||||||
}
|
|
||||||
sshConn = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupPingInterval() {
|
|
||||||
pingInterval = setInterval(() => {
|
|
||||||
if (sshConn && sshStream) {
|
|
||||||
try {
|
|
||||||
sshStream.write('\x00');
|
|
||||||
} catch (e: any) {
|
|
||||||
logger.error('SSH keepalive failed: ' + e.message);
|
|
||||||
cleanupSSH();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8082 });
|
||||||
|
|
||||||
|
sshLogger.success("SSH Terminal WebSocket server started", {
|
||||||
|
operation: "server_start",
|
||||||
|
port: 8082,
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on("connection", (ws: WebSocket) => {
|
||||||
|
let sshConn: Client | null = null;
|
||||||
|
let sshStream: ClientChannel | null = null;
|
||||||
|
let pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
cleanupSSH();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("message", (msg: RawData) => {
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(msg.toString());
|
||||||
|
} catch (e) {
|
||||||
|
sshLogger.error("Invalid JSON received", e, {
|
||||||
|
operation: "websocket_message",
|
||||||
|
messageLength: msg.toString().length,
|
||||||
|
});
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, data } = parsed;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "connectToHost":
|
||||||
|
handleConnectToHost(data).catch((error) => {
|
||||||
|
sshLogger.error("Failed to connect to host", error, {
|
||||||
|
operation: "ssh_connect",
|
||||||
|
hostId: data.hostConfig?.id,
|
||||||
|
ip: data.hostConfig?.ip,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message:
|
||||||
|
"Failed to connect to host: " +
|
||||||
|
(error instanceof Error ? error.message : "Unknown error"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "resize":
|
||||||
|
handleResize(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "disconnect":
|
||||||
|
cleanupSSH();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "input":
|
||||||
|
if (sshStream) {
|
||||||
|
if (data === "\t") {
|
||||||
|
sshStream.write(data);
|
||||||
|
} else if (data.startsWith("\x1b")) {
|
||||||
|
sshStream.write(data);
|
||||||
|
} else {
|
||||||
|
sshStream.write(Buffer.from(data, "utf8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ping":
|
||||||
|
ws.send(JSON.stringify({ type: "pong" }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
sshLogger.warn("Unknown message type received", {
|
||||||
|
operation: "websocket_message",
|
||||||
|
messageType: type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleConnectToHost(data: {
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
hostConfig: {
|
||||||
|
id: number;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
authType?: string;
|
||||||
|
credentialId?: number;
|
||||||
|
userId?: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const { cols, rows, hostConfig } = data;
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
key,
|
||||||
|
keyPassword,
|
||||||
|
keyType,
|
||||||
|
authType,
|
||||||
|
credentialId,
|
||||||
|
} = hostConfig;
|
||||||
|
|
||||||
|
if (!username || typeof username !== "string" || username.trim() === "") {
|
||||||
|
sshLogger.error("Invalid username provided", undefined, {
|
||||||
|
operation: "ssh_connect",
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "error", message: "Invalid username provided" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ip || typeof ip !== "string" || ip.trim() === "") {
|
||||||
|
sshLogger.error("Invalid IP provided", undefined, {
|
||||||
|
operation: "ssh_connect",
|
||||||
|
hostId: id,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "error", message: "Invalid IP provided" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!port || typeof port !== "number" || port <= 0) {
|
||||||
|
sshLogger.error("Invalid port provided", undefined, {
|
||||||
|
operation: "ssh_connect",
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
username,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "error", message: "Invalid port provided" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn = new Client();
|
||||||
|
|
||||||
|
const connectionTimeout = setTimeout(() => {
|
||||||
|
if (sshConn) {
|
||||||
|
sshLogger.error("SSH connection timeout", undefined, {
|
||||||
|
operation: "ssh_connect",
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "error", message: "SSH connection timeout" }),
|
||||||
|
);
|
||||||
|
cleanupSSH(connectionTimeout);
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||||
|
if (credentialId && id && hostConfig.userId) {
|
||||||
|
try {
|
||||||
|
const credentials = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, credentialId),
|
||||||
|
eq(sshCredentials.userId, hostConfig.userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (credentials.length > 0) {
|
||||||
|
const credential = credentials[0];
|
||||||
|
resolvedCredentials = {
|
||||||
|
password: credential.password,
|
||||||
|
key: credential.key,
|
||||||
|
keyPassword: credential.keyPassword,
|
||||||
|
keyType: credential.keyType,
|
||||||
|
authType: credential.authType,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
sshLogger.warn(`No credentials found for host ${id}`, {
|
||||||
|
operation: "ssh_credentials",
|
||||||
|
hostId: id,
|
||||||
|
credentialId,
|
||||||
|
userId: hostConfig.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
|
||||||
|
operation: "ssh_credentials",
|
||||||
|
hostId: id,
|
||||||
|
credentialId,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (credentialId && id) {
|
||||||
|
sshLogger.warn("Missing userId for credential resolution in terminal", {
|
||||||
|
operation: "ssh_credentials",
|
||||||
|
hostId: id,
|
||||||
|
credentialId,
|
||||||
|
hasUserId: !!hostConfig.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn.on("ready", () => {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
|
||||||
|
sshConn!.shell(
|
||||||
|
{
|
||||||
|
rows: data.rows,
|
||||||
|
cols: data.cols,
|
||||||
|
term: "xterm-256color",
|
||||||
|
} as PseudoTtyOptions,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
sshLogger.error("Shell error", err, {
|
||||||
|
operation: "ssh_shell",
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Shell error: " + err.message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sshStream = stream;
|
||||||
|
|
||||||
|
stream.on("data", (data: Buffer) => {
|
||||||
|
ws.send(JSON.stringify({ type: "data", data: data.toString() }));
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", () => {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "disconnected",
|
||||||
|
message: "Connection lost",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (err: Error) => {
|
||||||
|
sshLogger.error("SSH stream error", err, {
|
||||||
|
operation: "ssh_stream",
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "SSH stream error: " + err.message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
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 }));
|
||||||
|
cleanupSSH(connectionTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
sshConn.on("close", () => {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
cleanupSSH(connectionTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectConfig: any = {
|
||||||
|
host: ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
keepaliveInterval: 30000,
|
||||||
|
keepaliveCountMax: 3,
|
||||||
|
readyTimeout: 60000,
|
||||||
|
tcpKeepAlive: true,
|
||||||
|
tcpKeepAliveInitialDelay: 30000,
|
||||||
|
|
||||||
|
env: {
|
||||||
|
TERM: "xterm-256color",
|
||||||
|
LANG: "en_US.UTF-8",
|
||||||
|
LC_ALL: "en_US.UTF-8",
|
||||||
|
LC_CTYPE: "en_US.UTF-8",
|
||||||
|
LC_MESSAGES: "en_US.UTF-8",
|
||||||
|
LC_MONETARY: "en_US.UTF-8",
|
||||||
|
LC_NUMERIC: "en_US.UTF-8",
|
||||||
|
LC_TIME: "en_US.UTF-8",
|
||||||
|
LC_COLLATE: "en_US.UTF-8",
|
||||||
|
COLORTERM: "truecolor",
|
||||||
|
},
|
||||||
|
|
||||||
|
algorithms: {
|
||||||
|
kex: [
|
||||||
|
"diffie-hellman-group14-sha256",
|
||||||
|
"diffie-hellman-group14-sha1",
|
||||||
|
"diffie-hellman-group1-sha1",
|
||||||
|
"diffie-hellman-group-exchange-sha256",
|
||||||
|
"diffie-hellman-group-exchange-sha1",
|
||||||
|
"ecdh-sha2-nistp256",
|
||||||
|
"ecdh-sha2-nistp384",
|
||||||
|
"ecdh-sha2-nistp521",
|
||||||
|
],
|
||||||
|
cipher: [
|
||||||
|
"aes128-ctr",
|
||||||
|
"aes192-ctr",
|
||||||
|
"aes256-ctr",
|
||||||
|
"aes128-gcm@openssh.com",
|
||||||
|
"aes256-gcm@openssh.com",
|
||||||
|
"aes128-cbc",
|
||||||
|
"aes192-cbc",
|
||||||
|
"aes256-cbc",
|
||||||
|
"3des-cbc",
|
||||||
|
],
|
||||||
|
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||||
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (resolvedCredentials.authType === "key" && resolvedCredentials.key) {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!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");
|
||||||
|
|
||||||
|
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||||
|
|
||||||
|
if (resolvedCredentials.keyPassword) {
|
||||||
|
connectConfig.passphrase = resolvedCredentials.keyPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
resolvedCredentials.keyType &&
|
||||||
|
resolvedCredentials.keyType !== "auto"
|
||||||
|
) {
|
||||||
|
connectConfig.privateKeyType = resolvedCredentials.keyType;
|
||||||
|
}
|
||||||
|
} catch (keyError) {
|
||||||
|
sshLogger.error("SSH key format error: " + keyError.message);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "SSH key format error: Invalid private key format",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (resolvedCredentials.authType === "key") {
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
connectConfig.password = resolvedCredentials.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn.connect(connectConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize(data: { cols: number; rows: number }) {
|
||||||
|
if (sshStream && sshStream.setWindow) {
|
||||||
|
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sshStream) {
|
||||||
|
try {
|
||||||
|
sshStream.end();
|
||||||
|
} catch (e: any) {
|
||||||
|
sshLogger.error("Error closing stream: " + e.message);
|
||||||
|
}
|
||||||
|
sshStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sshConn) {
|
||||||
|
try {
|
||||||
|
sshConn.end();
|
||||||
|
} catch (e: any) {
|
||||||
|
sshLogger.error("Error closing connection: " + e.message);
|
||||||
|
}
|
||||||
|
sshConn = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupPingInterval() {
|
||||||
|
pingInterval = setInterval(() => {
|
||||||
|
if (sshConn && sshStream) {
|
||||||
|
try {
|
||||||
|
sshStream.write("\x00");
|
||||||
|
} catch (e: any) {
|
||||||
|
sshLogger.error("SSH keepalive failed: " + e.message);
|
||||||
|
cleanupSSH();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,56 +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 chalk from 'chalk';
|
import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||||
|
import "dotenv/config";
|
||||||
const fixedIconSymbol = '🚀';
|
|
||||||
|
|
||||||
const getTimeStamp = (): string => {
|
|
||||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
|
||||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${fixedIconSymbol}]`)} ${message}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const logger = {
|
|
||||||
info: (msg: string): void => {
|
|
||||||
console.log(formatMessage('info', chalk.cyan, msg));
|
|
||||||
},
|
|
||||||
warn: (msg: string): void => {
|
|
||||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
|
||||||
},
|
|
||||||
error: (msg: string, err?: unknown): void => {
|
|
||||||
console.error(formatMessage('error', chalk.redBright, msg));
|
|
||||||
if (err) console.error(err);
|
|
||||||
},
|
|
||||||
success: (msg: string): void => {
|
|
||||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
|
||||||
},
|
|
||||||
debug: (msg: string): void => {
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
logger.info("Starting all backend servers...");
|
const version = process.env.VERSION || "unknown";
|
||||||
|
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
||||||
|
operation: "startup",
|
||||||
|
version: version,
|
||||||
|
});
|
||||||
|
|
||||||
logger.success("All servers started successfully");
|
systemLogger.info("Initializing backend services...", {
|
||||||
|
operation: "startup",
|
||||||
|
});
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
systemLogger.success("All backend services initialized successfully", {
|
||||||
logger.info("Shutting down servers...");
|
operation: "startup_complete",
|
||||||
process.exit(0);
|
services: ["database", "terminal", "tunnel", "file_manager", "stats"],
|
||||||
});
|
version: version,
|
||||||
} catch (error) {
|
});
|
||||||
logger.error("Failed to start servers:", error);
|
|
||||||
process.exit(1);
|
process.on("SIGINT", () => {
|
||||||
}
|
systemLogger.info(
|
||||||
|
"Received SIGINT signal, initiating graceful shutdown...",
|
||||||
|
{ operation: "shutdown" },
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
systemLogger.info(
|
||||||
|
"Received SIGTERM signal, initiating graceful shutdown...",
|
||||||
|
{ operation: "shutdown" },
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
systemLogger.error("Uncaught exception occurred", error, {
|
||||||
|
operation: "error_handling",
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason, promise) => {
|
||||||
|
systemLogger.error("Unhandled promise rejection", reason, {
|
||||||
|
operation: "error_handling",
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
systemLogger.error("Failed to initialize backend services", error, {
|
||||||
|
operation: "startup_failed",
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
174
src/backend/utils/logger.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
||||||
|
|
||||||
|
export interface LogContext {
|
||||||
|
service?: string;
|
||||||
|
operation?: string;
|
||||||
|
userId?: string;
|
||||||
|
hostId?: number;
|
||||||
|
tunnelName?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
requestId?: string;
|
||||||
|
duration?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
private serviceName: string;
|
||||||
|
private serviceIcon: string;
|
||||||
|
private serviceColor: string;
|
||||||
|
|
||||||
|
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||||
|
this.serviceName = serviceName;
|
||||||
|
this.serviceIcon = serviceIcon;
|
||||||
|
this.serviceColor = serviceColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTimeStamp(): string {
|
||||||
|
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatMessage(
|
||||||
|
level: LogLevel,
|
||||||
|
message: string,
|
||||||
|
context?: LogContext,
|
||||||
|
): string {
|
||||||
|
const timestamp = this.getTimeStamp();
|
||||||
|
const levelColor = this.getLevelColor(level);
|
||||||
|
const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`);
|
||||||
|
const levelTag = levelColor(`[${level.toUpperCase()}]`);
|
||||||
|
|
||||||
|
let contextStr = "";
|
||||||
|
if (context) {
|
||||||
|
const contextParts = [];
|
||||||
|
if (context.operation) contextParts.push(`op:${context.operation}`);
|
||||||
|
if (context.userId) contextParts.push(`user:${context.userId}`);
|
||||||
|
if (context.hostId) contextParts.push(`host:${context.hostId}`);
|
||||||
|
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
|
||||||
|
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
|
||||||
|
if (context.requestId) contextParts.push(`req:${context.requestId}`);
|
||||||
|
if (context.duration) contextParts.push(`duration:${context.duration}ms`);
|
||||||
|
|
||||||
|
if (contextParts.length > 0) {
|
||||||
|
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLevelColor(level: LogLevel): chalk.Chalk {
|
||||||
|
switch (level) {
|
||||||
|
case "debug":
|
||||||
|
return chalk.magenta;
|
||||||
|
case "info":
|
||||||
|
return chalk.cyan;
|
||||||
|
case "warn":
|
||||||
|
return chalk.yellow;
|
||||||
|
case "error":
|
||||||
|
return chalk.redBright;
|
||||||
|
case "success":
|
||||||
|
return chalk.greenBright;
|
||||||
|
default:
|
||||||
|
return chalk.white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldLog(level: LogLevel): boolean {
|
||||||
|
if (level === "debug" && process.env.NODE_ENV === "production") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: string, context?: LogContext): void {
|
||||||
|
if (!this.shouldLog("debug")) return;
|
||||||
|
console.debug(this.formatMessage("debug", message, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, context?: LogContext): void {
|
||||||
|
if (!this.shouldLog("info")) return;
|
||||||
|
console.log(this.formatMessage("info", message, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, context?: LogContext): void {
|
||||||
|
if (!this.shouldLog("warn")) return;
|
||||||
|
console.warn(this.formatMessage("warn", message, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, error?: unknown, context?: LogContext): void {
|
||||||
|
if (!this.shouldLog("error")) return;
|
||||||
|
console.error(this.formatMessage("error", message, context));
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message: string, context?: LogContext): void {
|
||||||
|
if (!this.shouldLog("success")) return;
|
||||||
|
console.log(this.formatMessage("success", message, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
auth(message: string, context?: LogContext): void {
|
||||||
|
this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db(message: string, context?: LogContext): void {
|
||||||
|
this.info(`DB: ${message}`, { ...context, operation: "database" });
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh(message: string, context?: LogContext): void {
|
||||||
|
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel(message: string, context?: LogContext): void {
|
||||||
|
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
|
||||||
|
}
|
||||||
|
|
||||||
|
file(message: string, context?: LogContext): void {
|
||||||
|
this.info(`FILE: ${message}`, { ...context, operation: "file" });
|
||||||
|
}
|
||||||
|
|
||||||
|
api(message: string, context?: LogContext): void {
|
||||||
|
this.info(`API: ${message}`, { ...context, operation: "api" });
|
||||||
|
}
|
||||||
|
|
||||||
|
request(message: string, context?: LogContext): void {
|
||||||
|
this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
response(message: string, context?: LogContext): void {
|
||||||
|
this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
|
||||||
|
}
|
||||||
|
|
||||||
|
connection(message: string, context?: LogContext): void {
|
||||||
|
this.info(`CONNECTION: ${message}`, {
|
||||||
|
...context,
|
||||||
|
operation: "connection",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(message: string, context?: LogContext): void {
|
||||||
|
this.info(`DISCONNECT: ${message}`, {
|
||||||
|
...context,
|
||||||
|
operation: "disconnect",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(message: string, context?: LogContext): void {
|
||||||
|
this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const databaseLogger = new Logger("DATABASE", "🗄️", "#6366f1");
|
||||||
|
export const sshLogger = new Logger("SSH", "🖥️", "#0ea5e9");
|
||||||
|
export const tunnelLogger = new Logger("TUNNEL", "📡", "#a855f7");
|
||||||
|
export const fileLogger = new Logger("FILE", "📁", "#f59e0b");
|
||||||
|
export const statsLogger = new Logger("STATS", "📊", "#22c55e");
|
||||||
|
export const apiLogger = new Logger("API", "🌐", "#3b82f6");
|
||||||
|
export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||||
|
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||||
|
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||||
|
|
||||||
|
export const logger = systemLogger;
|
||||||
@@ -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)
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
theme,
|
|
||||||
setTheme: (theme: Theme) => {
|
|
||||||
localStorage.setItem(storageKey, theme)
|
|
||||||
setTheme(theme)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
root.classList.add(theme);
|
||||||
<ThemeProviderContext.Provider {...props} value={value}>
|
}, [theme]);
|
||||||
{children}
|
|
||||||
</ThemeProviderContext.Provider>
|
const value = {
|
||||||
)
|
theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
localStorage.setItem(storageKey, theme);
|
||||||
|
setTheme(theme);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider {...props} value={value}>
|
||||||
|
{children}
|
||||||
|
</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;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
import { Children, ReactElement, cloneElement, isValidElement } from 'react';
|
import { Children, ReactElement, cloneElement, isValidElement } from "react";
|
||||||
|
|
||||||
import { 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,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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,8 +32,14 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ComponentProps<"button">,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
@@ -41,11 +47,8 @@ function Button({
|
|||||||
size,
|
size,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: ButtonProps) {
|
||||||
VariantProps<typeof buttonVariants> & {
|
const Comp = asChild ? Slot : "button";
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -53,7 +56,7 @@ function Button({
|
|||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants, type ButtonProps };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
198
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
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",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
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",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
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",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
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",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
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",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
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",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-4 w-4 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
41
src/components/ui/password-input.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface PasswordInputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
export const PasswordInput = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
PasswordInputProps
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
className={cn("h-11 text-base pr-12", className)} // extra padding-right
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition"
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PasswordInput.displayName = "PasswordInput";
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,24 +31,24 @@ 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-[#434345] hover:bg-[#2a2a2c] active:bg-[#1a1a1c] 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}
|
||||||
>
|
>
|
||||||
{withHandle && (
|
{withHandle && (
|
||||||
<div className="bg-[#434345] hover:bg-[#2a2a2c] active:bg-[#1a1a1c] z-10 flex h-4 w-3 items-center justify-center rounded-xs border transition-colors duration-150">
|
<div className="bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed z-10 flex h-4 w-3 items-center justify-center rounded-xs border transition-colors duration-150">
|
||||||
<GripVerticalIcon className="size-2.5" />
|
<GripVerticalIcon className="size-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -58,14 +58,14 @@ function SheetContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:pointer-events-none",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:pointer-events-none",
|
||||||
side === "right" &&
|
side === "right" &&
|
||||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l",
|
||||||
side === "left" &&
|
side === "left" &&
|
||||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r",
|
||||||
side === "top" &&
|
side === "top" &&
|
||||||
"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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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, 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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes";
|
||||||
import { Toaster as Sonner, 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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
24
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
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",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -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 };
|
||||||
|
|||||||
68
src/hooks/use-confirmation.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface ConfirmationOptions {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfirmation() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [options, setOptions] = useState<ConfirmationOptions | null>(null);
|
||||||
|
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const confirm = (opts: ConfirmationOptions, callback: () => void) => {
|
||||||
|
setOptions(opts);
|
||||||
|
setOnConfirm(() => callback);
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
setOptions(null);
|
||||||
|
setOnConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setOptions(null);
|
||||||
|
setOnConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmWithToast = (
|
||||||
|
message: string,
|
||||||
|
callback: () => void,
|
||||||
|
variant: "default" | "destructive" = "default",
|
||||||
|
) => {
|
||||||
|
const actionText = variant === "destructive" ? "Delete" : "Confirm";
|
||||||
|
const cancelText = "Cancel";
|
||||||
|
|
||||||
|
toast(message, {
|
||||||
|
action: {
|
||||||
|
label: actionText,
|
||||||
|
onClick: callback,
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
label: cancelText,
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
duration: 10000,
|
||||||
|
className: variant === "destructive" ? "border-red-500" : "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
options,
|
||||||
|
confirm,
|
||||||
|
handleConfirm,
|
||||||
|
handleCancel,
|
||||||
|
confirmWithToast,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,41 @@
|
|||||||
// i18n configuration for multi-language support
|
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 HttpApi from 'i18next-http-backend';
|
import enTranslation from "../locales/en/translation.json";
|
||||||
|
import zhTranslation from "../locales/zh/translation.json";
|
||||||
|
|
||||||
// Initialize i18n
|
|
||||||
i18n
|
i18n
|
||||||
.use(HttpApi) // Load translations using http
|
.use(LanguageDetector)
|
||||||
.use(LanguageDetector) // Detect user language
|
.use(initReactI18next)
|
||||||
.use(initReactI18next) // Pass i18n instance to react-i18next
|
|
||||||
.init({
|
.init({
|
||||||
supportedLngs: ['en', 'zh'], // Supported languages
|
supportedLngs: ["en", "zh"],
|
||||||
fallbackLng: 'en', // Fallback language
|
fallbackLng: "en",
|
||||||
debug: false,
|
debug: false,
|
||||||
|
|
||||||
// Detection options - disabled to always use English by default
|
|
||||||
detection: {
|
detection: {
|
||||||
order: ['localStorage', 'cookie'], // Only check user's saved preference
|
order: ["localStorage", "cookie"],
|
||||||
caches: ['localStorage', 'cookie'],
|
caches: ["localStorage", "cookie"],
|
||||||
lookupLocalStorage: 'i18nextLng',
|
lookupLocalStorage: "i18nextLng",
|
||||||
lookupCookie: 'i18nextLng',
|
lookupCookie: "i18nextLng",
|
||||||
checkWhitelist: true,
|
checkWhitelist: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Backend options
|
resources: {
|
||||||
backend: {
|
en: {
|
||||||
loadPath: '/locales/{{lng}}/translation.json',
|
translation: enTranslation,
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
translation: zhTranslation,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false, // React already escapes values
|
escapeValue: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
react: {
|
react: {
|
||||||
useSuspense: false, // Disable suspense for SSR compatibility
|
useSuspense: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
273
src/index.css
@@ -4,153 +4,180 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
color: rgba(255, 255, 255, 0.87);
|
||||||
background-color: #09090b;
|
background-color: #09090b;
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
--primary: oklch(0.21 0.006 285.885);
|
--primary: oklch(0.21 0.006 285.885);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.92 0.004 286.32);
|
--border: oklch(0.92 0.004 286.32);
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--input: oklch(0.92 0.004 286.32);
|
||||||
--ring: oklch(0.705 0.015 286.067);
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
|
--color-dark-bg: #18181b;
|
||||||
|
--color-dark-bg-darker: #0e0e10;
|
||||||
|
--color-dark-bg-darkest: #09090b;
|
||||||
|
--color-dark-bg-input: #222225;
|
||||||
|
--color-dark-bg-button: #23232a;
|
||||||
|
--color-dark-bg-active: #1d1d1f;
|
||||||
|
--color-dark-bg-header: #131316;
|
||||||
|
--color-dark-border: #303032;
|
||||||
|
--color-dark-border-active: #2d2d30;
|
||||||
|
--color-dark-border-hover: #434345;
|
||||||
|
--color-dark-hover: #2d2d30;
|
||||||
|
--color-dark-active: #2a2a2c;
|
||||||
|
--color-dark-pressed: #1a1a1c;
|
||||||
|
--color-dark-hover-alt: #2a2a2d;
|
||||||
|
--color-dark-border-light: #5a5a5d;
|
||||||
|
--color-dark-bg-light: #141416;
|
||||||
|
--color-dark-border-medium: #373739;
|
||||||
|
--color-dark-bg-very-light: #101014;
|
||||||
|
--color-dark-bg-panel: #1b1b1e;
|
||||||
|
--color-dark-border-panel: #222224;
|
||||||
|
--color-dark-bg-panel-hover: #232327;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.21 0.006 285.885);
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.92 0.004 286.32);
|
--primary: oklch(0.92 0.004 286.32);
|
||||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.274 0.006 286.033);
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
--accent: oklch(0.274 0.006 286.033);
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.552 0.016 285.938);
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.21 0.006 285.885);
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
html,
|
||||||
@apply border-border outline-ring/50;
|
body {
|
||||||
}
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
* {
|
||||||
@apply bg-background text-foreground;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar {
|
.thin-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #303032 transparent;
|
scrollbar-color: #303032 transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar {
|
.thin-scrollbar::-webkit-scrollbar {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar-track {
|
.thin-scrollbar::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background-color: #303032;
|
background-color: #303032;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar {
|
.thin-scrollbar::-webkit-scrollbar {
|
||||||
|
|||||||
388
src/lib/frontend-logger.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
||||||
|
|
||||||
|
export interface LogContext {
|
||||||
|
operation?: string;
|
||||||
|
userId?: string;
|
||||||
|
hostId?: number;
|
||||||
|
tunnelName?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
requestId?: string;
|
||||||
|
duration?: number;
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
status?: number;
|
||||||
|
statusText?: string;
|
||||||
|
responseTime?: number;
|
||||||
|
retryCount?: number;
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FrontendLogger {
|
||||||
|
private serviceName: string;
|
||||||
|
private serviceIcon: string;
|
||||||
|
private serviceColor: string;
|
||||||
|
private isDevelopment: boolean;
|
||||||
|
|
||||||
|
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||||
|
this.serviceName = serviceName;
|
||||||
|
this.serviceIcon = serviceIcon;
|
||||||
|
this.serviceColor = serviceColor;
|
||||||
|
this.isDevelopment = process.env.NODE_ENV === "development";
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTimeStamp(): string {
|
||||||
|
const now = new Date();
|
||||||
|
return `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, "0")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatMessage(
|
||||||
|
level: LogLevel,
|
||||||
|
message: string,
|
||||||
|
context?: LogContext,
|
||||||
|
): string {
|
||||||
|
const timestamp = this.getTimeStamp();
|
||||||
|
const levelTag = this.getLevelTag(level);
|
||||||
|
const serviceTag = this.getServiceTag();
|
||||||
|
|
||||||
|
let contextStr = "";
|
||||||
|
if (context && this.isDevelopment) {
|
||||||
|
const contextParts = [];
|
||||||
|
if (context.operation) contextParts.push(context.operation);
|
||||||
|
if (context.userId) contextParts.push(`user:${context.userId}`);
|
||||||
|
if (context.hostId) contextParts.push(`host:${context.hostId}`);
|
||||||
|
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
|
||||||
|
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
|
||||||
|
if (context.responseTime) contextParts.push(`${context.responseTime}ms`);
|
||||||
|
if (context.status) contextParts.push(`status:${context.status}`);
|
||||||
|
if (context.errorCode) contextParts.push(`code:${context.errorCode}`);
|
||||||
|
|
||||||
|
if (contextParts.length > 0) {
|
||||||
|
contextStr = ` (${contextParts.join(", ")})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLevelTag(level: LogLevel): string {
|
||||||
|
const symbols = {
|
||||||
|
debug: "🔍",
|
||||||
|
info: "ℹ️",
|
||||||
|
warn: "⚠️",
|
||||||
|
error: "❌",
|
||||||
|
success: "✅",
|
||||||
|
};
|
||||||
|
return `${symbols[level]} [${level.toUpperCase()}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getServiceTag(): string {
|
||||||
|
return `${this.serviceIcon} [${this.serviceName}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldLog(level: LogLevel): boolean {
|
||||||
|
if (level === "debug" && !this.isDevelopment) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(
|
||||||
|
level: LogLevel,
|
||||||
|
message: string,
|
||||||
|
context?: LogContext,
|
||||||
|
error?: unknown,
|
||||||
|
): void {
|
||||||
|
if (!this.shouldLog(level)) return;
|
||||||
|
|
||||||
|
const formattedMessage = this.formatMessage(level, message, context);
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case "debug":
|
||||||
|
console.debug(formattedMessage);
|
||||||
|
break;
|
||||||
|
case "info":
|
||||||
|
console.log(formattedMessage);
|
||||||
|
break;
|
||||||
|
case "warn":
|
||||||
|
console.warn(formattedMessage);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
console.error(formattedMessage);
|
||||||
|
if (error) {
|
||||||
|
console.error("Error details:", error);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "success":
|
||||||
|
console.log(formattedMessage);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: string, context?: LogContext): void {
|
||||||
|
this.log("debug", message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, context?: LogContext): void {
|
||||||
|
this.log("info", message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, context?: LogContext): void {
|
||||||
|
this.log("warn", message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, error?: unknown, context?: LogContext): void {
|
||||||
|
this.log("error", message, context, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message: string, context?: LogContext): void {
|
||||||
|
this.log("success", message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
api(message: string, context?: LogContext): void {
|
||||||
|
this.info(`API: ${message}`, { ...context, operation: "api" });
|
||||||
|
}
|
||||||
|
|
||||||
|
request(message: string, context?: LogContext): void {
|
||||||
|
this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
response(message: string, context?: LogContext): void {
|
||||||
|
this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
|
||||||
|
}
|
||||||
|
|
||||||
|
auth(message: string, context?: LogContext): void {
|
||||||
|
this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh(message: string, context?: LogContext): void {
|
||||||
|
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel(message: string, context?: LogContext): void {
|
||||||
|
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
|
||||||
|
}
|
||||||
|
|
||||||
|
file(message: string, context?: LogContext): void {
|
||||||
|
this.info(`FILE: ${message}`, { ...context, operation: "file" });
|
||||||
|
}
|
||||||
|
|
||||||
|
connection(message: string, context?: LogContext): void {
|
||||||
|
this.info(`CONNECTION: ${message}`, {
|
||||||
|
...context,
|
||||||
|
operation: "connection",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(message: string, context?: LogContext): void {
|
||||||
|
this.info(`DISCONNECT: ${message}`, {
|
||||||
|
...context,
|
||||||
|
operation: "disconnect",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(message: string, context?: LogContext): void {
|
||||||
|
this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
|
||||||
|
}
|
||||||
|
|
||||||
|
performance(message: string, context?: LogContext): void {
|
||||||
|
this.info(`PERFORMANCE: ${message}`, {
|
||||||
|
...context,
|
||||||
|
operation: "performance",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
security(message: string, context?: LogContext): void {
|
||||||
|
this.warn(`SECURITY: ${message}`, { ...context, operation: "security" });
|
||||||
|
}
|
||||||
|
|
||||||
|
requestStart(method: string, url: string, context?: LogContext): void {
|
||||||
|
const cleanUrl = this.sanitizeUrl(url);
|
||||||
|
const shortUrl = this.getShortUrl(cleanUrl);
|
||||||
|
|
||||||
|
console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`);
|
||||||
|
this.request(`→ Starting request to ${cleanUrl}`, {
|
||||||
|
...context,
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: cleanUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
requestSuccess(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
status: number,
|
||||||
|
responseTime: number,
|
||||||
|
context?: LogContext,
|
||||||
|
): void {
|
||||||
|
const cleanUrl = this.sanitizeUrl(url);
|
||||||
|
const shortUrl = this.getShortUrl(cleanUrl);
|
||||||
|
const statusIcon = this.getStatusIcon(status);
|
||||||
|
const performanceIcon = this.getPerformanceIcon(responseTime);
|
||||||
|
|
||||||
|
this.response(
|
||||||
|
`← ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: cleanUrl,
|
||||||
|
status,
|
||||||
|
responseTime,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
requestError(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
status: number,
|
||||||
|
errorMessage: string,
|
||||||
|
responseTime?: number,
|
||||||
|
context?: LogContext,
|
||||||
|
): void {
|
||||||
|
const cleanUrl = this.sanitizeUrl(url);
|
||||||
|
const shortUrl = this.getShortUrl(cleanUrl);
|
||||||
|
const statusIcon = this.getStatusIcon(status);
|
||||||
|
|
||||||
|
this.error(`← ${statusIcon} ${status} ${errorMessage}`, undefined, {
|
||||||
|
...context,
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: cleanUrl,
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
responseTime,
|
||||||
|
});
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
networkError(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
errorMessage: string,
|
||||||
|
context?: LogContext,
|
||||||
|
): void {
|
||||||
|
const cleanUrl = this.sanitizeUrl(url);
|
||||||
|
const shortUrl = this.getShortUrl(cleanUrl);
|
||||||
|
|
||||||
|
this.error(`🌐 Network Error: ${errorMessage}`, undefined, {
|
||||||
|
...context,
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: cleanUrl,
|
||||||
|
errorMessage,
|
||||||
|
errorCode: "NETWORK_ERROR",
|
||||||
|
});
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
authError(method: string, url: string, context?: LogContext): void {
|
||||||
|
const cleanUrl = this.sanitizeUrl(url);
|
||||||
|
const shortUrl = this.getShortUrl(cleanUrl);
|
||||||
|
|
||||||
|
this.security(`🔐 Authentication Required`, {
|
||||||
|
...context,
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: cleanUrl,
|
||||||
|
errorCode: "AUTH_REQUIRED",
|
||||||
|
});
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAttempt(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
attempt: number,
|
||||||
|
maxAttempts: number,
|
||||||
|
context?: LogContext,
|
||||||
|
): void {
|
||||||
|
const cleanUrl = this.sanitizeUrl(url);
|
||||||
|
const shortUrl = this.getShortUrl(cleanUrl);
|
||||||
|
|
||||||
|
this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, {
|
||||||
|
...context,
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: cleanUrl,
|
||||||
|
retryCount: attempt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
apiOperation(operation: string, details: string, context?: LogContext): void {
|
||||||
|
this.info(`🔧 ${operation}: ${details}`, {
|
||||||
|
...context,
|
||||||
|
operation: "api_operation",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
requestSummary(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
status: number,
|
||||||
|
responseTime: number,
|
||||||
|
context?: LogContext,
|
||||||
|
): void {
|
||||||
|
const cleanUrl = this.sanitizeUrl(url);
|
||||||
|
const shortUrl = this.getShortUrl(cleanUrl);
|
||||||
|
const statusIcon = this.getStatusIcon(status);
|
||||||
|
const performanceIcon = this.getPerformanceIcon(responseTime);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
|
||||||
|
"color: #666; font-style: italic; font-size: 0.9em;",
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getShortUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const path = urlObj.pathname;
|
||||||
|
const query = urlObj.search;
|
||||||
|
return `${urlObj.hostname}${path}${query}`;
|
||||||
|
} catch {
|
||||||
|
return url.length > 50 ? url.substring(0, 47) + "..." : url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStatusIcon(status: number): string {
|
||||||
|
if (status >= 200 && status < 300) return "✅";
|
||||||
|
if (status >= 300 && status < 400) return "↩️";
|
||||||
|
if (status >= 400 && status < 500) return "⚠️";
|
||||||
|
if (status >= 500) return "❌";
|
||||||
|
return "❓";
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPerformanceIcon(responseTime: number): string {
|
||||||
|
if (responseTime < 100) return "⚡";
|
||||||
|
if (responseTime < 500) return "🚀";
|
||||||
|
if (responseTime < 1000) return "🏃";
|
||||||
|
if (responseTime < 3000) return "🚶";
|
||||||
|
return "🐌";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
if (
|
||||||
|
urlObj.searchParams.has("password") ||
|
||||||
|
urlObj.searchParams.has("token")
|
||||||
|
) {
|
||||||
|
urlObj.search = "";
|
||||||
|
}
|
||||||
|
return urlObj.toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiLogger = new FrontendLogger("API", "🌐", "#3b82f6");
|
||||||
|
export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
|
||||||
|
export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
|
||||||
|
export const tunnelLogger = new FrontendLogger("TUNNEL", "📡", "#1e3a8a");
|
||||||
|
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
|
||||||
|
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
|
||||||
|
export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
|
||||||
|
|
||||||
|
export const logger = systemLogger;
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,137 @@
|
|||||||
{
|
{
|
||||||
|
"credentials": {
|
||||||
|
"credentialsViewer": "Credentials Viewer",
|
||||||
|
"manageYourSSHCredentials": "Manage your SSH credentials securely",
|
||||||
|
"addCredential": "Add Credential",
|
||||||
|
"createCredential": "Create Credential",
|
||||||
|
"editCredential": "Edit Credential",
|
||||||
|
"viewCredential": "View Credential",
|
||||||
|
"duplicateCredential": "Duplicate Credential",
|
||||||
|
"deleteCredential": "Delete Credential",
|
||||||
|
"updateCredential": "Update Credential",
|
||||||
|
"credentialName": "Credential Name",
|
||||||
|
"credentialDescription": "Description",
|
||||||
|
"username": "Username",
|
||||||
|
"searchCredentials": "Search credentials...",
|
||||||
|
"selectFolder": "Select Folder",
|
||||||
|
"selectAuthType": "Select Auth Type",
|
||||||
|
"allFolders": "All Folders",
|
||||||
|
"allAuthTypes": "All Auth Types",
|
||||||
|
"uncategorized": "Uncategorized",
|
||||||
|
"totalCredentials": "Total",
|
||||||
|
"keyBased": "Key-based",
|
||||||
|
"passwordBased": "Password-based",
|
||||||
|
"folders": "Folders",
|
||||||
|
"noCredentialsMatchFilters": "No credentials match your filters",
|
||||||
|
"noCredentialsYet": "No credentials created yet",
|
||||||
|
"createFirstCredential": "Create your first credential",
|
||||||
|
"failedToFetchCredentials": "Failed to fetch credentials",
|
||||||
|
"credentialDeletedSuccessfully": "Credential deleted successfully",
|
||||||
|
"failedToDeleteCredential": "Failed to delete credential",
|
||||||
|
"confirmDeleteCredential": "Are you sure you want to delete credential \"{{name}}\"?",
|
||||||
|
"credentialCreatedSuccessfully": "Credential created successfully",
|
||||||
|
"credentialUpdatedSuccessfully": "Credential updated successfully",
|
||||||
|
"failedToSaveCredential": "Failed to save credential",
|
||||||
|
"failedToFetchCredentialDetails": "Failed to fetch credential details",
|
||||||
|
"failedToFetchHostsUsing": "Failed to fetch hosts using this credential",
|
||||||
|
"loadingCredentials": "Loading credentials...",
|
||||||
|
"retry": "Retry",
|
||||||
|
"noCredentials": "No Credentials",
|
||||||
|
"noCredentialsMessage": "You haven't added any credentials yet. Click \"Add Credential\" to get started.",
|
||||||
|
"sshCredentials": "SSH Credentials",
|
||||||
|
"credentialsCount": "{{count}} credentials",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"passwordRequired": "Password is required",
|
||||||
|
"sshKeyRequired": "SSH key is required",
|
||||||
|
"credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully",
|
||||||
|
"general": "General",
|
||||||
|
"description": "Description",
|
||||||
|
"folder": "Folder",
|
||||||
|
"tags": "Tags",
|
||||||
|
"addTagsSpaceToAdd": "Add tags (press space to add)",
|
||||||
|
"password": "Password",
|
||||||
|
"key": "Key",
|
||||||
|
"sshPrivateKey": "SSH Private Key",
|
||||||
|
"upload": "Upload",
|
||||||
|
"updateKey": "Update Key",
|
||||||
|
"keyPassword": "Key Password (optional)",
|
||||||
|
"keyType": "Key Type",
|
||||||
|
"keyTypeRSA": "RSA",
|
||||||
|
"keyTypeECDSA": "ECDSA",
|
||||||
|
"keyTypeEd25519": "Ed25519",
|
||||||
|
"updateCredential": "Update Credential",
|
||||||
|
"basicInfo": "Basic Info",
|
||||||
|
"authentication": "Authentication",
|
||||||
|
"organization": "Organization",
|
||||||
|
"basicInformation": "Basic Information",
|
||||||
|
"basicInformationDescription": "Enter the basic information for this credential",
|
||||||
|
"authenticationMethod": "Authentication Method",
|
||||||
|
"authenticationMethodDescription": "Choose how you want to authenticate with SSH servers",
|
||||||
|
"organizationDescription": "Organize your credentials with folders and tags",
|
||||||
|
"enterCredentialName": "Enter credential name",
|
||||||
|
"enterCredentialDescription": "Enter description (optional)",
|
||||||
|
"enterUsername": "Enter username",
|
||||||
|
"nameIsRequired": "Credential name is required",
|
||||||
|
"usernameIsRequired": "Username is required",
|
||||||
|
"authenticationType": "Authentication Type",
|
||||||
|
"passwordAuthDescription": "Use password authentication",
|
||||||
|
"sshKeyAuthDescription": "Use SSH key authentication",
|
||||||
|
"passwordIsRequired": "Password is required",
|
||||||
|
"sshKeyIsRequired": "SSH key is required",
|
||||||
|
"sshKeyType": "SSH Key Type",
|
||||||
|
"privateKey": "Private Key",
|
||||||
|
"enterPassword": "Enter password",
|
||||||
|
"enterPrivateKey": "Enter private key",
|
||||||
|
"keyPassphrase": "Key Passphrase",
|
||||||
|
"enterKeyPassphrase": "Enter key passphrase (optional)",
|
||||||
|
"keyPassphraseOptional": "Optional: leave empty if your key has no passphrase",
|
||||||
|
"leaveEmptyToKeepCurrent": "Leave empty to keep current value",
|
||||||
|
"uploadKeyFile": "Upload Key File",
|
||||||
|
"generateKeyPair": "Generate Key Pair",
|
||||||
|
"sshKeyGenerationNotImplemented": "SSH key generation feature coming soon",
|
||||||
|
"connectionTestingNotImplemented": "Connection testing feature coming soon",
|
||||||
|
"testConnection": "Test Connection",
|
||||||
|
"selectOrCreateFolder": "Select or create folder",
|
||||||
|
"noFolder": "No folder",
|
||||||
|
"orCreateNewFolder": "Or create new folder",
|
||||||
|
"addTag": "Add tag",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"overview": "Overview",
|
||||||
|
"security": "Security",
|
||||||
|
"usage": "Usage",
|
||||||
|
"securityDetails": "Security Details",
|
||||||
|
"securityDetailsDescription": "View encrypted credential information",
|
||||||
|
"credentialSecured": "Credential Secured",
|
||||||
|
"credentialSecuredDescription": "All sensitive data is encrypted with AES-256",
|
||||||
|
"passwordAuthentication": "Password Authentication",
|
||||||
|
"keyAuthentication": "Key Authentication",
|
||||||
|
"keyType": "Key Type",
|
||||||
|
"securityReminder": "Security Reminder",
|
||||||
|
"securityReminderText": "Never share your credentials. All data is encrypted at rest.",
|
||||||
|
"hostsUsingCredential": "Hosts Using This Credential",
|
||||||
|
"noHostsUsingCredential": "No hosts are currently using this credential",
|
||||||
|
"timesUsed": "Times Used",
|
||||||
|
"lastUsed": "Last Used",
|
||||||
|
"connectedHosts": "Connected Hosts",
|
||||||
|
"created": "Created",
|
||||||
|
"lastModified": "Last Modified",
|
||||||
|
"usageStatistics": "Usage Statistics",
|
||||||
|
"copiedToClipboard": "{{field}} copied to clipboard",
|
||||||
|
"failedToCopy": "Failed to copy to clipboard",
|
||||||
|
"sshKey": "SSH Key",
|
||||||
|
"createCredentialDescription": "Create a new SSH credential for secure access",
|
||||||
|
"editCredentialDescription": "Update the credential information",
|
||||||
|
"listView": "List",
|
||||||
|
"folderView": "Folders",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The credential will be moved to \"Uncategorized\".",
|
||||||
|
"removedFromFolder": "Credential \"{{name}}\" removed from folder successfully",
|
||||||
|
"failedToRemoveFromFolder": "Failed to remove credential from folder",
|
||||||
|
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||||
|
"failedToRenameFolder": "Failed to rename folder",
|
||||||
|
"movedToFolder": "Credential \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||||
|
"failedToMoveToFolder": "Failed to move credential to folder"
|
||||||
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"title": "SSH Tools",
|
"title": "SSH Tools",
|
||||||
"closeTools": "Close SSH Tools",
|
"closeTools": "Close SSH Tools",
|
||||||
@@ -18,6 +151,24 @@
|
|||||||
"failedToLoadAlerts": "Failed to load alerts",
|
"failedToLoadAlerts": "Failed to load alerts",
|
||||||
"failedToDismissAlert": "Failed to dismiss alert"
|
"failedToDismissAlert": "Failed to dismiss alert"
|
||||||
},
|
},
|
||||||
|
"serverConfig": {
|
||||||
|
"title": "Server Configuration",
|
||||||
|
"description": "Configure the Termix server URL to connect to your backend services",
|
||||||
|
"serverUrl": "Server URL",
|
||||||
|
"enterServerUrl": "Please enter a server URL",
|
||||||
|
"testConnectionFirst": "Please test the connection first",
|
||||||
|
"connectionSuccess": "Connection successful!",
|
||||||
|
"connectionFailed": "Connection failed",
|
||||||
|
"connectionError": "Connection error occurred",
|
||||||
|
"connected": "Connected",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"configSaved": "Configuration saved successfully",
|
||||||
|
"saveFailed": "Failed to save configuration",
|
||||||
|
"saveError": "Error saving configuration",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"saveConfig": "Save Configuration",
|
||||||
|
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:8081 or https://your-server.com)"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
@@ -32,6 +183,7 @@
|
|||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"required": "Required",
|
"required": "Required",
|
||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
|
"clear": "Clear",
|
||||||
"toggleSidebar": "Toggle Sidebar",
|
"toggleSidebar": "Toggle Sidebar",
|
||||||
"sidebar": "Sidebar",
|
"sidebar": "Sidebar",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -72,11 +224,13 @@
|
|||||||
"register": "Register",
|
"register": "Register",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
"version": "Version",
|
||||||
"confirmPassword": "Confirm Password",
|
"confirmPassword": "Confirm Password",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"change": "Change",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
@@ -115,26 +269,31 @@
|
|||||||
"passwordResetSuccess": "Password reset successfully! You can now log in with your new password.",
|
"passwordResetSuccess": "Password reset successfully! You can now log in with your new password.",
|
||||||
"failedToInitiatePasswordReset": "Failed to initiate password reset",
|
"failedToInitiatePasswordReset": "Failed to initiate password reset",
|
||||||
"failedToVerifyResetCode": "Failed to verify reset code",
|
"failedToVerifyResetCode": "Failed to verify reset code",
|
||||||
"failedToCompletePasswordReset": "Failed to complete password reset"
|
"failedToCompletePasswordReset": "Failed to complete password reset",
|
||||||
|
"documentation": "Documentation"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
|
"credentials": "Credentials",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"tunnels": "Tunnels",
|
"tunnels": "Tunnels",
|
||||||
"fileManager": "File Manager",
|
"fileManager": "File Manager",
|
||||||
"serverStats": "Server Stats",
|
"serverStats": "Server Stats",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
|
"userProfile": "User Profile",
|
||||||
"tools": "Tools",
|
"tools": "Tools",
|
||||||
"newTab": "New Tab",
|
"newTab": "New Tab",
|
||||||
"splitScreen": "Split Screen",
|
"splitScreen": "Split Screen",
|
||||||
"closeTab": "Close Tab",
|
"closeTab": "Close Tab",
|
||||||
"sshManager": "SSH Manager",
|
"sshManager": "SSH Manager",
|
||||||
"hostManager": "Host Manager",
|
"hostManager": "Host Manager",
|
||||||
"cannotSplitTab": "Cannot split this tab"
|
"cannotSplitTab": "Cannot split this tab",
|
||||||
|
"tabNavigation": "Tab Navigation"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Admin Settings",
|
"title": "Admin Settings",
|
||||||
|
"oidc": "OIDC",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"userManagement": "User Management",
|
"userManagement": "User Management",
|
||||||
"makeAdmin": "Make Admin",
|
"makeAdmin": "Make Admin",
|
||||||
@@ -179,7 +338,12 @@
|
|||||||
"allowNewAccountRegistration": "Allow new account registration",
|
"allowNewAccountRegistration": "Allow new account registration",
|
||||||
"missingRequiredFields": "Missing required fields: {{fields}}",
|
"missingRequiredFields": "Missing required fields: {{fields}}",
|
||||||
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
|
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
|
||||||
|
"failedToFetchOidcConfig": "Failed to fetch OIDC configuration",
|
||||||
|
"failedToFetchRegistrationStatus": "Failed to fetch registration status",
|
||||||
|
"failedToFetchUsers": "Failed to fetch users",
|
||||||
|
"oidcConfigurationDisabled": "OIDC configuration disabled successfully!",
|
||||||
"failedToUpdateOidcConfig": "Failed to update OIDC configuration",
|
"failedToUpdateOidcConfig": "Failed to update OIDC configuration",
|
||||||
|
"failedToDisableOidcConfig": "Failed to disable OIDC configuration",
|
||||||
"enterUsernameToMakeAdmin": "Enter username to make admin",
|
"enterUsernameToMakeAdmin": "Enter username to make admin",
|
||||||
"userIsNowAdmin": "User {{username}} is now an admin",
|
"userIsNowAdmin": "User {{username}} is now an admin",
|
||||||
"failedToMakeUserAdmin": "Failed to make user admin",
|
"failedToMakeUserAdmin": "Failed to make user admin",
|
||||||
@@ -207,8 +371,10 @@
|
|||||||
"importJsonDesc": "Upload a JSON file to bulk import multiple SSH hosts (max 100).",
|
"importJsonDesc": "Upload a JSON file to bulk import multiple SSH hosts (max 100).",
|
||||||
"downloadSample": "Download Sample",
|
"downloadSample": "Download Sample",
|
||||||
"formatGuide": "Format Guide",
|
"formatGuide": "Format Guide",
|
||||||
|
"exportCredentialWarning": "Warning: Host \"{{name}}\" uses credential authentication. The exported file will not include the credential data and will need to be manually reconfigured after import. Do you want to continue?",
|
||||||
|
"exportSensitiveDataWarning": "Warning: Host \"{{name}}\" contains sensitive authentication data (password/SSH key). The exported file will not include this data for security reasons. You'll need to reconfigure authentication after import. Do you want to continue?",
|
||||||
"uncategorized": "Uncategorized",
|
"uncategorized": "Uncategorized",
|
||||||
"confirmDelete": "Are you sure you want to delete \"{{name}}\"?",
|
"confirmDelete": "Are you sure you want to delete \"{{name}}\" ?",
|
||||||
"failedToDeleteHost": "Failed to delete host",
|
"failedToDeleteHost": "Failed to delete host",
|
||||||
"jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts",
|
"jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts",
|
||||||
"noHostsInJson": "No hosts found in JSON file",
|
"noHostsInJson": "No hosts found in JSON file",
|
||||||
@@ -276,6 +442,11 @@
|
|||||||
"authentication": "Authentication",
|
"authentication": "Authentication",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
|
"credential": "Credential",
|
||||||
|
"selectCredential": "Select Credential",
|
||||||
|
"selectCredentialPlaceholder": "Choose a credential...",
|
||||||
|
"credentialRequired": "Credential is required when using credential authentication",
|
||||||
|
"credentialDescription": "Selecting a credential will overwrite the current username and use the credential's authentication details.",
|
||||||
"sshPrivateKey": "SSH Private Key",
|
"sshPrivateKey": "SSH Private Key",
|
||||||
"keyPassword": "Key Password",
|
"keyPassword": "Key Password",
|
||||||
"keyType": "Key Type",
|
"keyType": "Key Type",
|
||||||
@@ -288,7 +459,11 @@
|
|||||||
"dsa": "DSA",
|
"dsa": "DSA",
|
||||||
"rsaSha2256": "RSA SHA2-256",
|
"rsaSha2256": "RSA SHA2-256",
|
||||||
"rsaSha2512": "RSA SHA2-512",
|
"rsaSha2512": "RSA SHA2-512",
|
||||||
|
"uploadFile": "Upload File",
|
||||||
|
"pasteKey": "Paste Key",
|
||||||
"updateKey": "Update Key",
|
"updateKey": "Update Key",
|
||||||
|
"existingKey": "Existing Key (click to change)",
|
||||||
|
"existingCredential": "Existing Credential (click to change)",
|
||||||
"addTagsSpaceToAdd": "add tags (space to add)",
|
"addTagsSpaceToAdd": "add tags (space to add)",
|
||||||
"terminalBadge": "Terminal",
|
"terminalBadge": "Terminal",
|
||||||
"tunnelBadge": "Tunnel",
|
"tunnelBadge": "Tunnel",
|
||||||
@@ -297,7 +472,14 @@
|
|||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"tunnel": "Tunnel",
|
"tunnel": "Tunnel",
|
||||||
"fileManager": "File Manager",
|
"fileManager": "File Manager",
|
||||||
"hostViewer": "Host Viewer"
|
"hostViewer": "Host Viewer",
|
||||||
|
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The host will be moved to \"No Folder\".",
|
||||||
|
"removedFromFolder": "Host \"{{name}}\" removed from folder successfully",
|
||||||
|
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
||||||
|
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||||
|
"failedToRenameFolder": "Failed to rename folder",
|
||||||
|
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||||
|
"failedToMoveToFolder": "Failed to move host to folder"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "Terminal",
|
"title": "Terminal",
|
||||||
@@ -314,10 +496,21 @@
|
|||||||
"reconnect": "Reconnect",
|
"reconnect": "Reconnect",
|
||||||
"sessionEnded": "Session Ended",
|
"sessionEnded": "Session Ended",
|
||||||
"connectionLost": "Connection Lost",
|
"connectionLost": "Connection Lost",
|
||||||
"error": "ERROR",
|
"error": "ERROR: {{message}}",
|
||||||
"disconnected": "Disconnected",
|
"disconnected": "Disconnected",
|
||||||
"connectionClosed": "Connection closed",
|
"connectionClosed": "Connection closed",
|
||||||
"connectionError": "Connection error"
|
"connectionError": "Connection error: {{message}}",
|
||||||
|
"connected": "Connected",
|
||||||
|
"sshConnected": "SSH connection established",
|
||||||
|
"authError": "Authentication failed: {{message}}",
|
||||||
|
"unknownError": "Unknown error occurred",
|
||||||
|
"messageParseError": "Failed to parse server message",
|
||||||
|
"websocketError": "WebSocket connection error",
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"reconnecting": "Reconnecting... ({{attempt}}/{{max}})",
|
||||||
|
"reconnected": "Reconnected successfully",
|
||||||
|
"maxReconnectAttemptsReached": "Maximum reconnection attempts reached",
|
||||||
|
"connectionTimeout": "Connection timeout"
|
||||||
},
|
},
|
||||||
"fileManager": {
|
"fileManager": {
|
||||||
"title": "File Manager",
|
"title": "File Manager",
|
||||||
@@ -337,6 +530,11 @@
|
|||||||
"clickToSelectFile": "Click to select a file",
|
"clickToSelectFile": "Click to select a file",
|
||||||
"chooseFile": "Choose File",
|
"chooseFile": "Choose File",
|
||||||
"uploading": "Uploading...",
|
"uploading": "Uploading...",
|
||||||
|
"uploadingFile": "Uploading {{name}}...",
|
||||||
|
"creatingFile": "Creating {{name}}...",
|
||||||
|
"creatingFolder": "Creating {{name}}...",
|
||||||
|
"deletingItem": "Deleting {{type}} {{name}}...",
|
||||||
|
"renamingItem": "Renaming {{type}} {{oldName}} to {{newName}}...",
|
||||||
"createNewFile": "Create New File",
|
"createNewFile": "Create New File",
|
||||||
"fileName": "File Name",
|
"fileName": "File Name",
|
||||||
"creating": "Creating...",
|
"creating": "Creating...",
|
||||||
@@ -506,7 +704,18 @@
|
|||||||
"memoryUsage": "Memory Usage",
|
"memoryUsage": "Memory Usage",
|
||||||
"rootStorageSpace": "Root Storage Space",
|
"rootStorageSpace": "Root Storage Space",
|
||||||
"of": "of",
|
"of": "of",
|
||||||
"feedbackMessage": "Have ideas for what should come next for server management? Share them on"
|
"feedbackMessage": "Have ideas for what should come next for server management? Share them on",
|
||||||
|
"failedToFetchHostConfig": "Failed to fetch host configuration",
|
||||||
|
"failedToFetchStatus": "Failed to fetch server status",
|
||||||
|
"failedToFetchMetrics": "Failed to fetch server metrics",
|
||||||
|
"failedToFetchHomeData": "Failed to fetch home data",
|
||||||
|
"loadingMetrics": "Loading metrics...",
|
||||||
|
"refreshing": "Refreshing...",
|
||||||
|
"serverOffline": "Server Offline",
|
||||||
|
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||||
|
"load": "Load",
|
||||||
|
"free": "Free",
|
||||||
|
"available": "Available"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"loginTitle": "Login to Termix",
|
"loginTitle": "Login to Termix",
|
||||||
@@ -577,6 +786,7 @@
|
|||||||
"external": "External",
|
"external": "External",
|
||||||
"loginWithExternal": "Login with External Provider",
|
"loginWithExternal": "Login with External Provider",
|
||||||
"loginWithExternalDesc": "Login using your configured external identity provider",
|
"loginWithExternalDesc": "Login using your configured external identity provider",
|
||||||
|
"externalNotSupportedInElectron": "External authentication is not supported in the Electron app yet. Please use the web version for OIDC login.",
|
||||||
"resetPasswordButton": "Reset Password",
|
"resetPasswordButton": "Reset Password",
|
||||||
"sendResetCode": "Send Reset Code",
|
"sendResetCode": "Send Reset Code",
|
||||||
"resetCodeDesc": "Enter your username to receive a password reset code. The code will be logged in the docker container logs.",
|
"resetCodeDesc": "Enter your username to receive a password reset code. The code will be logged in the docker container logs.",
|
||||||
@@ -608,7 +818,6 @@
|
|||||||
"oidcAuthFailed": "OIDC authentication failed",
|
"oidcAuthFailed": "OIDC authentication failed",
|
||||||
"noTokenReceived": "No token received from login",
|
"noTokenReceived": "No token received from login",
|
||||||
"invalidAuthUrl": "Invalid authorization URL received from backend",
|
"invalidAuthUrl": "Invalid authorization URL received from backend",
|
||||||
"connectionTimeout": "Connection timeout",
|
|
||||||
"invalidInput": "Invalid input",
|
"invalidInput": "Invalid input",
|
||||||
"requiredField": "This field is required",
|
"requiredField": "This field is required",
|
||||||
"minLength": "Minimum length is {{min}}",
|
"minLength": "Minimum length is {{min}}",
|
||||||
@@ -653,6 +862,9 @@
|
|||||||
"external": "External (OIDC)",
|
"external": "External (OIDC)",
|
||||||
"selectPreferredLanguage": "Select your preferred language for the interface"
|
"selectPreferredLanguage": "Select your preferred language for the interface"
|
||||||
},
|
},
|
||||||
|
"user": {
|
||||||
|
"failedToLoadVersionInfo": "Failed to load version information"
|
||||||
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"enterCode": "000000",
|
"enterCode": "000000",
|
||||||
"ipAddress": "127.0.0.1",
|
"ipAddress": "127.0.0.1",
|
||||||
@@ -665,6 +877,10 @@
|
|||||||
"folder": "folder",
|
"folder": "folder",
|
||||||
"password": "password",
|
"password": "password",
|
||||||
"keyPassword": "key password",
|
"keyPassword": "key password",
|
||||||
|
"pastePrivateKey": "Paste your private key here...",
|
||||||
|
"credentialName": "My SSH Server",
|
||||||
|
"description": "SSH credential description",
|
||||||
|
"searchCredentials": "Search credentials by name, username, or tags...",
|
||||||
"sshConfig": "endpoint ssh configuration",
|
"sshConfig": "endpoint ssh configuration",
|
||||||
"homePath": "/home",
|
"homePath": "/home",
|
||||||
"clientId": "your-client-id",
|
"clientId": "your-client-id",
|
||||||
@@ -675,6 +891,7 @@
|
|||||||
"userIdField": "sub",
|
"userIdField": "sub",
|
||||||
"usernameField": "name",
|
"usernameField": "name",
|
||||||
"scopes": "openid email profile",
|
"scopes": "openid email profile",
|
||||||
|
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
|
||||||
"enterUsername": "Enter username to make admin",
|
"enterUsername": "Enter username to make admin",
|
||||||
"searchHosts": "Search hosts by name, username, IP, folder, tags...",
|
"searchHosts": "Search hosts by name, username, IP, folder, tags...",
|
||||||
"enterPassword": "Enter your password",
|
"enterPassword": "Enter your password",
|
||||||
@@ -810,5 +1027,9 @@
|
|||||||
"invalidVerificationCode": "Invalid verification code",
|
"invalidVerificationCode": "Invalid verification code",
|
||||||
"failedToDisableTotp": "Failed to disable TOTP",
|
"failedToDisableTotp": "Failed to disable TOTP",
|
||||||
"failedToGenerateBackupCodes": "Failed to generate backup codes"
|
"failedToGenerateBackupCodes": "Failed to generate backup codes"
|
||||||
|
},
|
||||||
|
"mobile": {
|
||||||
|
"selectHostToStart": "Select a host to start your terminal session",
|
||||||
|
"limitedSupportMessage": "Mobile support is currently limited. A dedicated mobile app is coming soon to enhance your experience."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,136 @@
|
|||||||
{
|
{
|
||||||
|
"credentials": {
|
||||||
|
"credentialsViewer": "凭证查看器",
|
||||||
|
"credentialsManager": "凭据管理器",
|
||||||
|
"manageYourSSHCredentials": "安全管理您的SSH凭据",
|
||||||
|
"addCredential": "添加凭据",
|
||||||
|
"createCredential": "创建凭据",
|
||||||
|
"editCredential": "编辑凭据",
|
||||||
|
"viewCredential": "查看凭据",
|
||||||
|
"duplicateCredential": "复制凭据",
|
||||||
|
"deleteCredential": "删除凭据",
|
||||||
|
"updateCredential": "更新凭据",
|
||||||
|
"credentialName": "凭据名称",
|
||||||
|
"credentialDescription": "描述",
|
||||||
|
"username": "用户名",
|
||||||
|
"searchCredentials": "搜索凭据...",
|
||||||
|
"selectFolder": "选择文件夹",
|
||||||
|
"selectAuthType": "选择认证类型",
|
||||||
|
"allFolders": "所有文件夹",
|
||||||
|
"allAuthTypes": "所有认证类型",
|
||||||
|
"uncategorized": "未分类",
|
||||||
|
"totalCredentials": "总计",
|
||||||
|
"keyBased": "密钥认证",
|
||||||
|
"passwordBased": "密码认证",
|
||||||
|
"folders": "文件夹",
|
||||||
|
"noCredentialsMatchFilters": "没有符合筛选条件的凭据",
|
||||||
|
"noCredentialsYet": "还未创建凭据",
|
||||||
|
"createFirstCredential": "创建您的第一个凭据",
|
||||||
|
"failedToFetchCredentials": "获取凭据失败",
|
||||||
|
"credentialDeletedSuccessfully": "凭据删除成功",
|
||||||
|
"failedToDeleteCredential": "删除凭据失败",
|
||||||
|
"confirmDeleteCredential": "确定要删除凭据「{{name}}」吗?",
|
||||||
|
"credentialCreatedSuccessfully": "凭据创建成功",
|
||||||
|
"credentialUpdatedSuccessfully": "凭据更新成功",
|
||||||
|
"failedToSaveCredential": "保存凭据失败",
|
||||||
|
"failedToFetchCredentialDetails": "获取凭据详情失败",
|
||||||
|
"failedToFetchHostsUsing": "获取使用此凭据的主机失败",
|
||||||
|
"loadingCredentials": "正在加载凭据...",
|
||||||
|
"retry": "重试",
|
||||||
|
"noCredentials": "暂无凭据",
|
||||||
|
"noCredentialsMessage": "你还没有添加任何凭证。点击“添加凭证”以开始。",
|
||||||
|
"sshCredentials": "SSH凭据",
|
||||||
|
"credentialsCount": "{{count}} 个凭据",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"passwordRequired": "密码为必填项",
|
||||||
|
"sshKeyRequired": "SSH密钥为必填项",
|
||||||
|
"credentialAddedSuccessfully": "凭据「{{name}}」添加成功",
|
||||||
|
"general": "常规",
|
||||||
|
"description": "描述",
|
||||||
|
"folder": "文件夹",
|
||||||
|
"tags": "标签",
|
||||||
|
"addTagsSpaceToAdd": "添加标签(按空格键添加)",
|
||||||
|
"password": "密码",
|
||||||
|
"key": "密钥",
|
||||||
|
"sshPrivateKey": "SSH私钥",
|
||||||
|
"upload": "上传",
|
||||||
|
"updateKey": "更新密钥",
|
||||||
|
"keyPassword": "密钥密码(可选)",
|
||||||
|
"keyType": "密钥类型",
|
||||||
|
"keyTypeRSA": "RSA",
|
||||||
|
"keyTypeECDSA": "ECDSA",
|
||||||
|
"keyTypeEd25519": "Ed25519",
|
||||||
|
"basicInfo": "基本信息",
|
||||||
|
"authentication": "认证方式",
|
||||||
|
"organization": "组织管理",
|
||||||
|
"basicInformation": "基本信息",
|
||||||
|
"basicInformationDescription": "输入此凭据的基本信息",
|
||||||
|
"authenticationMethod": "认证方式",
|
||||||
|
"authenticationMethodDescription": "选择如何与SSH服务器进行认证",
|
||||||
|
"organizationDescription": "使用文件夹和标签来组织您的凭据",
|
||||||
|
"enterCredentialName": "输入凭据名称",
|
||||||
|
"enterCredentialDescription": "输入描述(可选)",
|
||||||
|
"enterUsername": "输入用户名",
|
||||||
|
"nameIsRequired": "凭据名称是必需的",
|
||||||
|
"usernameIsRequired": "用户名是必需的",
|
||||||
|
"authenticationType": "认证类型",
|
||||||
|
"passwordAuthDescription": "使用密码认证",
|
||||||
|
"sshKeyAuthDescription": "使用SSH密钥认证",
|
||||||
|
"passwordIsRequired": "密码是必需的",
|
||||||
|
"sshKeyIsRequired": "SSH密钥是必需的",
|
||||||
|
"sshKeyType": "SSH密钥类型",
|
||||||
|
"privateKey": "私钥",
|
||||||
|
"enterPassword": "输入密码",
|
||||||
|
"enterPrivateKey": "输入私钥",
|
||||||
|
"keyPassphrase": "密钥密码",
|
||||||
|
"enterKeyPassphrase": "输入密钥密码(可选)",
|
||||||
|
"keyPassphraseOptional": "可选:如果您的密钥没有密码,请留空",
|
||||||
|
"leaveEmptyToKeepCurrent": "留空以保持当前值",
|
||||||
|
"uploadKeyFile": "上传密钥文件",
|
||||||
|
"generateKeyPair": "生成密钥对",
|
||||||
|
"sshKeyGenerationNotImplemented": "SSH密钥生成功能即将推出",
|
||||||
|
"connectionTestingNotImplemented": "连接测试功能即将推出",
|
||||||
|
"testConnection": "测试连接",
|
||||||
|
"selectOrCreateFolder": "选择或创建文件夹",
|
||||||
|
"noFolder": "无文件夹",
|
||||||
|
"orCreateNewFolder": "或创建新文件夹",
|
||||||
|
"addTag": "添加标签",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"overview": "概览",
|
||||||
|
"security": "安全",
|
||||||
|
"usage": "使用情况",
|
||||||
|
"securityDetails": "安全详情",
|
||||||
|
"securityDetailsDescription": "查看加密的凭据信息",
|
||||||
|
"credentialSecured": "凭据已加密",
|
||||||
|
"credentialSecuredDescription": "所有敏感数据均使用AES-256加密",
|
||||||
|
"passwordAuthentication": "密码认证",
|
||||||
|
"keyAuthentication": "密钥认证",
|
||||||
|
"securityReminder": "安全提醒",
|
||||||
|
"securityReminderText": "请勿分享您的凭据。所有数据均已静态加密。",
|
||||||
|
"hostsUsingCredential": "使用此凭据的主机",
|
||||||
|
"noHostsUsingCredential": "当前没有主机使用此凭据",
|
||||||
|
"timesUsed": "使用次数",
|
||||||
|
"lastUsed": "最后使用",
|
||||||
|
"connectedHosts": "连接的主机",
|
||||||
|
"created": "创建时间",
|
||||||
|
"lastModified": "最后修改",
|
||||||
|
"usageStatistics": "使用统计",
|
||||||
|
"copiedToClipboard": "{{field}}已复制到剪贴板",
|
||||||
|
"failedToCopy": "复制到剪贴板失败",
|
||||||
|
"sshKey": "SSH密钥",
|
||||||
|
"createCredentialDescription": "创建新的SSH凭据以进行安全访问",
|
||||||
|
"editCredentialDescription": "更新凭据信息",
|
||||||
|
"listView": "列表",
|
||||||
|
"folderView": "文件夹",
|
||||||
|
"unknown": "未知",
|
||||||
|
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?凭据将被移动到\"未分类\"。",
|
||||||
|
"removedFromFolder": "凭据\"{{name}}\"已成功从文件夹中移除",
|
||||||
|
"failedToRemoveFromFolder": "从文件夹中移除凭据失败",
|
||||||
|
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||||
|
"failedToRenameFolder": "重命名文件夹失败",
|
||||||
|
"movedToFolder": "凭据\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||||
|
"failedToMoveToFolder": "移动凭据到文件夹失败"
|
||||||
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"title": "SSH 工具",
|
"title": "SSH 工具",
|
||||||
"closeTools": "关闭 SSH 工具",
|
"closeTools": "关闭 SSH 工具",
|
||||||
@@ -18,6 +150,24 @@
|
|||||||
"failedToLoadAlerts": "加载警报失败",
|
"failedToLoadAlerts": "加载警报失败",
|
||||||
"failedToDismissAlert": "关闭警报失败"
|
"failedToDismissAlert": "关闭警报失败"
|
||||||
},
|
},
|
||||||
|
"serverConfig": {
|
||||||
|
"title": "服务器配置",
|
||||||
|
"description": "配置 Termix 服务器 URL 以连接到您的后端服务",
|
||||||
|
"serverUrl": "服务器 URL",
|
||||||
|
"enterServerUrl": "请输入服务器 URL",
|
||||||
|
"testConnectionFirst": "请先测试连接",
|
||||||
|
"connectionSuccess": "连接成功!",
|
||||||
|
"connectionFailed": "连接失败",
|
||||||
|
"connectionError": "连接发生错误",
|
||||||
|
"connected": "已连接",
|
||||||
|
"disconnected": "未连接",
|
||||||
|
"configSaved": "配置保存成功",
|
||||||
|
"saveFailed": "保存配置失败",
|
||||||
|
"saveError": "保存配置时出错",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"saveConfig": "保存配置",
|
||||||
|
"helpText": "输入您的 Termix 服务器运行地址(例如:http://localhost:8081 或 https://your-server.com)"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"online": "在线",
|
"online": "在线",
|
||||||
@@ -29,9 +179,10 @@
|
|||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"info": "信息",
|
"info": "信息",
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"loading": "加载中",
|
"loading": "加载中...",
|
||||||
"required": "必填",
|
"required": "必填",
|
||||||
"optional": "可选",
|
"optional": "可选",
|
||||||
|
"clear": "清除",
|
||||||
"toggleSidebar": "切换侧边栏",
|
"toggleSidebar": "切换侧边栏",
|
||||||
"sidebar": "侧边栏",
|
"sidebar": "侧边栏",
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
@@ -42,8 +193,7 @@
|
|||||||
"updateAvailable": "有可用更新",
|
"updateAvailable": "有可用更新",
|
||||||
"sshPath": "SSH 路径",
|
"sshPath": "SSH 路径",
|
||||||
"localPath": "本地路径",
|
"localPath": "本地路径",
|
||||||
"loading": "加载中...",
|
"noAuthCredentials": "此 SSH 主机没有可用的认证凭据",
|
||||||
"noAuthCredentials": "此 SSH 主机没有可用的身份验证凭据",
|
|
||||||
"noReleases": "没有发布版本",
|
"noReleases": "没有发布版本",
|
||||||
"updatesAndReleases": "更新与发布",
|
"updatesAndReleases": "更新与发布",
|
||||||
"newVersionAvailable": "有新版本 ({{version}}) 可用。",
|
"newVersionAvailable": "有新版本 ({{version}}) 可用。",
|
||||||
@@ -56,13 +206,10 @@
|
|||||||
"resetPassword": "重置密码",
|
"resetPassword": "重置密码",
|
||||||
"resetCode": "重置代码",
|
"resetCode": "重置代码",
|
||||||
"newPassword": "新密码",
|
"newPassword": "新密码",
|
||||||
"sshPath": "SSH 路径",
|
|
||||||
"localPath": "本地路径",
|
|
||||||
"folder": "文件夹",
|
"folder": "文件夹",
|
||||||
"file": "文件",
|
"file": "文件",
|
||||||
"renamedSuccessfully": "重命名成功",
|
"renamedSuccessfully": "重命名成功",
|
||||||
"deletedSuccessfully": "删除成功",
|
"deletedSuccessfully": "删除成功",
|
||||||
"noAuthCredentials": "此 SSH 主机没有可用的认证凭据",
|
|
||||||
"noTunnelConnections": "没有配置隧道连接",
|
"noTunnelConnections": "没有配置隧道连接",
|
||||||
"sshTools": "SSH 工具",
|
"sshTools": "SSH 工具",
|
||||||
"english": "英语",
|
"english": "英语",
|
||||||
@@ -77,27 +224,21 @@
|
|||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
"submit": "提交",
|
"submit": "提交",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
"change": "更改",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"loading": "加载中...",
|
|
||||||
"error": "错误",
|
|
||||||
"success": "成功",
|
|
||||||
"warning": "警告",
|
|
||||||
"info": "信息",
|
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"yes": "是",
|
"yes": "是",
|
||||||
"no": "否",
|
"no": "否",
|
||||||
"ok": "确定",
|
"ok": "确定",
|
||||||
"close": "关闭",
|
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
"important": "重要",
|
"important": "重要",
|
||||||
"notEnabled": "未启用",
|
"notEnabled": "未启用",
|
||||||
"settingUp": "设置中...",
|
"settingUp": "设置中...",
|
||||||
"back": "返回",
|
|
||||||
"next": "下一步",
|
"next": "下一步",
|
||||||
"previous": "上一步",
|
"previous": "上一步",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
@@ -115,31 +256,36 @@
|
|||||||
"passwordResetSuccess": "密码重置成功!您现在可以使用新密码登录。",
|
"passwordResetSuccess": "密码重置成功!您现在可以使用新密码登录。",
|
||||||
"failedToInitiatePasswordReset": "启动密码重置失败",
|
"failedToInitiatePasswordReset": "启动密码重置失败",
|
||||||
"failedToVerifyResetCode": "验证重置代码失败",
|
"failedToVerifyResetCode": "验证重置代码失败",
|
||||||
"failedToCompletePasswordReset": "完成密码重置失败"
|
"failedToCompletePasswordReset": "完成密码重置失败",
|
||||||
|
"documentation": "文档"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
"hosts": "主机",
|
"hosts": "主机",
|
||||||
|
"credentials": "凭据",
|
||||||
"terminal": "终端",
|
"terminal": "终端",
|
||||||
"tunnels": "隧道",
|
"tunnels": "隧道",
|
||||||
"fileManager": "文件管理器",
|
"fileManager": "文件管理器",
|
||||||
"serverStats": "服务器统计",
|
"serverStats": "服务器统计",
|
||||||
"admin": "管理员",
|
"admin": "管理员",
|
||||||
|
"userProfile": "用户资料",
|
||||||
"tools": "工具",
|
"tools": "工具",
|
||||||
"newTab": "新标签页",
|
"newTab": "新标签页",
|
||||||
"splitScreen": "分屏",
|
"splitScreen": "分屏",
|
||||||
"closeTab": "关闭标签页",
|
"closeTab": "关闭标签页",
|
||||||
"sshManager": "SSH 管理器",
|
"sshManager": "SSH 管理器",
|
||||||
"hostManager": "主机管理器",
|
"hostManager": "主机管理器",
|
||||||
"cannotSplitTab": "无法分割此标签页"
|
"cannotSplitTab": "无法分割此标签页",
|
||||||
|
"tabNavigation": "标签导航"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "管理员设置",
|
"title": "管理员设置",
|
||||||
|
"oidc": "OIDC",
|
||||||
"users": "用户",
|
"users": "用户",
|
||||||
"userManagement": "用户管理",
|
"userManagement": "用户管理",
|
||||||
"makeAdmin": "设为管理员",
|
"makeAdmin": "设为管理员",
|
||||||
"removeAdmin": "移除管理员",
|
"removeAdmin": "移除管理员",
|
||||||
"deleteUser": "删除用户",
|
"deleteUser": "删除用户 {{username}} 吗?此操作无法撤销。",
|
||||||
"allowRegistration": "允许注册",
|
"allowRegistration": "允许注册",
|
||||||
"oidcSettings": "OIDC 设置",
|
"oidcSettings": "OIDC 设置",
|
||||||
"clientId": "客户端 ID",
|
"clientId": "客户端 ID",
|
||||||
@@ -179,14 +325,18 @@
|
|||||||
"allowNewAccountRegistration": "允许新账户注册",
|
"allowNewAccountRegistration": "允许新账户注册",
|
||||||
"missingRequiredFields": "缺少必填字段:{{fields}}",
|
"missingRequiredFields": "缺少必填字段:{{fields}}",
|
||||||
"oidcConfigurationUpdated": "OIDC 配置更新成功!",
|
"oidcConfigurationUpdated": "OIDC 配置更新成功!",
|
||||||
|
"failedToFetchOidcConfig": "获取 OIDC 配置失败",
|
||||||
|
"failedToFetchRegistrationStatus": "获取注册状态失败",
|
||||||
|
"failedToFetchUsers": "获取用户列表失败",
|
||||||
|
"oidcConfigurationDisabled": "OIDC 配置禁用成功!",
|
||||||
"failedToUpdateOidcConfig": "更新 OIDC 配置失败",
|
"failedToUpdateOidcConfig": "更新 OIDC 配置失败",
|
||||||
|
"failedToDisableOidcConfig": "禁用 OIDC 配置失败",
|
||||||
"enterUsernameToMakeAdmin": "输入用户名以设为管理员",
|
"enterUsernameToMakeAdmin": "输入用户名以设为管理员",
|
||||||
"userIsNowAdmin": "用户 {{username}} 现在是管理员",
|
"userIsNowAdmin": "用户 {{username}} 现在是管理员",
|
||||||
"failedToMakeUserAdmin": "设为管理员失败",
|
"failedToMakeUserAdmin": "设为管理员失败",
|
||||||
"removeAdminStatus": "移除 {{username}} 的管理员权限吗?",
|
"removeAdminStatus": "移除 {{username}} 的管理员权限吗?",
|
||||||
"adminStatusRemoved": "已移除 {{username}} 的管理员权限",
|
"adminStatusRemoved": "已移除 {{username}} 的管理员权限",
|
||||||
"failedToRemoveAdminStatus": "移除管理员权限失败",
|
"failedToRemoveAdminStatus": "移除管理员权限失败",
|
||||||
"deleteUser": "删除用户 {{username}} 吗?此操作无法撤销。",
|
|
||||||
"userDeletedSuccessfully": "用户 {{username}} 删除成功",
|
"userDeletedSuccessfully": "用户 {{username}} 删除成功",
|
||||||
"failedToDeleteUser": "删除用户失败",
|
"failedToDeleteUser": "删除用户失败",
|
||||||
"overrideUserInfoUrl": "覆盖用户信息 URL(非必填)"
|
"overrideUserInfoUrl": "覆盖用户信息 URL(非必填)"
|
||||||
@@ -207,6 +357,8 @@
|
|||||||
"importJsonDesc": "上传 JSON 文件以批量导入多个 SSH 主机(最多 100 个)。",
|
"importJsonDesc": "上传 JSON 文件以批量导入多个 SSH 主机(最多 100 个)。",
|
||||||
"downloadSample": "下载示例",
|
"downloadSample": "下载示例",
|
||||||
"formatGuide": "格式指南",
|
"formatGuide": "格式指南",
|
||||||
|
"exportCredentialWarning": "警告:主机 \"{{name}}\" 使用凭据认证。导出的文件将不包含凭据数据,导入后需要手动重新配置。您确定要继续吗?",
|
||||||
|
"exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥)。出于安全考虑,导出的文件将不包含此数据。导入后您需要重新配置认证。您确定要继续吗?",
|
||||||
"uncategorized": "未分类",
|
"uncategorized": "未分类",
|
||||||
"confirmDelete": "确定要删除 \"{{name}}\" 吗?",
|
"confirmDelete": "确定要删除 \"{{name}}\" 吗?",
|
||||||
"failedToDeleteHost": "删除主机失败",
|
"failedToDeleteHost": "删除主机失败",
|
||||||
@@ -218,11 +370,12 @@
|
|||||||
"importError": "导入错误",
|
"importError": "导入错误",
|
||||||
"failedToImportJson": "导入 JSON 文件失败",
|
"failedToImportJson": "导入 JSON 文件失败",
|
||||||
"connectionDetails": "连接详情",
|
"connectionDetails": "连接详情",
|
||||||
"organization": "组织",
|
"organization": "组织管理",
|
||||||
"ipAddress": "IP 地址",
|
"ipAddress": "IP 地址",
|
||||||
"port": "端口",
|
"port": "端口",
|
||||||
"name": "名称",
|
"name": "名称",
|
||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
|
"hostName": "主机名",
|
||||||
"folder": "文件夹",
|
"folder": "文件夹",
|
||||||
"tags": "标签",
|
"tags": "标签",
|
||||||
"passwordRequired": "使用密码认证时需要密码",
|
"passwordRequired": "使用密码认证时需要密码",
|
||||||
@@ -232,16 +385,11 @@
|
|||||||
"addHost": "添加主机",
|
"addHost": "添加主机",
|
||||||
"editHost": "编辑主机",
|
"editHost": "编辑主机",
|
||||||
"deleteHost": "删除主机",
|
"deleteHost": "删除主机",
|
||||||
"hostName": "主机名",
|
|
||||||
"ipAddress": "IP 地址",
|
|
||||||
"port": "端口",
|
|
||||||
"authType": "认证类型",
|
"authType": "认证类型",
|
||||||
"passwordAuth": "密码",
|
"passwordAuth": "密码",
|
||||||
"keyAuth": "SSH 密钥",
|
"keyAuth": "SSH 密钥",
|
||||||
"keyPassword": "密钥密码",
|
"keyPassword": "密钥密码",
|
||||||
"keyType": "密钥类型",
|
"keyType": "密钥类型",
|
||||||
"folder": "文件夹",
|
|
||||||
"tags": "标签",
|
|
||||||
"pin": "固定",
|
"pin": "固定",
|
||||||
"enableTerminal": "启用终端",
|
"enableTerminal": "启用终端",
|
||||||
"enableTunnel": "启用隧道",
|
"enableTunnel": "启用隧道",
|
||||||
@@ -255,8 +403,6 @@
|
|||||||
"connecting": "连接中...",
|
"connecting": "连接中...",
|
||||||
"connectionFailed": "连接失败",
|
"connectionFailed": "连接失败",
|
||||||
"connectionSuccess": "连接成功",
|
"connectionSuccess": "连接成功",
|
||||||
"connectionDetails": "连接详情",
|
|
||||||
"organization": "组织管理",
|
|
||||||
"addTags": "添加标签(空格添加)",
|
"addTags": "添加标签(空格添加)",
|
||||||
"sourcePort": "源端口",
|
"sourcePort": "源端口",
|
||||||
"sourcePortDesc": "(源指通用标签页中的当前连接详情)",
|
"sourcePortDesc": "(源指通用标签页中的当前连接详情)",
|
||||||
@@ -296,9 +442,12 @@
|
|||||||
"authentication": "认证方式",
|
"authentication": "认证方式",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"key": "密钥",
|
"key": "密钥",
|
||||||
|
"credential": "凭证",
|
||||||
|
"selectCredential": "选择凭证",
|
||||||
|
"selectCredentialPlaceholder": "选择一个凭证...",
|
||||||
|
"credentialRequired": "使用凭证认证时需要选择凭证",
|
||||||
|
"credentialDescription": "选择凭证将覆盖当前用户名并使用凭证的认证详细信息。",
|
||||||
"sshPrivateKey": "SSH 私钥",
|
"sshPrivateKey": "SSH 私钥",
|
||||||
"keyPassword": "密钥密码",
|
|
||||||
"keyType": "密钥类型",
|
|
||||||
"maxRetriesDescription": "隧道连接的最大重试次数。",
|
"maxRetriesDescription": "隧道连接的最大重试次数。",
|
||||||
"retryIntervalDescription": "重试尝试之间的等待时间。",
|
"retryIntervalDescription": "重试尝试之间的等待时间。",
|
||||||
"otherInstallMethods": "其他安装方法:",
|
"otherInstallMethods": "其他安装方法:",
|
||||||
@@ -326,7 +475,11 @@
|
|||||||
"dsa": "DSA",
|
"dsa": "DSA",
|
||||||
"rsaSha2256": "RSA SHA2-256",
|
"rsaSha2256": "RSA SHA2-256",
|
||||||
"rsaSha2512": "RSA SHA2-512",
|
"rsaSha2512": "RSA SHA2-512",
|
||||||
|
"uploadFile": "上传文件",
|
||||||
|
"pasteKey": "粘贴密钥",
|
||||||
"updateKey": "更新密钥",
|
"updateKey": "更新密钥",
|
||||||
|
"existingKey": "现有密钥(点击更改)",
|
||||||
|
"existingCredential": "现有凭据(点击更改)",
|
||||||
"addTagsSpaceToAdd": "添加标签(空格添加)",
|
"addTagsSpaceToAdd": "添加标签(空格添加)",
|
||||||
"terminalBadge": "终端",
|
"terminalBadge": "终端",
|
||||||
"tunnelBadge": "隧道",
|
"tunnelBadge": "隧道",
|
||||||
@@ -334,7 +487,14 @@
|
|||||||
"general": "常规",
|
"general": "常规",
|
||||||
"terminal": "终端",
|
"terminal": "终端",
|
||||||
"tunnel": "隧道",
|
"tunnel": "隧道",
|
||||||
"fileManager": "文件管理器"
|
"fileManager": "文件管理器",
|
||||||
|
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?主机将被移动到\"无文件夹\"。",
|
||||||
|
"removedFromFolder": "主机\"{{name}}\"已成功从文件夹中移除",
|
||||||
|
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
||||||
|
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||||
|
"failedToRenameFolder": "重命名文件夹失败",
|
||||||
|
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||||
|
"failedToMoveToFolder": "移动主机到文件夹失败"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "终端",
|
"title": "终端",
|
||||||
@@ -354,7 +514,18 @@
|
|||||||
"error": "错误",
|
"error": "错误",
|
||||||
"disconnected": "已断开连接",
|
"disconnected": "已断开连接",
|
||||||
"connectionClosed": "连接已关闭",
|
"connectionClosed": "连接已关闭",
|
||||||
"connectionError": "连接错误"
|
"connectionError": "连接错误",
|
||||||
|
"connected": "已连接",
|
||||||
|
"sshConnected": "SSH 连接已建立",
|
||||||
|
"authError": "认证失败:{{message}}",
|
||||||
|
"unknownError": "发生未知错误",
|
||||||
|
"messageParseError": "解析服务器消息失败",
|
||||||
|
"websocketError": "WebSocket 连接错误",
|
||||||
|
"connecting": "连接中...",
|
||||||
|
"reconnecting": "重新连接中... ({{attempt}}/{{max}})",
|
||||||
|
"reconnected": "重新连接成功",
|
||||||
|
"maxReconnectAttemptsReached": "已达到最大重连尝试次数",
|
||||||
|
"connectionTimeout": "连接超时"
|
||||||
},
|
},
|
||||||
"fileManager": {
|
"fileManager": {
|
||||||
"title": "文件管理器",
|
"title": "文件管理器",
|
||||||
@@ -374,6 +545,11 @@
|
|||||||
"clickToSelectFile": "点击选择文件",
|
"clickToSelectFile": "点击选择文件",
|
||||||
"chooseFile": "选择文件",
|
"chooseFile": "选择文件",
|
||||||
"uploading": "上传中...",
|
"uploading": "上传中...",
|
||||||
|
"uploadingFile": "正在上传 {{name}}...",
|
||||||
|
"creatingFile": "正在创建 {{name}}...",
|
||||||
|
"creatingFolder": "正在创建 {{name}}...",
|
||||||
|
"deletingItem": "正在删除 {{type}} {{name}}...",
|
||||||
|
"renamingItem": "正在重命名 {{type}} {{oldName}} 为 {{newName}}...",
|
||||||
"createNewFile": "创建新文件",
|
"createNewFile": "创建新文件",
|
||||||
"fileName": "文件名",
|
"fileName": "文件名",
|
||||||
"creating": "创建中...",
|
"creating": "创建中...",
|
||||||
@@ -401,16 +577,11 @@
|
|||||||
"failedToRenameItem": "重命名项目失败",
|
"failedToRenameItem": "重命名项目失败",
|
||||||
"upload": "上传",
|
"upload": "上传",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"newFile": "新建文件",
|
|
||||||
"newFolder": "新建文件夹",
|
|
||||||
"rename": "重命名",
|
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"permissions": "权限",
|
"permissions": "权限",
|
||||||
"size": "大小",
|
"size": "大小",
|
||||||
"modified": "修改时间",
|
"modified": "修改时间",
|
||||||
"path": "路径",
|
"path": "路径",
|
||||||
"fileName": "文件名",
|
|
||||||
"folderName": "文件夹名",
|
|
||||||
"confirmDelete": "确定要删除 {{name}} 吗?",
|
"confirmDelete": "确定要删除 {{name}} 吗?",
|
||||||
"uploadSuccess": "文件上传成功",
|
"uploadSuccess": "文件上传成功",
|
||||||
"uploadFailed": "文件上传失败",
|
"uploadFailed": "文件上传失败",
|
||||||
@@ -430,10 +601,7 @@
|
|||||||
"fileSavedSuccessfully": "文件保存成功",
|
"fileSavedSuccessfully": "文件保存成功",
|
||||||
"saveTimeout": "保存操作超时。文件可能已成功保存,但操作用时过长。请检查 Docker 日志以确认。",
|
"saveTimeout": "保存操作超时。文件可能已成功保存,但操作用时过长。请检查 Docker 日志以确认。",
|
||||||
"failedToSaveFile": "保存文件失败",
|
"failedToSaveFile": "保存文件失败",
|
||||||
"folder": "文件夹",
|
|
||||||
"file": "文件",
|
|
||||||
"deletedSuccessfully": "删除成功",
|
"deletedSuccessfully": "删除成功",
|
||||||
"failedToDeleteItem": "删除项目失败",
|
|
||||||
"connectToServer": "连接到服务器",
|
"connectToServer": "连接到服务器",
|
||||||
"selectServerToEdit": "从侧边栏选择服务器以开始编辑文件",
|
"selectServerToEdit": "从侧边栏选择服务器以开始编辑文件",
|
||||||
"fileOperations": "文件操作",
|
"fileOperations": "文件操作",
|
||||||
@@ -461,11 +629,11 @@
|
|||||||
"tunnels": {
|
"tunnels": {
|
||||||
"title": "SSH 隧道",
|
"title": "SSH 隧道",
|
||||||
"noSshTunnels": "没有 SSH 隧道",
|
"noSshTunnels": "没有 SSH 隧道",
|
||||||
"createFirstTunnelMessage": "您还没有创建任何 SSH 隧道。在主机管理器中配置隧道连接以开始使用。",
|
"createFirstTunnelMessage": "创建您的第一个 SSH 隧道以开始使用。使用 SSH 管理器添加具有隧道连接的主机。",
|
||||||
"connected": "已连接",
|
"connected": "已连接",
|
||||||
"disconnected": "已断开",
|
"disconnected": "已断开连接",
|
||||||
"connecting": "连接中...",
|
"connecting": "连接中...",
|
||||||
"disconnecting": "断开中...",
|
"disconnecting": "断开连接中...",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
"error": "错误",
|
"error": "错误",
|
||||||
"failed": "失败",
|
"failed": "失败",
|
||||||
@@ -501,17 +669,7 @@
|
|||||||
"local": "本地",
|
"local": "本地",
|
||||||
"remote": "远程",
|
"remote": "远程",
|
||||||
"dynamic": "动态",
|
"dynamic": "动态",
|
||||||
"noSshTunnels": "没有 SSH 隧道",
|
|
||||||
"createFirstTunnelMessage": "创建您的第一个 SSH 隧道以开始使用。使用 SSH 管理器添加具有隧道连接的主机。",
|
|
||||||
"unknown": "未知",
|
|
||||||
"connected": "已连接",
|
|
||||||
"connecting": "连接中...",
|
|
||||||
"disconnecting": "断开连接中...",
|
|
||||||
"disconnected": "已断开连接",
|
|
||||||
"portMapping": "端口 {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
|
"portMapping": "端口 {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
|
||||||
"disconnect": "断开连接",
|
|
||||||
"connect": "连接",
|
|
||||||
"canceling": "取消中...",
|
|
||||||
"endpointHostNotFound": "未找到端点主机"
|
"endpointHostNotFound": "未找到端点主机"
|
||||||
},
|
},
|
||||||
"serverStats": {
|
"serverStats": {
|
||||||
@@ -521,7 +679,7 @@
|
|||||||
"disk": "磁盘",
|
"disk": "磁盘",
|
||||||
"network": "网络",
|
"network": "网络",
|
||||||
"uptime": "运行时间",
|
"uptime": "运行时间",
|
||||||
"loadAverage": "平均负载",
|
"loadAverage": "平均: {{avg1}}, {{avg5}}, {{avg15}}",
|
||||||
"processes": "进程",
|
"processes": "进程",
|
||||||
"connections": "连接",
|
"connections": "连接",
|
||||||
"usage": "使用率",
|
"usage": "使用率",
|
||||||
@@ -537,13 +695,20 @@
|
|||||||
"cpuCores_one": "{{count}} 个 CPU",
|
"cpuCores_one": "{{count}} 个 CPU",
|
||||||
"cpuCores_other": "{{count}} 个 CPU",
|
"cpuCores_other": "{{count}} 个 CPU",
|
||||||
"naCpus": "N/A CPU",
|
"naCpus": "N/A CPU",
|
||||||
"loadAverage": "平均: {{avg1}}, {{avg5}}, {{avg15}}",
|
|
||||||
"loadAverageNA": "平均: N/A",
|
"loadAverageNA": "平均: N/A",
|
||||||
"cpuUsage": "CPU 使用率",
|
"cpuUsage": "CPU 使用率",
|
||||||
"memoryUsage": "内存使用率",
|
"memoryUsage": "内存使用率",
|
||||||
"rootStorageSpace": "根目录存储空间",
|
"rootStorageSpace": "根目录存储空间",
|
||||||
"of": "的",
|
"of": "的",
|
||||||
"feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧"
|
"feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧",
|
||||||
|
"failedToFetchHostConfig": "获取主机配置失败",
|
||||||
|
"failedToFetchStatus": "获取服务器状态失败",
|
||||||
|
"failedToFetchMetrics": "获取服务器指标失败",
|
||||||
|
"loadingMetrics": "正在加载指标...",
|
||||||
|
"refreshing": "正在刷新...",
|
||||||
|
"serverOffline": "服务器离线",
|
||||||
|
"cannotFetchMetrics": "无法从离线服务器获取指标",
|
||||||
|
"load": "负载"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"loginTitle": "登录 Termix",
|
"loginTitle": "登录 Termix",
|
||||||
@@ -613,7 +778,8 @@
|
|||||||
"firstUserMessage": "您是第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是错误,请检查 docker 日志,或创建",
|
"firstUserMessage": "您是第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是错误,请检查 docker 日志,或创建",
|
||||||
"external": "外部",
|
"external": "外部",
|
||||||
"loginWithExternal": "使用外部提供商登录",
|
"loginWithExternal": "使用外部提供商登录",
|
||||||
"loginWithExternalDesc": "使用您配置的外部身份提供商登录",
|
"loginWithExternalDesc": "使用您配置的外部身份提供者登录",
|
||||||
|
"externalNotSupportedInElectron": "Electron 应用暂不支持外部身份验证。请使用网页版本进行 OIDC 登录。",
|
||||||
"resetPasswordButton": "重置密码",
|
"resetPasswordButton": "重置密码",
|
||||||
"sendResetCode": "发送重置代码",
|
"sendResetCode": "发送重置代码",
|
||||||
"resetCodeDesc": "输入您的用户名以接收密码重置代码。代码将记录在 docker 容器日志中。",
|
"resetCodeDesc": "输入您的用户名以接收密码重置代码。代码将记录在 docker 容器日志中。",
|
||||||
@@ -645,7 +811,6 @@
|
|||||||
"oidcAuthFailed": "OIDC 认证失败",
|
"oidcAuthFailed": "OIDC 认证失败",
|
||||||
"noTokenReceived": "登录未收到令牌",
|
"noTokenReceived": "登录未收到令牌",
|
||||||
"invalidAuthUrl": "从后端收到无效的授权 URL",
|
"invalidAuthUrl": "从后端收到无效的授权 URL",
|
||||||
"connectionTimeout": "连接超时",
|
|
||||||
"invalidInput": "输入无效",
|
"invalidInput": "输入无效",
|
||||||
"requiredField": "此字段为必填项",
|
"requiredField": "此字段为必填项",
|
||||||
"minLength": "最小长度为 {{min}}",
|
"minLength": "最小长度为 {{min}}",
|
||||||
@@ -690,6 +855,9 @@
|
|||||||
"external": "外部 (OIDC)",
|
"external": "外部 (OIDC)",
|
||||||
"selectPreferredLanguage": "选择您的界面首选语言"
|
"selectPreferredLanguage": "选择您的界面首选语言"
|
||||||
},
|
},
|
||||||
|
"user": {
|
||||||
|
"failedToLoadVersionInfo": "加载版本信息失败"
|
||||||
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"enterCode": "000000",
|
"enterCode": "000000",
|
||||||
"ipAddress": "127.0.0.1",
|
"ipAddress": "127.0.0.1",
|
||||||
@@ -701,7 +869,11 @@
|
|||||||
"hostname": "主机名",
|
"hostname": "主机名",
|
||||||
"folder": "文件夹",
|
"folder": "文件夹",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
|
"credentialName": "我的SSH服务器",
|
||||||
|
"description": "SSH凭据描述",
|
||||||
|
"searchCredentials": "按名称、用户名或标签搜索凭据...",
|
||||||
"keyPassword": "密钥密码",
|
"keyPassword": "密钥密码",
|
||||||
|
"pastePrivateKey": "在此粘贴您的私钥...",
|
||||||
"sshConfig": "端点 SSH 配置",
|
"sshConfig": "端点 SSH 配置",
|
||||||
"homePath": "/home",
|
"homePath": "/home",
|
||||||
"clientId": "您的客户端 ID",
|
"clientId": "您的客户端 ID",
|
||||||
@@ -712,6 +884,7 @@
|
|||||||
"userIdField": "sub",
|
"userIdField": "sub",
|
||||||
"usernameField": "name",
|
"usernameField": "name",
|
||||||
"scopes": "openid email profile",
|
"scopes": "openid email profile",
|
||||||
|
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
|
||||||
"enterUsername": "输入用户名以设为管理员",
|
"enterUsername": "输入用户名以设为管理员",
|
||||||
"searchHosts": "按名称、用户名、IP、文件夹、标签搜索主机...",
|
"searchHosts": "按名称、用户名、IP、文件夹、标签搜索主机...",
|
||||||
"enterPassword": "输入您的密码",
|
"enterPassword": "输入您的密码",
|
||||||
@@ -762,7 +935,6 @@
|
|||||||
"deleteItem": "删除项目",
|
"deleteItem": "删除项目",
|
||||||
"createNewFile": "创建新文件",
|
"createNewFile": "创建新文件",
|
||||||
"createNewFolder": "创建新文件夹",
|
"createNewFolder": "创建新文件夹",
|
||||||
"deleteItem": "删除项目",
|
|
||||||
"renameItem": "重命名项目",
|
"renameItem": "重命名项目",
|
||||||
"clickToSelectFile": "点击选择文件",
|
"clickToSelectFile": "点击选择文件",
|
||||||
"noSshHosts": "没有 SSH 主机",
|
"noSshHosts": "没有 SSH 主机",
|
||||||
@@ -827,8 +999,6 @@
|
|||||||
"updateKey": "更新密钥",
|
"updateKey": "更新密钥",
|
||||||
"sshpassRequired": "密码认证需要 Sshpass",
|
"sshpassRequired": "密码认证需要 Sshpass",
|
||||||
"sshServerConfigRequired": "需要 SSH 服务器配置",
|
"sshServerConfigRequired": "需要 SSH 服务器配置",
|
||||||
"sshManagerAlreadyOpen": "SSH 管理器已打开",
|
|
||||||
"disabledDuringSplitScreen": "分屏期间禁用",
|
|
||||||
"productionFolder": "生产环境",
|
"productionFolder": "生产环境",
|
||||||
"databaseServer": "数据库服务器",
|
"databaseServer": "数据库服务器",
|
||||||
"unknownError": "未知错误",
|
"unknownError": "未知错误",
|
||||||
@@ -851,5 +1021,9 @@
|
|||||||
"invalidVerificationCode": "无效的验证码",
|
"invalidVerificationCode": "无效的验证码",
|
||||||
"failedToDisableTotp": "禁用 TOTP 失败",
|
"failedToDisableTotp": "禁用 TOTP 失败",
|
||||||
"failedToGenerateBackupCodes": "生成备用码失败"
|
"failedToGenerateBackupCodes": "生成备用码失败"
|
||||||
|
},
|
||||||
|
"mobile": {
|
||||||
|
"selectHostToStart": "选择一个主机以开始您的终端会话",
|
||||||
|
"limitedSupportMessage": "移动端支持目前有限。我们即将推出专门的移动应用以提升您的体验。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
84
src/main.tsx
@@ -1,14 +1,72 @@
|
|||||||
import {StrictMode} 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 App from './App.tsx'
|
import DesktopApp from "./ui/Desktop/DesktopApp.tsx";
|
||||||
import {ThemeProvider} from "@/components/theme-provider"
|
import { MobileApp } from "./ui/Mobile/MobileApp.tsx";
|
||||||
import './i18n/i18n' // Initialize i18n
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import "./i18n/i18n";
|
||||||
|
import { isElectron } from "./ui/main-axios.ts";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
function useWindowWidth() {
|
||||||
<StrictMode>
|
const [width, setWidth] = useState(window.innerWidth);
|
||||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
<App/>
|
const lastSwitchTime = useRef(0);
|
||||||
</ThemeProvider>
|
const isCurrentlyMobile = useRef(window.innerWidth < 768);
|
||||||
</StrictMode>,
|
const hasSwitchedOnce = useRef(false);
|
||||||
)
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
const handleResize = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
const newWidth = window.innerWidth;
|
||||||
|
const newIsMobile = newWidth < 768;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (hasSwitchedOnce.current && now - lastSwitchTime.current < 10000) {
|
||||||
|
setWidth(newWidth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
newIsMobile !== isCurrentlyMobile.current &&
|
||||||
|
now - lastSwitchTime.current > 5000
|
||||||
|
) {
|
||||||
|
lastSwitchTime.current = now;
|
||||||
|
isCurrentlyMobile.current = newIsMobile;
|
||||||
|
hasSwitchedOnce.current = true;
|
||||||
|
setWidth(newWidth);
|
||||||
|
setIsMobile(newIsMobile);
|
||||||
|
} else {
|
||||||
|
setWidth(newWidth);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootApp() {
|
||||||
|
const width = useWindowWidth();
|
||||||
|
const isMobile = width < 768;
|
||||||
|
if (isElectron()) {
|
||||||
|
return <DesktopApp />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||||
|
<RootApp />
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|||||||
440
src/types/index.ts
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// CENTRAL TYPE DEFINITIONS
|
||||||
|
// ============================================================================
|
||||||
|
// This file contains all shared interfaces and types used across the application
|
||||||
|
// to avoid duplication and ensure consistency.
|
||||||
|
|
||||||
|
import type { Client } from "ssh2";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH HOST TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SSHHost {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
tags: string[];
|
||||||
|
pin: boolean;
|
||||||
|
authType: "password" | "key" | "credential";
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
credentialId?: number;
|
||||||
|
userId?: string;
|
||||||
|
enableTerminal: boolean;
|
||||||
|
enableTunnel: boolean;
|
||||||
|
enableFileManager: boolean;
|
||||||
|
defaultPath: string;
|
||||||
|
tunnelConnections: TunnelConnection[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSHHostData {
|
||||||
|
name?: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
folder?: string;
|
||||||
|
tags?: string[];
|
||||||
|
pin?: boolean;
|
||||||
|
authType: "password" | "key" | "credential";
|
||||||
|
password?: string;
|
||||||
|
key?: File | null;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
credentialId?: number | null;
|
||||||
|
enableTerminal?: boolean;
|
||||||
|
enableTunnel?: boolean;
|
||||||
|
enableFileManager?: boolean;
|
||||||
|
defaultPath?: string;
|
||||||
|
tunnelConnections?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CREDENTIAL TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Credential {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
folder?: string;
|
||||||
|
tags: string[];
|
||||||
|
authType: "password" | "key";
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
usageCount: number;
|
||||||
|
lastUsed?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialData {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
folder?: string;
|
||||||
|
tags: string[];
|
||||||
|
authType: "password" | "key";
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
key?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
keyType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TUNNEL TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TunnelConnection {
|
||||||
|
sourcePort: number;
|
||||||
|
endpointPort: number;
|
||||||
|
endpointHost: string;
|
||||||
|
maxRetries: number;
|
||||||
|
retryInterval: number;
|
||||||
|
autoStart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TunnelConfig {
|
||||||
|
name: string;
|
||||||
|
hostName: string;
|
||||||
|
sourceIP: string;
|
||||||
|
sourceSSHPort: number;
|
||||||
|
sourceUsername: string;
|
||||||
|
sourcePassword?: string;
|
||||||
|
sourceAuthMethod: string;
|
||||||
|
sourceSSHKey?: string;
|
||||||
|
sourceKeyPassword?: string;
|
||||||
|
sourceKeyType?: string;
|
||||||
|
sourceCredentialId?: number;
|
||||||
|
sourceUserId?: string;
|
||||||
|
endpointIP: string;
|
||||||
|
endpointSSHPort: number;
|
||||||
|
endpointUsername: string;
|
||||||
|
endpointPassword?: string;
|
||||||
|
endpointAuthMethod: string;
|
||||||
|
endpointSSHKey?: string;
|
||||||
|
endpointKeyPassword?: string;
|
||||||
|
endpointKeyType?: string;
|
||||||
|
endpointCredentialId?: number;
|
||||||
|
endpointUserId?: string;
|
||||||
|
sourcePort: number;
|
||||||
|
endpointPort: number;
|
||||||
|
maxRetries: number;
|
||||||
|
retryInterval: number;
|
||||||
|
autoStart: boolean;
|
||||||
|
isPinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TunnelStatus {
|
||||||
|
connected: boolean;
|
||||||
|
status: ConnectionState;
|
||||||
|
retryCount?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
nextRetryIn?: number;
|
||||||
|
reason?: string;
|
||||||
|
errorType?: ErrorType;
|
||||||
|
manualDisconnect?: boolean;
|
||||||
|
retryExhausted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FILE MANAGER TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Tab {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
fileName: string;
|
||||||
|
content: string;
|
||||||
|
isSSH?: boolean;
|
||||||
|
sshSessionId?: string;
|
||||||
|
filePath?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
dirty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileManagerFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type?: "file" | "directory";
|
||||||
|
isSSH?: boolean;
|
||||||
|
sshSessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileManagerShortcut {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileItem {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isPinned?: boolean;
|
||||||
|
type: "file" | "directory";
|
||||||
|
sshSessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortcutItem {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSHConnection {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
isPinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HOST INFO TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface HostInfo {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ALERT TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TermixAlert {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
expiresAt: string;
|
||||||
|
priority?: "low" | "medium" | "high" | "critical";
|
||||||
|
type?: "info" | "warning" | "error" | "success";
|
||||||
|
actionUrl?: string;
|
||||||
|
actionText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TAB TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TabContextTab {
|
||||||
|
id: number;
|
||||||
|
type:
|
||||||
|
| "home"
|
||||||
|
| "terminal"
|
||||||
|
| "ssh_manager"
|
||||||
|
| "server"
|
||||||
|
| "admin"
|
||||||
|
| "file_manager"
|
||||||
|
| "user_profile";
|
||||||
|
title: string;
|
||||||
|
hostConfig?: any;
|
||||||
|
terminalRef?: React.RefObject<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONNECTION STATES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CONNECTION_STATES = {
|
||||||
|
DISCONNECTED: "disconnected",
|
||||||
|
CONNECTING: "connecting",
|
||||||
|
CONNECTED: "connected",
|
||||||
|
VERIFYING: "verifying",
|
||||||
|
FAILED: "failed",
|
||||||
|
UNSTABLE: "unstable",
|
||||||
|
RETRYING: "retrying",
|
||||||
|
WAITING: "waiting",
|
||||||
|
DISCONNECTING: "disconnecting",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ConnectionState =
|
||||||
|
(typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES];
|
||||||
|
|
||||||
|
export type ErrorType =
|
||||||
|
| "CONNECTION_FAILED"
|
||||||
|
| "AUTHENTICATION_FAILED"
|
||||||
|
| "TIMEOUT"
|
||||||
|
| "NETWORK_ERROR"
|
||||||
|
| "UNKNOWN";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AUTHENTICATION TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AuthType = "password" | "key" | "credential";
|
||||||
|
|
||||||
|
export type KeyType = "rsa" | "ecdsa" | "ed25519";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API RESPONSE TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
status?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMPONENT PROP TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CredentialsManagerProps {
|
||||||
|
onEditCredential?: (credential: Credential) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialEditorProps {
|
||||||
|
editingCredential?: Credential | null;
|
||||||
|
onFormSubmit?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialViewerProps {
|
||||||
|
credential: Credential;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialSelectorProps {
|
||||||
|
value?: number | null;
|
||||||
|
onValueChange: (value: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostManagerProps {
|
||||||
|
onSelectView?: (view: string) => void;
|
||||||
|
isTopbarOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSHManagerHostEditorProps {
|
||||||
|
editingHost?: SSHHost | null;
|
||||||
|
onFormSubmit?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSHManagerHostViewerProps {
|
||||||
|
onEditHost?: (host: SSHHost) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostProps {
|
||||||
|
host: SSHHost;
|
||||||
|
onHostConnect?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSHTunnelProps {
|
||||||
|
filterHostKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSHTunnelViewerProps {
|
||||||
|
hosts?: SSHHost[];
|
||||||
|
tunnelStatuses?: Record<string, TunnelStatus>;
|
||||||
|
tunnelActions?: Record<
|
||||||
|
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 {
|
||||||
|
onSelectView?: (view: string) => void;
|
||||||
|
embedded?: boolean;
|
||||||
|
initialHost?: SSHHost | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileManagerLeftSidebarProps {
|
||||||
|
onSelectView?: (view: string) => void;
|
||||||
|
onOpenFile: (file: any) => void;
|
||||||
|
tabs: Tab[];
|
||||||
|
host: SSHHost;
|
||||||
|
onOperationComplete?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
onSuccess?: (message: string) => void;
|
||||||
|
onPathChange?: (path: string) => void;
|
||||||
|
onDeleteItem?: (item: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileManagerOperationsProps {
|
||||||
|
currentPath: string;
|
||||||
|
sshSessionId: string | null;
|
||||||
|
onOperationComplete?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
onSuccess?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertCardProps {
|
||||||
|
alert: TermixAlert;
|
||||||
|
onDismiss: (alertId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertManagerProps {
|
||||||
|
alerts: TermixAlert[];
|
||||||
|
onDismiss: (alertId: string) => void;
|
||||||
|
loggedIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSHTunnelObjectProps {
|
||||||
|
host: SSHHost;
|
||||||
|
tunnelStatuses: Record<string, TunnelStatus>;
|
||||||
|
tunnelActions: Record<string, boolean>;
|
||||||
|
onTunnelAction: (
|
||||||
|
action: "connect" | "disconnect" | "cancel",
|
||||||
|
host: SSHHost,
|
||||||
|
tunnelIndex: number,
|
||||||
|
) => Promise<any>;
|
||||||
|
compact?: boolean;
|
||||||
|
bare?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolderStats {
|
||||||
|
totalHosts: number;
|
||||||
|
hostsByType: Array<{
|
||||||
|
type: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BACKEND TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface HostConfig {
|
||||||
|
host: SSHHost;
|
||||||
|
tunnels: TunnelConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerificationData {
|
||||||
|
conn: Client;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
startTime: number;
|
||||||
|
attempts: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UTILITY TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||||
|
|
||||||
|
export type RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||||
|
|
||||||
|
export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {useSidebar} from "@/components/ui/sidebar";
|
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
|
||||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
|
||||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
|
||||||
import {Input} from "@/components/ui/input.tsx";
|
|
||||||
import {Label} from "@/components/ui/label.tsx";
|
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table.tsx";
|
|
||||||
import {Shield, Trash2, Users} from "lucide-react";
|
|
||||||
import {toast} from "sonner";
|
|
||||||
import {useTranslation} from "react-i18next";
|
|
||||||
import {
|
|
||||||
getOIDCConfig,
|
|
||||||
getRegistrationAllowed,
|
|
||||||
getUserList,
|
|
||||||
updateRegistrationAllowed,
|
|
||||||
updateOIDCConfig,
|
|
||||||
makeUserAdmin,
|
|
||||||
removeAdminStatus,
|
|
||||||
deleteUser
|
|
||||||
} from "@/ui/main-axios.ts";
|
|
||||||
|
|
||||||
function getCookie(name: string) {
|
|
||||||
return document.cookie.split('; ').reduce((r, v) => {
|
|
||||||
const parts = v.split('=');
|
|
||||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
|
||||||
}, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdminSettingsProps {
|
|
||||||
isTopbarOpen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const {state: sidebarState} = useSidebar();
|
|
||||||
|
|
||||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
|
||||||
const [regLoading, setRegLoading] = React.useState(false);
|
|
||||||
|
|
||||||
const [oidcConfig, setOidcConfig] = React.useState({
|
|
||||||
client_id: '',
|
|
||||||
client_secret: '',
|
|
||||||
issuer_url: '',
|
|
||||||
authorization_url: '',
|
|
||||||
token_url: '',
|
|
||||||
identifier_path: 'sub',
|
|
||||||
name_path: 'name',
|
|
||||||
scopes: 'openid email profile',
|
|
||||||
userinfo_url: ''
|
|
||||||
});
|
|
||||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
|
||||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const [users, setUsers] = React.useState<Array<{
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
is_admin: boolean;
|
|
||||||
is_oidc: boolean
|
|
||||||
}>>([]);
|
|
||||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
|
||||||
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
|
||||||
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
|
||||||
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
if (!jwt) return;
|
|
||||||
getOIDCConfig()
|
|
||||||
.then(res => {
|
|
||||||
if (res) setOidcConfig(res);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
});
|
|
||||||
fetchUsers();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
getRegistrationAllowed()
|
|
||||||
.then(res => {
|
|
||||||
if (typeof res?.allowed === 'boolean') {
|
|
||||||
setAllowRegistration(res.allowed);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
if (!jwt) return;
|
|
||||||
setUsersLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getUserList();
|
|
||||||
setUsers(response.users);
|
|
||||||
} finally {
|
|
||||||
setUsersLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleRegistration = async (checked: boolean) => {
|
|
||||||
setRegLoading(true);
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
try {
|
|
||||||
await updateRegistrationAllowed(checked);
|
|
||||||
setAllowRegistration(checked);
|
|
||||||
} finally {
|
|
||||||
setRegLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setOidcLoading(true);
|
|
||||||
setOidcError(null);
|
|
||||||
|
|
||||||
const required = ['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) {
|
|
||||||
setOidcError(t('admin.missingRequiredFields', { fields: missing.join(', ') }));
|
|
||||||
setOidcLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
try {
|
|
||||||
await updateOIDCConfig(oidcConfig);
|
|
||||||
toast.success(t('admin.oidcConfigurationUpdated'));
|
|
||||||
} catch (err: any) {
|
|
||||||
setOidcError(err?.response?.data?.error || t('admin.failedToUpdateOidcConfig'));
|
|
||||||
} finally {
|
|
||||||
setOidcLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOIDCConfigChange = (field: string, value: string) => {
|
|
||||||
setOidcConfig(prev => ({...prev, [field]: value}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMakeUserAdmin = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newAdminUsername.trim()) return;
|
|
||||||
setMakeAdminLoading(true);
|
|
||||||
setMakeAdminError(null);
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
try {
|
|
||||||
await makeUserAdmin(newAdminUsername.trim());
|
|
||||||
toast.success(t('admin.userIsNowAdmin', { username: newAdminUsername }));
|
|
||||||
setNewAdminUsername("");
|
|
||||||
fetchUsers();
|
|
||||||
} catch (err: any) {
|
|
||||||
setMakeAdminError(err?.response?.data?.error || t('admin.failedToMakeUserAdmin'));
|
|
||||||
} finally {
|
|
||||||
setMakeAdminLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveAdminStatus = async (username: string) => {
|
|
||||||
if (!confirm(t('admin.removeAdminStatus', { username }))) return;
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
try {
|
|
||||||
await removeAdminStatus(username);
|
|
||||||
toast.success(t('admin.adminStatusRemoved', { username }));
|
|
||||||
fetchUsers();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to remove admin status:', err);
|
|
||||||
toast.error(t('admin.failedToRemoveAdminStatus'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteUser = async (username: string) => {
|
|
||||||
if (!confirm(t('admin.deleteUser', { username }))) return;
|
|
||||||
const jwt = getCookie("jwt");
|
|
||||||
try {
|
|
||||||
await deleteUser(username);
|
|
||||||
toast.success(t('admin.userDeletedSuccessfully', { username }));
|
|
||||||
fetchUsers();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to delete user:', err);
|
|
||||||
toast.error(t('admin.failedToDeleteUser'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
|
||||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
|
||||||
const bottomMarginPx = 8;
|
|
||||||
const wrapperStyle: React.CSSProperties = {
|
|
||||||
marginLeft: leftMarginPx,
|
|
||||||
marginRight: 17,
|
|
||||||
marginTop: topMarginPx,
|
|
||||||
marginBottom: bottomMarginPx,
|
|
||||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={wrapperStyle}
|
|
||||||
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
|
|
||||||
<div className="h-full w-full flex flex-col">
|
|
||||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
|
||||||
<h1 className="font-bold text-lg">{t('admin.title')}</h1>
|
|
||||||
</div>
|
|
||||||
<Separator className="p-0.25 w-full"/>
|
|
||||||
|
|
||||||
<div className="px-6 py-4 overflow-auto">
|
|
||||||
<Tabs defaultValue="registration" className="w-full">
|
|
||||||
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
|
|
||||||
<TabsTrigger value="registration" className="flex items-center gap-2">
|
|
||||||
<Users className="h-4 w-4"/>
|
|
||||||
{t('admin.general')}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="oidc" className="flex items-center gap-2">
|
|
||||||
<Shield className="h-4 w-4"/>
|
|
||||||
OIDC
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="users" className="flex items-center gap-2">
|
|
||||||
<Users className="h-4 w-4"/>
|
|
||||||
{t('admin.users')}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="admins" className="flex items-center gap-2">
|
|
||||||
<Shield className="h-4 w-4"/>
|
|
||||||
{t('admin.adminManagement')}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="registration" className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold">{t('admin.userRegistration')}</h3>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
|
|
||||||
disabled={regLoading}/>
|
|
||||||
{t('admin.allowNewAccountRegistration')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="oidc" className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
|
|
||||||
|
|
||||||
{oidcError && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
|
||||||
<AlertDescription>{oidcError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="client_id">{t('admin.clientId')}</Label>
|
|
||||||
<Input id="client_id" value={oidcConfig.client_id}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
|
||||||
placeholder={t('placeholders.clientId')} required/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="client_secret">{t('admin.clientSecret')}</Label>
|
|
||||||
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
|
||||||
placeholder={t('placeholders.clientSecret')} required/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="authorization_url">{t('admin.authorizationUrl')}</Label>
|
|
||||||
<Input id="authorization_url" value={oidcConfig.authorization_url}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
|
||||||
placeholder={t('placeholders.authUrl')}
|
|
||||||
required/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="issuer_url">{t('admin.issuerUrl')}</Label>
|
|
||||||
<Input id="issuer_url" value={oidcConfig.issuer_url}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
|
||||||
placeholder={t('placeholders.redirectUrl')} required/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="token_url">{t('admin.tokenUrl')}</Label>
|
|
||||||
<Input id="token_url" value={oidcConfig.token_url}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
|
||||||
placeholder={t('placeholders.tokenUrl')} required/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="identifier_path">{t('admin.userIdentifierPath')}</Label>
|
|
||||||
<Input id="identifier_path" value={oidcConfig.identifier_path}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
|
||||||
placeholder={t('placeholders.userIdField')} required/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name_path">{t('admin.displayNamePath')}</Label>
|
|
||||||
<Input id="name_path" value={oidcConfig.name_path}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
|
||||||
placeholder={t('placeholders.usernameField')} required/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="scopes">{t('admin.scopes')}</Label>
|
|
||||||
<Input id="scopes" value={oidcConfig.scopes}
|
|
||||||
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
|
|
||||||
placeholder={t('placeholders.scopes')} required/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="userinfo_url">{t('admin.overrideUserInfoUrl')}</Label>
|
|
||||||
<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 className="flex gap-2 pt-2">
|
|
||||||
<Button type="submit" className="flex-1"
|
|
||||||
disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')}</Button>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setOidcConfig({
|
|
||||||
client_id: '',
|
|
||||||
client_secret: '',
|
|
||||||
issuer_url: '',
|
|
||||||
authorization_url: '',
|
|
||||||
token_url: '',
|
|
||||||
identifier_path: 'sub',
|
|
||||||
name_path: 'name',
|
|
||||||
scopes: 'openid email profile',
|
|
||||||
userinfo_url: ''
|
|
||||||
})}>{t('admin.reset')}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="users" className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold">{t('admin.userManagement')}</h3>
|
|
||||||
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
|
|
||||||
size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}</Button>
|
|
||||||
</div>
|
|
||||||
{usersLoading ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">{t('admin.loadingUsers')}</div>
|
|
||||||
) : (
|
|
||||||
<div className="border rounded-md overflow-hidden">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="px-4">{t('admin.username')}</TableHead>
|
|
||||||
<TableHead className="px-4">{t('admin.type')}</TableHead>
|
|
||||||
<TableHead className="px-4">{t('admin.actions')}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{users.map((user) => (
|
|
||||||
<TableRow key={user.id}>
|
|
||||||
<TableCell className="px-4 font-medium">
|
|
||||||
{user.username}
|
|
||||||
{user.is_admin && (
|
|
||||||
<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">{t('admin.adminBadge')}</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
|
||||||
<TableCell className="px-4">
|
|
||||||
<Button variant="ghost" size="sm"
|
|
||||||
onClick={() => handleDeleteUser(user.username)}
|
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
||||||
disabled={user.is_admin}>
|
|
||||||
<Trash2 className="h-4 w-4"/>
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="admins" className="space-y-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold">{t('admin.adminManagement')}</h3>
|
|
||||||
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
|
|
||||||
<h4 className="font-medium">{t('admin.makeUserAdmin')}</h4>
|
|
||||||
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="new-admin-username">{t('admin.username')}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input id="new-admin-username" value={newAdminUsername}
|
|
||||||
onChange={(e) => setNewAdminUsername(e.target.value)}
|
|
||||||
placeholder={t('admin.enterUsernameToMakeAdmin')} required/>
|
|
||||||
<Button type="submit"
|
|
||||||
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{makeAdminError && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
|
||||||
<AlertDescription>{makeAdminError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="font-medium">{t('admin.currentAdmins')}</h4>
|
|
||||||
<div className="border rounded-md overflow-hidden">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="px-4">{t('admin.username')}</TableHead>
|
|
||||||
<TableHead className="px-4">{t('admin.type')}</TableHead>
|
|
||||||
<TableHead className="px-4">{t('admin.actions')}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{users.filter(u => u.is_admin).map((admin) => (
|
|
||||||
<TableRow key={admin.id}>
|
|
||||||
<TableCell className="px-4 font-medium">
|
|
||||||
{admin.username}
|
|
||||||
<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">{t('admin.adminBadge')}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
|
||||||
<TableCell className="px-4">
|
|
||||||
<Button variant="ghost" size="sm"
|
|
||||||
onClick={() => handleRemoveAdminStatus(admin.username)}
|
|
||||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
|
||||||
<Shield className="h-4 w-4"/>
|
|
||||||
{t('admin.removeAdminButton')}
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AdminSettings;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { FileManagerTabList } from "./FileManagerTabList.tsx";
|
|
||||||
|
|
||||||
interface FileManagerTopNavbarProps {
|
|
||||||
tabs: {id: string | number, title: string}[];
|
|
||||||
activeTab: string | number;
|
|
||||||
setActiveTab: (tab: string | number) => void;
|
|
||||||
closeTab: (tab: string | number) => void;
|
|
||||||
onHomeClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement {
|
|
||||||
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FileManagerTabList
|
|
||||||
tabs={tabs}
|
|
||||||
activeTab={activeTab}
|
|
||||||
setActiveTab={setActiveTab}
|
|
||||||
closeTab={closeTab}
|
|
||||||
onHomeClick={onHomeClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,692 +0,0 @@
|
|||||||
import React, {useState, useEffect, useRef} from "react";
|
|
||||||
import {FileManagerLeftSidebar} from "@/ui/Apps/File Manager/FileManagerLeftSidebar.tsx";
|
|
||||||
import {FileManagerTabList} from "@/ui/Apps/File Manager/FileManagerTabList.tsx";
|
|
||||||
import {FileManagerHomeView} from "@/ui/Apps/File Manager/FileManagerHomeView.tsx";
|
|
||||||
import {FileManagerFileEditor} from "@/ui/Apps/File Manager/FileManagerFileEditor.tsx";
|
|
||||||
import {FileManagerOperations} from "@/ui/Apps/File Manager/FileManagerOperations.tsx";
|
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
|
||||||
import {FIleManagerTopNavbar} from "@/ui/Apps/File Manager/FIleManagerTopNavbar.tsx";
|
|
||||||
import {cn} from '@/lib/utils.ts';
|
|
||||||
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
|
||||||
import {Separator} from '@/components/ui/separator.tsx';
|
|
||||||
import {toast} from 'sonner';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
|
||||||
import {
|
|
||||||
getFileManagerRecent,
|
|
||||||
getFileManagerPinned,
|
|
||||||
getFileManagerShortcuts,
|
|
||||||
addFileManagerRecent,
|
|
||||||
removeFileManagerRecent,
|
|
||||||
addFileManagerPinned,
|
|
||||||
removeFileManagerPinned,
|
|
||||||
addFileManagerShortcut,
|
|
||||||
removeFileManagerShortcut,
|
|
||||||
readSSHFile,
|
|
||||||
writeSSHFile,
|
|
||||||
getSSHStatus,
|
|
||||||
connectSSH
|
|
||||||
} from '@/ui/main-axios.ts';
|
|
||||||
|
|
||||||
interface Tab {
|
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
fileName: string;
|
|
||||||
content: string;
|
|
||||||
isSSH?: boolean;
|
|
||||||
sshSessionId?: string;
|
|
||||||
filePath?: string;
|
|
||||||
loading?: boolean;
|
|
||||||
dirty?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SSHHost {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
folder: string;
|
|
||||||
tags: string[];
|
|
||||||
pin: boolean;
|
|
||||||
authType: string;
|
|
||||||
password?: string;
|
|
||||||
key?: string;
|
|
||||||
keyPassword?: string;
|
|
||||||
keyType?: string;
|
|
||||||
enableTerminal: boolean;
|
|
||||||
enableTunnel: boolean;
|
|
||||||
enableFileManager: boolean;
|
|
||||||
defaultPath: string;
|
|
||||||
tunnelConnections: any[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileManager({onSelectView, embedded = false, initialHost = null}: {
|
|
||||||
onSelectView?: (view: string) => void,
|
|
||||||
embedded?: boolean,
|
|
||||||
initialHost?: SSHHost | null
|
|
||||||
}): React.ReactElement {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
|
||||||
const [activeTab, setActiveTab] = useState<string | number>('home');
|
|
||||||
const [recent, setRecent] = useState<any[]>([]);
|
|
||||||
const [pinned, setPinned] = useState<any[]>([]);
|
|
||||||
const [shortcuts, setShortcuts] = useState<any[]>([]);
|
|
||||||
|
|
||||||
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
const [showOperations, setShowOperations] = useState(false);
|
|
||||||
const [currentPath, setCurrentPath] = useState('/');
|
|
||||||
|
|
||||||
const [deletingItem, setDeletingItem] = useState<any | null>(null);
|
|
||||||
|
|
||||||
const sidebarRef = useRef<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
|
|
||||||
setCurrentHost(initialHost);
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
const path = initialHost.defaultPath || '/';
|
|
||||||
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
|
||||||
sidebarRef.current.openFolder(initialHost, path);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}, [initialHost]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentHost) {
|
|
||||||
fetchHomeData();
|
|
||||||
} else {
|
|
||||||
setRecent([]);
|
|
||||||
setPinned([]);
|
|
||||||
setShortcuts([]);
|
|
||||||
}
|
|
||||||
}, [currentHost]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'home' && currentHost) {
|
|
||||||
fetchHomeData();
|
|
||||||
}
|
|
||||||
}, [activeTab, currentHost]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'home' && currentHost) {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchHomeData();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [activeTab, currentHost]);
|
|
||||||
|
|
||||||
async function fetchHomeData() {
|
|
||||||
if (!currentHost) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const homeDataPromise = Promise.all([
|
|
||||||
getFileManagerRecent(currentHost.id),
|
|
||||||
getFileManagerPinned(currentHost.id),
|
|
||||||
getFileManagerShortcuts(currentHost.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error(t('fileManager.fetchHomeDataTimeout'))), 15000)
|
|
||||||
);
|
|
||||||
|
|
||||||
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]) as [any, any, any];
|
|
||||||
|
|
||||||
const recentWithPinnedStatus = (recentRes || []).map(file => ({
|
|
||||||
...file,
|
|
||||||
type: 'file',
|
|
||||||
isPinned: (pinnedRes || []).some(pinnedFile =>
|
|
||||||
pinnedFile.path === file.path && pinnedFile.name === file.name
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pinnedWithType = (pinnedRes || []).map(file => ({
|
|
||||||
...file,
|
|
||||||
type: 'file'
|
|
||||||
}));
|
|
||||||
|
|
||||||
setRecent(recentWithPinnedStatus);
|
|
||||||
setPinned(pinnedWithType);
|
|
||||||
setShortcuts((shortcutsRes || []).map(shortcut => ({
|
|
||||||
...shortcut,
|
|
||||||
type: 'directory'
|
|
||||||
})));
|
|
||||||
} catch (err: any) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatErrorMessage = (err: any, defaultMessage: string): string => {
|
|
||||||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
|
||||||
const axiosErr = err as any;
|
|
||||||
if (axiosErr.response?.status === 403) {
|
|
||||||
return `${t('fileManager.permissionDenied')}. ${defaultMessage}. ${t('fileManager.checkDockerLogs')}.`;
|
|
||||||
} else if (axiosErr.response?.status === 500) {
|
|
||||||
const backendError = axiosErr.response?.data?.error || t('fileManager.internalServerError');
|
|
||||||
return `${t('fileManager.serverError')} (500): ${backendError}. ${t('fileManager.checkDockerLogs')}.`;
|
|
||||||
} else if (axiosErr.response?.data?.error) {
|
|
||||||
const backendError = axiosErr.response.data.error;
|
|
||||||
return `${axiosErr.response?.status ? `${t('fileManager.error')} ${axiosErr.response.status}: ` : ''}${backendError}. ${t('fileManager.checkDockerLogs')}.`;
|
|
||||||
} else {
|
|
||||||
return `${t('fileManager.requestFailed')} ${axiosErr.response?.status || t('fileManager.unknown')}. ${t('fileManager.checkDockerLogs')}.`;
|
|
||||||
}
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
return `${err.message}. ${t('fileManager.checkDockerLogs')}.`;
|
|
||||||
} else {
|
|
||||||
return `${defaultMessage}. ${t('fileManager.checkDockerLogs')}.`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenFile = async (file: any) => {
|
|
||||||
const tabId = file.path;
|
|
||||||
|
|
||||||
if (!tabs.find(t => t.id === tabId)) {
|
|
||||||
const currentSshSessionId = currentHost?.id.toString();
|
|
||||||
|
|
||||||
setTabs([...tabs, {
|
|
||||||
id: tabId,
|
|
||||||
title: file.name,
|
|
||||||
fileName: file.name,
|
|
||||||
content: '',
|
|
||||||
filePath: file.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: currentSshSessionId,
|
|
||||||
loading: true
|
|
||||||
}]);
|
|
||||||
try {
|
|
||||||
const res = await readSSHFile(currentSshSessionId, file.path);
|
|
||||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {
|
|
||||||
...t,
|
|
||||||
content: res.content,
|
|
||||||
loading: false,
|
|
||||||
error: undefined
|
|
||||||
} : t));
|
|
||||||
await addFileManagerRecent({
|
|
||||||
name: file.name,
|
|
||||||
path: file.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: currentSshSessionId,
|
|
||||||
hostId: currentHost?.id
|
|
||||||
});
|
|
||||||
fetchHomeData();
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = formatErrorMessage(err, t('fileManager.cannotReadFile'));
|
|
||||||
toast.error(errorMessage);
|
|
||||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false} : t));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setActiveTab(tabId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveRecent = async (file: any) => {
|
|
||||||
try {
|
|
||||||
await removeFileManagerRecent({
|
|
||||||
name: file.name,
|
|
||||||
path: file.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: file.sshSessionId,
|
|
||||||
hostId: currentHost?.id
|
|
||||||
});
|
|
||||||
fetchHomeData();
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePinFile = async (file: any) => {
|
|
||||||
try {
|
|
||||||
await addFileManagerPinned({
|
|
||||||
name: file.name,
|
|
||||||
path: file.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: file.sshSessionId,
|
|
||||||
hostId: currentHost?.id
|
|
||||||
});
|
|
||||||
fetchHomeData();
|
|
||||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
|
||||||
sidebarRef.current.fetchFiles();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnpinFile = async (file: any) => {
|
|
||||||
try {
|
|
||||||
await removeFileManagerPinned({
|
|
||||||
name: file.name,
|
|
||||||
path: file.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: file.sshSessionId,
|
|
||||||
hostId: currentHost?.id
|
|
||||||
});
|
|
||||||
fetchHomeData();
|
|
||||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
|
||||||
sidebarRef.current.fetchFiles();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenShortcut = async (shortcut: any) => {
|
|
||||||
if (sidebarRef.current?.isOpeningShortcut) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
|
||||||
try {
|
|
||||||
sidebarRef.current.isOpeningShortcut = true;
|
|
||||||
|
|
||||||
const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`;
|
|
||||||
|
|
||||||
await sidebarRef.current.openFolder(currentHost, normalizedPath);
|
|
||||||
} catch (err) {
|
|
||||||
} finally {
|
|
||||||
if (sidebarRef.current) {
|
|
||||||
sidebarRef.current.isOpeningShortcut = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddShortcut = async (folderPath: string) => {
|
|
||||||
try {
|
|
||||||
const name = folderPath.split('/').pop() || folderPath;
|
|
||||||
await addFileManagerShortcut({
|
|
||||||
name,
|
|
||||||
path: folderPath,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: currentHost?.id.toString(),
|
|
||||||
hostId: currentHost?.id
|
|
||||||
});
|
|
||||||
fetchHomeData();
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveShortcut = async (shortcut: any) => {
|
|
||||||
try {
|
|
||||||
await removeFileManagerShortcut({
|
|
||||||
name: shortcut.name,
|
|
||||||
path: shortcut.path,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: currentHost?.id.toString(),
|
|
||||||
hostId: currentHost?.id
|
|
||||||
});
|
|
||||||
fetchHomeData();
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeTab = (tabId: string | number) => {
|
|
||||||
const idx = tabs.findIndex(t => t.id === tabId);
|
|
||||||
const newTabs = tabs.filter(t => t.id !== tabId);
|
|
||||||
setTabs(newTabs);
|
|
||||||
if (activeTab === tabId) {
|
|
||||||
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
|
|
||||||
else setActiveTab('home');
|
|
||||||
}
|
|
||||||
if (currentHost) {
|
|
||||||
fetchHomeData();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setTabContent = (tabId: string | number, content: string) => {
|
|
||||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {
|
|
||||||
...t,
|
|
||||||
content,
|
|
||||||
dirty: true,
|
|
||||||
error: undefined,
|
|
||||||
success: undefined
|
|
||||||
} : t));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (tab: Tab) => {
|
|
||||||
if (isSaving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!tab.sshSessionId) {
|
|
||||||
throw new Error(t('fileManager.noSshSessionId'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tab.filePath) {
|
|
||||||
throw new Error(t('fileManager.noFilePath'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentHost?.id) {
|
|
||||||
throw new Error(t('fileManager.noCurrentHost'));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statusPromise = getSSHStatus(tab.sshSessionId);
|
|
||||||
const statusTimeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error(t('fileManager.sshStatusCheckTimeout'))), 10000)
|
|
||||||
);
|
|
||||||
|
|
||||||
const status = await Promise.race([statusPromise, statusTimeoutPromise]) as { connected: boolean };
|
|
||||||
|
|
||||||
if (!status.connected) {
|
|
||||||
const connectPromise = connectSSH(tab.sshSessionId, {
|
|
||||||
ip: currentHost.ip,
|
|
||||||
port: currentHost.port,
|
|
||||||
username: currentHost.username,
|
|
||||||
password: currentHost.password,
|
|
||||||
sshKey: currentHost.key,
|
|
||||||
keyPassword: currentHost.keyPassword
|
|
||||||
});
|
|
||||||
const connectTimeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error(t('fileManager.sshReconnectionTimeout'))), 15000)
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.race([connectPromise, connectTimeoutPromise]);
|
|
||||||
}
|
|
||||||
} catch (statusErr) {
|
|
||||||
}
|
|
||||||
|
|
||||||
const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content);
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(() => {
|
|
||||||
reject(new Error(t('fileManager.saveOperationTimeout')));
|
|
||||||
}, 30000)
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await Promise.race([savePromise, timeoutPromise]);
|
|
||||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
|
||||||
...t,
|
|
||||||
loading: false
|
|
||||||
} : t));
|
|
||||||
|
|
||||||
toast.success(t('fileManager.fileSavedSuccessfully'));
|
|
||||||
|
|
||||||
Promise.allSettled([
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await addFileManagerRecent({
|
|
||||||
name: tab.fileName,
|
|
||||||
path: tab.filePath,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: tab.sshSessionId,
|
|
||||||
hostId: currentHost.id
|
|
||||||
});
|
|
||||||
} catch (recentErr) {
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await fetchHomeData();
|
|
||||||
} catch (refreshErr) {
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
]).then(() => {
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
let errorMessage = formatErrorMessage(err, t('fileManager.cannotSaveFile'));
|
|
||||||
|
|
||||||
if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) {
|
|
||||||
errorMessage = t('fileManager.saveTimeout');
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(`${t('fileManager.failedToSaveFile')}: ${errorMessage}`);
|
|
||||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
|
||||||
...t,
|
|
||||||
loading: false
|
|
||||||
} : t));
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHostChange = (_host: SSHHost | null) => {
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOperationComplete = () => {
|
|
||||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
|
||||||
sidebarRef.current.fetchFiles();
|
|
||||||
}
|
|
||||||
if (currentHost) {
|
|
||||||
fetchHomeData();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuccess = (message: string) => {
|
|
||||||
toast.success(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = (error: string) => {
|
|
||||||
toast.error(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCurrentPath = (newPath: string) => {
|
|
||||||
setCurrentPath(newPath);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFromSidebar = (item: any) => {
|
|
||||||
setDeletingItem(item);
|
|
||||||
};
|
|
||||||
|
|
||||||
const performDelete = async (item: any) => {
|
|
||||||
if (!currentHost?.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
|
||||||
await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
|
|
||||||
toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`);
|
|
||||||
setDeletingItem(null);
|
|
||||||
handleOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
handleError(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!currentHost) {
|
|
||||||
return (
|
|
||||||
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
|
||||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
|
||||||
<FileManagerLeftSidebar
|
|
||||||
onSelectView={onSelectView || (() => {
|
|
||||||
})}
|
|
||||||
onOpenFile={handleOpenFile}
|
|
||||||
tabs={tabs}
|
|
||||||
ref={sidebarRef}
|
|
||||||
host={initialHost as SSHHost}
|
|
||||||
onOperationComplete={handleOperationComplete}
|
|
||||||
onError={handleError}
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
onPathChange={updateCurrentPath}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 256,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: '#09090b'
|
|
||||||
}}>
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-xl font-semibold text-white mb-2">{t('fileManager.connectToServer')}</h2>
|
|
||||||
<p className="text-muted-foreground">{t('fileManager.selectServerToEdit')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
|
||||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
|
||||||
<FileManagerLeftSidebar
|
|
||||||
onSelectView={onSelectView || (() => {
|
|
||||||
})}
|
|
||||||
onOpenFile={handleOpenFile}
|
|
||||||
tabs={tabs}
|
|
||||||
ref={sidebarRef}
|
|
||||||
host={currentHost as SSHHost}
|
|
||||||
onOperationComplete={handleOperationComplete}
|
|
||||||
onError={handleError}
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
onPathChange={updateCurrentPath}
|
|
||||||
onDeleteItem={handleDeleteFromSidebar}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 50, zIndex: 30}}>
|
|
||||||
<div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-[50px] relative">
|
|
||||||
<div
|
|
||||||
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
|
||||||
<FIleManagerTopNavbar
|
|
||||||
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
|
|
||||||
activeTab={activeTab}
|
|
||||||
setActiveTab={setActiveTab}
|
|
||||||
closeTab={closeTab}
|
|
||||||
onHomeClick={() => {
|
|
||||||
setActiveTab('home');
|
|
||||||
if (currentHost) {
|
|
||||||
fetchHomeData();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center gap-2 flex-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowOperations(!showOperations)}
|
|
||||||
className={cn(
|
|
||||||
'w-[30px] h-[30px]',
|
|
||||||
showOperations ? 'bg-[#2d2d30] border-[#434345]' : ''
|
|
||||||
)}
|
|
||||||
title={t('fileManager.fileOperations')}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4"/>
|
|
||||||
</Button>
|
|
||||||
<div className="p-0.25 w-px h-[30px] bg-[#303032]"></div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
const tab = tabs.find(t => t.id === activeTab);
|
|
||||||
if (tab && !isSaving) handleSave(tab);
|
|
||||||
}}
|
|
||||||
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
|
|
||||||
className={cn(
|
|
||||||
'w-[30px] h-[30px]',
|
|
||||||
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"/>}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 44,
|
|
||||||
left: 256,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
zIndex: 10,
|
|
||||||
background: '#101014',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}>
|
|
||||||
<div className="flex h-full">
|
|
||||||
<div className="flex-1">
|
|
||||||
{activeTab === 'home' ? (
|
|
||||||
<FileManagerHomeView
|
|
||||||
recent={recent}
|
|
||||||
pinned={pinned}
|
|
||||||
shortcuts={shortcuts}
|
|
||||||
onOpenFile={handleOpenFile}
|
|
||||||
onRemoveRecent={handleRemoveRecent}
|
|
||||||
onPinFile={handlePinFile}
|
|
||||||
onUnpinFile={handleUnpinFile}
|
|
||||||
onOpenShortcut={handleOpenShortcut}
|
|
||||||
onRemoveShortcut={handleRemoveShortcut}
|
|
||||||
onAddShortcut={handleAddShortcut}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
(() => {
|
|
||||||
const tab = tabs.find(t => t.id === activeTab);
|
|
||||||
if (!tab) return null;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<FileManagerFileEditor
|
|
||||||
content={tab.content}
|
|
||||||
fileName={tab.fileName}
|
|
||||||
onContentChange={content => setTabContent(tab.id, content)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showOperations && (
|
|
||||||
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
|
|
||||||
<FileManagerOperations
|
|
||||||
currentPath={currentPath}
|
|
||||||
sshSessionId={currentHost?.id.toString() || null}
|
|
||||||
onOperationComplete={handleOperationComplete}
|
|
||||||
onError={handleError}
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{deletingItem && (
|
|
||||||
<div className="fixed inset-0 z-[99999]">
|
|
||||||
<div className="absolute inset-0 bg-black/60"></div>
|
|
||||||
|
|
||||||
<div className="relative h-full flex items-center justify-center">
|
|
||||||
<div className="bg-[#18181b] border-2 border-[#303032] 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">
|
|
||||||
<Trash2 className="w-5 h-5 text-red-400"/>
|
|
||||||
{t('fileManager.confirmDelete')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-white mb-4">
|
|
||||||
{t('fileManager.confirmDeleteMessage', { name: deletingItem.name })}
|
|
||||||
{deletingItem.type === 'directory' && ` ${t('fileManager.deleteDirectoryWarning')}`}
|
|
||||||
</p>
|
|
||||||
<p className="text-red-400 text-sm mb-6">
|
|
||||||
{t('fileManager.actionCannotBeUndone')}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => performDelete(deletingItem)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{t('common.delete')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingItem(null)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
import React, {useState, useEffect} from "react";
|
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
|
||||||
import {loadLanguage} from '@uiw/codemirror-extensions-langs';
|
|
||||||
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
|
|
||||||
import {oneDark} from '@codemirror/theme-one-dark';
|
|
||||||
import {EditorView} from '@codemirror/view';
|
|
||||||
|
|
||||||
interface FileManagerCodeEditorProps {
|
|
||||||
content: string;
|
|
||||||
fileName: string;
|
|
||||||
onContentChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileManagerFileEditor({content, fileName, onContentChange}: FileManagerCodeEditorProps) {
|
|
||||||
function getLanguageName(filename: string): string {
|
|
||||||
if (!filename || typeof filename !== 'string') {
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
const lastDotIndex = filename.lastIndexOf('.');
|
|
||||||
if (lastDotIndex === -1) {
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
|
|
||||||
|
|
||||||
switch (ext) {
|
|
||||||
case 'ng':
|
|
||||||
return 'angular';
|
|
||||||
case 'apl':
|
|
||||||
return 'apl';
|
|
||||||
case 'asc':
|
|
||||||
return 'asciiArmor';
|
|
||||||
case 'ast':
|
|
||||||
return 'asterisk';
|
|
||||||
case 'bf':
|
|
||||||
return 'brainfuck';
|
|
||||||
case 'c':
|
|
||||||
return 'c';
|
|
||||||
case 'ceylon':
|
|
||||||
return 'ceylon';
|
|
||||||
case 'clj':
|
|
||||||
return 'clojure';
|
|
||||||
case 'cmake':
|
|
||||||
return 'cmake';
|
|
||||||
case 'cob':
|
|
||||||
case 'cbl':
|
|
||||||
return 'cobol';
|
|
||||||
case 'coffee':
|
|
||||||
return 'coffeescript';
|
|
||||||
case 'lisp':
|
|
||||||
return 'commonLisp';
|
|
||||||
case 'cpp':
|
|
||||||
case 'cc':
|
|
||||||
case 'cxx':
|
|
||||||
return 'cpp';
|
|
||||||
case 'cr':
|
|
||||||
return 'crystal';
|
|
||||||
case 'cs':
|
|
||||||
return 'csharp';
|
|
||||||
case 'css':
|
|
||||||
return 'css';
|
|
||||||
case 'cypher':
|
|
||||||
return 'cypher';
|
|
||||||
case 'd':
|
|
||||||
return 'd';
|
|
||||||
case 'dart':
|
|
||||||
return 'dart';
|
|
||||||
case 'diff':
|
|
||||||
case 'patch':
|
|
||||||
return 'diff';
|
|
||||||
case 'dockerfile':
|
|
||||||
return 'dockerfile';
|
|
||||||
case 'dtd':
|
|
||||||
return 'dtd';
|
|
||||||
case 'dylan':
|
|
||||||
return 'dylan';
|
|
||||||
case 'ebnf':
|
|
||||||
return 'ebnf';
|
|
||||||
case 'ecl':
|
|
||||||
return 'ecl';
|
|
||||||
case 'eiffel':
|
|
||||||
return 'eiffel';
|
|
||||||
case 'elm':
|
|
||||||
return 'elm';
|
|
||||||
case 'erl':
|
|
||||||
return 'erlang';
|
|
||||||
case 'factor':
|
|
||||||
return 'factor';
|
|
||||||
case 'fcl':
|
|
||||||
return 'fcl';
|
|
||||||
case 'fs':
|
|
||||||
return 'forth';
|
|
||||||
case 'f90':
|
|
||||||
case 'for':
|
|
||||||
return 'fortran';
|
|
||||||
case 's':
|
|
||||||
return 'gas';
|
|
||||||
case 'feature':
|
|
||||||
return 'gherkin';
|
|
||||||
case 'go':
|
|
||||||
return 'go';
|
|
||||||
case 'groovy':
|
|
||||||
return 'groovy';
|
|
||||||
case 'hs':
|
|
||||||
return 'haskell';
|
|
||||||
case 'hx':
|
|
||||||
return 'haxe';
|
|
||||||
case 'html':
|
|
||||||
case 'htm':
|
|
||||||
return 'html';
|
|
||||||
case 'http':
|
|
||||||
return 'http';
|
|
||||||
case 'idl':
|
|
||||||
return 'idl';
|
|
||||||
case 'java':
|
|
||||||
return 'java';
|
|
||||||
case 'js':
|
|
||||||
case 'mjs':
|
|
||||||
case 'cjs':
|
|
||||||
return 'javascript';
|
|
||||||
case 'jinja2':
|
|
||||||
case 'j2':
|
|
||||||
return 'jinja2';
|
|
||||||
case 'json':
|
|
||||||
return 'json';
|
|
||||||
case 'jsx':
|
|
||||||
return 'jsx';
|
|
||||||
case 'jl':
|
|
||||||
return 'julia';
|
|
||||||
case 'kt':
|
|
||||||
case 'kts':
|
|
||||||
return 'kotlin';
|
|
||||||
case 'less':
|
|
||||||
return 'less';
|
|
||||||
case 'lezer':
|
|
||||||
return 'lezer';
|
|
||||||
case 'liquid':
|
|
||||||
return 'liquid';
|
|
||||||
case 'litcoffee':
|
|
||||||
return 'livescript';
|
|
||||||
case 'lua':
|
|
||||||
return 'lua';
|
|
||||||
case 'md':
|
|
||||||
return 'markdown';
|
|
||||||
case 'nb':
|
|
||||||
case 'mat':
|
|
||||||
return 'mathematica';
|
|
||||||
case 'mbox':
|
|
||||||
return 'mbox';
|
|
||||||
case 'mmd':
|
|
||||||
return 'mermaid';
|
|
||||||
case 'mrc':
|
|
||||||
return 'mirc';
|
|
||||||
case 'moo':
|
|
||||||
return 'modelica';
|
|
||||||
case 'mscgen':
|
|
||||||
return 'mscgen';
|
|
||||||
case 'm':
|
|
||||||
return 'mumps';
|
|
||||||
case 'sql':
|
|
||||||
return 'mysql';
|
|
||||||
case 'nc':
|
|
||||||
return 'nesC';
|
|
||||||
case 'nginx':
|
|
||||||
return 'nginx';
|
|
||||||
case 'nix':
|
|
||||||
return 'nix';
|
|
||||||
case 'nsi':
|
|
||||||
return 'nsis';
|
|
||||||
case 'nt':
|
|
||||||
return 'ntriples';
|
|
||||||
case 'mm':
|
|
||||||
return 'objectiveCpp';
|
|
||||||
case 'octave':
|
|
||||||
return 'octave';
|
|
||||||
case 'oz':
|
|
||||||
return 'oz';
|
|
||||||
case 'pas':
|
|
||||||
return 'pascal';
|
|
||||||
case 'pl':
|
|
||||||
case 'pm':
|
|
||||||
return 'perl';
|
|
||||||
case 'pgsql':
|
|
||||||
return 'pgsql';
|
|
||||||
case 'php':
|
|
||||||
return 'php';
|
|
||||||
case 'pig':
|
|
||||||
return 'pig';
|
|
||||||
case 'ps1':
|
|
||||||
return 'powershell';
|
|
||||||
case 'properties':
|
|
||||||
return 'properties';
|
|
||||||
case 'proto':
|
|
||||||
return 'protobuf';
|
|
||||||
case 'pp':
|
|
||||||
return 'puppet';
|
|
||||||
case 'py':
|
|
||||||
return 'python';
|
|
||||||
case 'q':
|
|
||||||
return 'q';
|
|
||||||
case 'r':
|
|
||||||
return 'r';
|
|
||||||
case 'rb':
|
|
||||||
return 'ruby';
|
|
||||||
case 'rs':
|
|
||||||
return 'rust';
|
|
||||||
case 'sas':
|
|
||||||
return 'sas';
|
|
||||||
case 'sass':
|
|
||||||
case 'scss':
|
|
||||||
return 'sass';
|
|
||||||
case 'scala':
|
|
||||||
return 'scala';
|
|
||||||
case 'scm':
|
|
||||||
return 'scheme';
|
|
||||||
case 'shader':
|
|
||||||
return 'shader';
|
|
||||||
case 'sh':
|
|
||||||
case 'bash':
|
|
||||||
return 'shell';
|
|
||||||
case 'siv':
|
|
||||||
return 'sieve';
|
|
||||||
case 'st':
|
|
||||||
return 'smalltalk';
|
|
||||||
case 'sol':
|
|
||||||
return 'solidity';
|
|
||||||
case 'solr':
|
|
||||||
return 'solr';
|
|
||||||
case 'rq':
|
|
||||||
return 'sparql';
|
|
||||||
case 'xlsx':
|
|
||||||
case 'ods':
|
|
||||||
case 'csv':
|
|
||||||
return 'spreadsheet';
|
|
||||||
case 'nut':
|
|
||||||
return 'squirrel';
|
|
||||||
case 'tex':
|
|
||||||
return 'stex';
|
|
||||||
case 'styl':
|
|
||||||
return 'stylus';
|
|
||||||
case 'svelte':
|
|
||||||
return 'svelte';
|
|
||||||
case 'swift':
|
|
||||||
return 'swift';
|
|
||||||
case 'tcl':
|
|
||||||
return 'tcl';
|
|
||||||
case 'textile':
|
|
||||||
return 'textile';
|
|
||||||
case 'tiddlywiki':
|
|
||||||
return 'tiddlyWiki';
|
|
||||||
case 'tiki':
|
|
||||||
return 'tiki';
|
|
||||||
case 'toml':
|
|
||||||
return 'toml';
|
|
||||||
case 'troff':
|
|
||||||
return 'troff';
|
|
||||||
case 'tsx':
|
|
||||||
return 'tsx';
|
|
||||||
case 'ttcn':
|
|
||||||
return 'ttcn';
|
|
||||||
case 'ttl':
|
|
||||||
case 'turtle':
|
|
||||||
return 'turtle';
|
|
||||||
case 'ts':
|
|
||||||
return 'typescript';
|
|
||||||
case 'vb':
|
|
||||||
return 'vb';
|
|
||||||
case 'vbs':
|
|
||||||
return 'vbscript';
|
|
||||||
case 'vm':
|
|
||||||
return 'velocity';
|
|
||||||
case 'v':
|
|
||||||
return 'verilog';
|
|
||||||
case 'vhd':
|
|
||||||
case 'vhdl':
|
|
||||||
return 'vhdl';
|
|
||||||
case 'vue':
|
|
||||||
return 'vue';
|
|
||||||
case 'wat':
|
|
||||||
return 'wast';
|
|
||||||
case 'webidl':
|
|
||||||
return 'webIDL';
|
|
||||||
case 'xq':
|
|
||||||
case 'xquery':
|
|
||||||
return 'xQuery';
|
|
||||||
case 'xml':
|
|
||||||
return 'xml';
|
|
||||||
case 'yacas':
|
|
||||||
return 'yacas';
|
|
||||||
case 'yaml':
|
|
||||||
case 'yml':
|
|
||||||
return 'yaml';
|
|
||||||
case 'z80':
|
|
||||||
return 'z80';
|
|
||||||
default:
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.style.overflowX = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflowX = '';
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
overflow: 'auto',
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
className="config-codemirror-scroll-wrapper"
|
|
||||||
>
|
|
||||||
<CodeMirror
|
|
||||||
value={content}
|
|
||||||
extensions={[
|
|
||||||
loadLanguage(getLanguageName(fileName || 'untitled.txt') as any) || [],
|
|
||||||
hyperLink,
|
|
||||||
oneDark,
|
|
||||||
EditorView.theme({
|
|
||||||
'&': {
|
|
||||||
backgroundColor: '#09090b !important',
|
|
||||||
},
|
|
||||||
'.cm-gutters': {
|
|
||||||
backgroundColor: '#18181b !important',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
]}
|
|
||||||
onChange={(value: any) => onContentChange(value)}
|
|
||||||
theme={undefined}
|
|
||||||
height="100%"
|
|
||||||
basicSetup={{lineNumbers: true}}
|
|
||||||
style={{minHeight: '100%', minWidth: '100%', flex: 1}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
|
||||||
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
|
|
||||||
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
|
|
||||||
import {Input} from '@/components/ui/input.tsx';
|
|
||||||
import {useState} from 'react';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
|
||||||
|
|
||||||
interface FileItem {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
isPinned?: boolean;
|
|
||||||
type: 'file' | 'directory';
|
|
||||||
sshSessionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShortcutItem {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileManagerHomeViewProps {
|
|
||||||
recent: FileItem[];
|
|
||||||
pinned: FileItem[];
|
|
||||||
shortcuts: ShortcutItem[];
|
|
||||||
onOpenFile: (file: FileItem) => void;
|
|
||||||
onRemoveRecent: (file: FileItem) => void;
|
|
||||||
onPinFile: (file: FileItem) => void;
|
|
||||||
onUnpinFile: (file: FileItem) => void;
|
|
||||||
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
|
||||||
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
|
||||||
onAddShortcut: (path: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileManagerHomeView({
|
|
||||||
recent,
|
|
||||||
pinned,
|
|
||||||
shortcuts,
|
|
||||||
onOpenFile,
|
|
||||||
onRemoveRecent,
|
|
||||||
onPinFile,
|
|
||||||
onUnpinFile,
|
|
||||||
onOpenShortcut,
|
|
||||||
onRemoveShortcut,
|
|
||||||
onAddShortcut
|
|
||||||
}: FileManagerHomeViewProps) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
|
||||||
const [newShortcut, setNewShortcut] = useState('');
|
|
||||||
|
|
||||||
|
|
||||||
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
|
|
||||||
<div key={file.path}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
|
||||||
onClick={() => onOpenFile(file)}
|
|
||||||
>
|
|
||||||
{file.type === 'directory' ?
|
|
||||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
|
||||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>
|
|
||||||
}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
|
||||||
{file.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{onPin && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
|
||||||
onClick={onPin}
|
|
||||||
>
|
|
||||||
<Pin
|
|
||||||
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onRemove && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
|
||||||
onClick={onRemove}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
|
||||||
<div key={shortcut.path}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
|
||||||
onClick={() => onOpenShortcut(shortcut)}
|
|
||||||
>
|
|
||||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
|
||||||
{shortcut.path}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
|
||||||
onClick={() => onRemoveShortcut(shortcut)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
|
|
||||||
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
|
||||||
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
|
|
||||||
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">{t('fileManager.recent')}</TabsTrigger>
|
|
||||||
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">{t('fileManager.pinned')}</TabsTrigger>
|
|
||||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">{t('fileManager.folderShortcuts')}</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="recent" className="mt-0">
|
|
||||||
<div
|
|
||||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
|
||||||
{recent.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-8 col-span-full">
|
|
||||||
<span className="text-sm text-muted-foreground">{t('fileManager.noRecentFiles')}</span>
|
|
||||||
</div>
|
|
||||||
) : recent.map((file) =>
|
|
||||||
renderFileCard(
|
|
||||||
file,
|
|
||||||
() => onRemoveRecent(file),
|
|
||||||
() => file.isPinned ? onUnpinFile(file) : onPinFile(file),
|
|
||||||
file.isPinned
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="pinned" className="mt-0">
|
|
||||||
<div
|
|
||||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
|
||||||
{pinned.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-8 col-span-full">
|
|
||||||
<span className="text-sm text-muted-foreground">{t('fileManager.noPinnedFiles')}</span>
|
|
||||||
</div>
|
|
||||||
) : pinned.map((file) =>
|
|
||||||
renderFileCard(
|
|
||||||
file,
|
|
||||||
undefined,
|
|
||||||
() => onUnpinFile(file),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="shortcuts" className="mt-0">
|
|
||||||
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border-2 border-[#303032] rounded-lg">
|
|
||||||
<Input
|
|
||||||
placeholder={t('fileManager.enterFolderPath')}
|
|
||||||
value={newShortcut}
|
|
||||||
onChange={e => setNewShortcut(e.target.value)}
|
|
||||||
className="flex-1 bg-[#23232a] border-2 border-[#303032] text-white placeholder:text-muted-foreground"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && newShortcut.trim()) {
|
|
||||||
onAddShortcut(newShortcut.trim());
|
|
||||||
setNewShortcut('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 px-2 bg-[#23232a] border-2 !border-[#303032] hover:bg-[#2d2d30] rounded-md"
|
|
||||||
onClick={() => {
|
|
||||||
if (newShortcut.trim()) {
|
|
||||||
onAddShortcut(newShortcut.trim());
|
|
||||||
setNewShortcut('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5 mr-1"/>
|
|
||||||
{t('common.add')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
|
||||||
{shortcuts.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-4 col-span-full">
|
|
||||||
<span className="text-sm text-muted-foreground">{t('fileManager.noShortcuts')}</span>
|
|
||||||
</div>
|
|
||||||
) : shortcuts.map((shortcut) =>
|
|
||||||
renderShortcutCard(shortcut)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,574 +0,0 @@
|
|||||||
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
|
|
||||||
import {Separator} from '@/components/ui/separator.tsx';
|
|
||||||
import {CornerDownLeft, Folder, File, Server, 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 {
|
|
||||||
listSSHFiles,
|
|
||||||
renameSSHItem,
|
|
||||||
deleteSSHItem,
|
|
||||||
getFileManagerRecent,
|
|
||||||
getFileManagerPinned,
|
|
||||||
addFileManagerPinned,
|
|
||||||
removeFileManagerPinned,
|
|
||||||
readSSHFile,
|
|
||||||
getSSHStatus,
|
|
||||||
connectSSH
|
|
||||||
} from '@/ui/main-axios.ts';
|
|
||||||
|
|
||||||
interface SSHHost {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
folder: string;
|
|
||||||
tags: string[];
|
|
||||||
pin: boolean;
|
|
||||||
authType: string;
|
|
||||||
password?: string;
|
|
||||||
key?: string;
|
|
||||||
keyPassword?: string;
|
|
||||||
keyType?: string;
|
|
||||||
enableTerminal: boolean;
|
|
||||||
enableTunnel: boolean;
|
|
||||||
enableFileManager: boolean;
|
|
||||||
defaultPath: string;
|
|
||||||
tunnelConnections: any[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
|
||||||
{onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: {
|
|
||||||
onSelectView?: (view: string) => void;
|
|
||||||
onOpenFile: (file: any) => void;
|
|
||||||
tabs: any[];
|
|
||||||
host: SSHHost;
|
|
||||||
onOperationComplete?: () => void;
|
|
||||||
onError?: (error: string) => void;
|
|
||||||
onSuccess?: (message: string) => void;
|
|
||||||
onPathChange?: (path: string) => void;
|
|
||||||
onDeleteItem?: (item: any) => void;
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const [currentPath, setCurrentPath] = useState('/');
|
|
||||||
const [files, setFiles] = useState<any[]>([]);
|
|
||||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
||||||
const [fileSearch, setFileSearch] = useState('');
|
|
||||||
const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
|
||||||
return () => clearTimeout(handler);
|
|
||||||
}, [search]);
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
|
|
||||||
return () => clearTimeout(handler);
|
|
||||||
}, [fileSearch]);
|
|
||||||
|
|
||||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
|
||||||
const [filesLoading, setFilesLoading] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [connectingSSH, setConnectingSSH] = useState(false);
|
|
||||||
const [connectionCache, setConnectionCache] = useState<Record<string, {
|
|
||||||
sessionId: string;
|
|
||||||
timestamp: number
|
|
||||||
}>>({});
|
|
||||||
const [fetchingFiles, setFetchingFiles] = useState(false);
|
|
||||||
|
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
|
||||||
visible: boolean;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
item: any;
|
|
||||||
}>({
|
|
||||||
visible: false,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
item: null
|
|
||||||
});
|
|
||||||
|
|
||||||
const [renamingItem, setRenamingItem] = useState<{
|
|
||||||
item: any;
|
|
||||||
newName: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const nextPath = host?.defaultPath || '/';
|
|
||||||
setCurrentPath(nextPath);
|
|
||||||
onPathChange?.(nextPath);
|
|
||||||
(async () => {
|
|
||||||
await connectToSSH(host);
|
|
||||||
})();
|
|
||||||
}, [host?.id]);
|
|
||||||
|
|
||||||
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
|
||||||
const sessionId = server.id.toString();
|
|
||||||
|
|
||||||
const cached = connectionCache[sessionId];
|
|
||||||
if (cached && Date.now() - cached.timestamp < 30000) {
|
|
||||||
setSshSessionId(cached.sessionId);
|
|
||||||
return cached.sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectingSSH) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setConnectingSSH(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!server.password && !server.key) {
|
|
||||||
toast.error(t('common.noAuthCredentials'));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectionConfig = {
|
|
||||||
ip: server.ip,
|
|
||||||
port: server.port,
|
|
||||||
username: server.username,
|
|
||||||
password: server.password,
|
|
||||||
sshKey: server.key,
|
|
||||||
keyPassword: server.keyPassword,
|
|
||||||
};
|
|
||||||
|
|
||||||
await connectSSH(sessionId, connectionConfig);
|
|
||||||
|
|
||||||
setSshSessionId(sessionId);
|
|
||||||
|
|
||||||
setConnectionCache(prev => ({
|
|
||||||
...prev,
|
|
||||||
[sessionId]: {sessionId, timestamp: Date.now()}
|
|
||||||
}));
|
|
||||||
|
|
||||||
return sessionId;
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err?.response?.data?.error || t('fileManager.failedToConnectSSH'));
|
|
||||||
setSshSessionId(null);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setConnectingSSH(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFiles() {
|
|
||||||
if (fetchingFiles) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFetchingFiles(true);
|
|
||||||
setFiles([]);
|
|
||||||
setFilesLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let pinnedFiles: any[] = [];
|
|
||||||
try {
|
|
||||||
if (host) {
|
|
||||||
pinnedFiles = await getFileManagerPinned(host.id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
|
|
||||||
if (host && sshSessionId) {
|
|
||||||
let res: any[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = await getSSHStatus(sshSessionId);
|
|
||||||
if (!status.connected) {
|
|
||||||
const newSessionId = await connectToSSH(host);
|
|
||||||
if (newSessionId) {
|
|
||||||
setSshSessionId(newSessionId);
|
|
||||||
res = await listSSHFiles(newSessionId, currentPath);
|
|
||||||
} else {
|
|
||||||
throw new Error(t('fileManager.failedToReconnectSSH'));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res = await listSSHFiles(sshSessionId, currentPath);
|
|
||||||
}
|
|
||||||
} catch (sessionErr) {
|
|
||||||
const newSessionId = await connectToSSH(host);
|
|
||||||
if (newSessionId) {
|
|
||||||
setSshSessionId(newSessionId);
|
|
||||||
res = await listSSHFiles(newSessionId, currentPath);
|
|
||||||
} else {
|
|
||||||
throw sessionErr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processedFiles = (res || []).map((f: any) => {
|
|
||||||
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
|
|
||||||
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
|
|
||||||
return {
|
|
||||||
...f,
|
|
||||||
path: filePath,
|
|
||||||
isPinned,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: sshSessionId
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setFiles(processedFiles);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setFiles([]);
|
|
||||||
toast.error(err?.response?.data?.error || err?.message || t('fileManager.failedToListFiles'));
|
|
||||||
} finally {
|
|
||||||
setFilesLoading(false);
|
|
||||||
setFetchingFiles(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
fetchFiles();
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}, [currentPath, host, sshSessionId]);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
openFolder: async (_server: SSHHost, path: string) => {
|
|
||||||
if (connectingSSH || fetchingFiles) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPath === path) {
|
|
||||||
setTimeout(() => fetchFiles(), 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFetchingFiles(false);
|
|
||||||
setFilesLoading(false);
|
|
||||||
setFiles([]);
|
|
||||||
|
|
||||||
setCurrentPath(path);
|
|
||||||
onPathChange?.(path);
|
|
||||||
if (!sshSessionId) {
|
|
||||||
const sessionId = await connectToSSH(host);
|
|
||||||
if (sessionId) setSshSessionId(sessionId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fetchFiles: () => {
|
|
||||||
if (host && sshSessionId) {
|
|
||||||
fetchFiles();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getCurrentPath: () => currentPath
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (pathInputRef.current) {
|
|
||||||
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
|
|
||||||
}
|
|
||||||
}, [currentPath]);
|
|
||||||
|
|
||||||
const filteredFiles = files.filter(file => {
|
|
||||||
const q = debouncedFileSearch.trim().toLowerCase();
|
|
||||||
if (!q) return true;
|
|
||||||
return file.name.toLowerCase().includes(q);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent, item: any) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
const menuWidth = 160;
|
|
||||||
const menuHeight = 80;
|
|
||||||
|
|
||||||
let x = e.clientX;
|
|
||||||
let y = e.clientY;
|
|
||||||
|
|
||||||
if (x + menuWidth > viewportWidth) {
|
|
||||||
x = e.clientX - menuWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (y + menuHeight > viewportHeight) {
|
|
||||||
y = e.clientY - menuHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (x < 0) {
|
|
||||||
x = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (y < 0) {
|
|
||||||
y = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
setContextMenu({
|
|
||||||
visible: true,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
item
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeContextMenu = () => {
|
|
||||||
setContextMenu({ visible: false, x: 0, y: 0, item: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRename = async (item: any, newName: string) => {
|
|
||||||
if (!sshSessionId || !newName.trim() || newName === item.name) {
|
|
||||||
setRenamingItem(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await renameSSHItem(sshSessionId, item.path, newName.trim());
|
|
||||||
toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.renamedSuccessfully')}`);
|
|
||||||
setRenamingItem(null);
|
|
||||||
if (onOperationComplete) {
|
|
||||||
onOperationComplete();
|
|
||||||
} else {
|
|
||||||
fetchFiles();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (item: any) => {
|
|
||||||
if (!sshSessionId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteSSHItem(sshSessionId, item.path, item.type === 'directory');
|
|
||||||
toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.deletedSuccessfully')}`);
|
|
||||||
if (onOperationComplete) {
|
|
||||||
onOperationComplete();
|
|
||||||
} else {
|
|
||||||
fetchFiles();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startRename = (item: any) => {
|
|
||||||
setRenamingItem({ item, newName: item.name });
|
|
||||||
closeContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
const startDelete = (item: any) => {
|
|
||||||
onDeleteItem?.(item);
|
|
||||||
closeContextMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = () => closeContextMenu();
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('click', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePathChange = (newPath: string) => {
|
|
||||||
setCurrentPath(newPath);
|
|
||||||
onPathChange?.(newPath);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full w-[256px]" style={{maxWidth: 256}}>
|
|
||||||
<div className="flex flex-col flex-grow min-h-0">
|
|
||||||
<div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0">
|
|
||||||
{host && (
|
|
||||||
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
|
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="h-9 w-9 bg-[#18181b] border-2 border-[#303032] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
onClick={() => {
|
|
||||||
let path = currentPath;
|
|
||||||
if (path && path !== '/' && path !== '') {
|
|
||||||
if (path.endsWith('/')) path = path.slice(0, -1);
|
|
||||||
const lastSlash = path.lastIndexOf('/');
|
|
||||||
if (lastSlash > 0) {
|
|
||||||
handlePathChange(path.slice(0, lastSlash));
|
|
||||||
} else {
|
|
||||||
handlePathChange('/');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handlePathChange('/');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowUp className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
<Input ref={pathInputRef} value={currentPath}
|
|
||||||
onChange={e => handlePathChange(e.target.value)}
|
|
||||||
className="flex-1 bg-[#18181b] border-2 border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 py-2 border-b-1 border-[#303032] bg-[#18181b]">
|
|
||||||
<Input
|
|
||||||
placeholder={t('fileManager.searchFilesAndFolders')}
|
|
||||||
className="w-full h-7 text-sm bg-[#23232a] border-2 border-[#434345] text-white placeholder:text-muted-foreground rounded-md"
|
|
||||||
autoComplete="off"
|
|
||||||
value={fileSearch}
|
|
||||||
onChange={e => setFileSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 w-full bg-[#09090b] border-t-1 border-[#303032]">
|
|
||||||
<ScrollArea className="h-full w-full bg-[#09090b]">
|
|
||||||
<div className="p-2 pb-0">
|
|
||||||
{connectingSSH || filesLoading ? (
|
|
||||||
<div className="text-xs text-muted-foreground">{t('common.loading')}</div>
|
|
||||||
) : filteredFiles.length === 0 ? (
|
|
||||||
<div className="text-xs text-muted-foreground">{t('fileManager.noFilesOrFoldersFound')}</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{filteredFiles.map((item: any) => {
|
|
||||||
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
|
|
||||||
const isRenaming = renamingItem?.item?.path === item.path;
|
|
||||||
const isDeleting = false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.path}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded group max-w-full relative",
|
|
||||||
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
|
|
||||||
)}
|
|
||||||
style={{maxWidth: 220, marginBottom: 8}}
|
|
||||||
onContextMenu={(e) => !isOpen && handleContextMenu(e, item)}
|
|
||||||
>
|
|
||||||
{isRenaming ? (
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
{item.type === 'directory' ?
|
|
||||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
|
||||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
|
|
||||||
<Input
|
|
||||||
value={renamingItem.newName}
|
|
||||||
onChange={(e) => setRenamingItem(prev => prev ? {...prev, newName: e.target.value} : null)}
|
|
||||||
className="flex-1 h-6 text-sm bg-[#23232a] border border-[#434345] text-white"
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleRename(item, renamingItem.newName);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setRenamingItem(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => handleRename(item, renamingItem.newName)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
|
||||||
onClick={() => !isOpen && (item.type === 'directory' ? handlePathChange(item.path) : onOpenFile({
|
|
||||||
name: item.name,
|
|
||||||
path: item.path,
|
|
||||||
isSSH: item.isSSH,
|
|
||||||
sshSessionId: item.sshSessionId
|
|
||||||
}))}
|
|
||||||
>
|
|
||||||
{item.type === 'directory' ?
|
|
||||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
|
||||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
|
|
||||||
<span className="text-sm text-white truncate flex-1 min-w-0">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{item.type === 'file' && (
|
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
|
||||||
disabled={isOpen}
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
if (item.isPinned) {
|
|
||||||
await removeFileManagerPinned({
|
|
||||||
name: item.name,
|
|
||||||
path: item.path,
|
|
||||||
hostId: host?.id,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: host?.id.toString()
|
|
||||||
});
|
|
||||||
setFiles(files.map(f =>
|
|
||||||
f.path === item.path ? { ...f, isPinned: false } : f
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
await addFileManagerPinned({
|
|
||||||
name: item.name,
|
|
||||||
path: item.path,
|
|
||||||
hostId: host?.id,
|
|
||||||
isSSH: true,
|
|
||||||
sshSessionId: host?.id.toString()
|
|
||||||
});
|
|
||||||
setFiles(files.map(f =>
|
|
||||||
f.path === item.path ? { ...f, isPinned: true } : f
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pin className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isOpen && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleContextMenu(e, item);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{contextMenu.visible && contextMenu.item && (
|
|
||||||
<div
|
|
||||||
className="fixed z-[99998] bg-[#18181b] border-2 border-[#303032] rounded-lg shadow-xl py-1 min-w-[160px]"
|
|
||||||
style={{
|
|
||||||
left: contextMenu.x,
|
|
||||||
top: contextMenu.y,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-[#2d2d30] flex items-center gap-2"
|
|
||||||
onClick={() => startRename(contextMenu.item)}
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
Rename
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-[#2d2d30] flex items-center gap-2"
|
|
||||||
onClick={() => startDelete(contextMenu.item)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export {FileManagerLeftSidebar};
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
|
||||||
import {Card} from '@/components/ui/card.tsx';
|
|
||||||
import {Separator} from '@/components/ui/separator.tsx';
|
|
||||||
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
|
||||||
|
|
||||||
interface SSHConnection {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
isPinned?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileItem {
|
|
||||||
name: string;
|
|
||||||
type: 'file' | 'directory' | 'link';
|
|
||||||
path: string;
|
|
||||||
isStarred?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileManagerLeftSidebarVileViewerProps {
|
|
||||||
sshConnections: SSHConnection[];
|
|
||||||
onAddSSH: () => void;
|
|
||||||
onConnectSSH: (conn: SSHConnection) => void;
|
|
||||||
onEditSSH: (conn: SSHConnection) => void;
|
|
||||||
onDeleteSSH: (conn: SSHConnection) => void;
|
|
||||||
onPinSSH: (conn: SSHConnection) => void;
|
|
||||||
currentPath: string;
|
|
||||||
files: FileItem[];
|
|
||||||
onOpenFile: (file: FileItem) => void;
|
|
||||||
onOpenFolder: (folder: FileItem) => void;
|
|
||||||
onStarFile: (file: FileItem) => void;
|
|
||||||
onDeleteFile: (file: FileItem) => void;
|
|
||||||
isLoading?: boolean;
|
|
||||||
error?: string;
|
|
||||||
isSSHMode: boolean;
|
|
||||||
onSwitchToLocal: () => void;
|
|
||||||
onSwitchToSSH: (conn: SSHConnection) => void;
|
|
||||||
currentSSH?: SSHConnection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileManagerLeftSidebarFileViewer({
|
|
||||||
sshConnections,
|
|
||||||
onAddSSH,
|
|
||||||
onConnectSSH,
|
|
||||||
onEditSSH,
|
|
||||||
onDeleteSSH,
|
|
||||||
onPinSSH,
|
|
||||||
currentPath,
|
|
||||||
files,
|
|
||||||
onOpenFile,
|
|
||||||
onOpenFolder,
|
|
||||||
onStarFile,
|
|
||||||
onDeleteFile,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
isSSHMode,
|
|
||||||
onSwitchToLocal,
|
|
||||||
onSwitchToSSH,
|
|
||||||
currentSSH,
|
|
||||||
}: FileManagerLeftSidebarVileViewerProps) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? t('common.sshPath') : t('common.localPath')}</span>
|
|
||||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-xs text-muted-foreground">{t('common.loading')}</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-xs text-red-500">{error}</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{files.map((item) => (
|
|
||||||
<Card key={item.path}
|
|
||||||
className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border-2 border-[#303032] rounded">
|
|
||||||
<div className="flex items-center gap-2 flex-1 cursor-pointer"
|
|
||||||
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 className="flex items-center gap-1">
|
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
|
||||||
onClick={() => onStarFile(item)}>
|
|
||||||
<Pin
|
|
||||||
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
|
||||||
onClick={() => onDeleteFile(item)}>
|
|
||||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
{files.length === 0 &&
|
|
||||||
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
import React, {useState, useRef, useEffect} from 'react';
|
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
|
||||||
import {Input} from '@/components/ui/input.tsx';
|
|
||||||
import {Card} from '@/components/ui/card.tsx';
|
|
||||||
import {Separator} from '@/components/ui/separator.tsx';
|
|
||||||
import {
|
|
||||||
Upload,
|
|
||||||
FilePlus,
|
|
||||||
FolderPlus,
|
|
||||||
Trash2,
|
|
||||||
Edit3,
|
|
||||||
X,
|
|
||||||
Check,
|
|
||||||
AlertCircle,
|
|
||||||
FileText,
|
|
||||||
Folder
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {cn} from '@/lib/utils.ts';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
|
||||||
|
|
||||||
interface FileManagerOperationsProps {
|
|
||||||
currentPath: string;
|
|
||||||
sshSessionId: string | null;
|
|
||||||
onOperationComplete: () => void;
|
|
||||||
onError: (error: string) => void;
|
|
||||||
onSuccess: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileManagerOperations({
|
|
||||||
currentPath,
|
|
||||||
sshSessionId,
|
|
||||||
onOperationComplete,
|
|
||||||
onError,
|
|
||||||
onSuccess
|
|
||||||
}: FileManagerOperationsProps) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
|
||||||
const [showCreateFile, setShowCreateFile] = useState(false);
|
|
||||||
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
|
||||||
const [showDelete, setShowDelete] = useState(false);
|
|
||||||
const [showRename, setShowRename] = useState(false);
|
|
||||||
|
|
||||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
|
||||||
const [newFileName, setNewFileName] = useState('');
|
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
|
||||||
const [deletePath, setDeletePath] = useState('');
|
|
||||||
const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
|
|
||||||
const [renamePath, setRenamePath] = useState('');
|
|
||||||
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
|
|
||||||
const [newName, setNewName] = useState('');
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [showTextLabels, setShowTextLabels] = useState(true);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkContainerWidth = () => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
const width = containerRef.current.offsetWidth;
|
|
||||||
setShowTextLabels(width > 240);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkContainerWidth();
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(checkContainerWidth);
|
|
||||||
if (containerRef.current) {
|
|
||||||
resizeObserver.observe(containerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFileUpload = async () => {
|
|
||||||
if (!uploadFile || !sshSessionId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const content = await uploadFile.text();
|
|
||||||
const {uploadSSHFile} = await import('@/ui/main-axios.ts');
|
|
||||||
|
|
||||||
await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
|
|
||||||
onSuccess(t('fileManager.fileUploadedSuccessfully', { name: uploadFile.name }));
|
|
||||||
setShowUpload(false);
|
|
||||||
setUploadFile(null);
|
|
||||||
onOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
onError(error?.response?.data?.error || t('fileManager.failedToUploadFile'));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateFile = async () => {
|
|
||||||
if (!newFileName.trim() || !sshSessionId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const {createSSHFile} = await import('@/ui/main-axios.ts');
|
|
||||||
|
|
||||||
await createSSHFile(sshSessionId, currentPath, newFileName.trim());
|
|
||||||
onSuccess(t('fileManager.fileCreatedSuccessfully', { name: newFileName.trim() }));
|
|
||||||
setShowCreateFile(false);
|
|
||||||
setNewFileName('');
|
|
||||||
onOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
onError(error?.response?.data?.error || t('fileManager.failedToCreateFile'));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
|
||||||
if (!newFolderName.trim() || !sshSessionId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const {createSSHFolder} = await import('@/ui/main-axios.ts');
|
|
||||||
|
|
||||||
await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
|
|
||||||
onSuccess(t('fileManager.folderCreatedSuccessfully', { name: newFolderName.trim() }));
|
|
||||||
setShowCreateFolder(false);
|
|
||||||
setNewFolderName('');
|
|
||||||
onOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder'));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deletePath || !sshSessionId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
|
||||||
|
|
||||||
await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
|
|
||||||
onSuccess(t('fileManager.itemDeletedSuccessfully', { type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
|
||||||
setShowDelete(false);
|
|
||||||
setDeletePath('');
|
|
||||||
setDeleteIsDirectory(false);
|
|
||||||
onOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRename = async () => {
|
|
||||||
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const {renameSSHItem} = await import('@/ui/main-axios.ts');
|
|
||||||
|
|
||||||
await renameSSHItem(sshSessionId, renamePath, newName.trim());
|
|
||||||
onSuccess(t('fileManager.itemRenamedSuccessfully', { type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
|
||||||
setShowRename(false);
|
|
||||||
setRenamePath('');
|
|
||||||
setRenameIsDirectory(false);
|
|
||||||
setNewName('');
|
|
||||||
onOperationComplete();
|
|
||||||
} catch (error: any) {
|
|
||||||
onError(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openFileDialog = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
setUploadFile(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetStates = () => {
|
|
||||||
setShowUpload(false);
|
|
||||||
setShowCreateFile(false);
|
|
||||||
setShowCreateFolder(false);
|
|
||||||
setShowDelete(false);
|
|
||||||
setShowRename(false);
|
|
||||||
setUploadFile(null);
|
|
||||||
setNewFileName('');
|
|
||||||
setNewFolderName('');
|
|
||||||
setDeletePath('');
|
|
||||||
setDeleteIsDirectory(false);
|
|
||||||
setRenamePath('');
|
|
||||||
setRenameIsDirectory(false);
|
|
||||||
setNewName('');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!sshSessionId) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2"/>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('fileManager.connectToSsh')}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="p-4 space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowUpload(true)}
|
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
|
||||||
title={t('fileManager.uploadFile')}
|
|
||||||
>
|
|
||||||
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
|
||||||
{showTextLabels && <span className="truncate">{t('fileManager.uploadFile')}</span>}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCreateFile(true)}
|
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
|
||||||
title={t('fileManager.newFile')}
|
|
||||||
>
|
|
||||||
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
|
||||||
{showTextLabels && <span className="truncate">{t('fileManager.newFile')}</span>}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCreateFolder(true)}
|
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
|
||||||
title={t('fileManager.newFolder')}
|
|
||||||
>
|
|
||||||
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
|
||||||
{showTextLabels && <span className="truncate">{t('fileManager.newFolder')}</span>}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowRename(true)}
|
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
|
||||||
title={t('fileManager.rename')}
|
|
||||||
>
|
|
||||||
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
|
||||||
{showTextLabels && <span className="truncate">{t('fileManager.rename')}</span>}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowDelete(true)}
|
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
|
|
||||||
title={t('fileManager.deleteItem')}
|
|
||||||
>
|
|
||||||
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
|
||||||
{showTextLabels && <span className="truncate">{t('fileManager.deleteItem')}</span>}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[#141416] border-2 border-[#373739] rounded-md p-3">
|
|
||||||
<div className="flex items-start gap-2 text-sm">
|
|
||||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5"/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="text-muted-foreground block mb-1">{t('fileManager.currentPath')}:</span>
|
|
||||||
<span className="text-white font-mono text-xs break-all leading-relaxed">{currentPath}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="p-0.25 bg-[#303032]"/>
|
|
||||||
|
|
||||||
{showUpload && (
|
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<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">
|
|
||||||
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
|
|
||||||
<span className="break-words">{t('fileManager.uploadFileTitle')}</span>
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground break-words">
|
|
||||||
{t('fileManager.maxFileSize')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowUpload(false)}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="border-2 border-dashed border-[#434345] rounded-lg p-4 text-center">
|
|
||||||
{uploadFile ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<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-xs text-muted-foreground">
|
|
||||||
{(uploadFile.size / 1024).toFixed(2)} KB
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setUploadFile(null)}
|
|
||||||
className="w-full text-sm h-8"
|
|
||||||
>
|
|
||||||
{t('fileManager.removeFile')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<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>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={openFileDialog}
|
|
||||||
className="w-full text-sm h-8"
|
|
||||||
>
|
|
||||||
{t('fileManager.chooseFile')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
className="hidden"
|
|
||||||
accept="*/*"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleFileUpload}
|
|
||||||
disabled={!uploadFile || isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{isLoading ? t('fileManager.uploading') : t('fileManager.uploadFile')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowUpload(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showCreateFile && (
|
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<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"/>
|
|
||||||
<span className="break-words">{t('fileManager.createNewFile')}</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCreateFile(false)}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
|
||||||
{t('fileManager.fileName')}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={newFileName}
|
|
||||||
onChange={(e) => setNewFileName(e.target.value)}
|
|
||||||
placeholder={t('placeholders.fileName')}
|
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateFile}
|
|
||||||
disabled={!newFileName.trim() || isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{isLoading ? t('fileManager.creating') : t('fileManager.createFile')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowCreateFile(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showCreateFolder && (
|
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
|
||||||
<FolderPlus className="w-6 h-6 flex-shrink-0"/>
|
|
||||||
<span className="break-words">{t('fileManager.createNewFolder')}</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowCreateFolder(false)}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
|
||||||
{t('fileManager.folderName')}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={newFolderName}
|
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
|
||||||
placeholder={t('placeholders.folderName')}
|
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateFolder}
|
|
||||||
disabled={!newFolderName.trim() || isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{isLoading ? t('fileManager.creating') : t('fileManager.createFolder')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowCreateFolder(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showDelete && (
|
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<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"/>
|
|
||||||
<span className="break-words">{t('fileManager.deleteItem')}</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowDelete(false)}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-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">
|
|
||||||
<AlertCircle className="w-5 h-5 flex-shrink-0"/>
|
|
||||||
<span className="text-sm font-medium break-words">{t('fileManager.warningCannotUndo')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
|
||||||
{t('fileManager.itemPath')}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={deletePath}
|
|
||||||
onChange={(e) => setDeletePath(e.target.value)}
|
|
||||||
placeholder={t('placeholders.fullPath')}
|
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="deleteIsDirectory"
|
|
||||||
checked={deleteIsDirectory}
|
|
||||||
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
|
|
||||||
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<label htmlFor="deleteIsDirectory" className="text-sm text-white break-words">
|
|
||||||
{t('fileManager.thisIsDirectory')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={!deletePath || isLoading}
|
|
||||||
variant="destructive"
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{isLoading ? t('fileManager.deleting') : t('fileManager.deleteItem')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowDelete(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showRename && (
|
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
|
||||||
<Edit3 className="w-6 h-6 flex-shrink-0"/>
|
|
||||||
<span className="break-words">{t('fileManager.renameItem')}</span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowRename(false)}
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
|
||||||
{t('fileManager.currentPathLabel')}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={renamePath}
|
|
||||||
onChange={(e) => setRenamePath(e.target.value)}
|
|
||||||
placeholder={t('placeholders.currentPath')}
|
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
|
||||||
{t('fileManager.newName')}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
placeholder={t('placeholders.newName')}
|
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="renameIsDirectory"
|
|
||||||
checked={renameIsDirectory}
|
|
||||||
onChange={(e) => setRenameIsDirectory(e.target.checked)}
|
|
||||||
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<label htmlFor="renameIsDirectory" className="text-sm text-white break-words">
|
|
||||||
{t('fileManager.thisIsDirectoryRename')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleRename}
|
|
||||||
disabled={!renamePath || !newName.trim() || isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{isLoading ? t('fileManager.renaming') : t('fileManager.renameItem')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowRename(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full text-sm h-9"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
|
||||||
import {X, Home} from 'lucide-react';
|
|
||||||
|
|
||||||
interface FileManagerTab {
|
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileManagerTabList {
|
|
||||||
tabs: FileManagerTab[];
|
|
||||||
activeTab: string | number;
|
|
||||||
setActiveTab: (tab: string | number) => void;
|
|
||||||
closeTab: (tab: string | number) => void;
|
|
||||||
onHomeClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) {
|
|
||||||
return (
|
|
||||||
<div className="inline-flex items-center h-full gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={onHomeClick}
|
|
||||||
variant="outline"
|
|
||||||
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-[#303032] ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
|
||||||
>
|
|
||||||
<Home className="w-4 h-4"/>
|
|
||||||
</Button>
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const isActive = tab.id === activeTab;
|
|
||||||
return (
|
|
||||||
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
|
|
||||||
<Button
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
variant="outline"
|
|
||||||
className={`h-8 rounded-r-none !px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
|
||||||
>
|
|
||||||
{tab.title}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => closeTab(tab.id)}
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 rounded-l-none p-0 !w-9 border-1 border-[#303032]"
|
|
||||||
>
|
|
||||||
<X className="!w-4 !h-4" strokeWidth={2}/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||