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
|
||||
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
||||
github: [LukeGus]
|
||||
github: [LukeGus]
|
||||
|
||||
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/dependabot.yml
vendored
@@ -21,7 +21,7 @@ updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
@@ -37,4 +37,4 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "weekly"
|
||||
|
||||
4
.github/workflows/docker-image.yml
vendored
@@ -5,8 +5,8 @@ on:
|
||||
branches:
|
||||
- development
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
- "**.md"
|
||||
- ".gitignore"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
|
||||
6
.github/workflows/electron-build.yml
vendored
@@ -88,11 +88,13 @@ jobs:
|
||||
|
||||
- name: Create Linux Portable zip
|
||||
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
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Termix-Linux-Portable
|
||||
path: Termix-Linux-Portable.zip
|
||||
retention-days: 3
|
||||
retention-days: 30
|
||||
|
||||
2
.gitignore
vendored
@@ -23,3 +23,5 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
/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
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```sh
|
||||
git clone https://github.com/LukeGus/Termix
|
||||
```
|
||||
```sh
|
||||
git clone https://github.com/LukeGus/Termix
|
||||
```
|
||||
2. Install the dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
## Running the development server
|
||||
|
||||
@@ -23,10 +23,10 @@ Run the following commands:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
npx tsc -p tsconfig.node.json
|
||||
node ./dist/backend/starter.js
|
||||
npm run dev:backend
|
||||
```
|
||||
|
||||
a
|
||||
This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
|
||||
|
||||
## 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
|
||||
the [repository page](https://github.com/LukeGus/Termix).
|
||||
2. **Create a new branch**:
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
3. **Make your changes**: Implement your feature, fix, or improvement.
|
||||
4. **Commit your changes**:
|
||||
```sh
|
||||
git commit -m "Add feature: my new feature"
|
||||
```
|
||||
```sh
|
||||
git commit -m "Feature request my new feature"
|
||||
```
|
||||
5. **Push to your fork**:
|
||||
```sh
|
||||
git push origin feature/my-new-feature
|
||||
```
|
||||
```sh
|
||||
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.
|
||||
|
||||
## 📝 Guidelines
|
||||
|
||||
- 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.
|
||||
- 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。
|
||||
44
README.md
@@ -1,9 +1,17 @@
|
||||
# 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>
|
||||
|
||||
#### 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>
|
||||
</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
|
||||
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **Improved Platform Support** - Now includes an installable Electron app (in progress) for desktop, with a dedicated
|
||||
mobile app also planned.
|
||||
|
||||
# Planned Features
|
||||
- **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc
|
||||
- **Theming** - Modify theming for all tools
|
||||
- **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)
|
||||
- **Mobile Support** - Support a mobile app or version of the Termix website to manage servers from your phone
|
||||
|
||||
See [Projects](https://github.com/users/LukeGus/projects/3). If you are looking to contribute,
|
||||
see [Contributing](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md),
|
||||
|
||||
# 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
|
||||
services:
|
||||
termix:
|
||||
@@ -64,11 +80,18 @@ services:
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
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
|
||||
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
|
||||
|
||||
@@ -90,4 +113,5 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
|
||||
</p>
|
||||
|
||||
# License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See LICENSE for more information.
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ RUN apk add --no-cache python3 make g++
|
||||
|
||||
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
|
||||
|
||||
# Stage 2: Build frontend
|
||||
@@ -23,6 +27,12 @@ WORKDIR /app
|
||||
|
||||
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
|
||||
|
||||
# Stage 4: Production dependencies
|
||||
@@ -31,6 +41,10 @@ WORKDIR /app
|
||||
|
||||
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 && \
|
||||
npm cache clean --force
|
||||
|
||||
@@ -42,7 +56,13 @@ RUN apk add --no-cache python3 make g++
|
||||
|
||||
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
|
||||
|
||||
# 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 --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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=production-deps /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=native-builder /app/node_modules /app/node_modules
|
||||
COPY --from=backend-builder /app/dist/backend ./dist/backend
|
||||
|
||||
COPY package.json ./
|
||||
|
||||
@@ -12,4 +12,4 @@ services:
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
driver: local
|
||||
|
||||
@@ -19,9 +19,9 @@ cd /app
|
||||
export NODE_ENV=production
|
||||
|
||||
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
|
||||
su -s /bin/sh node -c "node dist/backend/starter.js"
|
||||
su -s /bin/sh node -c "node dist/backend/backend/starter.js"
|
||||
fi
|
||||
|
||||
echo "All services started"
|
||||
|
||||
@@ -18,7 +18,7 @@ http {
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
location /users/ {
|
||||
location ~ ^/users(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -27,7 +27,7 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /version/ {
|
||||
location ~ ^/version(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -36,7 +36,7 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /releases/ {
|
||||
location ~ ^/releases(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -45,7 +45,16 @@ http {
|
||||
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_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -129,7 +138,16 @@ http {
|
||||
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_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -138,7 +156,7 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /metrics/ {
|
||||
location ~ ^/metrics(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8085;
|
||||
proxy_http_version 1.1;
|
||||
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 globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
@@ -20,4 +20,4 @@ export default tseslint.config([
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
4065
openapi.json
9507
package-lock.json
generated
27
package.json
@@ -1,15 +1,24 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"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",
|
||||
"scripts": {
|
||||
"clean": "npx prettier . --write",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vite build && 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 .",
|
||||
"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": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
@@ -73,6 +82,7 @@
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"react-simple-keyboard": "^3.8.120",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
@@ -96,14 +106,21 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"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-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"prettier": "3.6.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "~5.9.2",
|
||||
"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 bodyParser from 'body-parser';
|
||||
import userRoutes from './routes/users.js';
|
||||
import sshRoutes from './routes/ssh.js';
|
||||
import alertRoutes from './routes/alerts.js';
|
||||
import chalk from 'chalk';
|
||||
import cors from 'cors';
|
||||
import fetch from 'node-fetch';
|
||||
import 'dotenv/config';
|
||||
import express from "express";
|
||||
import bodyParser from "body-parser";
|
||||
import userRoutes from "./routes/users.js";
|
||||
import sshRoutes from "./routes/ssh.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import cors from "cors";
|
||||
import fetch from "node-fetch";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import "dotenv/config";
|
||||
import { databaseLogger, apiLogger } from "../utils/logger.js";
|
||||
|
||||
const app = express();
|
||||
app.use(cors({
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
|
||||
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('#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));
|
||||
}
|
||||
}
|
||||
};
|
||||
app.use(
|
||||
cors({
|
||||
origin: "*",
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class GitHubCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 30 * 60 * 1000;
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 30 * 60 * 1000;
|
||||
|
||||
set(key: string, data: any): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION
|
||||
});
|
||||
set(key: string, data: any): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
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 {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
}
|
||||
|
||||
const githubCache = new GitHubCache();
|
||||
|
||||
const GITHUB_API_BASE = 'https://api.github.com';
|
||||
const REPO_OWNER = 'LukeGus';
|
||||
const REPO_NAME = 'Termix';
|
||||
const GITHUB_API_BASE = "https://api.github.com";
|
||||
const REPO_OWNER = "LukeGus";
|
||||
const REPO_NAME = "Termix";
|
||||
|
||||
interface GitHubRelease {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
assets: Array<{
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
assets: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
browser_download_url: string;
|
||||
}>;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
size: number;
|
||||
download_count: number;
|
||||
browser_download_url: string;
|
||||
}>;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
|
||||
const cachedData = githubCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return {
|
||||
data: cachedData,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cachedData.timestamp
|
||||
};
|
||||
async function fetchGitHubAPI(
|
||||
endpoint: string,
|
||||
cacheKey: string,
|
||||
): Promise<any> {
|
||||
const cachedData = githubCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
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 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'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
githubCache.set(cacheKey, data);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
githubCache.set(cacheKey, data);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
cached: false
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch from GitHub API: ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
data: data,
|
||||
cached: false,
|
||||
};
|
||||
} catch (error) {
|
||||
databaseLogger.error(`Failed to fetch from GitHub API`, error, {
|
||||
operation: "github_api",
|
||||
endpoint,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({status: 'ok'});
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
app.get('/version', async (req, res) => {
|
||||
const localVersion = process.env.VERSION;
|
||||
|
||||
if (!localVersion) {
|
||||
return res.status(401).send('Local Version Not Set');
|
||||
}
|
||||
app.get("/version", async (req, res) => {
|
||||
let localVersion = process.env.VERSION;
|
||||
|
||||
if (!localVersion) {
|
||||
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) {
|
||||
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);
|
||||
const packagePath = path.resolve(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
localVersion = packageJson.version;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate RSS format', error)
|
||||
res.status(500).json({
|
||||
error: 'Failed to generate RSS format',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
databaseLogger.error("Failed to read version from package.json", error, {
|
||||
operation: "version_check",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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.use('/ssh', sshRoutes);
|
||||
app.use('/alerts', alertRoutes);
|
||||
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}`;
|
||||
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({error: 'Internal Server Error'});
|
||||
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) {
|
||||
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;
|
||||
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 Database from 'better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import * as schema from "./schema.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
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('#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 dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const dbDir = path.resolve(dataDir);
|
||||
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);
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
id
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
username
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
password_hash
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
is_admin
|
||||
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 users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin 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
|
||||
(
|
||||
key
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
value
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT,
|
||||
ip
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
port
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
username
|
||||
TEXT
|
||||
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 ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT 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
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
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_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT 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
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
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_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT 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
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
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 file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT 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
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
alert_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
dismissed_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used 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 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 {
|
||||
sqlite.prepare(`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`).get();
|
||||
} catch (e) {
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
databaseLogger.debug(`Adding column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
});
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
} catch (alterError) {
|
||||
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
|
||||
}
|
||||
databaseLogger.success(`Column ${column} added to ${table}`, {
|
||||
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 = () => {
|
||||
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', 'oidc_identifier', 'TEXT');
|
||||
addColumnIfNotExists('users', 'client_id', 'TEXT');
|
||||
addColumnIfNotExists('users', 'client_secret', 'TEXT');
|
||||
addColumnIfNotExists('users', 'issuer_url', 'TEXT');
|
||||
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
|
||||
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
||||
try {
|
||||
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
||||
} catch (e) {
|
||||
}
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
||||
addColumnIfNotExists("users", "client_id", "TEXT");
|
||||
addColumnIfNotExists("users", "client_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
||||
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
||||
addColumnIfNotExists("users", "token_url", "TEXT");
|
||||
|
||||
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'name_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'scopes', 'TEXT');
|
||||
addColumnIfNotExists("users", "identifier_path", "TEXT");
|
||||
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||
|
||||
addColumnIfNotExists('users', 'totp_secret', 'TEXT');
|
||||
addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT');
|
||||
addColumnIfNotExists("users", "totp_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"');
|
||||
addColumnIfNotExists('ssh_data', 'password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'enable_file_manager', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"auth_type",
|
||||
'TEXT NOT NULL DEFAULT "password"',
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"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('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"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 {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
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) {
|
||||
logger.warn('Could not initialize default settings');
|
||||
}
|
||||
} catch (e) {
|
||||
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 {sql} from 'drizzle-orm';
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
username: text('username').notNull(),
|
||||
password_hash: text('password_hash').notNull(),
|
||||
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
password_hash: text("password_hash").notNull(),
|
||||
is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
|
||||
is_oidc: integer('is_oidc', {mode: 'boolean'}).notNull().default(false),
|
||||
oidc_identifier: text('oidc_identifier'),
|
||||
client_id: text('client_id'),
|
||||
client_secret: text('client_secret'),
|
||||
issuer_url: text('issuer_url'),
|
||||
authorization_url: text('authorization_url'),
|
||||
token_url: text('token_url'),
|
||||
identifier_path: text('identifier_path'),
|
||||
name_path: text('name_path'),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
totp_secret: text('totp_secret'),
|
||||
totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false),
|
||||
totp_backup_codes: text('totp_backup_codes'),
|
||||
is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
|
||||
oidc_identifier: text("oidc_identifier"),
|
||||
client_id: text("client_id"),
|
||||
client_secret: text("client_secret"),
|
||||
issuer_url: text("issuer_url"),
|
||||
authorization_url: text("authorization_url"),
|
||||
token_url: text("token_url"),
|
||||
identifier_path: text("identifier_path"),
|
||||
name_path: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
totp_secret: text("totp_secret"),
|
||||
totp_enabled: integer("totp_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
totp_backup_codes: text("totp_backup_codes"),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable('ssh_data', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name'),
|
||||
ip: text('ip').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
username: text('username').notNull(),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'),
|
||||
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
|
||||
authType: text('auth_type').notNull(),
|
||||
password: text('password'),
|
||||
key: text('key', {length: 8192}),
|
||||
keyPassword: text('key_password'),
|
||||
keyType: text('key_type'),
|
||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).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 sshData = sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name"),
|
||||
ip: text("ip").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
username: text("username").notNull(),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
||||
authType: text("auth_type").notNull(),
|
||||
|
||||
password: text("password"),
|
||||
key: text("key", { length: 8192 }),
|
||||
keyPassword: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id),
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.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', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.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', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const fileManagerPinned = sqliteTable("file_manager_pinned", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.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', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.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', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
alertId: text('alert_id').notNull(),
|
||||
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
export const dismissedAlerts = sqliteTable("dismissed_alerts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.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 {db} from '../db/index.js';
|
||||
import {dismissedAlerts} from '../db/schema.js';
|
||||
import {eq, and} from 'drizzle-orm';
|
||||
import chalk from 'chalk';
|
||||
import fetch from 'node-fetch';
|
||||
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));
|
||||
}
|
||||
}
|
||||
};
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import fetch from "node-fetch";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class AlertCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||
|
||||
set(key: string, data: any): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION
|
||||
});
|
||||
set(key: string, data: any): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
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 {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
}
|
||||
|
||||
const alertCache = new AlertCache();
|
||||
|
||||
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com';
|
||||
const REPO_OWNER = 'LukeGus';
|
||||
const REPO_NAME = 'Termix-Docs';
|
||||
const ALERTS_FILE = 'main/termix-alerts.json';
|
||||
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
|
||||
const REPO_OWNER = "LukeGus";
|
||||
const REPO_NAME = "Termix-Docs";
|
||||
const ALERTS_FILE = "main/termix-alerts.json";
|
||||
|
||||
interface TermixAlert {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt: string;
|
||||
priority?: 'low' | 'medium' | 'high' | 'critical';
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt: string;
|
||||
priority?: "low" | "medium" | "high" | "critical";
|
||||
type?: "info" | "warning" | "error" | "success";
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
}
|
||||
|
||||
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
const cacheKey = 'termix_alerts';
|
||||
const cachedData = alertCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
const cacheKey = "termix_alerts";
|
||||
const cachedData = alertCache.get(cacheKey);
|
||||
if (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 url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'TermixAlertChecker/1.0'
|
||||
}
|
||||
});
|
||||
const now = new Date();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const validAlerts = alerts.filter((alert) => {
|
||||
const expiryDate = new Date(alert.expiresAt);
|
||||
const isValid = expiryDate > now;
|
||||
return isValid;
|
||||
});
|
||||
|
||||
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const validAlerts = alerts.filter(alert => {
|
||||
const expiryDate = new Date(alert.expiresAt);
|
||||
const isValid = expiryDate > now;
|
||||
return isValid;
|
||||
});
|
||||
|
||||
alertCache.set(cacheKey, validAlerts);
|
||||
return validAlerts;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch alerts from GitHub', error);
|
||||
return [];
|
||||
}
|
||||
alertCache.set(cacheKey, validAlerts);
|
||||
return validAlerts;
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to fetch alerts from GitHub", {
|
||||
operation: "alerts_fetch",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Route: Get all active alerts
|
||||
// GET /alerts
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const alerts = await fetchAlertsFromGitHub();
|
||||
res.json({
|
||||
alerts,
|
||||
cached: alertCache.get('termix_alerts') !== null,
|
||||
total_count: alerts.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get alerts', error);
|
||||
res.status(500).json({error: 'Failed to fetch alerts'});
|
||||
}
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const alerts = await fetchAlertsFromGitHub();
|
||||
res.json({
|
||||
alerts,
|
||||
cached: alertCache.get("termix_alerts") !== null,
|
||||
total_count: alerts.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get alerts for a specific user (excluding dismissed ones)
|
||||
// GET /alerts/user/:userId
|
||||
router.get('/user/:userId', async (req, res) => {
|
||||
try {
|
||||
const {userId} = req.params;
|
||||
router.get("/user/:userId", async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
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'});
|
||||
if (!userId) {
|
||||
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) {
|
||||
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
|
||||
// POST /alerts/dismiss
|
||||
router.post('/dismiss', async (req, res) => {
|
||||
try {
|
||||
const {userId, alertId} = req.body;
|
||||
router.post("/dismiss", async (req, res) => {
|
||||
try {
|
||||
const { userId, alertId } = req.body;
|
||||
|
||||
if (!userId || !alertId) {
|
||||
logger.warn('Missing userId or alertId in dismiss request');
|
||||
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'});
|
||||
if (!userId || !alertId) {
|
||||
authLogger.warn("Missing userId or alertId in dismiss request");
|
||||
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) {
|
||||
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
|
||||
// GET /alerts/dismissed/:userId
|
||||
router.get('/dismissed/:userId', async (req, res) => {
|
||||
try {
|
||||
const {userId} = req.params;
|
||||
router.get("/dismissed/:userId", async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
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'});
|
||||
if (!userId) {
|
||||
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) {
|
||||
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)
|
||||
// DELETE /alerts/dismiss
|
||||
router.delete('/dismiss', async (req, res) => {
|
||||
try {
|
||||
const {userId, alertId} = req.body;
|
||||
router.delete("/dismiss", async (req, res) => {
|
||||
try {
|
||||
const { userId, alertId } = req.body;
|
||||
|
||||
if (!userId || !alertId) {
|
||||
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'});
|
||||
if (!userId || !alertId) {
|
||||
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" });
|
||||
}
|
||||
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;
|
||||
|
||||
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 {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const wss = new WebSocketServer({port: 8082});
|
||||
|
||||
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
|
||||
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
|
||||
// node ./dist/backend/starter.js
|
||||
|
||||
import './database/database.js'
|
||||
import './ssh/terminal.js';
|
||||
import './ssh/tunnel.js';
|
||||
import './ssh/file-manager.js';
|
||||
import './ssh/server-stats.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
};
|
||||
import "./database/database.js";
|
||||
import "./ssh/terminal.js";
|
||||
import "./ssh/tunnel.js";
|
||||
import "./ssh/file-manager.js";
|
||||
import "./ssh/server-stats.js";
|
||||
import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
import "dotenv/config";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
logger.info("Starting all backend servers...");
|
||||
try {
|
||||
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', () => {
|
||||
logger.info("Shutting down servers...");
|
||||
process.exit(0);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to start servers:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
systemLogger.success("All backend services initialized successfully", {
|
||||
operation: "startup_complete",
|
||||
services: ["database", "terminal", "tunnel", "file_manager", "stats"],
|
||||
version: version,
|
||||
});
|
||||
|
||||
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 = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
if (context === undefined)
|
||||
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 AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
@@ -20,7 +20,7 @@ function AccordionItem({
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
@@ -34,7 +34,7 @@ function AccordionTrigger({
|
||||
data-slot="accordion-trigger"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...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" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
@@ -58,7 +58,7 @@ function AccordionContent({
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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",
|
||||
@@ -16,8 +16,8 @@ const alertVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
@@ -31,7 +31,7 @@ function Alert({
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 font-medium tracking-tight whitespace-normal break-words",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
@@ -56,11 +56,11 @@ function AlertDescription({
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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",
|
||||
@@ -22,8 +22,8 @@ const badgeVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
@@ -32,7 +32,7 @@ function Badge({
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -40,7 +40,7 @@ function Badge({
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...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 { cn } from '@/lib/utils';
|
||||
import { type ButtonProps } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ButtonGroupProps {
|
||||
className?: string;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
orientation?: "horizontal" | "vertical";
|
||||
children: ReactElement<ButtonProps>[] | React.ReactNode;
|
||||
}
|
||||
|
||||
export const ButtonGroup = ({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
}: ButtonGroupProps) => {
|
||||
const isHorizontal = orientation === 'horizontal';
|
||||
const isVertical = orientation === 'vertical';
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
const isVertical = orientation === "vertical";
|
||||
|
||||
// Normalize and filter only valid React elements
|
||||
const childArray = Children.toArray(children).filter((child): child is ReactElement<ButtonProps> =>
|
||||
isValidElement(child)
|
||||
const childArray = Children.toArray(children).filter(
|
||||
(child): child is ReactElement<ButtonProps> => isValidElement(child),
|
||||
);
|
||||
const totalButtons = childArray.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
"flex",
|
||||
{
|
||||
'flex-col': isVertical,
|
||||
'w-fit': isVertical,
|
||||
"flex-col": isVertical,
|
||||
"w-fit": isVertical,
|
||||
},
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{childArray.map((child, index) => {
|
||||
@@ -41,18 +41,18 @@ export const ButtonGroup = ({
|
||||
return cloneElement(child, {
|
||||
className: cn(
|
||||
{
|
||||
'rounded-l-none': isHorizontal && !isFirst,
|
||||
'rounded-r-none': isHorizontal && !isLast,
|
||||
'border-l-0': isHorizontal && !isFirst,
|
||||
"rounded-l-none": isHorizontal && !isFirst,
|
||||
"rounded-r-none": isHorizontal && !isLast,
|
||||
"border-l-0": isHorizontal && !isFirst,
|
||||
|
||||
'rounded-t-none': isVertical && !isFirst,
|
||||
'rounded-b-none': isVertical && !isLast,
|
||||
'border-t-0': isVertical && !isFirst,
|
||||
"rounded-t-none": isVertical && !isFirst,
|
||||
"rounded-b-none": isVertical && !isLast,
|
||||
"border-t-0": isVertical && !isFirst,
|
||||
},
|
||||
child.props.className
|
||||
child.props.className,
|
||||
),
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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",
|
||||
@@ -32,8 +32,14 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -41,11 +47,8 @@ function Button({
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -53,7 +56,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...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">) {
|
||||
return (
|
||||
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-header"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -89,4 +89,4 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
@@ -13,7 +13,7 @@ function Checkbox({
|
||||
data-slot="checkbox"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -24,7 +24,7 @@ function Checkbox({
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</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 LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
@@ -9,23 +9,23 @@ import {
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const Form = FormProvider
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
@@ -37,21 +37,21 @@ const FormField = <
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
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 {
|
||||
id,
|
||||
@@ -60,19 +60,19 @@ const useFormField = () => {
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
@@ -82,14 +82,14 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
@@ -99,11 +99,12 @@ function FormLabel({
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
@@ -117,11 +118,11 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
@@ -130,15 +131,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -150,7 +151,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -162,4 +163,4 @@ export {
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
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">) {
|
||||
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",
|
||||
"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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
@@ -12,11 +12,11 @@ function Label({
|
||||
data-slot="label"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...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 PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
@@ -29,18 +29,18 @@ function PopoverContent({
|
||||
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 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: 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 ProgressPrimitive from "@radix-ui/react-progress"
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
@@ -13,7 +13,7 @@ function Progress({
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -23,7 +23,7 @@ function Progress({
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
import * as React from "react";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
@@ -13,17 +13,17 @@ function ResizablePanelGroup({
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
@@ -31,24 +31,24 @@ function ResizableHandle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
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",
|
||||
className
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{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" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
@@ -23,7 +23,7 @@ function ScrollArea({
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
@@ -41,7 +41,7 @@ function ScrollBar({
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -50,7 +50,7 @@ function ScrollBar({
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
export { ScrollArea, ScrollBar };
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
@@ -28,7 +28,7 @@ function SelectTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -36,7 +36,7 @@ function SelectTrigger({
|
||||
data-size={size}
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -45,7 +45,7 @@ function SelectTrigger({
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
@@ -72,7 +72,7 @@ function SelectContent({
|
||||
className={cn(
|
||||
"p-1",
|
||||
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}
|
||||
@@ -80,7 +80,7 @@ function SelectContent({
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
@@ -93,7 +93,7 @@ function SelectLabel({
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
@@ -106,7 +106,7 @@ function SelectItem({
|
||||
data-slot="select-item"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -117,7 +117,7 @@ function SelectItem({
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
@@ -130,7 +130,7 @@ function SelectSeparator({
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
@@ -142,13 +142,13 @@ function SelectScrollUpButton({
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
@@ -160,13 +160,13 @@ function SelectScrollDownButton({
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -180,4 +180,4 @@ export {
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
@@ -18,11 +18,11 @@ function Separator({
|
||||
orientation={orientation}
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type StatusProps = ComponentProps<typeof Badge> & {
|
||||
status: 'online' | 'offline' | 'maintenance' | 'degraded';
|
||||
status: "online" | "offline" | "maintenance" | "degraded";
|
||||
};
|
||||
|
||||
export const Status = ({ className, status, ...props }: StatusProps) => (
|
||||
<Badge
|
||||
className={cn('flex items-center gap-2', 'group', status, className)}
|
||||
className={cn("flex items-center gap-2", "group", status, className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
/>
|
||||
@@ -24,20 +24,20 @@ export const StatusIndicator = ({
|
||||
<span className="relative flex h-2 w-2" {...props}>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
|
||||
'group-[.online]:bg-emerald-500',
|
||||
'group-[.offline]:bg-red-500',
|
||||
'group-[.maintenance]:bg-blue-500',
|
||||
'group-[.degraded]:bg-amber-500'
|
||||
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
|
||||
"group-[.online]:bg-emerald-500",
|
||||
"group-[.offline]:bg-red-500",
|
||||
"group-[.maintenance]:bg-blue-500",
|
||||
"group-[.degraded]:bg-amber-500",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-2 w-2 rounded-full',
|
||||
'group-[.online]:bg-emerald-500',
|
||||
'group-[.offline]:bg-red-500',
|
||||
'group-[.maintenance]:bg-blue-500',
|
||||
'group-[.degraded]:bg-amber-500'
|
||||
"relative inline-flex h-2 w-2 rounded-full",
|
||||
"group-[.online]:bg-emerald-500",
|
||||
"group-[.offline]:bg-red-500",
|
||||
"group-[.maintenance]:bg-blue-500",
|
||||
"group-[.degraded]:bg-amber-500",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
@@ -52,13 +52,21 @@ export const StatusLabel = ({
|
||||
}: StatusLabelProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className={cn('text-muted-foreground', className)} {...props}>
|
||||
<span className={cn("text-muted-foreground", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="hidden group-[.online]:block">{t('common.online')}</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 className="hidden group-[.online]:block">
|
||||
{t("common.online")}
|
||||
</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>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
@@ -35,11 +35,11 @@ function SheetOverlay({
|
||||
data-slot="sheet-overlay"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
@@ -48,7 +48,7 @@ function SheetContent({
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@@ -58,14 +58,14 @@ function SheetContent({
|
||||
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",
|
||||
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" &&
|
||||
"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" &&
|
||||
"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" &&
|
||||
"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}
|
||||
>
|
||||
@@ -76,7 +76,7 @@ function SheetContent({
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
@@ -109,7 +109,7 @@ function SheetTitle({
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
@@ -122,7 +122,7 @@ function SheetDescription({
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -134,4 +134,4 @@ export {
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
const context = React.useContext(SidebarContext);
|
||||
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({
|
||||
@@ -60,36 +60,36 @@ function SidebarProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// 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.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
@@ -98,18 +98,18 @@ function SidebarProvider({
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// 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.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
@@ -121,8 +121,8 @@ function SidebarProvider({
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
@@ -138,7 +138,7 @@ function SidebarProvider({
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -146,7 +146,7 @@ function SidebarProvider({
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
@@ -157,11 +157,11 @@ function Sidebar({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
@@ -169,13 +169,13 @@ function Sidebar({
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Commented out mobile behavior to keep sidebar always visible
|
||||
@@ -222,7 +222,7 @@ function Sidebar({
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "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
|
||||
@@ -236,7 +236,7 @@ function Sidebar({
|
||||
variant === "floating" || variant === "inset"
|
||||
? "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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -249,7 +249,7 @@ function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
@@ -257,7 +257,7 @@ function SidebarTrigger({
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -267,19 +267,19 @@ function SidebarTrigger({
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<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",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
@@ -310,11 +310,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
className={cn(
|
||||
"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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
@@ -328,7 +328,7 @@ function SidebarInput({
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
@@ -364,7 +364,7 @@ function SidebarSeparator({
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -374,11 +374,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
@@ -397,7 +397,7 @@ function SidebarGroupLabel({
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -406,11 +406,11 @@ function SidebarGroupLabel({
|
||||
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",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
@@ -418,7 +418,7 @@ function SidebarGroupAction({
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -429,11 +429,11 @@ function SidebarGroupAction({
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
@@ -447,7 +447,7 @@ function SidebarGroupContent({
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
@@ -491,8 +491,8 @@ const sidebarMenuButtonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
@@ -503,12 +503,12 @@ function SidebarMenuButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
@@ -519,16 +519,16 @@ function SidebarMenuButton({
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -541,7 +541,7 @@ function SidebarMenuButton({
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
@@ -550,10 +550,10 @@ function SidebarMenuAction({
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -569,11 +569,11 @@ function SidebarMenuAction({
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
@@ -591,11 +591,11 @@ function SidebarMenuBadge({
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
@@ -603,12 +603,12 @@ function SidebarMenuSkeleton({
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -633,7 +633,7 @@ function SidebarMenuSkeleton({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
@@ -644,11 +644,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
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",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
@@ -662,7 +662,7 @@ function SidebarMenuSubItem({
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
@@ -672,11 +672,11 @@ function SidebarMenuSubButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -690,11 +690,11 @@ function SidebarMenuSubButton({
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -722,4 +722,4 @@ export {
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
export { Skeleton };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
@@ -17,7 +17,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
export { Toaster };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
@@ -12,18 +12,18 @@ function Switch({
|
||||
data-slot="switch"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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">) {
|
||||
return (
|
||||
@@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
@@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
@@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
@@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
@@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
data-slot="table-head"
|
||||
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]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
@@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
@@ -99,7 +99,7 @@ function TableCaption({
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -111,4 +111,4 @@ export {
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
@@ -13,7 +13,7 @@ function Tabs({
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
@@ -25,11 +25,11 @@ function TabsList({
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
@@ -41,11 +41,11 @@ function TabsTrigger({
|
||||
data-slot="tabs-trigger"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
@@ -58,7 +58,7 @@ function TabsContent({
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...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 TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
@@ -15,7 +15,7 @@ function TooltipProvider({
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
@@ -25,13 +25,13 @@ function Tooltip({
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
@@ -47,14 +47,14 @@ function TooltipContent({
|
||||
sideOffset={sideOffset}
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</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() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile
|
||||
return !!isMobile;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
// i18n configuration for multi-language support
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import HttpApi from 'i18next-http-backend';
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import enTranslation from "../locales/en/translation.json";
|
||||
import zhTranslation from "../locales/zh/translation.json";
|
||||
|
||||
// Initialize i18n
|
||||
i18n
|
||||
.use(HttpApi) // Load translations using http
|
||||
.use(LanguageDetector) // Detect user language
|
||||
.use(initReactI18next) // Pass i18n instance to react-i18next
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
supportedLngs: ['en', 'zh'], // Supported languages
|
||||
fallbackLng: 'en', // Fallback language
|
||||
supportedLngs: ["en", "zh"],
|
||||
fallbackLng: "en",
|
||||
debug: false,
|
||||
|
||||
// Detection options - disabled to always use English by default
|
||||
|
||||
detection: {
|
||||
order: ['localStorage', 'cookie'], // Only check user's saved preference
|
||||
caches: ['localStorage', 'cookie'],
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
lookupCookie: 'i18nextLng',
|
||||
order: ["localStorage", "cookie"],
|
||||
caches: ["localStorage", "cookie"],
|
||||
lookupLocalStorage: "i18nextLng",
|
||||
lookupCookie: "i18nextLng",
|
||||
checkWhitelist: true,
|
||||
},
|
||||
|
||||
// Backend options
|
||||
backend: {
|
||||
loadPath: '/locales/{{lng}}/translation.json',
|
||||
|
||||
resources: {
|
||||
en: {
|
||||
translation: enTranslation,
|
||||
},
|
||||
zh: {
|
||||
translation: zhTranslation,
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // React already escapes values
|
||||
escapeValue: false,
|
||||
},
|
||||
|
||||
|
||||
react: {
|
||||
useSuspense: false, // Disable suspense for SSR compatibility
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
export default i18n;
|
||||
|
||||
275
src/index.css
@@ -4,153 +4,180 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #09090b;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #09090b;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--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 {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.thin-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #303032 transparent;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #303032 transparent;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #303032;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
background-color: #303032;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
@@ -174,4 +201,4 @@
|
||||
.thin-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #434345 #18181b;
|
||||
}
|
||||
}
|
||||
|
||||
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 { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
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": {
|
||||
"title": "SSH Tools",
|
||||
"closeTools": "Close SSH Tools",
|
||||
@@ -18,6 +151,24 @@
|
||||
"failedToLoadAlerts": "Failed to load alerts",
|
||||
"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": {
|
||||
"close": "Close",
|
||||
"online": "Online",
|
||||
@@ -32,6 +183,7 @@
|
||||
"loading": "Loading",
|
||||
"required": "Required",
|
||||
"optional": "Optional",
|
||||
"clear": "Clear",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"sidebar": "Sidebar",
|
||||
"home": "Home",
|
||||
@@ -72,11 +224,13 @@
|
||||
"register": "Register",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"version": "Version",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"back": "Back",
|
||||
"email": "Email",
|
||||
"submit": "Submit",
|
||||
"cancel": "Cancel",
|
||||
"change": "Change",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
@@ -115,26 +269,31 @@
|
||||
"passwordResetSuccess": "Password reset successfully! You can now log in with your new password.",
|
||||
"failedToInitiatePasswordReset": "Failed to initiate password reset",
|
||||
"failedToVerifyResetCode": "Failed to verify reset code",
|
||||
"failedToCompletePasswordReset": "Failed to complete password reset"
|
||||
"failedToCompletePasswordReset": "Failed to complete password reset",
|
||||
"documentation": "Documentation"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"hosts": "Hosts",
|
||||
"credentials": "Credentials",
|
||||
"terminal": "Terminal",
|
||||
"tunnels": "Tunnels",
|
||||
"fileManager": "File Manager",
|
||||
"serverStats": "Server Stats",
|
||||
"admin": "Admin",
|
||||
"userProfile": "User Profile",
|
||||
"tools": "Tools",
|
||||
"newTab": "New Tab",
|
||||
"splitScreen": "Split Screen",
|
||||
"closeTab": "Close Tab",
|
||||
"sshManager": "SSH Manager",
|
||||
"hostManager": "Host Manager",
|
||||
"cannotSplitTab": "Cannot split this tab"
|
||||
"cannotSplitTab": "Cannot split this tab",
|
||||
"tabNavigation": "Tab Navigation"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Settings",
|
||||
"oidc": "OIDC",
|
||||
"users": "Users",
|
||||
"userManagement": "User Management",
|
||||
"makeAdmin": "Make Admin",
|
||||
@@ -179,7 +338,12 @@
|
||||
"allowNewAccountRegistration": "Allow new account registration",
|
||||
"missingRequiredFields": "Missing required fields: {{fields}}",
|
||||
"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",
|
||||
"failedToDisableOidcConfig": "Failed to disable OIDC configuration",
|
||||
"enterUsernameToMakeAdmin": "Enter username to make admin",
|
||||
"userIsNowAdmin": "User {{username}} is now an admin",
|
||||
"failedToMakeUserAdmin": "Failed to make user admin",
|
||||
@@ -207,8 +371,10 @@
|
||||
"importJsonDesc": "Upload a JSON file to bulk import multiple SSH hosts (max 100).",
|
||||
"downloadSample": "Download Sample",
|
||||
"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",
|
||||
"confirmDelete": "Are you sure you want to delete \"{{name}}\"?",
|
||||
"confirmDelete": "Are you sure you want to delete \"{{name}}\" ?",
|
||||
"failedToDeleteHost": "Failed to delete host",
|
||||
"jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts",
|
||||
"noHostsInJson": "No hosts found in JSON file",
|
||||
@@ -276,6 +442,11 @@
|
||||
"authentication": "Authentication",
|
||||
"password": "Password",
|
||||
"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",
|
||||
"keyPassword": "Key Password",
|
||||
"keyType": "Key Type",
|
||||
@@ -288,7 +459,11 @@
|
||||
"dsa": "DSA",
|
||||
"rsaSha2256": "RSA SHA2-256",
|
||||
"rsaSha2512": "RSA SHA2-512",
|
||||
"uploadFile": "Upload File",
|
||||
"pasteKey": "Paste Key",
|
||||
"updateKey": "Update Key",
|
||||
"existingKey": "Existing Key (click to change)",
|
||||
"existingCredential": "Existing Credential (click to change)",
|
||||
"addTagsSpaceToAdd": "add tags (space to add)",
|
||||
"terminalBadge": "Terminal",
|
||||
"tunnelBadge": "Tunnel",
|
||||
@@ -297,7 +472,14 @@
|
||||
"terminal": "Terminal",
|
||||
"tunnel": "Tunnel",
|
||||
"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": {
|
||||
"title": "Terminal",
|
||||
@@ -314,10 +496,21 @@
|
||||
"reconnect": "Reconnect",
|
||||
"sessionEnded": "Session Ended",
|
||||
"connectionLost": "Connection Lost",
|
||||
"error": "ERROR",
|
||||
"error": "ERROR: {{message}}",
|
||||
"disconnected": "Disconnected",
|
||||
"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": {
|
||||
"title": "File Manager",
|
||||
@@ -337,6 +530,11 @@
|
||||
"clickToSelectFile": "Click to select a file",
|
||||
"chooseFile": "Choose File",
|
||||
"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",
|
||||
"fileName": "File Name",
|
||||
"creating": "Creating...",
|
||||
@@ -506,7 +704,18 @@
|
||||
"memoryUsage": "Memory Usage",
|
||||
"rootStorageSpace": "Root Storage Space",
|
||||
"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": {
|
||||
"loginTitle": "Login to Termix",
|
||||
@@ -577,6 +786,7 @@
|
||||
"external": "External",
|
||||
"loginWithExternal": "Login with External 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",
|
||||
"sendResetCode": "Send Reset Code",
|
||||
"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",
|
||||
"noTokenReceived": "No token received from login",
|
||||
"invalidAuthUrl": "Invalid authorization URL received from backend",
|
||||
"connectionTimeout": "Connection timeout",
|
||||
"invalidInput": "Invalid input",
|
||||
"requiredField": "This field is required",
|
||||
"minLength": "Minimum length is {{min}}",
|
||||
@@ -653,6 +862,9 @@
|
||||
"external": "External (OIDC)",
|
||||
"selectPreferredLanguage": "Select your preferred language for the interface"
|
||||
},
|
||||
"user": {
|
||||
"failedToLoadVersionInfo": "Failed to load version information"
|
||||
},
|
||||
"placeholders": {
|
||||
"enterCode": "000000",
|
||||
"ipAddress": "127.0.0.1",
|
||||
@@ -665,6 +877,10 @@
|
||||
"folder": "folder",
|
||||
"password": "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",
|
||||
"homePath": "/home",
|
||||
"clientId": "your-client-id",
|
||||
@@ -675,6 +891,7 @@
|
||||
"userIdField": "sub",
|
||||
"usernameField": "name",
|
||||
"scopes": "openid email profile",
|
||||
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
|
||||
"enterUsername": "Enter username to make admin",
|
||||
"searchHosts": "Search hosts by name, username, IP, folder, tags...",
|
||||
"enterPassword": "Enter your password",
|
||||
@@ -810,5 +1027,9 @@
|
||||
"invalidVerificationCode": "Invalid verification code",
|
||||
"failedToDisableTotp": "Failed to disable TOTP",
|
||||
"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": {
|
||||
"title": "SSH 工具",
|
||||
"closeTools": "关闭 SSH 工具",
|
||||
@@ -18,6 +150,24 @@
|
||||
"failedToLoadAlerts": "加载警报失败",
|
||||
"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": {
|
||||
"close": "关闭",
|
||||
"online": "在线",
|
||||
@@ -29,9 +179,10 @@
|
||||
"warning": "警告",
|
||||
"info": "信息",
|
||||
"success": "成功",
|
||||
"loading": "加载中",
|
||||
"loading": "加载中...",
|
||||
"required": "必填",
|
||||
"optional": "可选",
|
||||
"clear": "清除",
|
||||
"toggleSidebar": "切换侧边栏",
|
||||
"sidebar": "侧边栏",
|
||||
"home": "首页",
|
||||
@@ -42,8 +193,7 @@
|
||||
"updateAvailable": "有可用更新",
|
||||
"sshPath": "SSH 路径",
|
||||
"localPath": "本地路径",
|
||||
"loading": "加载中...",
|
||||
"noAuthCredentials": "此 SSH 主机没有可用的身份验证凭据",
|
||||
"noAuthCredentials": "此 SSH 主机没有可用的认证凭据",
|
||||
"noReleases": "没有发布版本",
|
||||
"updatesAndReleases": "更新与发布",
|
||||
"newVersionAvailable": "有新版本 ({{version}}) 可用。",
|
||||
@@ -56,13 +206,10 @@
|
||||
"resetPassword": "重置密码",
|
||||
"resetCode": "重置代码",
|
||||
"newPassword": "新密码",
|
||||
"sshPath": "SSH 路径",
|
||||
"localPath": "本地路径",
|
||||
"folder": "文件夹",
|
||||
"file": "文件",
|
||||
"renamedSuccessfully": "重命名成功",
|
||||
"deletedSuccessfully": "删除成功",
|
||||
"noAuthCredentials": "此 SSH 主机没有可用的认证凭据",
|
||||
"noTunnelConnections": "没有配置隧道连接",
|
||||
"sshTools": "SSH 工具",
|
||||
"english": "英语",
|
||||
@@ -77,27 +224,21 @@
|
||||
"email": "邮箱",
|
||||
"submit": "提交",
|
||||
"cancel": "取消",
|
||||
"change": "更改",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"add": "添加",
|
||||
"search": "搜索",
|
||||
"loading": "加载中...",
|
||||
"error": "错误",
|
||||
"success": "成功",
|
||||
"warning": "警告",
|
||||
"info": "信息",
|
||||
"confirm": "确认",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"ok": "确定",
|
||||
"close": "关闭",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"important": "重要",
|
||||
"notEnabled": "未启用",
|
||||
"settingUp": "设置中...",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"previous": "上一步",
|
||||
"refresh": "刷新",
|
||||
@@ -115,31 +256,36 @@
|
||||
"passwordResetSuccess": "密码重置成功!您现在可以使用新密码登录。",
|
||||
"failedToInitiatePasswordReset": "启动密码重置失败",
|
||||
"failedToVerifyResetCode": "验证重置代码失败",
|
||||
"failedToCompletePasswordReset": "完成密码重置失败"
|
||||
"failedToCompletePasswordReset": "完成密码重置失败",
|
||||
"documentation": "文档"
|
||||
},
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"hosts": "主机",
|
||||
"credentials": "凭据",
|
||||
"terminal": "终端",
|
||||
"tunnels": "隧道",
|
||||
"fileManager": "文件管理器",
|
||||
"serverStats": "服务器统计",
|
||||
"admin": "管理员",
|
||||
"userProfile": "用户资料",
|
||||
"tools": "工具",
|
||||
"newTab": "新标签页",
|
||||
"splitScreen": "分屏",
|
||||
"closeTab": "关闭标签页",
|
||||
"sshManager": "SSH 管理器",
|
||||
"hostManager": "主机管理器",
|
||||
"cannotSplitTab": "无法分割此标签页"
|
||||
"cannotSplitTab": "无法分割此标签页",
|
||||
"tabNavigation": "标签导航"
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理员设置",
|
||||
"oidc": "OIDC",
|
||||
"users": "用户",
|
||||
"userManagement": "用户管理",
|
||||
"makeAdmin": "设为管理员",
|
||||
"removeAdmin": "移除管理员",
|
||||
"deleteUser": "删除用户",
|
||||
"deleteUser": "删除用户 {{username}} 吗?此操作无法撤销。",
|
||||
"allowRegistration": "允许注册",
|
||||
"oidcSettings": "OIDC 设置",
|
||||
"clientId": "客户端 ID",
|
||||
@@ -179,14 +325,18 @@
|
||||
"allowNewAccountRegistration": "允许新账户注册",
|
||||
"missingRequiredFields": "缺少必填字段:{{fields}}",
|
||||
"oidcConfigurationUpdated": "OIDC 配置更新成功!",
|
||||
"failedToFetchOidcConfig": "获取 OIDC 配置失败",
|
||||
"failedToFetchRegistrationStatus": "获取注册状态失败",
|
||||
"failedToFetchUsers": "获取用户列表失败",
|
||||
"oidcConfigurationDisabled": "OIDC 配置禁用成功!",
|
||||
"failedToUpdateOidcConfig": "更新 OIDC 配置失败",
|
||||
"failedToDisableOidcConfig": "禁用 OIDC 配置失败",
|
||||
"enterUsernameToMakeAdmin": "输入用户名以设为管理员",
|
||||
"userIsNowAdmin": "用户 {{username}} 现在是管理员",
|
||||
"failedToMakeUserAdmin": "设为管理员失败",
|
||||
"removeAdminStatus": "移除 {{username}} 的管理员权限吗?",
|
||||
"adminStatusRemoved": "已移除 {{username}} 的管理员权限",
|
||||
"failedToRemoveAdminStatus": "移除管理员权限失败",
|
||||
"deleteUser": "删除用户 {{username}} 吗?此操作无法撤销。",
|
||||
"userDeletedSuccessfully": "用户 {{username}} 删除成功",
|
||||
"failedToDeleteUser": "删除用户失败",
|
||||
"overrideUserInfoUrl": "覆盖用户信息 URL(非必填)"
|
||||
@@ -207,6 +357,8 @@
|
||||
"importJsonDesc": "上传 JSON 文件以批量导入多个 SSH 主机(最多 100 个)。",
|
||||
"downloadSample": "下载示例",
|
||||
"formatGuide": "格式指南",
|
||||
"exportCredentialWarning": "警告:主机 \"{{name}}\" 使用凭据认证。导出的文件将不包含凭据数据,导入后需要手动重新配置。您确定要继续吗?",
|
||||
"exportSensitiveDataWarning": "警告:主机 \"{{name}}\" 包含敏感认证数据(密码/SSH密钥)。出于安全考虑,导出的文件将不包含此数据。导入后您需要重新配置认证。您确定要继续吗?",
|
||||
"uncategorized": "未分类",
|
||||
"confirmDelete": "确定要删除 \"{{name}}\" 吗?",
|
||||
"failedToDeleteHost": "删除主机失败",
|
||||
@@ -218,11 +370,12 @@
|
||||
"importError": "导入错误",
|
||||
"failedToImportJson": "导入 JSON 文件失败",
|
||||
"connectionDetails": "连接详情",
|
||||
"organization": "组织",
|
||||
"organization": "组织管理",
|
||||
"ipAddress": "IP 地址",
|
||||
"port": "端口",
|
||||
"name": "名称",
|
||||
"username": "用户名",
|
||||
"hostName": "主机名",
|
||||
"folder": "文件夹",
|
||||
"tags": "标签",
|
||||
"passwordRequired": "使用密码认证时需要密码",
|
||||
@@ -232,16 +385,11 @@
|
||||
"addHost": "添加主机",
|
||||
"editHost": "编辑主机",
|
||||
"deleteHost": "删除主机",
|
||||
"hostName": "主机名",
|
||||
"ipAddress": "IP 地址",
|
||||
"port": "端口",
|
||||
"authType": "认证类型",
|
||||
"passwordAuth": "密码",
|
||||
"keyAuth": "SSH 密钥",
|
||||
"keyPassword": "密钥密码",
|
||||
"keyType": "密钥类型",
|
||||
"folder": "文件夹",
|
||||
"tags": "标签",
|
||||
"pin": "固定",
|
||||
"enableTerminal": "启用终端",
|
||||
"enableTunnel": "启用隧道",
|
||||
@@ -255,8 +403,6 @@
|
||||
"connecting": "连接中...",
|
||||
"connectionFailed": "连接失败",
|
||||
"connectionSuccess": "连接成功",
|
||||
"connectionDetails": "连接详情",
|
||||
"organization": "组织管理",
|
||||
"addTags": "添加标签(空格添加)",
|
||||
"sourcePort": "源端口",
|
||||
"sourcePortDesc": "(源指通用标签页中的当前连接详情)",
|
||||
@@ -296,9 +442,12 @@
|
||||
"authentication": "认证方式",
|
||||
"password": "密码",
|
||||
"key": "密钥",
|
||||
"credential": "凭证",
|
||||
"selectCredential": "选择凭证",
|
||||
"selectCredentialPlaceholder": "选择一个凭证...",
|
||||
"credentialRequired": "使用凭证认证时需要选择凭证",
|
||||
"credentialDescription": "选择凭证将覆盖当前用户名并使用凭证的认证详细信息。",
|
||||
"sshPrivateKey": "SSH 私钥",
|
||||
"keyPassword": "密钥密码",
|
||||
"keyType": "密钥类型",
|
||||
"maxRetriesDescription": "隧道连接的最大重试次数。",
|
||||
"retryIntervalDescription": "重试尝试之间的等待时间。",
|
||||
"otherInstallMethods": "其他安装方法:",
|
||||
@@ -326,7 +475,11 @@
|
||||
"dsa": "DSA",
|
||||
"rsaSha2256": "RSA SHA2-256",
|
||||
"rsaSha2512": "RSA SHA2-512",
|
||||
"uploadFile": "上传文件",
|
||||
"pasteKey": "粘贴密钥",
|
||||
"updateKey": "更新密钥",
|
||||
"existingKey": "现有密钥(点击更改)",
|
||||
"existingCredential": "现有凭据(点击更改)",
|
||||
"addTagsSpaceToAdd": "添加标签(空格添加)",
|
||||
"terminalBadge": "终端",
|
||||
"tunnelBadge": "隧道",
|
||||
@@ -334,7 +487,14 @@
|
||||
"general": "常规",
|
||||
"terminal": "终端",
|
||||
"tunnel": "隧道",
|
||||
"fileManager": "文件管理器"
|
||||
"fileManager": "文件管理器",
|
||||
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?主机将被移动到\"无文件夹\"。",
|
||||
"removedFromFolder": "主机\"{{name}}\"已成功从文件夹中移除",
|
||||
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
||||
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||
"failedToRenameFolder": "重命名文件夹失败",
|
||||
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||
"failedToMoveToFolder": "移动主机到文件夹失败"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "终端",
|
||||
@@ -354,7 +514,18 @@
|
||||
"error": "错误",
|
||||
"disconnected": "已断开连接",
|
||||
"connectionClosed": "连接已关闭",
|
||||
"connectionError": "连接错误"
|
||||
"connectionError": "连接错误",
|
||||
"connected": "已连接",
|
||||
"sshConnected": "SSH 连接已建立",
|
||||
"authError": "认证失败:{{message}}",
|
||||
"unknownError": "发生未知错误",
|
||||
"messageParseError": "解析服务器消息失败",
|
||||
"websocketError": "WebSocket 连接错误",
|
||||
"connecting": "连接中...",
|
||||
"reconnecting": "重新连接中... ({{attempt}}/{{max}})",
|
||||
"reconnected": "重新连接成功",
|
||||
"maxReconnectAttemptsReached": "已达到最大重连尝试次数",
|
||||
"connectionTimeout": "连接超时"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "文件管理器",
|
||||
@@ -374,6 +545,11 @@
|
||||
"clickToSelectFile": "点击选择文件",
|
||||
"chooseFile": "选择文件",
|
||||
"uploading": "上传中...",
|
||||
"uploadingFile": "正在上传 {{name}}...",
|
||||
"creatingFile": "正在创建 {{name}}...",
|
||||
"creatingFolder": "正在创建 {{name}}...",
|
||||
"deletingItem": "正在删除 {{type}} {{name}}...",
|
||||
"renamingItem": "正在重命名 {{type}} {{oldName}} 为 {{newName}}...",
|
||||
"createNewFile": "创建新文件",
|
||||
"fileName": "文件名",
|
||||
"creating": "创建中...",
|
||||
@@ -401,16 +577,11 @@
|
||||
"failedToRenameItem": "重命名项目失败",
|
||||
"upload": "上传",
|
||||
"download": "下载",
|
||||
"newFile": "新建文件",
|
||||
"newFolder": "新建文件夹",
|
||||
"rename": "重命名",
|
||||
"delete": "删除",
|
||||
"permissions": "权限",
|
||||
"size": "大小",
|
||||
"modified": "修改时间",
|
||||
"path": "路径",
|
||||
"fileName": "文件名",
|
||||
"folderName": "文件夹名",
|
||||
"confirmDelete": "确定要删除 {{name}} 吗?",
|
||||
"uploadSuccess": "文件上传成功",
|
||||
"uploadFailed": "文件上传失败",
|
||||
@@ -430,10 +601,7 @@
|
||||
"fileSavedSuccessfully": "文件保存成功",
|
||||
"saveTimeout": "保存操作超时。文件可能已成功保存,但操作用时过长。请检查 Docker 日志以确认。",
|
||||
"failedToSaveFile": "保存文件失败",
|
||||
"folder": "文件夹",
|
||||
"file": "文件",
|
||||
"deletedSuccessfully": "删除成功",
|
||||
"failedToDeleteItem": "删除项目失败",
|
||||
"connectToServer": "连接到服务器",
|
||||
"selectServerToEdit": "从侧边栏选择服务器以开始编辑文件",
|
||||
"fileOperations": "文件操作",
|
||||
@@ -461,11 +629,11 @@
|
||||
"tunnels": {
|
||||
"title": "SSH 隧道",
|
||||
"noSshTunnels": "没有 SSH 隧道",
|
||||
"createFirstTunnelMessage": "您还没有创建任何 SSH 隧道。在主机管理器中配置隧道连接以开始使用。",
|
||||
"createFirstTunnelMessage": "创建您的第一个 SSH 隧道以开始使用。使用 SSH 管理器添加具有隧道连接的主机。",
|
||||
"connected": "已连接",
|
||||
"disconnected": "已断开",
|
||||
"disconnected": "已断开连接",
|
||||
"connecting": "连接中...",
|
||||
"disconnecting": "断开中...",
|
||||
"disconnecting": "断开连接中...",
|
||||
"unknown": "未知",
|
||||
"error": "错误",
|
||||
"failed": "失败",
|
||||
@@ -501,17 +669,7 @@
|
||||
"local": "本地",
|
||||
"remote": "远程",
|
||||
"dynamic": "动态",
|
||||
"noSshTunnels": "没有 SSH 隧道",
|
||||
"createFirstTunnelMessage": "创建您的第一个 SSH 隧道以开始使用。使用 SSH 管理器添加具有隧道连接的主机。",
|
||||
"unknown": "未知",
|
||||
"connected": "已连接",
|
||||
"connecting": "连接中...",
|
||||
"disconnecting": "断开连接中...",
|
||||
"disconnected": "已断开连接",
|
||||
"portMapping": "端口 {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
|
||||
"disconnect": "断开连接",
|
||||
"connect": "连接",
|
||||
"canceling": "取消中...",
|
||||
"endpointHostNotFound": "未找到端点主机"
|
||||
},
|
||||
"serverStats": {
|
||||
@@ -521,7 +679,7 @@
|
||||
"disk": "磁盘",
|
||||
"network": "网络",
|
||||
"uptime": "运行时间",
|
||||
"loadAverage": "平均负载",
|
||||
"loadAverage": "平均: {{avg1}}, {{avg5}}, {{avg15}}",
|
||||
"processes": "进程",
|
||||
"connections": "连接",
|
||||
"usage": "使用率",
|
||||
@@ -537,13 +695,20 @@
|
||||
"cpuCores_one": "{{count}} 个 CPU",
|
||||
"cpuCores_other": "{{count}} 个 CPU",
|
||||
"naCpus": "N/A CPU",
|
||||
"loadAverage": "平均: {{avg1}}, {{avg5}}, {{avg15}}",
|
||||
"loadAverageNA": "平均: N/A",
|
||||
"cpuUsage": "CPU 使用率",
|
||||
"memoryUsage": "内存使用率",
|
||||
"rootStorageSpace": "根目录存储空间",
|
||||
"of": "的",
|
||||
"feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧"
|
||||
"feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧",
|
||||
"failedToFetchHostConfig": "获取主机配置失败",
|
||||
"failedToFetchStatus": "获取服务器状态失败",
|
||||
"failedToFetchMetrics": "获取服务器指标失败",
|
||||
"loadingMetrics": "正在加载指标...",
|
||||
"refreshing": "正在刷新...",
|
||||
"serverOffline": "服务器离线",
|
||||
"cannotFetchMetrics": "无法从离线服务器获取指标",
|
||||
"load": "负载"
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "登录 Termix",
|
||||
@@ -613,7 +778,8 @@
|
||||
"firstUserMessage": "您是第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是错误,请检查 docker 日志,或创建",
|
||||
"external": "外部",
|
||||
"loginWithExternal": "使用外部提供商登录",
|
||||
"loginWithExternalDesc": "使用您配置的外部身份提供商登录",
|
||||
"loginWithExternalDesc": "使用您配置的外部身份提供者登录",
|
||||
"externalNotSupportedInElectron": "Electron 应用暂不支持外部身份验证。请使用网页版本进行 OIDC 登录。",
|
||||
"resetPasswordButton": "重置密码",
|
||||
"sendResetCode": "发送重置代码",
|
||||
"resetCodeDesc": "输入您的用户名以接收密码重置代码。代码将记录在 docker 容器日志中。",
|
||||
@@ -645,7 +811,6 @@
|
||||
"oidcAuthFailed": "OIDC 认证失败",
|
||||
"noTokenReceived": "登录未收到令牌",
|
||||
"invalidAuthUrl": "从后端收到无效的授权 URL",
|
||||
"connectionTimeout": "连接超时",
|
||||
"invalidInput": "输入无效",
|
||||
"requiredField": "此字段为必填项",
|
||||
"minLength": "最小长度为 {{min}}",
|
||||
@@ -690,6 +855,9 @@
|
||||
"external": "外部 (OIDC)",
|
||||
"selectPreferredLanguage": "选择您的界面首选语言"
|
||||
},
|
||||
"user": {
|
||||
"failedToLoadVersionInfo": "加载版本信息失败"
|
||||
},
|
||||
"placeholders": {
|
||||
"enterCode": "000000",
|
||||
"ipAddress": "127.0.0.1",
|
||||
@@ -701,7 +869,11 @@
|
||||
"hostname": "主机名",
|
||||
"folder": "文件夹",
|
||||
"password": "密码",
|
||||
"credentialName": "我的SSH服务器",
|
||||
"description": "SSH凭据描述",
|
||||
"searchCredentials": "按名称、用户名或标签搜索凭据...",
|
||||
"keyPassword": "密钥密码",
|
||||
"pastePrivateKey": "在此粘贴您的私钥...",
|
||||
"sshConfig": "端点 SSH 配置",
|
||||
"homePath": "/home",
|
||||
"clientId": "您的客户端 ID",
|
||||
@@ -712,6 +884,7 @@
|
||||
"userIdField": "sub",
|
||||
"usernameField": "name",
|
||||
"scopes": "openid email profile",
|
||||
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
|
||||
"enterUsername": "输入用户名以设为管理员",
|
||||
"searchHosts": "按名称、用户名、IP、文件夹、标签搜索主机...",
|
||||
"enterPassword": "输入您的密码",
|
||||
@@ -762,7 +935,6 @@
|
||||
"deleteItem": "删除项目",
|
||||
"createNewFile": "创建新文件",
|
||||
"createNewFolder": "创建新文件夹",
|
||||
"deleteItem": "删除项目",
|
||||
"renameItem": "重命名项目",
|
||||
"clickToSelectFile": "点击选择文件",
|
||||
"noSshHosts": "没有 SSH 主机",
|
||||
@@ -827,8 +999,6 @@
|
||||
"updateKey": "更新密钥",
|
||||
"sshpassRequired": "密码认证需要 Sshpass",
|
||||
"sshServerConfigRequired": "需要 SSH 服务器配置",
|
||||
"sshManagerAlreadyOpen": "SSH 管理器已打开",
|
||||
"disabledDuringSplitScreen": "分屏期间禁用",
|
||||
"productionFolder": "生产环境",
|
||||
"databaseServer": "数据库服务器",
|
||||
"unknownError": "未知错误",
|
||||
@@ -851,5 +1021,9 @@
|
||||
"invalidVerificationCode": "无效的验证码",
|
||||
"failedToDisableTotp": "禁用 TOTP 失败",
|
||||
"failedToGenerateBackupCodes": "生成备用码失败"
|
||||
},
|
||||
"mobile": {
|
||||
"selectHostToStart": "选择一个主机以开始您的终端会话",
|
||||
"limitedSupportMessage": "移动端支持目前有限。我们即将推出专门的移动应用以提升您的体验。"
|
||||
}
|
||||
}
|
||||
}
|
||||
84
src/main.tsx
@@ -1,14 +1,72 @@
|
||||
import {StrictMode} from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import {ThemeProvider} from "@/components/theme-provider"
|
||||
import './i18n/i18n' // Initialize i18n
|
||||
import { StrictMode, useEffect, useState, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import DesktopApp from "./ui/Desktop/DesktopApp.tsx";
|
||||
import { MobileApp } from "./ui/Mobile/MobileApp.tsx";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./i18n/i18n";
|
||||
import { isElectron } from "./ui/main-axios.ts";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<App/>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
function useWindowWidth() {
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const lastSwitchTime = useRef(0);
|
||||
const isCurrentlyMobile = useRef(window.innerWidth < 768);
|
||||
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>
|
||||
);
|
||||
}
|
||||