Run prettier
This commit is contained in:
+78
-78
@@ -24,20 +24,20 @@ reviews:
|
|||||||
- path: "**/*.{ts,tsx}"
|
- path: "**/*.{ts,tsx}"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review TypeScript and React code for Termix server management platform. Key considerations:
|
Review TypeScript and React code for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
**Architecture & Patterns:**
|
**Architecture & Patterns:**
|
||||||
- Follow the established multi-port backend architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
- 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
|
- Use proper separation between Desktop and Mobile UI components
|
||||||
- Maintain consistent state management patterns with React hooks and context
|
- Maintain consistent state management patterns with React hooks and context
|
||||||
- Follow the established tab-based navigation system
|
- Follow the established tab-based navigation system
|
||||||
|
|
||||||
**Database & Backend:**
|
**Database & Backend:**
|
||||||
- Use Drizzle ORM with SQLite for database operations
|
- Use Drizzle ORM with SQLite for database operations
|
||||||
- Implement proper JWT authentication middleware patterns
|
- Implement proper JWT authentication middleware patterns
|
||||||
- Follow the established API error handling patterns in main-axios.ts
|
- Follow the established API error handling patterns in main-axios.ts
|
||||||
- Use proper logging with the structured logger system (apiLogger, authLogger, sshLogger, etc.)
|
- Use proper logging with the structured logger system (apiLogger, authLogger, sshLogger, etc.)
|
||||||
- Maintain proper input validation and sanitization
|
- Maintain proper input validation and sanitization
|
||||||
|
|
||||||
**UI/UX Guidelines:**
|
**UI/UX Guidelines:**
|
||||||
- Use Shadcn/UI components with Tailwind CSS for consistent styling
|
- Use Shadcn/UI components with Tailwind CSS for consistent styling
|
||||||
- Follow the established theme system with dark/light mode support
|
- Follow the established theme system with dark/light mode support
|
||||||
@@ -48,13 +48,13 @@ reviews:
|
|||||||
- Follow the established color token system (--primary, --secondary, --background, etc.)
|
- Follow the established color token system (--primary, --secondary, --background, etc.)
|
||||||
- Use proper Tailwind CSS classes instead of inline styles
|
- Use proper Tailwind CSS classes instead of inline styles
|
||||||
- Implement proper focus states and accessibility indicators
|
- Implement proper focus states and accessibility indicators
|
||||||
|
|
||||||
**SSH & Security:**
|
**SSH & Security:**
|
||||||
- Implement proper SSH connection management with session handling
|
- Implement proper SSH connection management with session handling
|
||||||
- Use secure credential storage and management patterns
|
- Use secure credential storage and management patterns
|
||||||
- Follow the established authentication flow (password, key, credential-based)
|
- Follow the established authentication flow (password, key, credential-based)
|
||||||
- Implement proper file operation security and validation
|
- Implement proper file operation security and validation
|
||||||
|
|
||||||
**Code Quality:**
|
**Code Quality:**
|
||||||
- Use proper TypeScript types from the centralized types/index.ts
|
- Use proper TypeScript types from the centralized types/index.ts
|
||||||
- Follow the established API patterns in main-axios.ts
|
- Follow the established API patterns in main-axios.ts
|
||||||
@@ -65,7 +65,7 @@ reviews:
|
|||||||
- Use proper component interaction patterns through props and callbacks
|
- Use proper component interaction patterns through props and callbacks
|
||||||
- Follow the established state management patterns with useState and useEffect
|
- Follow the established state management patterns with useState and useEffect
|
||||||
- Use proper event handling and form submission patterns
|
- Use proper event handling and form submission patterns
|
||||||
|
|
||||||
**Bug Detection & Fixes:**
|
**Bug Detection & Fixes:**
|
||||||
- Identify and fix memory leaks in useEffect cleanup functions
|
- Identify and fix memory leaks in useEffect cleanup functions
|
||||||
- Fix missing dependency arrays in useEffect hooks
|
- Fix missing dependency arrays in useEffect hooks
|
||||||
@@ -82,27 +82,27 @@ reviews:
|
|||||||
- Identify and fix performance issues and unnecessary re-renders
|
- Identify and fix performance issues and unnecessary re-renders
|
||||||
- Fix improper API error handling and user feedback
|
- Fix improper API error handling and user feedback
|
||||||
- Resolve authentication state inconsistencies and token management issues
|
- Resolve authentication state inconsistencies and token management issues
|
||||||
|
|
||||||
**Internationalization:**
|
**Internationalization:**
|
||||||
- Use the i18next translation system with proper t() function calls
|
- Use the i18next translation system with proper t() function calls
|
||||||
- Support both English and Chinese locales
|
- Support both English and Chinese locales
|
||||||
- Use proper translation keys and fallbacks
|
- Use proper translation keys and fallbacks
|
||||||
|
|
||||||
**Performance:**
|
**Performance:**
|
||||||
- Implement proper cleanup in useEffect hooks
|
- Implement proper cleanup in useEffect hooks
|
||||||
- Use proper memoization where appropriate
|
- Use proper memoization where appropriate
|
||||||
- Follow the established polling and refresh patterns
|
- Follow the established polling and refresh patterns
|
||||||
- Implement proper connection pooling and resource management
|
- Implement proper connection pooling and resource management
|
||||||
|
|
||||||
**Specific to Termix:**
|
**Specific to Termix:**
|
||||||
- Maintain compatibility with Electron and web versions
|
- Maintain compatibility with Electron and web versions
|
||||||
- Follow the established terminal integration patterns with xterm.js
|
- Follow the established terminal integration patterns with xterm.js
|
||||||
- Use proper file manager operations and SSH session management
|
- Use proper file manager operations and SSH session management
|
||||||
- Implement proper tunnel management and status tracking
|
- Implement proper tunnel management and status tracking
|
||||||
- Follow the established alert and notification system patterns
|
- Follow the established alert and notification system patterns
|
||||||
|
|
||||||
Highlight any deviations from these patterns and suggest improvements for maintainability, security, and user experience.
|
Highlight any deviations from these patterns and suggest improvements for maintainability, security, and user experience.
|
||||||
|
|
||||||
**General Bug Detection & Fixes:**
|
**General Bug Detection & Fixes:**
|
||||||
- Identify and fix common React bugs (missing keys, improper state updates, memory leaks)
|
- Identify and fix common React bugs (missing keys, improper state updates, memory leaks)
|
||||||
- Fix TypeScript errors and type safety issues
|
- Fix TypeScript errors and type safety issues
|
||||||
@@ -123,33 +123,33 @@ reviews:
|
|||||||
- path: "**/backend/**/*.{ts,js}"
|
- path: "**/backend/**/*.{ts,js}"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review backend code for Termix server management platform. Key considerations:
|
Review backend code for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
**Backend Architecture:**
|
**Backend Architecture:**
|
||||||
- Follow the multi-port microservice architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
- Follow the multi-port microservice architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
||||||
- Use Express.js with proper middleware patterns
|
- Use Express.js with proper middleware patterns
|
||||||
- Implement proper CORS and security headers
|
- Implement proper CORS and security headers
|
||||||
- Use proper request/response logging with structured logging
|
- Use proper request/response logging with structured logging
|
||||||
|
|
||||||
**Database Operations:**
|
**Database Operations:**
|
||||||
- Use Drizzle ORM with proper schema definitions
|
- Use Drizzle ORM with proper schema definitions
|
||||||
- Implement proper database migrations and schema updates
|
- Implement proper database migrations and schema updates
|
||||||
- Use proper transaction handling for critical operations
|
- Use proper transaction handling for critical operations
|
||||||
- Follow the established database connection patterns
|
- Follow the established database connection patterns
|
||||||
|
|
||||||
**Authentication & Security:**
|
**Authentication & Security:**
|
||||||
- Implement proper JWT token validation and refresh
|
- Implement proper JWT token validation and refresh
|
||||||
- Use bcryptjs for password hashing with proper salt rounds
|
- Use bcryptjs for password hashing with proper salt rounds
|
||||||
- Implement proper input validation and sanitization
|
- Implement proper input validation and sanitization
|
||||||
- Use proper CORS configuration for security
|
- Use proper CORS configuration for security
|
||||||
- Implement proper rate limiting and security headers
|
- Implement proper rate limiting and security headers
|
||||||
|
|
||||||
**SSH Operations:**
|
**SSH Operations:**
|
||||||
- Use ssh2 library with proper connection management
|
- Use ssh2 library with proper connection management
|
||||||
- Implement proper SSH key handling and validation
|
- Implement proper SSH key handling and validation
|
||||||
- Use proper session management and cleanup
|
- Use proper session management and cleanup
|
||||||
- Implement proper error handling for SSH operations
|
- Implement proper error handling for SSH operations
|
||||||
- Use proper file operation security and validation
|
- Use proper file operation security and validation
|
||||||
|
|
||||||
**API Design:**
|
**API Design:**
|
||||||
- Follow RESTful API patterns with proper HTTP status codes
|
- Follow RESTful API patterns with proper HTTP status codes
|
||||||
- Implement proper error response formatting
|
- Implement proper error response formatting
|
||||||
@@ -159,25 +159,25 @@ reviews:
|
|||||||
- Use the established multi-port API architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
- 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
|
- Follow the established error handling patterns with handleApiError function
|
||||||
- Use proper structured logging with service-specific loggers (apiLogger, authLogger, sshLogger, etc.)
|
- Use proper structured logging with service-specific loggers (apiLogger, authLogger, sshLogger, etc.)
|
||||||
|
|
||||||
**Logging & Monitoring:**
|
**Logging & Monitoring:**
|
||||||
- Use the structured logging system with proper context
|
- Use the structured logging system with proper context
|
||||||
- Implement proper error tracking and reporting
|
- Implement proper error tracking and reporting
|
||||||
- Use proper performance monitoring and metrics
|
- Use proper performance monitoring and metrics
|
||||||
- Implement proper health checks and status endpoints
|
- Implement proper health checks and status endpoints
|
||||||
|
|
||||||
Highlight any security vulnerabilities, performance issues, or architectural deviations.
|
Highlight any security vulnerabilities, performance issues, or architectural deviations.
|
||||||
|
|
||||||
- path: "**/components/**/*.{ts,tsx}"
|
- path: "**/components/**/*.{ts,tsx}"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review UI components for Termix server management platform. Key considerations:
|
Review UI components for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
**Component Design:**
|
**Component Design:**
|
||||||
- Use Shadcn/UI components as the foundation
|
- Use Shadcn/UI components as the foundation
|
||||||
- Implement proper component composition and reusability
|
- Implement proper component composition and reusability
|
||||||
- Use proper TypeScript interfaces and prop types
|
- Use proper TypeScript interfaces and prop types
|
||||||
- Follow the established component naming conventions
|
- Follow the established component naming conventions
|
||||||
|
|
||||||
**Styling & Theming:**
|
**Styling & Theming:**
|
||||||
- Use Tailwind CSS with proper responsive design
|
- Use Tailwind CSS with proper responsive design
|
||||||
- Implement proper dark/light theme support
|
- Implement proper dark/light theme support
|
||||||
@@ -187,91 +187,91 @@ reviews:
|
|||||||
- Follow the established color scheme and design tokens
|
- Follow the established color scheme and design tokens
|
||||||
- Use proper Tailwind CSS utility classes instead of custom CSS
|
- Use proper Tailwind CSS utility classes instead of custom CSS
|
||||||
- Implement proper focus states and hover effects
|
- Implement proper focus states and hover effects
|
||||||
|
|
||||||
**State Management:**
|
**State Management:**
|
||||||
- Use proper React hooks and context patterns
|
- Use proper React hooks and context patterns
|
||||||
- Implement proper state lifting and prop drilling avoidance
|
- Implement proper state lifting and prop drilling avoidance
|
||||||
- Use proper memoization with useMemo and useCallback
|
- Use proper memoization with useMemo and useCallback
|
||||||
- Implement proper cleanup in useEffect hooks
|
- Implement proper cleanup in useEffect hooks
|
||||||
|
|
||||||
**Form Handling:**
|
**Form Handling:**
|
||||||
- Use react-hook-form with proper validation
|
- Use react-hook-form with proper validation
|
||||||
- Implement proper form state management
|
- Implement proper form state management
|
||||||
- Use proper error handling and user feedback
|
- Use proper error handling and user feedback
|
||||||
- Implement proper accessibility for form elements
|
- Implement proper accessibility for form elements
|
||||||
|
|
||||||
**SSH Integration:**
|
**SSH Integration:**
|
||||||
- Implement proper SSH connection status indicators
|
- Implement proper SSH connection status indicators
|
||||||
- Use proper terminal integration with xterm.js
|
- Use proper terminal integration with xterm.js
|
||||||
- Implement proper file manager operations
|
- Implement proper file manager operations
|
||||||
- Use proper tunnel status and management UI
|
- Use proper tunnel status and management UI
|
||||||
|
|
||||||
Highlight any UI/UX issues, accessibility problems, or performance concerns.
|
Highlight any UI/UX issues, accessibility problems, or performance concerns.
|
||||||
|
|
||||||
- path: "**/types/**/*.{ts,js}"
|
- path: "**/types/**/*.{ts,js}"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review type definitions for Termix server management platform. Key considerations:
|
Review type definitions for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
**Type Design:**
|
**Type Design:**
|
||||||
- Use proper TypeScript interfaces and type definitions
|
- Use proper TypeScript interfaces and type definitions
|
||||||
- Implement proper type safety and validation
|
- Implement proper type safety and validation
|
||||||
- Use proper generic types and utility types
|
- Use proper generic types and utility types
|
||||||
- Follow the established type naming conventions
|
- Follow the established type naming conventions
|
||||||
|
|
||||||
**API Types:**
|
**API Types:**
|
||||||
- Define proper request/response types for all API endpoints
|
- Define proper request/response types for all API endpoints
|
||||||
- Use proper error types and status codes
|
- Use proper error types and status codes
|
||||||
- Implement proper validation types and schemas
|
- Implement proper validation types and schemas
|
||||||
- Use proper pagination and filtering types
|
- Use proper pagination and filtering types
|
||||||
|
|
||||||
**SSH Types:**
|
**SSH Types:**
|
||||||
- Define proper SSH connection and configuration types
|
- Define proper SSH connection and configuration types
|
||||||
- Use proper tunnel and credential types
|
- Use proper tunnel and credential types
|
||||||
- Implement proper file operation types
|
- Implement proper file operation types
|
||||||
- Use proper authentication and security types
|
- Use proper authentication and security types
|
||||||
|
|
||||||
**Type Safety:**
|
**Type Safety:**
|
||||||
- Ensure proper type coverage and completeness
|
- Ensure proper type coverage and completeness
|
||||||
- Use proper strict type checking
|
- Use proper strict type checking
|
||||||
- Implement proper type narrowing and guards
|
- Implement proper type narrowing and guards
|
||||||
- Use proper type assertions and casting
|
- Use proper type assertions and casting
|
||||||
|
|
||||||
Highlight any type safety issues, missing types, or type inconsistencies.
|
Highlight any type safety issues, missing types, or type inconsistencies.
|
||||||
|
|
||||||
- path: "**/hooks/**/*.{ts,tsx}"
|
- path: "**/hooks/**/*.{ts,tsx}"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review custom hooks for Termix server management platform. Key considerations:
|
Review custom hooks for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
**Hook Design:**
|
**Hook Design:**
|
||||||
- Use proper React hooks patterns and conventions
|
- Use proper React hooks patterns and conventions
|
||||||
- Implement proper hook composition and reusability
|
- Implement proper hook composition and reusability
|
||||||
- Use proper TypeScript types for hook parameters and return values
|
- Use proper TypeScript types for hook parameters and return values
|
||||||
- Follow the established hook naming conventions
|
- Follow the established hook naming conventions
|
||||||
|
|
||||||
**State Management:**
|
**State Management:**
|
||||||
- Implement proper state management with useState and useReducer
|
- Implement proper state management with useState and useReducer
|
||||||
- Use proper context and provider patterns
|
- Use proper context and provider patterns
|
||||||
- Implement proper state persistence and synchronization
|
- Implement proper state persistence and synchronization
|
||||||
- Use proper state cleanup and memory management
|
- Use proper state cleanup and memory management
|
||||||
|
|
||||||
**Side Effects:**
|
**Side Effects:**
|
||||||
- Use proper useEffect patterns with proper dependencies
|
- Use proper useEffect patterns with proper dependencies
|
||||||
- Implement proper cleanup functions and resource management
|
- Implement proper cleanup functions and resource management
|
||||||
- Use proper async operations and error handling
|
- Use proper async operations and error handling
|
||||||
- Implement proper polling and refresh patterns
|
- Implement proper polling and refresh patterns
|
||||||
|
|
||||||
**Performance:**
|
**Performance:**
|
||||||
- Use proper memoization with useMemo and useCallback
|
- Use proper memoization with useMemo and useCallback
|
||||||
- Implement proper debouncing and throttling
|
- Implement proper debouncing and throttling
|
||||||
- Use proper lazy loading and code splitting
|
- Use proper lazy loading and code splitting
|
||||||
- Implement proper optimization patterns
|
- Implement proper optimization patterns
|
||||||
|
|
||||||
**SSH Integration:**
|
**SSH Integration:**
|
||||||
- Implement proper SSH connection management hooks
|
- Implement proper SSH connection management hooks
|
||||||
- Use proper terminal integration hooks
|
- Use proper terminal integration hooks
|
||||||
- Implement proper file manager operation hooks
|
- Implement proper file manager operation hooks
|
||||||
- Use proper tunnel management hooks
|
- Use proper tunnel management hooks
|
||||||
|
|
||||||
**Hook-Specific Bug Detection:**
|
**Hook-Specific Bug Detection:**
|
||||||
- Fix missing cleanup functions in useEffect hooks that cause memory leaks
|
- Fix missing cleanup functions in useEffect hooks that cause memory leaks
|
||||||
- Resolve infinite loops caused by incorrect dependency arrays
|
- Resolve infinite loops caused by incorrect dependency arrays
|
||||||
@@ -283,25 +283,25 @@ reviews:
|
|||||||
- Identify and fix custom hook dependency issues
|
- Identify and fix custom hook dependency issues
|
||||||
- Resolve improper memoization that causes stale data
|
- Resolve improper memoization that causes stale data
|
||||||
- Fix improper error handling in custom hooks
|
- Fix improper error handling in custom hooks
|
||||||
|
|
||||||
Highlight any hook design issues, performance problems, or reusability concerns.
|
Highlight any hook design issues, performance problems, or reusability concerns.
|
||||||
|
|
||||||
- path: "**/lib/**/*.{ts,js}"
|
- path: "**/lib/**/*.{ts,js}"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review utility libraries and helper functions for Termix server management platform. Key considerations:
|
Review utility libraries and helper functions for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
**Utility Functions:**
|
**Utility Functions:**
|
||||||
- Implement proper utility functions with clear purposes
|
- Implement proper utility functions with clear purposes
|
||||||
- Use proper TypeScript types and JSDoc documentation
|
- Use proper TypeScript types and JSDoc documentation
|
||||||
- Implement proper error handling and validation
|
- Implement proper error handling and validation
|
||||||
- Follow the established utility naming conventions
|
- Follow the established utility naming conventions
|
||||||
|
|
||||||
**Logging System:**
|
**Logging System:**
|
||||||
- Use proper structured logging with context and metadata
|
- Use proper structured logging with context and metadata
|
||||||
- Implement proper log levels and filtering
|
- Implement proper log levels and filtering
|
||||||
- Use proper log formatting and output
|
- Use proper log formatting and output
|
||||||
- Implement proper log rotation and cleanup
|
- Implement proper log rotation and cleanup
|
||||||
|
|
||||||
**API Utilities:**
|
**API Utilities:**
|
||||||
- Implement proper API client configuration and management
|
- Implement proper API client configuration and management
|
||||||
- Use proper request/response interceptors
|
- Use proper request/response interceptors
|
||||||
@@ -311,19 +311,19 @@ reviews:
|
|||||||
- Use proper service-specific API instances (sshHostApi, tunnelApi, fileManagerApi, statsApi, authApi)
|
- Use proper service-specific API instances (sshHostApi, tunnelApi, fileManagerApi, statsApi, authApi)
|
||||||
- Follow the established error handling patterns with handleApiError function
|
- Follow the established error handling patterns with handleApiError function
|
||||||
- Use proper structured logging with service-specific loggers
|
- Use proper structured logging with service-specific loggers
|
||||||
|
|
||||||
**Security Utilities:**
|
**Security Utilities:**
|
||||||
- Implement proper input validation and sanitization
|
- Implement proper input validation and sanitization
|
||||||
- Use proper encryption and decryption functions
|
- Use proper encryption and decryption functions
|
||||||
- Implement proper secure random generation
|
- Implement proper secure random generation
|
||||||
- Use proper security headers and CORS handling
|
- Use proper security headers and CORS handling
|
||||||
|
|
||||||
**SSH Utilities:**
|
**SSH Utilities:**
|
||||||
- Implement proper SSH connection utilities
|
- Implement proper SSH connection utilities
|
||||||
- Use proper SSH key handling and validation
|
- Use proper SSH key handling and validation
|
||||||
- Implement proper SSH command execution
|
- Implement proper SSH command execution
|
||||||
- Use proper SSH file operation utilities
|
- Use proper SSH file operation utilities
|
||||||
|
|
||||||
**Utility Bug Detection:**
|
**Utility Bug Detection:**
|
||||||
- Fix improper error handling in utility functions that could crash the application
|
- Fix improper error handling in utility functions that could crash the application
|
||||||
- Resolve null/undefined access errors in utility functions
|
- Resolve null/undefined access errors in utility functions
|
||||||
@@ -335,61 +335,61 @@ reviews:
|
|||||||
- Identify and fix performance bottlenecks in utility functions
|
- Identify and fix performance bottlenecks in utility functions
|
||||||
- Fix improper data transformation and serialization issues
|
- Fix improper data transformation and serialization issues
|
||||||
- Resolve improper configuration and environment variable handling
|
- Resolve improper configuration and environment variable handling
|
||||||
|
|
||||||
Highlight any utility design issues, performance problems, or security concerns.
|
Highlight any utility design issues, performance problems, or security concerns.
|
||||||
|
|
||||||
- path: "**/main-axios.ts"
|
- path: "**/main-axios.ts"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review main-axios.ts API client configuration for Termix server management platform. Key considerations:
|
Review main-axios.ts API client configuration for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
**API Client Architecture:**
|
**API Client Architecture:**
|
||||||
- Maintain the multi-port API architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
- 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)
|
- Use proper service-specific API instances (sshHostApi, tunnelApi, fileManagerApi, statsApi, authApi)
|
||||||
- Implement proper API instance creation with createApiInstance function
|
- Implement proper API instance creation with createApiInstance function
|
||||||
- Use proper base URL configuration for different environments (dev, production, Electron)
|
- Use proper base URL configuration for different environments (dev, production, Electron)
|
||||||
|
|
||||||
**Error Handling:**
|
**Error Handling:**
|
||||||
- Use the centralized handleApiError function for consistent error handling
|
- Use the centralized handleApiError function for consistent error handling
|
||||||
- Implement proper error classification (auth, network, validation, server errors)
|
- Implement proper error classification (auth, network, validation, server errors)
|
||||||
- Use proper error logging with service-specific loggers
|
- Use proper error logging with service-specific loggers
|
||||||
- Implement proper error response formatting and user-friendly messages
|
- Implement proper error response formatting and user-friendly messages
|
||||||
|
|
||||||
**Request/Response Interceptors:**
|
**Request/Response Interceptors:**
|
||||||
- Implement proper JWT token handling in request interceptors
|
- Implement proper JWT token handling in request interceptors
|
||||||
- Use proper request timing and performance logging
|
- Use proper request timing and performance logging
|
||||||
- Implement proper response logging and error tracking
|
- Implement proper response logging and error tracking
|
||||||
- Use proper authentication token refresh and cleanup
|
- Use proper authentication token refresh and cleanup
|
||||||
|
|
||||||
**API Function Organization:**
|
**API Function Organization:**
|
||||||
- Group API functions by service (SSH Host Management, Tunnel Management, File Manager, etc.)
|
- Group API functions by service (SSH Host Management, Tunnel Management, File Manager, etc.)
|
||||||
- Use proper TypeScript types for all API functions
|
- Use proper TypeScript types for all API functions
|
||||||
- Implement proper parameter validation and sanitization
|
- Implement proper parameter validation and sanitization
|
||||||
- Use proper return type definitions and error handling
|
- Use proper return type definitions and error handling
|
||||||
|
|
||||||
**Authentication:**
|
**Authentication:**
|
||||||
- Implement proper JWT token management and refresh
|
- Implement proper JWT token management and refresh
|
||||||
- Use proper cookie handling for web and Electron environments
|
- Use proper cookie handling for web and Electron environments
|
||||||
- Implement proper authentication state management
|
- Implement proper authentication state management
|
||||||
- Use proper token expiration and cleanup
|
- Use proper token expiration and cleanup
|
||||||
|
|
||||||
**Logging:**
|
**Logging:**
|
||||||
- Use proper structured logging with context and metadata
|
- Use proper structured logging with context and metadata
|
||||||
- Implement proper request/response logging with performance metrics
|
- Implement proper request/response logging with performance metrics
|
||||||
- Use proper error logging with appropriate log levels
|
- Use proper error logging with appropriate log levels
|
||||||
- Implement proper service-specific logger selection
|
- Implement proper service-specific logger selection
|
||||||
|
|
||||||
**Performance:**
|
**Performance:**
|
||||||
- Implement proper request timeout and retry logic
|
- Implement proper request timeout and retry logic
|
||||||
- Use proper connection pooling and resource management
|
- Use proper connection pooling and resource management
|
||||||
- Implement proper request deduplication and caching
|
- Implement proper request deduplication and caching
|
||||||
- Use proper performance monitoring and metrics
|
- Use proper performance monitoring and metrics
|
||||||
|
|
||||||
**Security:**
|
**Security:**
|
||||||
- Implement proper input validation and sanitization
|
- Implement proper input validation and sanitization
|
||||||
- Use proper CORS and security header handling
|
- Use proper CORS and security header handling
|
||||||
- Implement proper authentication and authorization
|
- Implement proper authentication and authorization
|
||||||
- Use proper secure communication and data handling
|
- Use proper secure communication and data handling
|
||||||
|
|
||||||
**API Bug Detection:**
|
**API Bug Detection:**
|
||||||
- Fix improper error handling that could expose sensitive information
|
- Fix improper error handling that could expose sensitive information
|
||||||
- Resolve race conditions in concurrent API calls
|
- Resolve race conditions in concurrent API calls
|
||||||
@@ -403,31 +403,31 @@ reviews:
|
|||||||
- Resolve improper authentication token refresh and cleanup
|
- Resolve improper authentication token refresh and cleanup
|
||||||
- Fix improper CORS and security header configuration
|
- Fix improper CORS and security header configuration
|
||||||
- Identify and fix potential security vulnerabilities in API handling
|
- Identify and fix potential security vulnerabilities in API handling
|
||||||
|
|
||||||
Highlight any API design issues, error handling problems, or security concerns.
|
Highlight any API design issues, error handling problems, or security concerns.
|
||||||
|
|
||||||
- path: "**/electron/**/*.{ts,js,cjs}"
|
- path: "**/electron/**/*.{ts,js,cjs}"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review Electron application code for Termix server management platform. Key considerations:
|
Review Electron application code for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
**Electron Architecture:**
|
**Electron Architecture:**
|
||||||
- Use proper Electron main and renderer process separation
|
- Use proper Electron main and renderer process separation
|
||||||
- Implement proper IPC (Inter-Process Communication) patterns
|
- Implement proper IPC (Inter-Process Communication) patterns
|
||||||
- Use proper security and sandboxing configurations
|
- Use proper security and sandboxing configurations
|
||||||
- Follow the established Electron best practices
|
- Follow the established Electron best practices
|
||||||
|
|
||||||
**Security:**
|
**Security:**
|
||||||
- Implement proper security policies and configurations
|
- Implement proper security policies and configurations
|
||||||
- Use proper context isolation and node integration
|
- Use proper context isolation and node integration
|
||||||
- Implement proper CSP and security headers
|
- Implement proper CSP and security headers
|
||||||
- Use proper authentication and authorization handling
|
- Use proper authentication and authorization handling
|
||||||
|
|
||||||
**Performance:**
|
**Performance:**
|
||||||
- Implement proper memory management and cleanup
|
- Implement proper memory management and cleanup
|
||||||
- Use proper resource optimization and caching
|
- Use proper resource optimization and caching
|
||||||
- Implement proper background processing and threading
|
- Implement proper background processing and threading
|
||||||
- Use proper performance monitoring and profiling
|
- Use proper performance monitoring and profiling
|
||||||
|
|
||||||
**Electron Bug Detection:**
|
**Electron Bug Detection:**
|
||||||
- Fix improper IPC communication that could cause crashes
|
- Fix improper IPC communication that could cause crashes
|
||||||
- Resolve memory leaks in Electron main and renderer processes
|
- Resolve memory leaks in Electron main and renderer processes
|
||||||
@@ -441,31 +441,31 @@ reviews:
|
|||||||
- Resolve improper tray and menu functionality issues
|
- Resolve improper tray and menu functionality issues
|
||||||
- Fix improper security policies and CSP configuration
|
- Fix improper security policies and CSP configuration
|
||||||
- Identify and fix potential security vulnerabilities in Electron setup
|
- Identify and fix potential security vulnerabilities in Electron setup
|
||||||
|
|
||||||
Highlight any Electron-specific issues, security vulnerabilities, or performance problems.
|
Highlight any Electron-specific issues, security vulnerabilities, or performance problems.
|
||||||
|
|
||||||
- path: "**/docker/**/*"
|
- path: "**/docker/**/*"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review Docker configuration files for Termix server management platform. Key considerations:
|
Review Docker configuration files for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
**Dockerfile Design:**
|
**Dockerfile Design:**
|
||||||
- Use proper multi-stage builds for optimization
|
- Use proper multi-stage builds for optimization
|
||||||
- Implement proper layer caching and optimization
|
- Implement proper layer caching and optimization
|
||||||
- Use proper security and minimal base images
|
- Use proper security and minimal base images
|
||||||
- Follow the established Docker best practices
|
- Follow the established Docker best practices
|
||||||
|
|
||||||
**Security:**
|
**Security:**
|
||||||
- Implement proper user and permission management
|
- Implement proper user and permission management
|
||||||
- Use proper security scanning and vulnerability assessment
|
- Use proper security scanning and vulnerability assessment
|
||||||
- Implement proper secrets and credential management
|
- Implement proper secrets and credential management
|
||||||
- Use proper network security and isolation
|
- Use proper network security and isolation
|
||||||
|
|
||||||
**Performance:**
|
**Performance:**
|
||||||
- Implement proper resource optimization and allocation
|
- Implement proper resource optimization and allocation
|
||||||
- Use proper caching and build optimization
|
- Use proper caching and build optimization
|
||||||
- Implement proper monitoring and logging
|
- Implement proper monitoring and logging
|
||||||
- Use proper health checks and status monitoring
|
- Use proper health checks and status monitoring
|
||||||
|
|
||||||
**Docker Bug Detection:**
|
**Docker Bug Detection:**
|
||||||
- Fix improper multi-stage build optimization that causes large images
|
- Fix improper multi-stage build optimization that causes large images
|
||||||
- Resolve security vulnerabilities in base images and dependencies
|
- Resolve security vulnerabilities in base images and dependencies
|
||||||
@@ -479,91 +479,91 @@ reviews:
|
|||||||
- Resolve improper backup and recovery procedures
|
- Resolve improper backup and recovery procedures
|
||||||
- Fix improper scaling and load balancing configuration
|
- Fix improper scaling and load balancing configuration
|
||||||
- Identify and fix potential security vulnerabilities in Docker setup
|
- Identify and fix potential security vulnerabilities in Docker setup
|
||||||
|
|
||||||
Highlight any Docker configuration issues, security vulnerabilities, or performance problems.
|
Highlight any Docker configuration issues, security vulnerabilities, or performance problems.
|
||||||
|
|
||||||
- path: "**/*.md"
|
- path: "**/*.md"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review documentation files for Termix server management platform. Key considerations:
|
Review documentation files for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
**Documentation Quality:**
|
**Documentation Quality:**
|
||||||
- Ensure proper grammar, spelling, and clarity
|
- Ensure proper grammar, spelling, and clarity
|
||||||
- Use proper formatting and structure
|
- Use proper formatting and structure
|
||||||
- Implement proper code examples and snippets
|
- Implement proper code examples and snippets
|
||||||
- Follow the established documentation standards
|
- Follow the established documentation standards
|
||||||
|
|
||||||
**Content Accuracy:**
|
**Content Accuracy:**
|
||||||
- Ensure proper technical accuracy and completeness
|
- Ensure proper technical accuracy and completeness
|
||||||
- Use proper up-to-date information and examples
|
- Use proper up-to-date information and examples
|
||||||
- Implement proper cross-references and links
|
- Implement proper cross-references and links
|
||||||
- Use proper version and compatibility information
|
- Use proper version and compatibility information
|
||||||
|
|
||||||
**User Experience:**
|
**User Experience:**
|
||||||
- Ensure proper user-friendly language and explanations
|
- Ensure proper user-friendly language and explanations
|
||||||
- Use proper step-by-step instructions and guides
|
- Use proper step-by-step instructions and guides
|
||||||
- Implement proper troubleshooting and FAQ sections
|
- Implement proper troubleshooting and FAQ sections
|
||||||
- Use proper visual aids and diagrams where appropriate
|
- Use proper visual aids and diagrams where appropriate
|
||||||
|
|
||||||
Highlight any documentation issues, inaccuracies, or missing information.
|
Highlight any documentation issues, inaccuracies, or missing information.
|
||||||
|
|
||||||
- path: "**/index.css"
|
- path: "**/index.css"
|
||||||
instructions: |
|
instructions: |
|
||||||
Review index.css styling configuration for Termix server management platform. Key considerations:
|
Review index.css styling configuration for Termix server management platform. Key considerations:
|
||||||
|
|
||||||
**CSS Variable System:**
|
**CSS Variable System:**
|
||||||
- Define proper CSS custom properties for colors, spacing, and typography
|
- Define proper CSS custom properties for colors, spacing, and typography
|
||||||
- Use consistent naming conventions for CSS variables (--primary, --secondary, --background, etc.)
|
- Use consistent naming conventions for CSS variables (--primary, --secondary, --background, etc.)
|
||||||
- Implement proper dark/light theme variable definitions
|
- Implement proper dark/light theme variable definitions
|
||||||
- Use proper semantic color naming (--destructive, --muted, --accent, etc.)
|
- Use proper semantic color naming (--destructive, --muted, --accent, etc.)
|
||||||
|
|
||||||
**Design System:**
|
**Design System:**
|
||||||
- Follow the established design token system
|
- Follow the established design token system
|
||||||
- Use proper color palette definitions with proper contrast ratios
|
- Use proper color palette definitions with proper contrast ratios
|
||||||
- Implement proper typography scale and font family definitions
|
- Implement proper typography scale and font family definitions
|
||||||
- Use proper spacing and sizing scale definitions
|
- Use proper spacing and sizing scale definitions
|
||||||
|
|
||||||
**Theme Support:**
|
**Theme Support:**
|
||||||
- Implement proper dark and light theme variable definitions
|
- Implement proper dark and light theme variable definitions
|
||||||
- Use proper CSS custom property fallbacks
|
- Use proper CSS custom property fallbacks
|
||||||
- Implement proper theme switching support
|
- Implement proper theme switching support
|
||||||
- Use proper color scheme media queries
|
- Use proper color scheme media queries
|
||||||
|
|
||||||
**Component Styling:**
|
**Component Styling:**
|
||||||
- Define proper base styles for common components
|
- Define proper base styles for common components
|
||||||
- Use proper utility classes and helper styles
|
- Use proper utility classes and helper styles
|
||||||
- Implement proper responsive design utilities
|
- Implement proper responsive design utilities
|
||||||
- Use proper accessibility-focused styling
|
- Use proper accessibility-focused styling
|
||||||
|
|
||||||
**Color Management:**
|
**Color Management:**
|
||||||
- Avoid hardcoded color values, use CSS variables instead
|
- Avoid hardcoded color values, use CSS variables instead
|
||||||
- Implement proper color contrast and accessibility
|
- Implement proper color contrast and accessibility
|
||||||
- Use proper semantic color definitions
|
- Use proper semantic color definitions
|
||||||
- Implement proper color state variations (hover, focus, active)
|
- Implement proper color state variations (hover, focus, active)
|
||||||
|
|
||||||
**Typography:**
|
**Typography:**
|
||||||
- Define proper font family and weight definitions
|
- Define proper font family and weight definitions
|
||||||
- Use proper line height and letter spacing
|
- Use proper line height and letter spacing
|
||||||
- Implement proper text size and hierarchy
|
- Implement proper text size and hierarchy
|
||||||
- Use proper font loading and fallback strategies
|
- Use proper font loading and fallback strategies
|
||||||
|
|
||||||
**Layout Utilities:**
|
**Layout Utilities:**
|
||||||
- Define proper spacing and margin utilities
|
- Define proper spacing and margin utilities
|
||||||
- Use proper flexbox and grid utilities
|
- Use proper flexbox and grid utilities
|
||||||
- Implement proper responsive breakpoint utilities
|
- Implement proper responsive breakpoint utilities
|
||||||
- Use proper container and layout helpers
|
- Use proper container and layout helpers
|
||||||
|
|
||||||
**Accessibility:**
|
**Accessibility:**
|
||||||
- Implement proper focus indicators and states
|
- Implement proper focus indicators and states
|
||||||
- Use proper color contrast ratios
|
- Use proper color contrast ratios
|
||||||
- Implement proper reduced motion support
|
- Implement proper reduced motion support
|
||||||
- Use proper screen reader friendly styling
|
- Use proper screen reader friendly styling
|
||||||
|
|
||||||
**Performance:**
|
**Performance:**
|
||||||
- Use efficient CSS selectors and properties
|
- Use efficient CSS selectors and properties
|
||||||
- Implement proper CSS organization and structure
|
- Implement proper CSS organization and structure
|
||||||
- Use proper CSS custom property optimization
|
- Use proper CSS custom property optimization
|
||||||
- Implement proper critical CSS and loading strategies
|
- Implement proper critical CSS and loading strategies
|
||||||
|
|
||||||
Highlight any styling issues, accessibility problems, or design system inconsistencies.
|
Highlight any styling issues, accessibility problems, or design system inconsistencies.
|
||||||
auto_review:
|
auto_review:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -575,4 +575,4 @@ reviews:
|
|||||||
- "TEST"
|
- "TEST"
|
||||||
drafts: false
|
drafts: false
|
||||||
chat:
|
chat:
|
||||||
auto_reply: true
|
auto_reply: true
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
github: [ LukeGus ]
|
github: [LukeGus]
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ name: Bug report
|
|||||||
about: Create a report to help Termix improve
|
about: Create a report to help Termix improve
|
||||||
title: "[BUG]"
|
title: "[BUG]"
|
||||||
labels: bug
|
labels: bug
|
||||||
assignees: ''
|
assignees: ""
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
**Describe the bug**
|
||||||
@@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
|
|||||||
|
|
||||||
**To Reproduce**
|
**To Reproduce**
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
@@ -24,8 +24,9 @@ A clear and concise description of what you expected to happen.
|
|||||||
If applicable, add screenshots or console/Docker logs to help explain your problem.
|
If applicable, add screenshots or console/Docker logs to help explain your problem.
|
||||||
|
|
||||||
**Environment (please complete the following information):**
|
**Environment (please complete the following information):**
|
||||||
- Browser [e.g. chrome, safari]
|
|
||||||
- Version [e.g. 1.6.0]
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 1.6.0]
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ name: Feature request
|
|||||||
about: Suggest an idea for Termix
|
about: Suggest an idea for Termix
|
||||||
title: "[FEATURE]"
|
title: "[FEATURE]"
|
||||||
labels: enhancement
|
labels: enhancement
|
||||||
assignees: ''
|
assignees: ""
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
|||||||
@@ -37,4 +37,4 @@ updates:
|
|||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- development
|
- development
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**.md'
|
- "**.md"
|
||||||
- '.gitignore'
|
- ".gitignore"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag_name:
|
tag_name:
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- development
|
- development
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**.md'
|
- "**.md"
|
||||||
- '.gitignore'
|
- ".gitignore"
|
||||||
- 'docker/**'
|
- "docker/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
build_type:
|
build_type:
|
||||||
@@ -34,8 +34,8 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -79,8 +79,8 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -100,4 +100,3 @@ jobs:
|
|||||||
name: Termix-Linux-Portable
|
name: Termix-Linux-Portable
|
||||||
path: Termix-Linux-Portable.zip
|
path: Termix-Linux-Portable.zip
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Ignore artifacts:
|
||||||
|
build
|
||||||
|
coverage
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+21
-21
@@ -1,4 +1,4 @@
|
|||||||
_# Contributing
|
\_# Contributing
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -9,13 +9,13 @@ _# Contributing
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/LukeGus/Termix
|
git clone https://github.com/LukeGus/Termix
|
||||||
```
|
```
|
||||||
2. Install the dependencies:
|
2. Install the dependencies:
|
||||||
```sh
|
```sh
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running the development server
|
## Running the development server
|
||||||
|
|
||||||
@@ -34,18 +34,18 @@ This will start the backend and the frontend Vite server. You can access Termix
|
|||||||
1. **Fork the repository**: Click the "Fork" button at the top right of
|
1. **Fork the repository**: Click the "Fork" button at the top right of
|
||||||
the [repository page](https://github.com/LukeGus/Termix).
|
the [repository page](https://github.com/LukeGus/Termix).
|
||||||
2. **Create a new branch**:
|
2. **Create a new branch**:
|
||||||
```sh
|
```sh
|
||||||
git checkout -b feature/my-new-feature
|
git checkout -b feature/my-new-feature
|
||||||
```
|
```
|
||||||
3. **Make your changes**: Implement your feature, fix, or improvement.
|
3. **Make your changes**: Implement your feature, fix, or improvement.
|
||||||
4. **Commit your changes**:
|
4. **Commit your changes**:
|
||||||
```sh
|
```sh
|
||||||
git commit -m "Feature request my new feature"
|
git commit -m "Feature request my new feature"
|
||||||
```
|
```
|
||||||
5. **Push to your fork**:
|
5. **Push to your fork**:
|
||||||
```sh
|
```sh
|
||||||
git push origin feature/my-feature-request
|
git push origin feature/my-feature-request
|
||||||
```
|
```
|
||||||
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
|
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
|
||||||
|
|
||||||
## 📝 Guidelines
|
## 📝 Guidelines
|
||||||
@@ -62,7 +62,7 @@ This will start the backend and the frontend Vite server. You can access Termix
|
|||||||
### Background Colors
|
### Background Colors
|
||||||
|
|
||||||
| CSS Variable | Color Value | Usage | Description |
|
| CSS Variable | Color Value | Usage | Description |
|
||||||
|-------------------------------|-------------|-----------------------------|------------------------------------------|
|
| ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
|
||||||
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
|
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
|
||||||
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
|
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
|
||||||
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
|
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
|
||||||
@@ -74,7 +74,7 @@ This will start the backend and the frontend Vite server. You can access Termix
|
|||||||
### Element-Specific Backgrounds
|
### Element-Specific Backgrounds
|
||||||
|
|
||||||
| CSS Variable | Color Value | Usage | Description |
|
| CSS Variable | Color Value | Usage | Description |
|
||||||
|--------------------------|-------------|--------------------|-----------------------------------------------|
|
| ------------------------ | ----------- | ------------------ | --------------------------------------------- |
|
||||||
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
|
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
|
||||||
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
|
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
|
||||||
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
|
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
|
||||||
@@ -83,7 +83,7 @@ This will start the backend and the frontend Vite server. You can access Termix
|
|||||||
### Border Colors
|
### Border Colors
|
||||||
|
|
||||||
| CSS Variable | Color Value | Usage | Description |
|
| CSS Variable | Color Value | Usage | Description |
|
||||||
|------------------------------|-------------|-----------------|------------------------------------------|
|
| ---------------------------- | ----------- | --------------- | ---------------------------------------- |
|
||||||
| `--color-dark-border` | `#303032` | Default borders | Standard border color |
|
| `--color-dark-border` | `#303032` | Default borders | Standard border color |
|
||||||
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
|
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
|
||||||
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
|
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
|
||||||
@@ -94,7 +94,7 @@ This will start the backend and the frontend Vite server. You can access Termix
|
|||||||
### Interactive States
|
### Interactive States
|
||||||
|
|
||||||
| CSS Variable | Color Value | Usage | Description |
|
| CSS Variable | Color Value | Usage | Description |
|
||||||
|--------------------------|-------------|-------------------|-----------------------------------------------|
|
| ------------------------ | ----------- | ----------------- | --------------------------------------------- |
|
||||||
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
|
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
|
||||||
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
|
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
|
||||||
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |
|
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |
|
||||||
@@ -104,4 +104,4 @@ This will start the backend and the frontend Vite server. You can access Termix
|
|||||||
|
|
||||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the 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)
|
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||||
repo.
|
repo.
|
||||||
|
|||||||
+109
-109
@@ -1,109 +1,109 @@
|
|||||||
# 仓库统计
|
# 仓库统计
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> 英文</a> |
|
<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"> 中文
|
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

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

|

|
||||||

|

|
||||||

|

|
||||||
@@ -81,7 +80,7 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
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 (
|
Pre-built binaries are now available for download, including a Windows installer/portable app and a Linux portable app (
|
||||||
|
|||||||
+1
-1
@@ -18,4 +18,4 @@
|
|||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -15,9 +15,7 @@
|
|||||||
"!vite.config.ts",
|
"!vite.config.ts",
|
||||||
"!eslint.config.js"
|
"!eslint.config.js"
|
||||||
],
|
],
|
||||||
"asarUnpack": [
|
"asarUnpack": ["node_modules/node-fetch/**/*"],
|
||||||
"node_modules/node-fetch/**/*"
|
|
||||||
],
|
|
||||||
"extraMetadata": {
|
"extraMetadata": {
|
||||||
"main": "electron/main.cjs"
|
"main": "electron/main.cjs"
|
||||||
},
|
},
|
||||||
@@ -43,4 +41,4 @@
|
|||||||
"icon": "public/icon.png",
|
"icon": "public/icon.png",
|
||||||
"category": "Development"
|
"category": "Development"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+286
-249
@@ -1,297 +1,334 @@
|
|||||||
const {app, BrowserWindow, shell, ipcMain} = require('electron');
|
const { app, BrowserWindow, shell, ipcMain } = require("electron");
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
|
|
||||||
let mainWindow = null;
|
let mainWindow = null;
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||||
|
|
||||||
const gotTheLock = app.requestSingleInstanceLock();
|
const gotTheLock = app.requestSingleInstanceLock();
|
||||||
if (!gotTheLock) {
|
if (!gotTheLock) {
|
||||||
console.log('Another instance is already running, quitting...');
|
console.log("Another instance is already running, quitting...");
|
||||||
app.quit();
|
app.quit();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', (event, commandLine, workingDirectory) => {
|
app.on("second-instance", (event, commandLine, workingDirectory) => {
|
||||||
console.log('Second instance detected, focusing existing window...');
|
console.log("Second instance detected, focusing existing window...");
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 800,
|
height: 800,
|
||||||
minWidth: 800,
|
minWidth: 800,
|
||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
title: 'Termix',
|
title: "Termix",
|
||||||
icon: isDev
|
icon: isDev
|
||||||
? path.join(__dirname, '..', 'public', 'icon.png')
|
? path.join(__dirname, "..", "public", "icon.png")
|
||||||
: path.join(process.resourcesPath, 'public', 'icon.png'),
|
: path.join(process.resourcesPath, "public", "icon.png"),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
webSecurity: !isDev,
|
webSecurity: !isDev,
|
||||||
preload: path.join(__dirname, 'preload.js')
|
preload: path.join(__dirname, "preload.js"),
|
||||||
},
|
},
|
||||||
show: false
|
show: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== "darwin") {
|
||||||
mainWindow.setMenuBarVisibility(false);
|
mainWindow.setMenuBarVisibility(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (isDev) {
|
mainWindow.on("closed", () => {
|
||||||
mainWindow.loadURL('http://localhost:5173');
|
mainWindow = null;
|
||||||
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', () => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
console.log('Window ready to show');
|
shell.openExternal(url);
|
||||||
mainWindow.show();
|
return { action: "deny" };
|
||||||
});
|
});
|
||||||
|
|
||||||
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', () => {
|
ipcMain.handle("get-app-version", () => {
|
||||||
return app.getVersion();
|
return app.getVersion();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-platform', () => {
|
ipcMain.handle("get-platform", () => {
|
||||||
return process.platform;
|
return process.platform;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-server-config', () => {
|
ipcMain.handle("get-server-config", () => {
|
||||||
try {
|
try {
|
||||||
const userDataPath = app.getPath('userData');
|
const userDataPath = app.getPath("userData");
|
||||||
const configPath = path.join(userDataPath, 'server-config.json');
|
const configPath = path.join(userDataPath, "server-config.json");
|
||||||
|
|
||||||
if (fs.existsSync(configPath)) {
|
if (fs.existsSync(configPath)) {
|
||||||
const configData = fs.readFileSync(configPath, 'utf8');
|
const configData = fs.readFileSync(configPath, "utf8");
|
||||||
return JSON.parse(configData);
|
return JSON.parse(configData);
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading server config:', error);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reading server config:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('save-server-config', (event, config) => {
|
ipcMain.handle("save-server-config", (event, config) => {
|
||||||
try {
|
try {
|
||||||
const userDataPath = app.getPath('userData');
|
const userDataPath = app.getPath("userData");
|
||||||
const configPath = path.join(userDataPath, 'server-config.json');
|
const configPath = path.join(userDataPath, "server-config.json");
|
||||||
|
|
||||||
if (!fs.existsSync(userDataPath)) {
|
if (!fs.existsSync(userDataPath)) {
|
||||||
fs.mkdirSync(userDataPath, {recursive: true});
|
fs.mkdirSync(userDataPath, { recursive: true });
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
||||||
return {success: true};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving server config:', error);
|
|
||||||
return {success: false, error: error.message};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
ipcMain.handle('test-server-connection', async (event, serverUrl) => {
|
try {
|
||||||
|
let fetch;
|
||||||
try {
|
try {
|
||||||
let fetch;
|
fetch = globalThis.fetch || require("node:fetch");
|
||||||
try {
|
} catch (e) {
|
||||||
fetch = globalThis.fetch || require('node:fetch');
|
const https = require("https");
|
||||||
} catch (e) {
|
const http = require("http");
|
||||||
const https = require('https');
|
const { URL } = require("url");
|
||||||
const http = require('http');
|
|
||||||
const {URL} = require('url');
|
|
||||||
|
|
||||||
fetch = (url, options = {}) => {
|
fetch = (url, options = {}) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const isHttps = urlObj.protocol === 'https:';
|
const isHttps = urlObj.protocol === "https:";
|
||||||
const client = isHttps ? https : http;
|
const client = isHttps ? https : http;
|
||||||
|
|
||||||
const req = client.request(url, {
|
const req = client.request(
|
||||||
method: options.method || 'GET',
|
url,
|
||||||
headers: options.headers || {},
|
{
|
||||||
timeout: options.timeout || 5000
|
method: options.method || "GET",
|
||||||
}, (res) => {
|
headers: options.headers || {},
|
||||||
let data = '';
|
timeout: options.timeout || 5000,
|
||||||
res.on('data', chunk => data += chunk);
|
},
|
||||||
res.on('end', () => {
|
(res) => {
|
||||||
resolve({
|
let data = "";
|
||||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
res.on("data", (chunk) => (data += chunk));
|
||||||
status: res.statusCode,
|
res.on("end", () => {
|
||||||
text: () => Promise.resolve(data),
|
resolve({
|
||||||
json: () => Promise.resolve(JSON.parse(data))
|
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(/\/$/, '');
|
req.on("error", reject);
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("Request timeout"));
|
||||||
|
});
|
||||||
|
|
||||||
const healthUrl = `${normalizedServerUrl}/health`;
|
if (options.body) {
|
||||||
|
req.write(options.body);
|
||||||
try {
|
}
|
||||||
const response = await fetch(healthUrl, {
|
req.end();
|
||||||
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};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
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();
|
createWindow();
|
||||||
console.log('Termix started successfully');
|
} else if (mainWindow) {
|
||||||
|
mainWindow.show();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on("before-quit", () => {
|
||||||
if (process.platform !== 'darwin') {
|
console.log("App is quitting...");
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on("will-quit", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
console.log("App will quit...");
|
||||||
createWindow();
|
|
||||||
} else if (mainWindow) {
|
|
||||||
mainWindow.show();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
process.on("uncaughtException", (error) => {
|
||||||
console.log('App is quitting...');
|
console.error("Uncaught Exception:", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('will-quit', () => {
|
process.on("unhandledRejection", (reason, promise) => {
|
||||||
console.log('App will quit...');
|
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||||
});
|
|
||||||
|
|
||||||
process.on('uncaughtException', (error) => {
|
|
||||||
console.error('Uncaught Exception:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
|
||||||
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-17
@@ -1,27 +1,29 @@
|
|||||||
const {contextBridge, ipcRenderer} = require('electron');
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||||
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||||
|
|
||||||
getServerConfig: () => ipcRenderer.invoke('get-server-config'),
|
getServerConfig: () => ipcRenderer.invoke("get-server-config"),
|
||||||
saveServerConfig: (config) => ipcRenderer.invoke('save-server-config', config),
|
saveServerConfig: (config) =>
|
||||||
testServerConnection: (serverUrl) => ipcRenderer.invoke('test-server-connection', serverUrl),
|
ipcRenderer.invoke("save-server-config", config),
|
||||||
|
testServerConnection: (serverUrl) =>
|
||||||
|
ipcRenderer.invoke("test-server-connection", serverUrl),
|
||||||
|
|
||||||
showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options),
|
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
||||||
showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options),
|
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
||||||
|
|
||||||
onUpdateAvailable: (callback) => ipcRenderer.on('update-available', callback),
|
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
|
||||||
onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', callback),
|
onUpdateDownloaded: (callback) =>
|
||||||
|
ipcRenderer.on("update-downloaded", callback),
|
||||||
|
|
||||||
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
|
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
|
||||||
isElectron: true,
|
isElectron: true,
|
||||||
isDev: process.env.NODE_ENV === 'development',
|
isDev: process.env.NODE_ENV === "development",
|
||||||
|
|
||||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
|
||||||
|
|
||||||
|
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||||
});
|
});
|
||||||
|
|
||||||
window.IS_ELECTRON = true;
|
window.IS_ELECTRON = true;
|
||||||
|
|
||||||
console.log('electronAPI exposed to window');
|
console.log("electronAPI exposed to window");
|
||||||
|
|||||||
+10
-10
@@ -1,18 +1,18 @@
|
|||||||
import js from '@eslint/js'
|
import js from "@eslint/js";
|
||||||
import globals from 'globals'
|
import globals from "globals";
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from "typescript-eslint";
|
||||||
import { globalIgnores } from 'eslint/config'
|
import { globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
export default tseslint.config([
|
export default tseslint.config([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(["dist"]),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
reactHooks.configs['recommended-latest'],
|
reactHooks.configs["recommended-latest"],
|
||||||
reactRefresh.configs.vite,
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
@@ -20,4 +20,4 @@ export default tseslint.config([
|
|||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
|
|||||||
+2054
-2011
File diff suppressed because it is too large
Load Diff
Generated
+17
@@ -102,6 +102,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"prettier": "3.6.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
@@ -13330,6 +13331,22 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proc-log": {
|
"node_modules/proc-log": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"clean": "npx prettier . --write",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build && tsc -p tsconfig.node.json",
|
"build": "vite build && tsc -p tsconfig.node.json",
|
||||||
"build:backend": "tsc -p tsconfig.node.json",
|
"build:backend": "tsc -p tsconfig.node.json",
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"prettier": "3.6.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
|
|||||||
+250
-212
@@ -1,252 +1,290 @@
|
|||||||
import express from 'express';
|
import express from "express";
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from "body-parser";
|
||||||
import userRoutes from './routes/users.js';
|
import userRoutes from "./routes/users.js";
|
||||||
import sshRoutes from './routes/ssh.js';
|
import sshRoutes from "./routes/ssh.js";
|
||||||
import alertRoutes from './routes/alerts.js';
|
import alertRoutes from "./routes/alerts.js";
|
||||||
import credentialsRoutes from './routes/credentials.js';
|
import credentialsRoutes from "./routes/credentials.js";
|
||||||
import cors from 'cors';
|
import cors from "cors";
|
||||||
import fetch from 'node-fetch';
|
import fetch from "node-fetch";
|
||||||
import fs from 'fs';
|
import fs from "fs";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
import 'dotenv/config';
|
import "dotenv/config";
|
||||||
import {databaseLogger, apiLogger} from '../utils/logger.js';
|
import { databaseLogger, apiLogger } from "../utils/logger.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors({
|
app.use(
|
||||||
origin: '*',
|
cors({
|
||||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
origin: "*",
|
||||||
allowedHeaders: ['Content-Type', 'Authorization']
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
}));
|
allowedHeaders: ["Content-Type", "Authorization"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
data: any;
|
data: any;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class GitHubCache {
|
class GitHubCache {
|
||||||
private cache: Map<string, CacheEntry> = new Map();
|
private cache: Map<string, CacheEntry> = new Map();
|
||||||
private readonly CACHE_DURATION = 30 * 60 * 1000;
|
private readonly CACHE_DURATION = 30 * 60 * 1000;
|
||||||
|
|
||||||
set(key: string, data: any): void {
|
set(key: string, data: any): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.cache.set(key, {
|
this.cache.set(key, {
|
||||||
data,
|
data,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
expiresAt: now + this.CACHE_DURATION
|
expiresAt: now + this.CACHE_DURATION,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): any | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key: string): any | null {
|
if (Date.now() > entry.expiresAt) {
|
||||||
const entry = this.cache.get(key);
|
this.cache.delete(key);
|
||||||
if (!entry) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const githubCache = new GitHubCache();
|
const githubCache = new GitHubCache();
|
||||||
|
|
||||||
const GITHUB_API_BASE = 'https://api.github.com';
|
const GITHUB_API_BASE = "https://api.github.com";
|
||||||
const REPO_OWNER = 'LukeGus';
|
const REPO_OWNER = "LukeGus";
|
||||||
const REPO_NAME = 'Termix';
|
const REPO_NAME = "Termix";
|
||||||
|
|
||||||
interface GitHubRelease {
|
interface GitHubRelease {
|
||||||
|
id: number;
|
||||||
|
tag_name: string;
|
||||||
|
name: string;
|
||||||
|
body: string;
|
||||||
|
published_at: string;
|
||||||
|
html_url: string;
|
||||||
|
assets: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
tag_name: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
body: string;
|
size: number;
|
||||||
published_at: string;
|
download_count: number;
|
||||||
html_url: string;
|
browser_download_url: string;
|
||||||
assets: Array<{
|
}>;
|
||||||
id: number;
|
prerelease: boolean;
|
||||||
name: string;
|
draft: boolean;
|
||||||
size: number;
|
|
||||||
download_count: number;
|
|
||||||
browser_download_url: string;
|
|
||||||
}>;
|
|
||||||
prerelease: boolean;
|
|
||||||
draft: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
|
async function fetchGitHubAPI(
|
||||||
const cachedData = githubCache.get(cacheKey);
|
endpoint: string,
|
||||||
if (cachedData) {
|
cacheKey: string,
|
||||||
return {
|
): Promise<any> {
|
||||||
data: cachedData,
|
const cachedData = githubCache.get(cacheKey);
|
||||||
cached: true,
|
if (cachedData) {
|
||||||
cache_age: Date.now() - cachedData.timestamp
|
return {
|
||||||
};
|
data: cachedData,
|
||||||
|
cached: true,
|
||||||
|
cache_age: Date.now() - cachedData.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"User-Agent": "TermixUpdateChecker/1.0",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const data = await response.json();
|
||||||
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
|
githubCache.set(cacheKey, data);
|
||||||
headers: {
|
|
||||||
'Accept': 'application/vnd.github+json',
|
|
||||||
'User-Agent': 'TermixUpdateChecker/1.0',
|
|
||||||
'X-GitHub-Api-Version': '2022-11-28'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
return {
|
||||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
data: data,
|
||||||
}
|
cached: false,
|
||||||
|
};
|
||||||
const data = await response.json();
|
} catch (error) {
|
||||||
githubCache.set(cacheKey, data);
|
databaseLogger.error(`Failed to fetch from GitHub API`, error, {
|
||||||
|
operation: "github_api",
|
||||||
return {
|
endpoint,
|
||||||
data: data,
|
});
|
||||||
cached: false
|
throw error;
|
||||||
};
|
}
|
||||||
} catch (error) {
|
|
||||||
databaseLogger.error(`Failed to fetch from GitHub API`, error, {operation: 'github_api', endpoint});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
res.json({status: 'ok'});
|
res.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/version', async (req, res) => {
|
app.get("/version", async (req, res) => {
|
||||||
let localVersion = process.env.VERSION;
|
let localVersion = process.env.VERSION;
|
||||||
|
|
||||||
if (!localVersion) {
|
|
||||||
try {
|
|
||||||
const packagePath = path.resolve(process.cwd(), 'package.json');
|
|
||||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
||||||
localVersion = packageJson.version;
|
|
||||||
} catch (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');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!localVersion) {
|
||||||
try {
|
try {
|
||||||
const cacheKey = 'latest_release';
|
const packagePath = path.resolve(process.cwd(), "package.json");
|
||||||
const releaseData = await fetchGitHubAPI(
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
localVersion = packageJson.version;
|
||||||
cacheKey
|
|
||||||
);
|
|
||||||
|
|
||||||
const rawTag = releaseData.data.tag_name || releaseData.data.name || '';
|
|
||||||
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
|
|
||||||
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
|
||||||
|
|
||||||
if (!remoteVersion) {
|
|
||||||
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.get('/releases/rss', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
|
||||||
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
|
|
||||||
const cacheKey = `releases_rss_${page}_${per_page}`;
|
|
||||||
|
|
||||||
const releasesData = await fetchGitHubAPI(
|
|
||||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
|
|
||||||
cacheKey
|
|
||||||
);
|
|
||||||
|
|
||||||
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
|
|
||||||
id: release.id,
|
|
||||||
title: release.name || release.tag_name,
|
|
||||||
description: release.body,
|
|
||||||
link: release.html_url,
|
|
||||||
pubDate: release.published_at,
|
|
||||||
version: release.tag_name,
|
|
||||||
isPrerelease: release.prerelease,
|
|
||||||
isDraft: release.draft,
|
|
||||||
assets: release.assets.map(asset => ({
|
|
||||||
name: asset.name,
|
|
||||||
size: asset.size,
|
|
||||||
download_count: asset.download_count,
|
|
||||||
download_url: asset.browser_download_url
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
feed: {
|
|
||||||
title: `${REPO_NAME} Releases`,
|
|
||||||
description: `Latest releases from ${REPO_NAME} repository`,
|
|
||||||
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
|
|
||||||
updated: new Date().toISOString()
|
|
||||||
},
|
|
||||||
items: rssItems,
|
|
||||||
total_count: rssItems.length,
|
|
||||||
cached: releasesData.cached,
|
|
||||||
cache_age: releasesData.cache_age
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error('Failed to generate RSS format', error, {operation: 'rss_releases'});
|
databaseLogger.error("Failed to read version from package.json", error, {
|
||||||
res.status(500).json({
|
operation: "version_check",
|
||||||
error: 'Failed to generate RSS format',
|
});
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (!localVersion) {
|
||||||
app.use('/users', userRoutes);
|
databaseLogger.error("No version information available", undefined, {
|
||||||
app.use('/ssh', sshRoutes);
|
operation: "version_check",
|
||||||
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'});
|
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.get("/releases/rss", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const per_page = Math.min(
|
||||||
|
parseInt(req.query.per_page as string) || 20,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
const cacheKey = `releases_rss_${page}_${per_page}`;
|
||||||
|
|
||||||
|
const releasesData = await fetchGitHubAPI(
|
||||||
|
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
|
||||||
|
cacheKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
|
||||||
|
id: release.id,
|
||||||
|
title: release.name || release.tag_name,
|
||||||
|
description: release.body,
|
||||||
|
link: release.html_url,
|
||||||
|
pubDate: release.published_at,
|
||||||
|
version: release.tag_name,
|
||||||
|
isPrerelease: release.prerelease,
|
||||||
|
isDraft: release.draft,
|
||||||
|
assets: release.assets.map((asset) => ({
|
||||||
|
name: asset.name,
|
||||||
|
size: asset.size,
|
||||||
|
download_count: asset.download_count,
|
||||||
|
download_url: asset.browser_download_url,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
feed: {
|
||||||
|
title: `${REPO_NAME} Releases`,
|
||||||
|
description: `Latest releases from ${REPO_NAME} repository`,
|
||||||
|
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
items: rssItems,
|
||||||
|
total_count: rssItems.length,
|
||||||
|
cached: releasesData.cached,
|
||||||
|
cache_age: releasesData.cache_age,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to generate RSS format", error, {
|
||||||
|
operation: "rss_releases",
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to generate RSS format",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use("/users", userRoutes);
|
||||||
|
app.use("/ssh", sshRoutes);
|
||||||
|
app.use("/alerts", alertRoutes);
|
||||||
|
app.use("/credentials", credentialsRoutes);
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
(
|
||||||
|
err: unknown,
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response,
|
||||||
|
next: express.NextFunction,
|
||||||
|
) => {
|
||||||
|
apiLogger.error("Unhandled error in request", err, {
|
||||||
|
operation: "error_handler",
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
userAgent: req.get("User-Agent"),
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Internal Server Error" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const PORT = 8081;
|
const PORT = 8081;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
databaseLogger.success(`Database API server started on port ${PORT}`, {
|
databaseLogger.success(`Database API server started on port ${PORT}`, {
|
||||||
operation: 'server_start',
|
operation: "server_start",
|
||||||
port: PORT,
|
port: PORT,
|
||||||
routes: ['/users', '/ssh', '/alerts', '/credentials', '/health', '/version', '/releases/rss']
|
routes: [
|
||||||
});
|
"/users",
|
||||||
});
|
"/ssh",
|
||||||
|
"/alerts",
|
||||||
|
"/credentials",
|
||||||
|
"/health",
|
||||||
|
"/version",
|
||||||
|
"/releases/rss",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import {drizzle} from 'drizzle-orm/better-sqlite3';
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
import Database from 'better-sqlite3';
|
import Database from "better-sqlite3";
|
||||||
import * as schema from './schema.js';
|
import * as schema from "./schema.js";
|
||||||
import fs from 'fs';
|
import fs from "fs";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
import { databaseLogger } from '../../utils/logger.js';
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR || './db/data';
|
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||||
const dbDir = path.resolve(dataDir);
|
const dbDir = path.resolve(dataDir);
|
||||||
if (!fs.existsSync(dbDir)) {
|
if (!fs.existsSync(dbDir)) {
|
||||||
databaseLogger.info(`Creating database directory`, { operation: 'db_init', path: dbDir });
|
databaseLogger.info(`Creating database directory`, {
|
||||||
fs.mkdirSync(dbDir, {recursive: true});
|
operation: "db_init",
|
||||||
|
path: dbDir,
|
||||||
|
});
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbPath = path.join(dataDir, 'db.sqlite');
|
const dbPath = path.join(dataDir, "db.sqlite");
|
||||||
databaseLogger.info(`Initializing SQLite database`, { operation: 'db_init', path: dbPath });
|
databaseLogger.info(`Initializing SQLite database`, {
|
||||||
|
operation: "db_init",
|
||||||
|
path: dbPath,
|
||||||
|
});
|
||||||
const sqlite = new Database(dbPath);
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
@@ -137,90 +143,164 @@ sqlite.exec(`
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
const addColumnIfNotExists = (
|
||||||
|
table: string,
|
||||||
|
column: string,
|
||||||
|
definition: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${column}
|
||||||
|
FROM ${table} LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
sqlite.prepare(`SELECT ${column}
|
databaseLogger.debug(`Adding column ${column} to ${table}`, {
|
||||||
FROM ${table} LIMIT 1`).get();
|
operation: "schema_migration",
|
||||||
} catch (e) {
|
table,
|
||||||
try {
|
column,
|
||||||
databaseLogger.debug(`Adding column ${column} to ${table}`, { operation: 'schema_migration', table, column });
|
});
|
||||||
sqlite.exec(`ALTER TABLE ${table}
|
sqlite.exec(`ALTER TABLE ${table}
|
||||||
ADD COLUMN ${column} ${definition};`);
|
ADD COLUMN ${column} ${definition};`);
|
||||||
databaseLogger.success(`Column ${column} added to ${table}`, { operation: 'schema_migration', table, column });
|
databaseLogger.success(`Column ${column} added to ${table}`, {
|
||||||
} catch (alterError) {
|
operation: "schema_migration",
|
||||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, { operation: 'schema_migration', table, column, error: alterError });
|
table,
|
||||||
}
|
column,
|
||||||
|
});
|
||||||
|
} catch (alterError) {
|
||||||
|
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||||
|
operation: "schema_migration",
|
||||||
|
table,
|
||||||
|
column,
|
||||||
|
error: alterError,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const migrateSchema = () => {
|
const migrateSchema = () => {
|
||||||
databaseLogger.info('Checking for schema updates...', { operation: 'schema_migration' });
|
databaseLogger.info("Checking for schema updates...", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
});
|
||||||
|
|
||||||
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
|
||||||
addColumnIfNotExists('users', 'is_oidc', 'INTEGER NOT NULL DEFAULT 0');
|
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||||
addColumnIfNotExists('users', 'oidc_identifier', 'TEXT');
|
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
||||||
addColumnIfNotExists('users', 'client_id', 'TEXT');
|
addColumnIfNotExists("users", "client_id", "TEXT");
|
||||||
addColumnIfNotExists('users', 'client_secret', 'TEXT');
|
addColumnIfNotExists("users", "client_secret", "TEXT");
|
||||||
addColumnIfNotExists('users', 'issuer_url', 'TEXT');
|
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
||||||
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
|
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
||||||
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
addColumnIfNotExists("users", "token_url", "TEXT");
|
||||||
|
|
||||||
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
|
addColumnIfNotExists("users", "identifier_path", "TEXT");
|
||||||
addColumnIfNotExists('users', 'name_path', 'TEXT');
|
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||||
addColumnIfNotExists('users', 'scopes', 'TEXT');
|
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||||
|
|
||||||
addColumnIfNotExists('users', 'totp_secret', 'TEXT');
|
addColumnIfNotExists("users", "totp_secret", "TEXT");
|
||||||
addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0');
|
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||||
addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT');
|
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||||
|
|
||||||
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
|
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||||
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
|
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||||
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
|
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||||
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0');
|
addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
|
||||||
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"');
|
addColumnIfNotExists(
|
||||||
addColumnIfNotExists('ssh_data', 'password', 'TEXT');
|
"ssh_data",
|
||||||
addColumnIfNotExists('ssh_data', 'key', 'TEXT');
|
"auth_type",
|
||||||
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT');
|
'TEXT NOT NULL DEFAULT "password"',
|
||||||
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT');
|
);
|
||||||
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
|
addColumnIfNotExists("ssh_data", "password", "TEXT");
|
||||||
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
|
addColumnIfNotExists("ssh_data", "key", "TEXT");
|
||||||
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
|
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
|
||||||
addColumnIfNotExists('ssh_data', 'enable_file_manager', 'INTEGER NOT NULL DEFAULT 1');
|
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
|
||||||
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
addColumnIfNotExists(
|
||||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
"ssh_data",
|
||||||
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
"enable_terminal",
|
||||||
|
"INTEGER NOT NULL DEFAULT 1",
|
||||||
|
);
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"enable_tunnel",
|
||||||
|
"INTEGER NOT NULL DEFAULT 1",
|
||||||
|
);
|
||||||
|
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"enable_file_manager",
|
||||||
|
"INTEGER NOT NULL DEFAULT 1",
|
||||||
|
);
|
||||||
|
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"created_at",
|
||||||
|
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||||
|
);
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"updated_at",
|
||||||
|
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||||
|
);
|
||||||
|
|
||||||
addColumnIfNotExists('ssh_data', 'credential_id', 'INTEGER REFERENCES ssh_credentials(id)');
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"credential_id",
|
||||||
|
"INTEGER REFERENCES ssh_credentials(id)",
|
||||||
|
);
|
||||||
|
|
||||||
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
|
||||||
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||||
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||||
|
|
||||||
databaseLogger.success('Schema migration completed', { operation: 'schema_migration' });
|
databaseLogger.success("Schema migration completed", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const initializeDatabase = async () => {
|
const initializeDatabase = async () => {
|
||||||
migrateSchema();
|
migrateSchema();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
const row = sqlite
|
||||||
if (!row) {
|
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||||
databaseLogger.info('Initializing default settings', { operation: 'db_init', setting: 'allow_registration' });
|
.get();
|
||||||
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
|
if (!row) {
|
||||||
databaseLogger.success('Default settings initialized', { operation: 'db_init' });
|
databaseLogger.info("Initializing default settings", {
|
||||||
} else {
|
operation: "db_init",
|
||||||
databaseLogger.debug('Default settings already exist', { operation: 'db_init' });
|
setting: "allow_registration",
|
||||||
}
|
});
|
||||||
} catch (e) {
|
sqlite
|
||||||
databaseLogger.warn('Could not initialize default settings', { operation: 'db_init', error: e });
|
.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) {
|
||||||
|
databaseLogger.warn("Could not initialize default settings", {
|
||||||
|
operation: "db_init",
|
||||||
|
error: e,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeDatabase().catch(error => {
|
initializeDatabase().catch((error) => {
|
||||||
databaseLogger.error('Failed to initialize database', error, { operation: 'db_init' });
|
databaseLogger.error("Failed to initialize database", error, {
|
||||||
process.exit(1);
|
operation: "db_init",
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
databaseLogger.success('Database connection established', { operation: 'db_init', path: dbPath });
|
databaseLogger.success("Database connection established", {
|
||||||
export const db = drizzle(sqlite, {schema});
|
operation: "db_init",
|
||||||
|
path: dbPath,
|
||||||
|
});
|
||||||
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|||||||
@@ -1,117 +1,167 @@
|
|||||||
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
|
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||||
import {sql} from 'drizzle-orm';
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export const users = sqliteTable('users', {
|
export const users = sqliteTable("users", {
|
||||||
id: text('id').primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
username: text('username').notNull(),
|
username: text("username").notNull(),
|
||||||
password_hash: text('password_hash').notNull(),
|
password_hash: text("password_hash").notNull(),
|
||||||
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
|
is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||||
|
|
||||||
is_oidc: integer('is_oidc', {mode: 'boolean'}).notNull().default(false),
|
is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
|
||||||
oidc_identifier: text('oidc_identifier'),
|
oidc_identifier: text("oidc_identifier"),
|
||||||
client_id: text('client_id'),
|
client_id: text("client_id"),
|
||||||
client_secret: text('client_secret'),
|
client_secret: text("client_secret"),
|
||||||
issuer_url: text('issuer_url'),
|
issuer_url: text("issuer_url"),
|
||||||
authorization_url: text('authorization_url'),
|
authorization_url: text("authorization_url"),
|
||||||
token_url: text('token_url'),
|
token_url: text("token_url"),
|
||||||
identifier_path: text('identifier_path'),
|
identifier_path: text("identifier_path"),
|
||||||
name_path: text('name_path'),
|
name_path: text("name_path"),
|
||||||
scopes: text().default("openid email profile"),
|
scopes: text().default("openid email profile"),
|
||||||
|
|
||||||
totp_secret: text('totp_secret'),
|
totp_secret: text("totp_secret"),
|
||||||
totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false),
|
totp_enabled: integer("totp_enabled", { mode: "boolean" })
|
||||||
totp_backup_codes: text('totp_backup_codes'),
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
totp_backup_codes: text("totp_backup_codes"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const settings = sqliteTable('settings', {
|
export const settings = sqliteTable("settings", {
|
||||||
key: text('key').primaryKey(),
|
key: text("key").primaryKey(),
|
||||||
value: text('value').notNull(),
|
value: text("value").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sshData = sqliteTable('ssh_data', {
|
export const sshData = sqliteTable("ssh_data", {
|
||||||
id: integer('id').primaryKey({autoIncrement: true}),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text("user_id")
|
||||||
name: text('name'),
|
.notNull()
|
||||||
ip: text('ip').notNull(),
|
.references(() => users.id),
|
||||||
port: integer('port').notNull(),
|
name: text("name"),
|
||||||
username: text('username').notNull(),
|
ip: text("ip").notNull(),
|
||||||
folder: text('folder'),
|
port: integer("port").notNull(),
|
||||||
tags: text('tags'),
|
username: text("username").notNull(),
|
||||||
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
|
folder: text("folder"),
|
||||||
authType: text('auth_type').notNull(),
|
tags: text("tags"),
|
||||||
|
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
||||||
|
authType: text("auth_type").notNull(),
|
||||||
|
|
||||||
password: text('password'),
|
password: text("password"),
|
||||||
key: text('key', {length: 8192}),
|
key: text("key", { length: 8192 }),
|
||||||
keyPassword: text('key_password'),
|
keyPassword: text("key_password"),
|
||||||
keyType: text('key_type'),
|
keyType: text("key_type"),
|
||||||
|
|
||||||
credentialId: integer('credential_id').references(() => sshCredentials.id),
|
credentialId: integer("credential_id").references(() => sshCredentials.id),
|
||||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||||
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
.notNull()
|
||||||
tunnelConnections: text('tunnel_connections'),
|
.default(true),
|
||||||
enableFileManager: integer('enable_file_manager', {mode: 'boolean'}).notNull().default(true),
|
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
|
||||||
defaultPath: text('default_path'),
|
.notNull()
|
||||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
.default(true),
|
||||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
tunnelConnections: text("tunnel_connections"),
|
||||||
|
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
|
defaultPath: text("default_path"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fileManagerRecent = sqliteTable('file_manager_recent', {
|
export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
||||||
id: integer('id').primaryKey({autoIncrement: true}),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text("user_id")
|
||||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
.notNull()
|
||||||
name: text('name').notNull(),
|
.references(() => users.id),
|
||||||
path: text('path').notNull(),
|
hostId: integer("host_id")
|
||||||
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
|
.notNull()
|
||||||
|
.references(() => sshData.id),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
path: text("path").notNull(),
|
||||||
|
lastOpened: text("last_opened")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fileManagerPinned = sqliteTable('file_manager_pinned', {
|
export const fileManagerPinned = sqliteTable("file_manager_pinned", {
|
||||||
id: integer('id').primaryKey({autoIncrement: true}),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text("user_id")
|
||||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
.notNull()
|
||||||
name: text('name').notNull(),
|
.references(() => users.id),
|
||||||
path: text('path').notNull(),
|
hostId: integer("host_id")
|
||||||
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
.notNull()
|
||||||
|
.references(() => sshData.id),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
path: text("path").notNull(),
|
||||||
|
pinnedAt: text("pinned_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fileManagerShortcuts = sqliteTable('file_manager_shortcuts', {
|
export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
||||||
id: integer('id').primaryKey({autoIncrement: true}),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text("user_id")
|
||||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
.notNull()
|
||||||
name: text('name').notNull(),
|
.references(() => users.id),
|
||||||
path: text('path').notNull(),
|
hostId: integer("host_id")
|
||||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
.notNull()
|
||||||
|
.references(() => sshData.id),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
path: text("path").notNull(),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dismissedAlerts = sqliteTable('dismissed_alerts', {
|
export const dismissedAlerts = sqliteTable("dismissed_alerts", {
|
||||||
id: integer('id').primaryKey({autoIncrement: true}),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text("user_id")
|
||||||
alertId: text('alert_id').notNull(),
|
.notNull()
|
||||||
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
.references(() => users.id),
|
||||||
|
alertId: text("alert_id").notNull(),
|
||||||
|
dismissedAt: text("dismissed_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sshCredentials = sqliteTable('ssh_credentials', {
|
export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||||
id: integer('id').primaryKey({autoIncrement: true}),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text("user_id")
|
||||||
name: text('name').notNull(),
|
.notNull()
|
||||||
description: text('description'),
|
.references(() => users.id),
|
||||||
folder: text('folder'),
|
name: text("name").notNull(),
|
||||||
tags: text('tags'),
|
description: text("description"),
|
||||||
authType: text('auth_type').notNull(),
|
folder: text("folder"),
|
||||||
username: text('username').notNull(),
|
tags: text("tags"),
|
||||||
password: text('password'),
|
authType: text("auth_type").notNull(),
|
||||||
key: text('key', {length: 16384}),
|
username: text("username").notNull(),
|
||||||
keyPassword: text('key_password'),
|
password: text("password"),
|
||||||
keyType: text('key_type'),
|
key: text("key", { length: 16384 }),
|
||||||
usageCount: integer('usage_count').notNull().default(0),
|
keyPassword: text("key_password"),
|
||||||
lastUsed: text('last_used'),
|
keyType: text("key_type"),
|
||||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
usageCount: integer("usage_count").notNull().default(0),
|
||||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
lastUsed: text("last_used"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sshCredentialUsage = sqliteTable('ssh_credential_usage', {
|
export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
||||||
id: integer('id').primaryKey({autoIncrement: true}),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
credentialId: integer('credential_id').notNull().references(() => sshCredentials.id),
|
credentialId: integer("credential_id")
|
||||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
.notNull()
|
||||||
userId: text('user_id').notNull().references(() => users.id),
|
.references(() => sshCredentials.id),
|
||||||
usedAt: text('used_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
hostId: integer("host_id")
|
||||||
});
|
.notNull()
|
||||||
|
.references(() => sshData.id),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
usedAt: text("used_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,248 +1,261 @@
|
|||||||
import express from 'express';
|
import express from "express";
|
||||||
import {db} from '../db/index.js';
|
import { db } from "../db/index.js";
|
||||||
import {dismissedAlerts} from '../db/schema.js';
|
import { dismissedAlerts } from "../db/schema.js";
|
||||||
import {eq, and} from 'drizzle-orm';
|
import { eq, and } from "drizzle-orm";
|
||||||
import fetch from 'node-fetch';
|
import fetch from "node-fetch";
|
||||||
import {authLogger} from '../../utils/logger.js';
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
|
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
data: any;
|
data: any;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AlertCache {
|
class AlertCache {
|
||||||
private cache: Map<string, CacheEntry> = new Map();
|
private cache: Map<string, CacheEntry> = new Map();
|
||||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||||
|
|
||||||
set(key: string, data: any): void {
|
set(key: string, data: any): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.cache.set(key, {
|
this.cache.set(key, {
|
||||||
data,
|
data,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
expiresAt: now + this.CACHE_DURATION
|
expiresAt: now + this.CACHE_DURATION,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): any | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key: string): any | null {
|
if (Date.now() > entry.expiresAt) {
|
||||||
const entry = this.cache.get(key);
|
this.cache.delete(key);
|
||||||
if (!entry) {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const alertCache = new AlertCache();
|
const alertCache = new AlertCache();
|
||||||
|
|
||||||
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com';
|
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
|
||||||
const REPO_OWNER = 'LukeGus';
|
const REPO_OWNER = "LukeGus";
|
||||||
const REPO_NAME = 'Termix-Docs';
|
const REPO_NAME = "Termix-Docs";
|
||||||
const ALERTS_FILE = 'main/termix-alerts.json';
|
const ALERTS_FILE = "main/termix-alerts.json";
|
||||||
|
|
||||||
interface TermixAlert {
|
interface TermixAlert {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
priority?: 'low' | 'medium' | 'high' | 'critical';
|
priority?: "low" | "medium" | "high" | "critical";
|
||||||
type?: 'info' | 'warning' | 'error' | 'success';
|
type?: "info" | "warning" | "error" | "success";
|
||||||
actionUrl?: string;
|
actionUrl?: string;
|
||||||
actionText?: string;
|
actionText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||||
const cacheKey = 'termix_alerts';
|
const cacheKey = "termix_alerts";
|
||||||
const cachedData = alertCache.get(cacheKey);
|
const cachedData = alertCache.get(cacheKey);
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
return cachedData;
|
return cachedData;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "TermixAlertChecker/1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
authLogger.warn("GitHub API returned error status", {
|
||||||
|
operation: "alerts_fetch",
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`GitHub raw content error: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'User-Agent': 'TermixAlertChecker/1.0'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const now = new Date();
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
|
const validAlerts = alerts.filter((alert) => {
|
||||||
|
const expiryDate = new Date(alert.expiresAt);
|
||||||
|
const isValid = expiryDate > now;
|
||||||
|
return isValid;
|
||||||
|
});
|
||||||
|
|
||||||
const now = new Date();
|
alertCache.set(cacheKey, validAlerts);
|
||||||
|
return validAlerts;
|
||||||
const validAlerts = alerts.filter(alert => {
|
} catch (error) {
|
||||||
const expiryDate = new Date(alert.expiresAt);
|
authLogger.error("Failed to fetch alerts from GitHub", {
|
||||||
const isValid = expiryDate > now;
|
operation: "alerts_fetch",
|
||||||
return isValid;
|
error: error instanceof Error ? error.message : "Unknown 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();
|
const router = express.Router();
|
||||||
|
|
||||||
// Route: Get all active alerts
|
// Route: Get all active alerts
|
||||||
// GET /alerts
|
// GET /alerts
|
||||||
router.get('/', async (req, res) => {
|
router.get("/", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const alerts = await fetchAlertsFromGitHub();
|
const alerts = await fetchAlertsFromGitHub();
|
||||||
res.json({
|
res.json({
|
||||||
alerts,
|
alerts,
|
||||||
cached: alertCache.get('termix_alerts') !== null,
|
cached: alertCache.get("termix_alerts") !== null,
|
||||||
total_count: alerts.length
|
total_count: alerts.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
authLogger.error('Failed to get alerts', error);
|
authLogger.error("Failed to get alerts", error);
|
||||||
res.status(500).json({error: 'Failed to fetch alerts'});
|
res.status(500).json({ error: "Failed to fetch alerts" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route: Get alerts for a specific user (excluding dismissed ones)
|
// Route: Get alerts for a specific user (excluding dismissed ones)
|
||||||
// GET /alerts/user/:userId
|
// GET /alerts/user/:userId
|
||||||
router.get('/user/:userId', async (req, res) => {
|
router.get("/user/:userId", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {userId} = req.params;
|
const { userId } = req.params;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(400).json({error: 'User ID is required'});
|
return res.status(400).json({ error: "User ID is required" });
|
||||||
}
|
|
||||||
|
|
||||||
const allAlerts = await fetchAlertsFromGitHub();
|
|
||||||
|
|
||||||
const 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'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allAlerts = await fetchAlertsFromGitHub();
|
||||||
|
|
||||||
|
const dismissedAlertRecords = await db
|
||||||
|
.select({ alertId: dismissedAlerts.alertId })
|
||||||
|
.from(dismissedAlerts)
|
||||||
|
.where(eq(dismissedAlerts.userId, userId));
|
||||||
|
|
||||||
|
const dismissedAlertIds = new Set(
|
||||||
|
dismissedAlertRecords.map((record) => record.alertId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const userAlerts = allAlerts.filter(
|
||||||
|
(alert) => !dismissedAlertIds.has(alert.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
alerts: userAlerts,
|
||||||
|
total_count: userAlerts.length,
|
||||||
|
dismissed_count: dismissedAlertIds.size,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to get user alerts", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch user alerts" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route: Dismiss an alert for a user
|
// Route: Dismiss an alert for a user
|
||||||
// POST /alerts/dismiss
|
// POST /alerts/dismiss
|
||||||
router.post('/dismiss', async (req, res) => {
|
router.post("/dismiss", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {userId, alertId} = req.body;
|
const { userId, alertId } = req.body;
|
||||||
|
|
||||||
if (!userId || !alertId) {
|
if (!userId || !alertId) {
|
||||||
authLogger.warn('Missing userId or alertId in dismiss request');
|
authLogger.warn("Missing userId or alertId in dismiss request");
|
||||||
return res.status(400).json({error: 'User ID and Alert ID are required'});
|
return res
|
||||||
}
|
.status(400)
|
||||||
|
.json({ error: "User ID and Alert ID are required" });
|
||||||
const existingDismissal = await db
|
|
||||||
.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'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingDismissal = await db
|
||||||
|
.select()
|
||||||
|
.from(dismissedAlerts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dismissedAlerts.userId, userId),
|
||||||
|
eq(dismissedAlerts.alertId, alertId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingDismissal.length > 0) {
|
||||||
|
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
|
||||||
|
return res.status(409).json({ error: "Alert already dismissed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.insert(dismissedAlerts).values({
|
||||||
|
userId,
|
||||||
|
alertId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: "Alert dismissed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to dismiss alert", error);
|
||||||
|
res.status(500).json({ error: "Failed to dismiss alert" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route: Get dismissed alerts for a user
|
// Route: Get dismissed alerts for a user
|
||||||
// GET /alerts/dismissed/:userId
|
// GET /alerts/dismissed/:userId
|
||||||
router.get('/dismissed/:userId', async (req, res) => {
|
router.get("/dismissed/:userId", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {userId} = req.params;
|
const { userId } = req.params;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(400).json({error: 'User ID is required'});
|
return res.status(400).json({ error: "User ID is required" });
|
||||||
}
|
|
||||||
|
|
||||||
const dismissedAlertRecords = await db
|
|
||||||
.select({
|
|
||||||
alertId: dismissedAlerts.alertId,
|
|
||||||
dismissedAt: dismissedAlerts.dismissedAt
|
|
||||||
})
|
|
||||||
.from(dismissedAlerts)
|
|
||||||
.where(eq(dismissedAlerts.userId, userId));
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
dismissed_alerts: dismissedAlertRecords,
|
|
||||||
total_count: dismissedAlertRecords.length
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
authLogger.error('Failed to get dismissed alerts', error);
|
|
||||||
res.status(500).json({error: 'Failed to fetch dismissed alerts'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dismissedAlertRecords = await db
|
||||||
|
.select({
|
||||||
|
alertId: dismissedAlerts.alertId,
|
||||||
|
dismissedAt: dismissedAlerts.dismissedAt,
|
||||||
|
})
|
||||||
|
.from(dismissedAlerts)
|
||||||
|
.where(eq(dismissedAlerts.userId, userId));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
dismissed_alerts: dismissedAlertRecords,
|
||||||
|
total_count: dismissedAlertRecords.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to get dismissed alerts", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route: Undismiss an alert for a user (remove from dismissed list)
|
// Route: Undismiss an alert for a user (remove from dismissed list)
|
||||||
// DELETE /alerts/dismiss
|
// DELETE /alerts/dismiss
|
||||||
router.delete('/dismiss', async (req, res) => {
|
router.delete("/dismiss", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {userId, alertId} = req.body;
|
const { userId, alertId } = req.body;
|
||||||
|
|
||||||
if (!userId || !alertId) {
|
if (!userId || !alertId) {
|
||||||
return res.status(400).json({error: 'User ID and Alert ID are required'});
|
return res
|
||||||
}
|
.status(400)
|
||||||
|
.json({ error: "User ID and Alert ID are required" });
|
||||||
const result = await db
|
|
||||||
.delete(dismissedAlerts)
|
|
||||||
.where(and(
|
|
||||||
eq(dismissedAlerts.userId, userId),
|
|
||||||
eq(dismissedAlerts.alertId, alertId)
|
|
||||||
));
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
return res.status(404).json({error: 'Dismissed alert not found'});
|
|
||||||
}
|
|
||||||
res.json({message: 'Alert undismissed successfully'});
|
|
||||||
} catch (error) {
|
|
||||||
authLogger.error('Failed to undismiss alert', error);
|
|
||||||
res.status(500).json({error: 'Failed to undismiss alert'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.delete(dismissedAlerts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dismissedAlerts.userId, userId),
|
||||||
|
eq(dismissedAlerts.alertId, alertId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: "Dismissed alert not found" });
|
||||||
|
}
|
||||||
|
res.json({ message: "Alert undismissed successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to undismiss alert", error);
|
||||||
|
res.status(500).json({ error: "Failed to undismiss alert" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+929
-709
File diff suppressed because it is too large
Load Diff
+1421
-1203
File diff suppressed because it is too large
Load Diff
+1153
-1040
File diff suppressed because it is too large
Load Diff
+758
-640
File diff suppressed because it is too large
Load Diff
+495
-396
@@ -1,399 +1,498 @@
|
|||||||
import {WebSocketServer, WebSocket, type RawData} from 'ws';
|
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||||
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
|
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||||
import {db} from '../database/db/index.js';
|
import { db } from "../database/db/index.js";
|
||||||
import {sshCredentials} from '../database/db/schema.js';
|
import { sshCredentials } from "../database/db/schema.js";
|
||||||
import {eq, and} from 'drizzle-orm';
|
import { eq, and } from "drizzle-orm";
|
||||||
import {sshLogger} from '../utils/logger.js';
|
import { sshLogger } from "../utils/logger.js";
|
||||||
|
|
||||||
const wss = new WebSocketServer({port: 8082});
|
const wss = new WebSocketServer({ port: 8082 });
|
||||||
|
|
||||||
sshLogger.success('SSH Terminal WebSocket server started', {operation: 'server_start', port: 8082});
|
sshLogger.success("SSH Terminal WebSocket server started", {
|
||||||
|
operation: "server_start",
|
||||||
wss.on('connection', (ws: WebSocket) => {
|
port: 8082,
|
||||||
let sshConn: Client | null = null;
|
});
|
||||||
let sshStream: ClientChannel | null = null;
|
|
||||||
let pingInterval: NodeJS.Timeout | null = null;
|
wss.on("connection", (ws: WebSocket) => {
|
||||||
|
let sshConn: Client | null = null;
|
||||||
ws.on('close', () => {
|
let sshStream: ClientChannel | null = null;
|
||||||
cleanupSSH();
|
let pingInterval: NodeJS.Timeout | null = null;
|
||||||
});
|
|
||||||
|
ws.on("close", () => {
|
||||||
ws.on('message', (msg: RawData) => {
|
cleanupSSH();
|
||||||
let parsed: any;
|
});
|
||||||
try {
|
|
||||||
parsed = JSON.parse(msg.toString());
|
ws.on("message", (msg: RawData) => {
|
||||||
} catch (e) {
|
let parsed: any;
|
||||||
sshLogger.error('Invalid JSON received', e, {
|
try {
|
||||||
operation: 'websocket_message',
|
parsed = JSON.parse(msg.toString());
|
||||||
messageLength: msg.toString().length
|
} catch (e) {
|
||||||
});
|
sshLogger.error("Invalid JSON received", e, {
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'}));
|
operation: "websocket_message",
|
||||||
return;
|
messageLength: msg.toString().length,
|
||||||
}
|
});
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||||||
const {type, data} = parsed;
|
return;
|
||||||
|
}
|
||||||
switch (type) {
|
|
||||||
case 'connectToHost':
|
const { type, data } = parsed;
|
||||||
handleConnectToHost(data).catch(error => {
|
|
||||||
sshLogger.error('Failed to connect to host', error, {
|
switch (type) {
|
||||||
operation: 'ssh_connect',
|
case "connectToHost":
|
||||||
hostId: data.hostConfig?.id,
|
handleConnectToHost(data).catch((error) => {
|
||||||
ip: data.hostConfig?.ip
|
sshLogger.error("Failed to connect to host", error, {
|
||||||
});
|
operation: "ssh_connect",
|
||||||
ws.send(JSON.stringify({
|
hostId: data.hostConfig?.id,
|
||||||
type: 'error',
|
ip: data.hostConfig?.ip,
|
||||||
message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error')
|
});
|
||||||
}));
|
ws.send(
|
||||||
});
|
JSON.stringify({
|
||||||
break;
|
type: "error",
|
||||||
|
message:
|
||||||
case 'resize':
|
"Failed to connect to host: " +
|
||||||
handleResize(data);
|
(error instanceof Error ? error.message : "Unknown error"),
|
||||||
break;
|
}),
|
||||||
|
);
|
||||||
case 'disconnect':
|
});
|
||||||
cleanupSSH();
|
break;
|
||||||
break;
|
|
||||||
|
case "resize":
|
||||||
case 'input':
|
handleResize(data);
|
||||||
if (sshStream) {
|
break;
|
||||||
if (data === '\t') {
|
|
||||||
sshStream.write(data);
|
case "disconnect":
|
||||||
} else if (data.startsWith('\x1b')) {
|
cleanupSSH();
|
||||||
sshStream.write(data);
|
break;
|
||||||
} else {
|
|
||||||
sshStream.write(Buffer.from(data, 'utf8'));
|
case "input":
|
||||||
}
|
if (sshStream) {
|
||||||
}
|
if (data === "\t") {
|
||||||
break;
|
sshStream.write(data);
|
||||||
|
} else if (data.startsWith("\x1b")) {
|
||||||
case 'ping':
|
sshStream.write(data);
|
||||||
ws.send(JSON.stringify({type: 'pong'}));
|
} else {
|
||||||
break;
|
sshStream.write(Buffer.from(data, "utf8"));
|
||||||
|
}
|
||||||
default:
|
}
|
||||||
sshLogger.warn('Unknown message type received', {operation: 'websocket_message', messageType: type});
|
break;
|
||||||
}
|
|
||||||
});
|
case "ping":
|
||||||
|
ws.send(JSON.stringify({ type: "pong" }));
|
||||||
async function handleConnectToHost(data: {
|
break;
|
||||||
cols: number;
|
|
||||||
rows: number;
|
default:
|
||||||
hostConfig: {
|
sshLogger.warn("Unknown message type received", {
|
||||||
id: number;
|
operation: "websocket_message",
|
||||||
ip: string;
|
messageType: type,
|
||||||
port: number;
|
});
|
||||||
username: string;
|
}
|
||||||
password?: string;
|
});
|
||||||
key?: string;
|
|
||||||
keyPassword?: string;
|
async function handleConnectToHost(data: {
|
||||||
keyType?: string;
|
cols: number;
|
||||||
authType?: string;
|
rows: number;
|
||||||
credentialId?: number;
|
hostConfig: {
|
||||||
userId?: string;
|
id: number;
|
||||||
};
|
ip: string;
|
||||||
}) {
|
port: number;
|
||||||
const {cols, rows, hostConfig} = data;
|
username: string;
|
||||||
const {id, ip, port, username, password, key, keyPassword, keyType, authType, credentialId} = hostConfig;
|
password?: string;
|
||||||
|
key?: string;
|
||||||
if (!username || typeof username !== 'string' || username.trim() === '') {
|
keyPassword?: string;
|
||||||
sshLogger.error('Invalid username provided', undefined, {operation: 'ssh_connect', hostId: id, ip});
|
keyType?: string;
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'}));
|
authType?: string;
|
||||||
return;
|
credentialId?: number;
|
||||||
}
|
userId?: string;
|
||||||
|
};
|
||||||
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
|
}) {
|
||||||
sshLogger.error('Invalid IP provided', undefined, {operation: 'ssh_connect', hostId: id, username});
|
const { cols, rows, hostConfig } = data;
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'}));
|
const {
|
||||||
return;
|
id,
|
||||||
}
|
ip,
|
||||||
|
port,
|
||||||
if (!port || typeof port !== 'number' || port <= 0) {
|
username,
|
||||||
sshLogger.error('Invalid port provided', undefined, {
|
password,
|
||||||
operation: 'ssh_connect',
|
key,
|
||||||
hostId: id,
|
keyPassword,
|
||||||
ip,
|
keyType,
|
||||||
username,
|
authType,
|
||||||
port
|
credentialId,
|
||||||
});
|
} = hostConfig;
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
|
|
||||||
return;
|
if (!username || typeof username !== "string" || username.trim() === "") {
|
||||||
}
|
sshLogger.error("Invalid username provided", undefined, {
|
||||||
|
operation: "ssh_connect",
|
||||||
sshConn = new Client();
|
hostId: id,
|
||||||
|
ip,
|
||||||
const connectionTimeout = setTimeout(() => {
|
});
|
||||||
if (sshConn) {
|
ws.send(
|
||||||
sshLogger.error('SSH connection timeout', undefined, {
|
JSON.stringify({ type: "error", message: "Invalid username provided" }),
|
||||||
operation: 'ssh_connect',
|
);
|
||||||
hostId: id,
|
return;
|
||||||
ip,
|
}
|
||||||
port,
|
|
||||||
username
|
if (!ip || typeof ip !== "string" || ip.trim() === "") {
|
||||||
});
|
sshLogger.error("Invalid IP provided", undefined, {
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
|
operation: "ssh_connect",
|
||||||
cleanupSSH(connectionTimeout);
|
hostId: id,
|
||||||
}
|
username,
|
||||||
}, 60000);
|
});
|
||||||
|
ws.send(
|
||||||
let resolvedCredentials = {password, key, keyPassword, keyType, authType};
|
JSON.stringify({ type: "error", message: "Invalid IP provided" }),
|
||||||
if (credentialId && id && hostConfig.userId) {
|
);
|
||||||
try {
|
return;
|
||||||
const credentials = await db
|
}
|
||||||
.select()
|
|
||||||
.from(sshCredentials)
|
if (!port || typeof port !== "number" || port <= 0) {
|
||||||
.where(and(
|
sshLogger.error("Invalid port provided", undefined, {
|
||||||
eq(sshCredentials.id, credentialId),
|
operation: "ssh_connect",
|
||||||
eq(sshCredentials.userId, hostConfig.userId)
|
hostId: id,
|
||||||
));
|
ip,
|
||||||
|
username,
|
||||||
if (credentials.length > 0) {
|
port,
|
||||||
const credential = credentials[0];
|
});
|
||||||
resolvedCredentials = {
|
ws.send(
|
||||||
password: credential.password,
|
JSON.stringify({ type: "error", message: "Invalid port provided" }),
|
||||||
key: credential.key,
|
);
|
||||||
keyPassword: credential.keyPassword,
|
return;
|
||||||
keyType: credential.keyType,
|
}
|
||||||
authType: credential.authType
|
|
||||||
};
|
sshConn = new Client();
|
||||||
} else {
|
|
||||||
sshLogger.warn(`No credentials found for host ${id}`, {
|
const connectionTimeout = setTimeout(() => {
|
||||||
operation: 'ssh_credentials',
|
if (sshConn) {
|
||||||
hostId: id,
|
sshLogger.error("SSH connection timeout", undefined, {
|
||||||
credentialId,
|
operation: "ssh_connect",
|
||||||
userId: hostConfig.userId
|
hostId: id,
|
||||||
});
|
ip,
|
||||||
}
|
port,
|
||||||
} catch (error) {
|
username,
|
||||||
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
|
});
|
||||||
operation: 'ssh_credentials',
|
ws.send(
|
||||||
hostId: id,
|
JSON.stringify({ type: "error", message: "SSH connection timeout" }),
|
||||||
credentialId,
|
);
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
cleanupSSH(connectionTimeout);
|
||||||
});
|
}
|
||||||
}
|
}, 60000);
|
||||||
} else if (credentialId && id) {
|
|
||||||
sshLogger.warn('Missing userId for credential resolution in terminal', {
|
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||||
operation: 'ssh_credentials',
|
if (credentialId && id && hostConfig.userId) {
|
||||||
hostId: id,
|
try {
|
||||||
credentialId,
|
const credentials = await db
|
||||||
hasUserId: !!hostConfig.userId
|
.select()
|
||||||
});
|
.from(sshCredentials)
|
||||||
}
|
.where(
|
||||||
|
and(
|
||||||
sshConn.on('ready', () => {
|
eq(sshCredentials.id, credentialId),
|
||||||
clearTimeout(connectionTimeout);
|
eq(sshCredentials.userId, hostConfig.userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
sshConn!.shell({
|
|
||||||
rows: data.rows,
|
if (credentials.length > 0) {
|
||||||
cols: data.cols,
|
const credential = credentials[0];
|
||||||
term: 'xterm-256color'
|
resolvedCredentials = {
|
||||||
} as PseudoTtyOptions, (err, stream) => {
|
password: credential.password,
|
||||||
if (err) {
|
key: credential.key,
|
||||||
sshLogger.error('Shell error', err, {operation: 'ssh_shell', hostId: id, ip, port, username});
|
keyPassword: credential.keyPassword,
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
keyType: credential.keyType,
|
||||||
return;
|
authType: credential.authType,
|
||||||
}
|
};
|
||||||
|
} else {
|
||||||
sshStream = stream;
|
sshLogger.warn(`No credentials found for host ${id}`, {
|
||||||
|
operation: "ssh_credentials",
|
||||||
stream.on('data', (data: Buffer) => {
|
hostId: id,
|
||||||
ws.send(JSON.stringify({type: 'data', data: data.toString()}));
|
credentialId,
|
||||||
});
|
userId: hostConfig.userId,
|
||||||
|
});
|
||||||
stream.on('close', () => {
|
}
|
||||||
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
} catch (error) {
|
||||||
});
|
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
|
||||||
|
operation: "ssh_credentials",
|
||||||
stream.on('error', (err: Error) => {
|
hostId: id,
|
||||||
sshLogger.error('SSH stream error', err, {operation: 'ssh_stream', hostId: id, ip, port, username});
|
credentialId,
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
setupPingInterval();
|
} else if (credentialId && id) {
|
||||||
|
sshLogger.warn("Missing userId for credential resolution in terminal", {
|
||||||
ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'}));
|
operation: "ssh_credentials",
|
||||||
});
|
hostId: id,
|
||||||
});
|
credentialId,
|
||||||
|
hasUserId: !!hostConfig.userId,
|
||||||
sshConn.on('error', (err: Error) => {
|
});
|
||||||
clearTimeout(connectionTimeout);
|
}
|
||||||
sshLogger.error('SSH connection error', err, {
|
|
||||||
operation: 'ssh_connect',
|
sshConn.on("ready", () => {
|
||||||
hostId: id,
|
clearTimeout(connectionTimeout);
|
||||||
ip,
|
|
||||||
port,
|
sshConn!.shell(
|
||||||
username,
|
{
|
||||||
authType: resolvedCredentials.authType
|
rows: data.rows,
|
||||||
});
|
cols: data.cols,
|
||||||
|
term: "xterm-256color",
|
||||||
let errorMessage = 'SSH error: ' + err.message;
|
} as PseudoTtyOptions,
|
||||||
if (err.message.includes('No matching key exchange algorithm')) {
|
(err, stream) => {
|
||||||
errorMessage = 'SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.';
|
if (err) {
|
||||||
} else if (err.message.includes('No matching cipher')) {
|
sshLogger.error("Shell error", err, {
|
||||||
errorMessage = 'SSH error: No compatible cipher found. This may be due to an older SSH server or network device.';
|
operation: "ssh_shell",
|
||||||
} else if (err.message.includes('No matching MAC')) {
|
hostId: id,
|
||||||
errorMessage = 'SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.';
|
ip,
|
||||||
} else if (err.message.includes('ENOTFOUND') || err.message.includes('ENOENT')) {
|
port,
|
||||||
errorMessage = 'SSH error: Could not resolve hostname or connect to server.';
|
username,
|
||||||
} else if (err.message.includes('ECONNREFUSED')) {
|
});
|
||||||
errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.';
|
ws.send(
|
||||||
} else if (err.message.includes('ETIMEDOUT')) {
|
JSON.stringify({
|
||||||
errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.';
|
type: "error",
|
||||||
} else if (err.message.includes('ECONNRESET') || err.message.includes('EPIPE')) {
|
message: "Shell error: " + err.message,
|
||||||
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.';
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send(JSON.stringify({type: 'error', message: errorMessage}));
|
sshStream = stream;
|
||||||
cleanupSSH(connectionTimeout);
|
|
||||||
});
|
stream.on("data", (data: Buffer) => {
|
||||||
|
ws.send(JSON.stringify({ type: "data", data: data.toString() }));
|
||||||
sshConn.on('close', () => {
|
});
|
||||||
clearTimeout(connectionTimeout);
|
|
||||||
cleanupSSH(connectionTimeout);
|
stream.on("close", () => {
|
||||||
});
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
const connectConfig: any = {
|
type: "disconnected",
|
||||||
host: ip,
|
message: "Connection lost",
|
||||||
port,
|
}),
|
||||||
username,
|
);
|
||||||
keepaliveInterval: 30000,
|
});
|
||||||
keepaliveCountMax: 3,
|
|
||||||
readyTimeout: 60000,
|
stream.on("error", (err: Error) => {
|
||||||
tcpKeepAlive: true,
|
sshLogger.error("SSH stream error", err, {
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
operation: "ssh_stream",
|
||||||
|
hostId: id,
|
||||||
env: {
|
ip,
|
||||||
TERM: 'xterm-256color',
|
port,
|
||||||
LANG: 'en_US.UTF-8',
|
username,
|
||||||
LC_ALL: 'en_US.UTF-8',
|
});
|
||||||
LC_CTYPE: 'en_US.UTF-8',
|
ws.send(
|
||||||
LC_MESSAGES: 'en_US.UTF-8',
|
JSON.stringify({
|
||||||
LC_MONETARY: 'en_US.UTF-8',
|
type: "error",
|
||||||
LC_NUMERIC: 'en_US.UTF-8',
|
message: "SSH stream error: " + err.message,
|
||||||
LC_TIME: 'en_US.UTF-8',
|
}),
|
||||||
LC_COLLATE: 'en_US.UTF-8',
|
);
|
||||||
COLORTERM: 'truecolor',
|
});
|
||||||
},
|
|
||||||
|
setupPingInterval();
|
||||||
algorithms: {
|
|
||||||
kex: [
|
ws.send(
|
||||||
'diffie-hellman-group14-sha256',
|
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||||||
'diffie-hellman-group14-sha1',
|
);
|
||||||
'diffie-hellman-group1-sha1',
|
},
|
||||||
'diffie-hellman-group-exchange-sha256',
|
);
|
||||||
'diffie-hellman-group-exchange-sha1',
|
});
|
||||||
'ecdh-sha2-nistp256',
|
|
||||||
'ecdh-sha2-nistp384',
|
sshConn.on("error", (err: Error) => {
|
||||||
'ecdh-sha2-nistp521'
|
clearTimeout(connectionTimeout);
|
||||||
],
|
sshLogger.error("SSH connection error", err, {
|
||||||
cipher: [
|
operation: "ssh_connect",
|
||||||
'aes128-ctr',
|
hostId: id,
|
||||||
'aes192-ctr',
|
ip,
|
||||||
'aes256-ctr',
|
port,
|
||||||
'aes128-gcm@openssh.com',
|
username,
|
||||||
'aes256-gcm@openssh.com',
|
authType: resolvedCredentials.authType,
|
||||||
'aes128-cbc',
|
});
|
||||||
'aes192-cbc',
|
|
||||||
'aes256-cbc',
|
let errorMessage = "SSH error: " + err.message;
|
||||||
'3des-cbc'
|
if (err.message.includes("No matching key exchange algorithm")) {
|
||||||
],
|
errorMessage =
|
||||||
hmac: [
|
"SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.";
|
||||||
'hmac-sha2-256',
|
} else if (err.message.includes("No matching cipher")) {
|
||||||
'hmac-sha2-512',
|
errorMessage =
|
||||||
'hmac-sha1',
|
"SSH error: No compatible cipher found. This may be due to an older SSH server or network device.";
|
||||||
'hmac-md5'
|
} else if (err.message.includes("No matching MAC")) {
|
||||||
],
|
errorMessage =
|
||||||
compress: [
|
"SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.";
|
||||||
'none',
|
} else if (
|
||||||
'zlib@openssh.com',
|
err.message.includes("ENOTFOUND") ||
|
||||||
'zlib'
|
err.message.includes("ENOENT")
|
||||||
]
|
) {
|
||||||
}
|
errorMessage =
|
||||||
};
|
"SSH error: Could not resolve hostname or connect to server.";
|
||||||
if (resolvedCredentials.authType === 'key' && resolvedCredentials.key) {
|
} else if (err.message.includes("ECONNREFUSED")) {
|
||||||
try {
|
errorMessage =
|
||||||
if (!resolvedCredentials.key.includes('-----BEGIN') || !resolvedCredentials.key.includes('-----END')) {
|
"SSH error: Connection refused. The server may not be running or the port may be incorrect.";
|
||||||
throw new Error('Invalid private key format');
|
} else if (err.message.includes("ETIMEDOUT")) {
|
||||||
}
|
errorMessage =
|
||||||
|
"SSH error: Connection timed out. Check your network connection and server availability.";
|
||||||
const cleanKey = resolvedCredentials.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
} else if (
|
||||||
|
err.message.includes("ECONNRESET") ||
|
||||||
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
|
err.message.includes("EPIPE")
|
||||||
|
) {
|
||||||
if (resolvedCredentials.keyPassword) {
|
errorMessage =
|
||||||
connectConfig.passphrase = resolvedCredentials.keyPassword;
|
"SSH error: Connection was reset. This may be due to network issues or server timeout.";
|
||||||
}
|
} else if (
|
||||||
|
err.message.includes("authentication failed") ||
|
||||||
if (resolvedCredentials.keyType && resolvedCredentials.keyType !== 'auto') {
|
err.message.includes("Permission denied")
|
||||||
connectConfig.privateKeyType = resolvedCredentials.keyType;
|
) {
|
||||||
}
|
errorMessage =
|
||||||
} catch (keyError) {
|
"SSH error: Authentication failed. Please check your username and password/key.";
|
||||||
sshLogger.error('SSH key format error: ' + keyError.message);
|
}
|
||||||
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
|
|
||||||
return;
|
ws.send(JSON.stringify({ type: "error", message: errorMessage }));
|
||||||
}
|
cleanupSSH(connectionTimeout);
|
||||||
} 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'}));
|
sshConn.on("close", () => {
|
||||||
return;
|
clearTimeout(connectionTimeout);
|
||||||
} else {
|
cleanupSSH(connectionTimeout);
|
||||||
connectConfig.password = resolvedCredentials.password;
|
});
|
||||||
}
|
|
||||||
|
const connectConfig: any = {
|
||||||
sshConn.connect(connectConfig);
|
host: ip,
|
||||||
}
|
port,
|
||||||
|
username,
|
||||||
function handleResize(data: { cols: number; rows: number }) {
|
keepaliveInterval: 30000,
|
||||||
if (sshStream && sshStream.setWindow) {
|
keepaliveCountMax: 3,
|
||||||
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
readyTimeout: 60000,
|
||||||
ws.send(JSON.stringify({type: 'resized', cols: data.cols, rows: data.rows}));
|
tcpKeepAlive: true,
|
||||||
}
|
tcpKeepAliveInitialDelay: 30000,
|
||||||
}
|
|
||||||
|
env: {
|
||||||
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
|
TERM: "xterm-256color",
|
||||||
if (timeoutId) {
|
LANG: "en_US.UTF-8",
|
||||||
clearTimeout(timeoutId);
|
LC_ALL: "en_US.UTF-8",
|
||||||
}
|
LC_CTYPE: "en_US.UTF-8",
|
||||||
|
LC_MESSAGES: "en_US.UTF-8",
|
||||||
if (pingInterval) {
|
LC_MONETARY: "en_US.UTF-8",
|
||||||
clearInterval(pingInterval);
|
LC_NUMERIC: "en_US.UTF-8",
|
||||||
pingInterval = null;
|
LC_TIME: "en_US.UTF-8",
|
||||||
}
|
LC_COLLATE: "en_US.UTF-8",
|
||||||
|
COLORTERM: "truecolor",
|
||||||
if (sshStream) {
|
},
|
||||||
try {
|
|
||||||
sshStream.end();
|
algorithms: {
|
||||||
} catch (e: any) {
|
kex: [
|
||||||
sshLogger.error('Error closing stream: ' + e.message);
|
"diffie-hellman-group14-sha256",
|
||||||
}
|
"diffie-hellman-group14-sha1",
|
||||||
sshStream = null;
|
"diffie-hellman-group1-sha1",
|
||||||
}
|
"diffie-hellman-group-exchange-sha256",
|
||||||
|
"diffie-hellman-group-exchange-sha1",
|
||||||
if (sshConn) {
|
"ecdh-sha2-nistp256",
|
||||||
try {
|
"ecdh-sha2-nistp384",
|
||||||
sshConn.end();
|
"ecdh-sha2-nistp521",
|
||||||
} catch (e: any) {
|
],
|
||||||
sshLogger.error('Error closing connection: ' + e.message);
|
cipher: [
|
||||||
}
|
"aes128-ctr",
|
||||||
sshConn = null;
|
"aes192-ctr",
|
||||||
}
|
"aes256-ctr",
|
||||||
}
|
"aes128-gcm@openssh.com",
|
||||||
|
"aes256-gcm@openssh.com",
|
||||||
function setupPingInterval() {
|
"aes128-cbc",
|
||||||
pingInterval = setInterval(() => {
|
"aes192-cbc",
|
||||||
if (sshConn && sshStream) {
|
"aes256-cbc",
|
||||||
try {
|
"3des-cbc",
|
||||||
sshStream.write('\x00');
|
],
|
||||||
} catch (e: any) {
|
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||||
sshLogger.error('SSH keepalive failed: ' + e.message);
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||||
cleanupSSH();
|
},
|
||||||
}
|
};
|
||||||
}
|
if (resolvedCredentials.authType === "key" && resolvedCredentials.key) {
|
||||||
}, 60000);
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+960
-872
File diff suppressed because it is too large
Load Diff
+55
-42
@@ -1,52 +1,65 @@
|
|||||||
// npx tsc -p tsconfig.node.json
|
// npx tsc -p tsconfig.node.json
|
||||||
// node ./dist/backend/starter.js
|
// node ./dist/backend/starter.js
|
||||||
|
|
||||||
import './database/database.js'
|
import "./database/database.js";
|
||||||
import './ssh/terminal.js';
|
import "./ssh/terminal.js";
|
||||||
import './ssh/tunnel.js';
|
import "./ssh/tunnel.js";
|
||||||
import './ssh/file-manager.js';
|
import "./ssh/file-manager.js";
|
||||||
import './ssh/server-stats.js';
|
import "./ssh/server-stats.js";
|
||||||
import { systemLogger, versionLogger } from './utils/logger.js';
|
import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||||
import 'dotenv/config';
|
import "dotenv/config";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const version = process.env.VERSION || 'unknown';
|
const version = process.env.VERSION || "unknown";
|
||||||
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
||||||
operation: 'startup',
|
operation: "startup",
|
||||||
version: version
|
version: version,
|
||||||
});
|
});
|
||||||
|
|
||||||
systemLogger.info("Initializing backend services...", { operation: 'startup' });
|
|
||||||
|
|
||||||
systemLogger.success("All backend services initialized successfully", {
|
|
||||||
operation: 'startup_complete',
|
|
||||||
services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats'],
|
|
||||||
version: version
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
systemLogger.info("Initializing backend services...", {
|
||||||
systemLogger.info("Received SIGINT signal, initiating graceful shutdown...", { operation: 'shutdown' });
|
operation: "startup",
|
||||||
process.exit(0);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
systemLogger.success("All backend services initialized successfully", {
|
||||||
systemLogger.info("Received SIGTERM signal, initiating graceful shutdown...", { operation: 'shutdown' });
|
operation: "startup_complete",
|
||||||
process.exit(0);
|
services: ["database", "terminal", "tunnel", "file_manager", "stats"],
|
||||||
});
|
version: version,
|
||||||
|
});
|
||||||
|
|
||||||
process.on('uncaughtException', (error) => {
|
process.on("SIGINT", () => {
|
||||||
systemLogger.error("Uncaught exception occurred", error, { operation: 'error_handling' });
|
systemLogger.info(
|
||||||
process.exit(1);
|
"Received SIGINT signal, initiating graceful shutdown...",
|
||||||
});
|
{ operation: "shutdown" },
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
process.on("SIGTERM", () => {
|
||||||
systemLogger.error("Unhandled promise rejection", reason, { operation: 'error_handling' });
|
systemLogger.info(
|
||||||
process.exit(1);
|
"Received SIGTERM signal, initiating graceful shutdown...",
|
||||||
});
|
{ operation: "shutdown" },
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
process.on("uncaughtException", (error) => {
|
||||||
systemLogger.error("Failed to initialize backend services", error, { operation: 'startup_failed' });
|
systemLogger.error("Uncaught exception occurred", error, {
|
||||||
process.exit(1);
|
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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|||||||
+140
-124
@@ -1,158 +1,174 @@
|
|||||||
import chalk from 'chalk';
|
import chalk from "chalk";
|
||||||
|
|
||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
|
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
||||||
|
|
||||||
export interface LogContext {
|
export interface LogContext {
|
||||||
service?: string;
|
service?: string;
|
||||||
operation?: string;
|
operation?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
hostId?: number;
|
hostId?: number;
|
||||||
tunnelName?: string;
|
tunnelName?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private serviceName: string;
|
private serviceName: string;
|
||||||
private serviceIcon: string;
|
private serviceIcon: string;
|
||||||
private serviceColor: string;
|
private serviceColor: string;
|
||||||
|
|
||||||
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||||
this.serviceName = serviceName;
|
this.serviceName = serviceName;
|
||||||
this.serviceIcon = serviceIcon;
|
this.serviceIcon = serviceIcon;
|
||||||
this.serviceColor = serviceColor;
|
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(",")}]`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTimeStamp(): string {
|
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
||||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private formatMessage(level: LogLevel, message: string, context?: LogContext): string {
|
private getLevelColor(level: LogLevel): chalk.Chalk {
|
||||||
const timestamp = this.getTimeStamp();
|
switch (level) {
|
||||||
const levelColor = this.getLevelColor(level);
|
case "debug":
|
||||||
const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`);
|
return chalk.magenta;
|
||||||
const levelTag = levelColor(`[${level.toUpperCase()}]`);
|
case "info":
|
||||||
|
return chalk.cyan;
|
||||||
let contextStr = '';
|
case "warn":
|
||||||
if (context) {
|
return chalk.yellow;
|
||||||
const contextParts = [];
|
case "error":
|
||||||
if (context.operation) contextParts.push(`op:${context.operation}`);
|
return chalk.redBright;
|
||||||
if (context.userId) contextParts.push(`user:${context.userId}`);
|
case "success":
|
||||||
if (context.hostId) contextParts.push(`host:${context.hostId}`);
|
return chalk.greenBright;
|
||||||
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
|
default:
|
||||||
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
|
return chalk.white;
|
||||||
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 {
|
private shouldLog(level: LogLevel): boolean {
|
||||||
switch (level) {
|
if (level === "debug" && process.env.NODE_ENV === "production") {
|
||||||
case 'debug': return chalk.magenta;
|
return false;
|
||||||
case 'info': return chalk.cyan;
|
|
||||||
case 'warn': return chalk.yellow;
|
|
||||||
case 'error': return chalk.redBright;
|
|
||||||
case 'success': return chalk.greenBright;
|
|
||||||
default: return chalk.white;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private shouldLog(level: LogLevel): boolean {
|
debug(message: string, context?: LogContext): void {
|
||||||
if (level === 'debug' && process.env.NODE_ENV === 'production') {
|
if (!this.shouldLog("debug")) return;
|
||||||
return false;
|
console.debug(this.formatMessage("debug", message, context));
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug(message: string, context?: LogContext): void {
|
info(message: string, context?: LogContext): void {
|
||||||
if (!this.shouldLog('debug')) return;
|
if (!this.shouldLog("info")) return;
|
||||||
console.debug(this.formatMessage('debug', message, context));
|
console.log(this.formatMessage("info", message, context));
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string, context?: LogContext): void {
|
warn(message: string, context?: LogContext): void {
|
||||||
if (!this.shouldLog('info')) return;
|
if (!this.shouldLog("warn")) return;
|
||||||
console.log(this.formatMessage('info', message, context));
|
console.warn(this.formatMessage("warn", message, context));
|
||||||
}
|
}
|
||||||
|
|
||||||
warn(message: string, context?: LogContext): void {
|
error(message: string, error?: unknown, context?: LogContext): void {
|
||||||
if (!this.shouldLog('warn')) return;
|
if (!this.shouldLog("error")) return;
|
||||||
console.warn(this.formatMessage('warn', message, context));
|
console.error(this.formatMessage("error", message, context));
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
error(message: string, error?: unknown, context?: LogContext): void {
|
success(message: string, context?: LogContext): void {
|
||||||
if (!this.shouldLog('error')) return;
|
if (!this.shouldLog("success")) return;
|
||||||
console.error(this.formatMessage('error', message, context));
|
console.log(this.formatMessage("success", message, context));
|
||||||
if (error) {
|
}
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
success(message: string, context?: LogContext): void {
|
auth(message: string, context?: LogContext): void {
|
||||||
if (!this.shouldLog('success')) return;
|
this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
|
||||||
console.log(this.formatMessage('success', message, context));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
auth(message: string, context?: LogContext): void {
|
db(message: string, context?: LogContext): void {
|
||||||
this.info(`AUTH: ${message}`, { ...context, operation: 'auth' });
|
this.info(`DB: ${message}`, { ...context, operation: "database" });
|
||||||
}
|
}
|
||||||
|
|
||||||
db(message: string, context?: LogContext): void {
|
ssh(message: string, context?: LogContext): void {
|
||||||
this.info(`DB: ${message}`, { ...context, operation: 'database' });
|
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
|
||||||
}
|
}
|
||||||
|
|
||||||
ssh(message: string, context?: LogContext): void {
|
tunnel(message: string, context?: LogContext): void {
|
||||||
this.info(`SSH: ${message}`, { ...context, operation: 'ssh' });
|
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
|
||||||
}
|
}
|
||||||
|
|
||||||
tunnel(message: string, context?: LogContext): void {
|
file(message: string, context?: LogContext): void {
|
||||||
this.info(`TUNNEL: ${message}`, { ...context, operation: 'tunnel' });
|
this.info(`FILE: ${message}`, { ...context, operation: "file" });
|
||||||
}
|
}
|
||||||
|
|
||||||
file(message: string, context?: LogContext): void {
|
api(message: string, context?: LogContext): void {
|
||||||
this.info(`FILE: ${message}`, { ...context, operation: 'file' });
|
this.info(`API: ${message}`, { ...context, operation: "api" });
|
||||||
}
|
}
|
||||||
|
|
||||||
api(message: string, context?: LogContext): void {
|
request(message: string, context?: LogContext): void {
|
||||||
this.info(`API: ${message}`, { ...context, operation: 'api' });
|
this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
|
||||||
}
|
}
|
||||||
|
|
||||||
request(message: string, context?: LogContext): void {
|
response(message: string, context?: LogContext): void {
|
||||||
this.info(`REQUEST: ${message}`, { ...context, operation: 'request' });
|
this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
|
||||||
}
|
}
|
||||||
|
|
||||||
response(message: string, context?: LogContext): void {
|
connection(message: string, context?: LogContext): void {
|
||||||
this.info(`RESPONSE: ${message}`, { ...context, operation: 'response' });
|
this.info(`CONNECTION: ${message}`, {
|
||||||
}
|
...context,
|
||||||
|
operation: "connection",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
connection(message: string, context?: LogContext): void {
|
disconnect(message: string, context?: LogContext): void {
|
||||||
this.info(`CONNECTION: ${message}`, { ...context, operation: 'connection' });
|
this.info(`DISCONNECT: ${message}`, {
|
||||||
}
|
...context,
|
||||||
|
operation: "disconnect",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
disconnect(message: string, context?: LogContext): void {
|
retry(message: string, context?: LogContext): void {
|
||||||
this.info(`DISCONNECT: ${message}`, { ...context, operation: 'disconnect' });
|
this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
|
||||||
}
|
}
|
||||||
|
|
||||||
retry(message: string, context?: LogContext): void {
|
|
||||||
this.warn(`RETRY: ${message}`, { ...context, operation: 'retry' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const databaseLogger = new Logger('DATABASE', '🗄️', '#6366f1');
|
export const databaseLogger = new Logger("DATABASE", "🗄️", "#6366f1");
|
||||||
export const sshLogger = new Logger('SSH', '🖥️', '#0ea5e9');
|
export const sshLogger = new Logger("SSH", "🖥️", "#0ea5e9");
|
||||||
export const tunnelLogger = new Logger('TUNNEL', '📡', '#a855f7');
|
export const tunnelLogger = new Logger("TUNNEL", "📡", "#a855f7");
|
||||||
export const fileLogger = new Logger('FILE', '📁', '#f59e0b');
|
export const fileLogger = new Logger("FILE", "📁", "#f59e0b");
|
||||||
export const statsLogger = new Logger('STATS', '📊', '#22c55e');
|
export const statsLogger = new Logger("STATS", "📊", "#22c55e");
|
||||||
export const apiLogger = new Logger('API', '🌐', '#3b82f6');
|
export const apiLogger = new Logger("API", "🌐", "#3b82f6");
|
||||||
export const authLogger = new Logger('AUTH', '🔐', '#ef4444');
|
export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||||
export const systemLogger = new Logger('SYSTEM', '🚀', '#14b8a6');
|
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||||
export const versionLogger = new Logger('VERSION', '📦', '#8b5cf6');
|
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||||
|
|
||||||
export const logger = systemLogger;
|
export const logger = systemLogger;
|
||||||
|
|||||||
@@ -1,73 +1,73 @@
|
|||||||
import {createContext, useContext, useEffect, useState} from "react"
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
type Theme = "dark" | "light" | "system"
|
type Theme = "dark" | "light" | "system";
|
||||||
|
|
||||||
type ThemeProviderProps = {
|
type ThemeProviderProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
defaultTheme?: Theme
|
defaultTheme?: Theme;
|
||||||
storageKey?: string
|
storageKey?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ThemeProviderState = {
|
type ThemeProviderState = {
|
||||||
theme: Theme
|
theme: Theme;
|
||||||
setTheme: (theme: Theme) => void
|
setTheme: (theme: Theme) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const initialState: ThemeProviderState = {
|
const initialState: ThemeProviderState = {
|
||||||
theme: "system",
|
theme: "system",
|
||||||
setTheme: () => null,
|
setTheme: () => null,
|
||||||
}
|
};
|
||||||
|
|
||||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
defaultTheme = "system",
|
defaultTheme = "system",
|
||||||
storageKey = "vite-ui-theme",
|
storageKey = "vite-ui-theme",
|
||||||
...props
|
...props
|
||||||
}: ThemeProviderProps) {
|
}: ThemeProviderProps) {
|
||||||
const [theme, setTheme] = useState<Theme>(
|
const [theme, setTheme] = useState<Theme>(
|
||||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = window.document.documentElement
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
root.classList.remove("light", "dark")
|
root.classList.remove("light", "dark");
|
||||||
|
|
||||||
if (theme === "system") {
|
if (theme === "system") {
|
||||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
.matches
|
.matches
|
||||||
? "dark"
|
? "dark"
|
||||||
: "light"
|
: "light";
|
||||||
|
|
||||||
root.classList.add(systemTheme)
|
root.classList.add(systemTheme);
|
||||||
return
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
root.classList.add(theme)
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
theme,
|
|
||||||
setTheme: (theme: Theme) => {
|
|
||||||
localStorage.setItem(storageKey, theme)
|
|
||||||
setTheme(theme)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
root.classList.add(theme);
|
||||||
<ThemeProviderContext.Provider {...props} value={value}>
|
}, [theme]);
|
||||||
{children}
|
|
||||||
</ThemeProviderContext.Provider>
|
const value = {
|
||||||
)
|
theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
localStorage.setItem(storageKey, theme);
|
||||||
|
setTheme(theme);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider {...props} value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeProviderContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTheme = () => {
|
export const useTheme = () => {
|
||||||
const context = useContext(ThemeProviderContext)
|
const context = useContext(ThemeProviderContext);
|
||||||
|
|
||||||
if (context === undefined)
|
if (context === undefined)
|
||||||
throw new Error("useTheme must be used within a ThemeProvider")
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
|
||||||
return context
|
return context;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
import { ChevronDownIcon } from "lucide-react"
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Accordion({
|
function Accordion({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccordionItem({
|
function AccordionItem({
|
||||||
@@ -20,7 +20,7 @@ function AccordionItem({
|
|||||||
className={cn("border-b last:border-b-0", className)}
|
className={cn("border-b last:border-b-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccordionTrigger({
|
function AccordionTrigger({
|
||||||
@@ -34,7 +34,7 @@ function AccordionTrigger({
|
|||||||
data-slot="accordion-trigger"
|
data-slot="accordion-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -42,7 +42,7 @@ function AccordionTrigger({
|
|||||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
</AccordionPrimitive.Trigger>
|
</AccordionPrimitive.Trigger>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionPrimitive.Header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccordionContent({
|
function AccordionContent({
|
||||||
@@ -58,7 +58,7 @@ function AccordionContent({
|
|||||||
>
|
>
|
||||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
</AccordionPrimitive.Content>
|
</AccordionPrimitive.Content>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
|
|||||||
+11
-11
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const alertVariants = cva(
|
const alertVariants = cva(
|
||||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
@@ -16,8 +16,8 @@ const alertVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Alert({
|
function Alert({
|
||||||
className,
|
className,
|
||||||
@@ -31,7 +31,7 @@ function Alert({
|
|||||||
className={cn(alertVariants({ variant }), className)}
|
className={cn(alertVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="alert-title"
|
data-slot="alert-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"col-start-2 font-medium tracking-tight whitespace-normal break-words",
|
"col-start-2 font-medium tracking-tight whitespace-normal break-words",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDescription({
|
function AlertDescription({
|
||||||
@@ -56,11 +56,11 @@ function AlertDescription({
|
|||||||
data-slot="alert-description"
|
data-slot="alert-description"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Alert, AlertTitle, AlertDescription }
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
@@ -22,8 +22,8 @@ const badgeVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
@@ -32,7 +32,7 @@ function Badge({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span"> &
|
}: React.ComponentProps<"span"> &
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "span"
|
const Comp = asChild ? Slot : "span";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -40,7 +40,7 @@ function Badge({
|
|||||||
className={cn(badgeVariants({ variant }), className)}
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
import { Children, ReactElement, cloneElement, isValidElement } from 'react';
|
import { Children, ReactElement, cloneElement, isValidElement } from "react";
|
||||||
|
|
||||||
import { type ButtonProps } from '@/components/ui/button';
|
import { type ButtonProps } from "@/components/ui/button";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ButtonGroupProps {
|
interface ButtonGroupProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
orientation?: 'horizontal' | 'vertical';
|
orientation?: "horizontal" | "vertical";
|
||||||
children: ReactElement<ButtonProps>[] | React.ReactNode;
|
children: ReactElement<ButtonProps>[] | React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ButtonGroup = ({
|
export const ButtonGroup = ({
|
||||||
className,
|
className,
|
||||||
orientation = 'horizontal',
|
orientation = "horizontal",
|
||||||
children,
|
children,
|
||||||
}: ButtonGroupProps) => {
|
}: ButtonGroupProps) => {
|
||||||
const isHorizontal = orientation === 'horizontal';
|
const isHorizontal = orientation === "horizontal";
|
||||||
const isVertical = orientation === 'vertical';
|
const isVertical = orientation === "vertical";
|
||||||
|
|
||||||
// Normalize and filter only valid React elements
|
// Normalize and filter only valid React elements
|
||||||
const childArray = Children.toArray(children).filter((child): child is ReactElement<ButtonProps> =>
|
const childArray = Children.toArray(children).filter(
|
||||||
isValidElement(child)
|
(child): child is ReactElement<ButtonProps> => isValidElement(child),
|
||||||
);
|
);
|
||||||
const totalButtons = childArray.length;
|
const totalButtons = childArray.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex',
|
"flex",
|
||||||
{
|
{
|
||||||
'flex-col': isVertical,
|
"flex-col": isVertical,
|
||||||
'w-fit': isVertical,
|
"w-fit": isVertical,
|
||||||
},
|
},
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{childArray.map((child, index) => {
|
{childArray.map((child, index) => {
|
||||||
@@ -41,18 +41,18 @@ export const ButtonGroup = ({
|
|||||||
return cloneElement(child, {
|
return cloneElement(child, {
|
||||||
className: cn(
|
className: cn(
|
||||||
{
|
{
|
||||||
'rounded-l-none': isHorizontal && !isFirst,
|
"rounded-l-none": isHorizontal && !isFirst,
|
||||||
'rounded-r-none': isHorizontal && !isLast,
|
"rounded-r-none": isHorizontal && !isLast,
|
||||||
'border-l-0': isHorizontal && !isFirst,
|
"border-l-0": isHorizontal && !isFirst,
|
||||||
|
|
||||||
'rounded-t-none': isVertical && !isFirst,
|
"rounded-t-none": isVertical && !isFirst,
|
||||||
'rounded-b-none': isVertical && !isLast,
|
"rounded-b-none": isVertical && !isLast,
|
||||||
'border-t-0': isVertical && !isFirst,
|
"border-t-0": isVertical && !isFirst,
|
||||||
},
|
},
|
||||||
child.props.className
|
child.props.className,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
@@ -32,13 +32,13 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ComponentProps<"button">,
|
extends React.ComponentProps<"button">,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
@@ -48,7 +48,7 @@ function Button({
|
|||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -56,7 +56,7 @@ function Button({
|
|||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants, type ButtonProps }
|
export { Button, buttonVariants, type ButtonProps };
|
||||||
|
|||||||
+13
-13
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("leading-none font-semibold", className)}
|
className={cn("leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn(
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("px-6", className)}
|
className={cn("px-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -89,4 +89,4 @@ export {
|
|||||||
CardAction,
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import { CheckIcon } from "lucide-react"
|
import { CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Checkbox({
|
function Checkbox({
|
||||||
className,
|
className,
|
||||||
@@ -13,7 +13,7 @@ function Checkbox({
|
|||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -24,7 +24,7 @@ function Checkbox({
|
|||||||
<CheckIcon className="size-3.5" />
|
<CheckIcon className="size-3.5" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Checkbox }
|
export { Checkbox };
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react"
|
import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
@@ -27,16 +27,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
))
|
));
|
||||||
DropdownMenuSubTrigger.displayName =
|
DropdownMenuSubTrigger.displayName =
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
@@ -46,13 +46,13 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuSubContent.displayName =
|
DropdownMenuSubContent.displayName =
|
||||||
DropdownMenuPrimitive.SubContent.displayName
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
@@ -64,18 +64,18 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
))
|
));
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
@@ -83,12 +83,12 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
@@ -98,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
))
|
));
|
||||||
DropdownMenuCheckboxItem.displayName =
|
DropdownMenuCheckboxItem.displayName =
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
@@ -122,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -133,13 +133,13 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
))
|
));
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
@@ -147,12 +147,12 @@ const DropdownMenuLabel = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
@@ -163,8 +163,8 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({
|
const DropdownMenuShortcut = ({
|
||||||
className,
|
className,
|
||||||
@@ -175,9 +175,9 @@ const DropdownMenuShortcut = ({
|
|||||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -195,4 +195,4 @@ export {
|
|||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
}
|
};
|
||||||
|
|||||||
+40
-39
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
FormProvider,
|
FormProvider,
|
||||||
@@ -9,23 +9,23 @@ import {
|
|||||||
type ControllerProps,
|
type ControllerProps,
|
||||||
type FieldPath,
|
type FieldPath,
|
||||||
type FieldValues,
|
type FieldValues,
|
||||||
} from "react-hook-form"
|
} from "react-hook-form";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
const Form = FormProvider
|
const Form = FormProvider;
|
||||||
|
|
||||||
type FormFieldContextValue<
|
type FormFieldContextValue<
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
> = {
|
> = {
|
||||||
name: TName
|
name: TName;
|
||||||
}
|
};
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
{} as FormFieldContextValue
|
{} as FormFieldContextValue,
|
||||||
)
|
);
|
||||||
|
|
||||||
const FormField = <
|
const FormField = <
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
@@ -37,21 +37,21 @@ const FormField = <
|
|||||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
<Controller {...props} />
|
<Controller {...props} />
|
||||||
</FormFieldContext.Provider>
|
</FormFieldContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const useFormField = () => {
|
const useFormField = () => {
|
||||||
const fieldContext = React.useContext(FormFieldContext)
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
const itemContext = React.useContext(FormItemContext)
|
const itemContext = React.useContext(FormItemContext);
|
||||||
const { getFieldState } = useFormContext()
|
const { getFieldState } = useFormContext();
|
||||||
const formState = useFormState({ name: fieldContext.name })
|
const formState = useFormState({ name: fieldContext.name });
|
||||||
const fieldState = getFieldState(fieldContext.name, formState)
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
if (!fieldContext) {
|
if (!fieldContext) {
|
||||||
throw new Error("useFormField should be used within <FormField>")
|
throw new Error("useFormField should be used within <FormField>");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = itemContext
|
const { id } = itemContext;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -60,19 +60,19 @@ const useFormField = () => {
|
|||||||
formDescriptionId: `${id}-form-item-description`,
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
formMessageId: `${id}-form-item-message`,
|
formMessageId: `${id}-form-item-message`,
|
||||||
...fieldState,
|
...fieldState,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
type FormItemContextValue = {
|
type FormItemContextValue = {
|
||||||
id: string
|
id: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
{} as FormItemContextValue
|
{} as FormItemContextValue,
|
||||||
)
|
);
|
||||||
|
|
||||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
const id = React.useId()
|
const id = React.useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItemContext.Provider value={{ id }}>
|
<FormItemContext.Provider value={{ id }}>
|
||||||
@@ -82,14 +82,14 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</FormItemContext.Provider>
|
</FormItemContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormLabel({
|
function FormLabel({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
const { error, formItemId } = useFormField()
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Label
|
<Label
|
||||||
@@ -99,11 +99,12 @@ function FormLabel({
|
|||||||
htmlFor={formItemId}
|
htmlFor={formItemId}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
|
useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slot
|
<Slot
|
||||||
@@ -117,11 +118,11 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
|||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
const { formDescriptionId } = useFormField()
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
@@ -130,15 +131,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
const { error, formMessageId } = useFormField()
|
const { error, formMessageId } = useFormField();
|
||||||
const body = error ? String(error?.message ?? "") : props.children
|
const body = error ? String(error?.message ?? "") : props.children;
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -150,7 +151,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
|||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
</p>
|
</p>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -162,4 +163,4 @@ export {
|
|||||||
FormDescription,
|
FormDescription,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
FormField,
|
FormField,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
@@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Label({
|
function Label({
|
||||||
className,
|
className,
|
||||||
@@ -12,11 +12,11 @@ function Label({
|
|||||||
data-slot="label"
|
data-slot="label"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Label }
|
export { Label };
|
||||||
|
|||||||
@@ -6,35 +6,36 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface PasswordInputProps
|
interface PasswordInputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
export const PasswordInput = React.forwardRef<
|
||||||
({ className, ...props }, ref) => {
|
HTMLInputElement,
|
||||||
const [showPassword, setShowPassword] = React.useState(false);
|
PasswordInputProps
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className={cn("h-11 text-base pr-12", className)} // extra padding-right
|
className={cn("h-11 text-base pr-12", className)} // extra padding-right
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword((prev) => !prev)}
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition"
|
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"}
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
>
|
>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
<EyeOff className="h-5 w-5" />
|
<EyeOff className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
<Eye className="h-5 w-5" />
|
<Eye className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
PasswordInput.displayName = "PasswordInput";
|
PasswordInput.displayName = "PasswordInput";
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Popover({
|
function Popover({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverTrigger({
|
function PopoverTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverContent({
|
function PopoverContent({
|
||||||
@@ -29,18 +29,18 @@ function PopoverContent({
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</PopoverPrimitive.Portal>
|
</PopoverPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverAnchor({
|
function PopoverAnchor({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Progress({
|
function Progress({
|
||||||
className,
|
className,
|
||||||
@@ -13,7 +13,7 @@ function Progress({
|
|||||||
data-slot="progress"
|
data-slot="progress"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -23,7 +23,7 @@ function Progress({
|
|||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
/>
|
/>
|
||||||
</ProgressPrimitive.Root>
|
</ProgressPrimitive.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Progress }
|
export { Progress };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { GripVerticalIcon } from "lucide-react"
|
import { GripVerticalIcon } from "lucide-react";
|
||||||
import * as ResizablePrimitive from "react-resizable-panels"
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function ResizablePanelGroup({
|
function ResizablePanelGroup({
|
||||||
className,
|
className,
|
||||||
@@ -13,17 +13,17 @@ function ResizablePanelGroup({
|
|||||||
data-slot="resizable-panel-group"
|
data-slot="resizable-panel-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResizablePanel({
|
function ResizablePanel({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResizableHandle({
|
function ResizableHandle({
|
||||||
@@ -31,14 +31,14 @@ function ResizableHandle({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
withHandle?: boolean
|
withHandle?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
data-slot="resizable-handle"
|
data-slot="resizable-handle"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed transition-colors duration-150",
|
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed transition-colors duration-150",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -48,7 +48,7 @@ function ResizableHandle({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function ScrollArea({
|
function ScrollArea({
|
||||||
className,
|
className,
|
||||||
@@ -23,7 +23,7 @@ function ScrollArea({
|
|||||||
<ScrollBar />
|
<ScrollBar />
|
||||||
<ScrollAreaPrimitive.Corner />
|
<ScrollAreaPrimitive.Corner />
|
||||||
</ScrollAreaPrimitive.Root>
|
</ScrollAreaPrimitive.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScrollBar({
|
function ScrollBar({
|
||||||
@@ -41,7 +41,7 @@ function ScrollBar({
|
|||||||
"h-full w-2.5 border-l border-l-transparent",
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
orientation === "horizontal" &&
|
orientation === "horizontal" &&
|
||||||
"h-2.5 flex-col border-t border-t-transparent",
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -50,7 +50,7 @@ function ScrollBar({
|
|||||||
className="bg-border relative flex-1 rounded-full"
|
className="bg-border relative flex-1 rounded-full"
|
||||||
/>
|
/>
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar }
|
export { ScrollArea, ScrollBar };
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Select({
|
function Select({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({
|
function SelectGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({
|
function SelectValue({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
@@ -28,7 +28,7 @@ function SelectTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default"
|
size?: "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
@@ -36,7 +36,7 @@ function SelectTrigger({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -45,7 +45,7 @@ function SelectTrigger({
|
|||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({
|
function SelectContent({
|
||||||
@@ -62,7 +62,7 @@ function SelectContent({
|
|||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -72,7 +72,7 @@ function SelectContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -80,7 +80,7 @@ function SelectContent({
|
|||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({
|
||||||
@@ -93,7 +93,7 @@ function SelectLabel({
|
|||||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
@@ -106,7 +106,7 @@ function SelectItem({
|
|||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -117,7 +117,7 @@ function SelectItem({
|
|||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({
|
||||||
@@ -130,7 +130,7 @@ function SelectSeparator({
|
|||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
@@ -142,13 +142,13 @@ function SelectScrollUpButton({
|
|||||||
data-slot="select-scroll-up-button"
|
data-slot="select-scroll-up-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-4" />
|
<ChevronUpIcon className="size-4" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
function SelectScrollDownButton({
|
||||||
@@ -160,13 +160,13 @@ function SelectScrollDownButton({
|
|||||||
data-slot="select-scroll-down-button"
|
data-slot="select-scroll-down-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-4" />
|
<ChevronDownIcon className="size-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -180,4 +180,4 @@ export {
|
|||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
@@ -18,11 +18,11 @@ function Separator({
|
|||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Separator }
|
export { Separator };
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
import type { ComponentProps, HTMLAttributes } from "react";
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export type StatusProps = ComponentProps<typeof Badge> & {
|
export type StatusProps = ComponentProps<typeof Badge> & {
|
||||||
status: 'online' | 'offline' | 'maintenance' | 'degraded';
|
status: "online" | "offline" | "maintenance" | "degraded";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Status = ({ className, status, ...props }: StatusProps) => (
|
export const Status = ({ className, status, ...props }: StatusProps) => (
|
||||||
<Badge
|
<Badge
|
||||||
className={cn('flex items-center gap-2', 'group', status, className)}
|
className={cn("flex items-center gap-2", "group", status, className)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -24,20 +24,20 @@ export const StatusIndicator = ({
|
|||||||
<span className="relative flex h-2 w-2" {...props}>
|
<span className="relative flex h-2 w-2" {...props}>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
|
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
|
||||||
'group-[.online]:bg-emerald-500',
|
"group-[.online]:bg-emerald-500",
|
||||||
'group-[.offline]:bg-red-500',
|
"group-[.offline]:bg-red-500",
|
||||||
'group-[.maintenance]:bg-blue-500',
|
"group-[.maintenance]:bg-blue-500",
|
||||||
'group-[.degraded]:bg-amber-500'
|
"group-[.degraded]:bg-amber-500",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex h-2 w-2 rounded-full',
|
"relative inline-flex h-2 w-2 rounded-full",
|
||||||
'group-[.online]:bg-emerald-500',
|
"group-[.online]:bg-emerald-500",
|
||||||
'group-[.offline]:bg-red-500',
|
"group-[.offline]:bg-red-500",
|
||||||
'group-[.maintenance]:bg-blue-500',
|
"group-[.maintenance]:bg-blue-500",
|
||||||
'group-[.degraded]:bg-amber-500'
|
"group-[.degraded]:bg-amber-500",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -52,13 +52,21 @@ export const StatusLabel = ({
|
|||||||
}: StatusLabelProps) => {
|
}: StatusLabelProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<span className={cn('text-muted-foreground', className)} {...props}>
|
<span className={cn("text-muted-foreground", className)} {...props}>
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
<span className="hidden group-[.online]:block">{t('common.online')}</span>
|
<span className="hidden group-[.online]:block">
|
||||||
<span className="hidden group-[.offline]:block">{t('common.offline')}</span>
|
{t("common.online")}
|
||||||
<span className="hidden group-[.maintenance]:block">{t('common.maintenance')}</span>
|
</span>
|
||||||
<span className="hidden group-[.degraded]:block">{t('common.degraded')}</span>
|
<span className="hidden group-[.offline]:block">
|
||||||
|
{t("common.offline")}
|
||||||
|
</span>
|
||||||
|
<span className="hidden group-[.maintenance]:block">
|
||||||
|
{t("common.maintenance")}
|
||||||
|
</span>
|
||||||
|
<span className="hidden group-[.degraded]:block">
|
||||||
|
{t("common.degraded")}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+18
-18
@@ -1,29 +1,29 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTrigger({
|
function SheetTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetClose({
|
function SheetClose({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetPortal({
|
function SheetPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetOverlay({
|
function SheetOverlay({
|
||||||
@@ -35,11 +35,11 @@ function SheetOverlay({
|
|||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetContent({
|
function SheetContent({
|
||||||
@@ -48,7 +48,7 @@ function SheetContent({
|
|||||||
side = "right",
|
side = "right",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
@@ -65,7 +65,7 @@ function SheetContent({
|
|||||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
side === "bottom" &&
|
side === "bottom" &&
|
||||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -76,7 +76,7 @@ function SheetContent({
|
|||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -86,7 +86,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -96,7 +96,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTitle({
|
function SheetTitle({
|
||||||
@@ -109,7 +109,7 @@ function SheetTitle({
|
|||||||
className={cn("text-foreground font-semibold", className)}
|
className={cn("text-foreground font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetDescription({
|
function SheetDescription({
|
||||||
@@ -122,7 +122,7 @@ function SheetDescription({
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -134,4 +134,4 @@ export {
|
|||||||
SheetFooter,
|
SheetFooter,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
+122
-122
@@ -1,54 +1,54 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { PanelLeftIcon } from "lucide-react"
|
import { PanelLeftIcon } from "lucide-react";
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input";
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet";
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
const SIDEBAR_WIDTH = "16rem"
|
const SIDEBAR_WIDTH = "16rem";
|
||||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
|
|
||||||
type SidebarContextProps = {
|
type SidebarContextProps = {
|
||||||
state: "expanded" | "collapsed"
|
state: "expanded" | "collapsed";
|
||||||
open: boolean
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void;
|
||||||
openMobile: boolean
|
openMobile: boolean;
|
||||||
setOpenMobile: (open: boolean) => void
|
setOpenMobile: (open: boolean) => void;
|
||||||
isMobile: boolean
|
isMobile: boolean;
|
||||||
toggleSidebar: () => void
|
toggleSidebar: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||||
|
|
||||||
function useSidebar() {
|
function useSidebar() {
|
||||||
const context = React.useContext(SidebarContext)
|
const context = React.useContext(SidebarContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return context
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarProvider({
|
function SidebarProvider({
|
||||||
@@ -60,36 +60,36 @@ function SidebarProvider({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean;
|
||||||
open?: boolean
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile();
|
||||||
const [openMobile, setOpenMobile] = React.useState(false)
|
const [openMobile, setOpenMobile] = React.useState(false);
|
||||||
|
|
||||||
// This is the internal state of the sidebar.
|
// This is the internal state of the sidebar.
|
||||||
// We use openProp and setOpenProp for control from outside the component.
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||||
const open = openProp ?? _open
|
const open = openProp ?? _open;
|
||||||
const setOpen = React.useCallback(
|
const setOpen = React.useCallback(
|
||||||
(value: boolean | ((value: boolean) => boolean)) => {
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
const openState = typeof value === "function" ? value(open) : value
|
const openState = typeof value === "function" ? value(open) : value;
|
||||||
if (setOpenProp) {
|
if (setOpenProp) {
|
||||||
setOpenProp(openState)
|
setOpenProp(openState);
|
||||||
} else {
|
} else {
|
||||||
_setOpen(openState)
|
_setOpen(openState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This sets the cookie to keep the sidebar state.
|
// This sets the cookie to keep the sidebar state.
|
||||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||||
},
|
},
|
||||||
[setOpenProp, open]
|
[setOpenProp, open],
|
||||||
)
|
);
|
||||||
|
|
||||||
// Helper to toggle the sidebar.
|
// Helper to toggle the sidebar.
|
||||||
const toggleSidebar = React.useCallback(() => {
|
const toggleSidebar = React.useCallback(() => {
|
||||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||||
}, [isMobile, setOpen, setOpenMobile])
|
}, [isMobile, setOpen, setOpenMobile]);
|
||||||
|
|
||||||
// Adds a keyboard shortcut to toggle the sidebar.
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -98,18 +98,18 @@ function SidebarProvider({
|
|||||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
(event.metaKey || event.ctrlKey)
|
(event.metaKey || event.ctrlKey)
|
||||||
) {
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
toggleSidebar()
|
toggleSidebar();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [toggleSidebar])
|
}, [toggleSidebar]);
|
||||||
|
|
||||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
// This makes it easier to style the sidebar with Tailwind classes.
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
const state = open ? "expanded" : "collapsed"
|
const state = open ? "expanded" : "collapsed";
|
||||||
|
|
||||||
const contextValue = React.useMemo<SidebarContextProps>(
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -121,8 +121,8 @@ function SidebarProvider({
|
|||||||
setOpenMobile,
|
setOpenMobile,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
}),
|
}),
|
||||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarContext.Provider value={contextValue}>
|
<SidebarContext.Provider value={contextValue}>
|
||||||
@@ -138,7 +138,7 @@ function SidebarProvider({
|
|||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -146,7 +146,7 @@ function SidebarProvider({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SidebarContext.Provider>
|
</SidebarContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar({
|
function Sidebar({
|
||||||
@@ -157,11 +157,11 @@ function Sidebar({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
side?: "left" | "right"
|
side?: "left" | "right";
|
||||||
variant?: "sidebar" | "floating" | "inset"
|
variant?: "sidebar" | "floating" | "inset";
|
||||||
collapsible?: "offcanvas" | "icon" | "none"
|
collapsible?: "offcanvas" | "icon" | "none";
|
||||||
}) {
|
}) {
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
if (collapsible === "none") {
|
if (collapsible === "none") {
|
||||||
return (
|
return (
|
||||||
@@ -169,13 +169,13 @@ function Sidebar({
|
|||||||
data-slot="sidebar"
|
data-slot="sidebar"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commented out mobile behavior to keep sidebar always visible
|
// Commented out mobile behavior to keep sidebar always visible
|
||||||
@@ -222,7 +222,7 @@ function Sidebar({
|
|||||||
"group-data-[side=right]:rotate-180",
|
"group-data-[side=right]:rotate-180",
|
||||||
variant === "floating" || variant === "inset"
|
variant === "floating" || variant === "inset"
|
||||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -236,7 +236,7 @@ function Sidebar({
|
|||||||
variant === "floating" || variant === "inset"
|
variant === "floating" || variant === "inset"
|
||||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -249,7 +249,7 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarTrigger({
|
function SidebarTrigger({
|
||||||
@@ -257,7 +257,7 @@ function SidebarTrigger({
|
|||||||
onClick,
|
onClick,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Button>) {
|
}: React.ComponentProps<typeof Button>) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -267,19 +267,19 @@ function SidebarTrigger({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className={cn("size-7", className)}
|
className={cn("size-7", className)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onClick?.(event)
|
onClick?.(event);
|
||||||
toggleSidebar()
|
toggleSidebar();
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PanelLeftIcon />
|
<PanelLeftIcon />
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -296,11 +296,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
|||||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
@@ -310,11 +310,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"bg-background relative flex w-full flex-1 flex-col",
|
"bg-background relative flex w-full flex-1 flex-col",
|
||||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarInput({
|
function SidebarInput({
|
||||||
@@ -328,7 +328,7 @@ function SidebarInput({
|
|||||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -339,7 +339,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-2 p-2", className)}
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -350,7 +350,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-2 p-2", className)}
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarSeparator({
|
function SidebarSeparator({
|
||||||
@@ -364,7 +364,7 @@ function SidebarSeparator({
|
|||||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -374,11 +374,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-sidebar="content"
|
data-sidebar="content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -389,7 +389,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupLabel({
|
function SidebarGroupLabel({
|
||||||
@@ -397,7 +397,7 @@ function SidebarGroupLabel({
|
|||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "div"
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -406,11 +406,11 @@ function SidebarGroupLabel({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupAction({
|
function SidebarGroupAction({
|
||||||
@@ -418,7 +418,7 @@ function SidebarGroupAction({
|
|||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -429,11 +429,11 @@ function SidebarGroupAction({
|
|||||||
// Increases the hit area of the button on mobile.
|
// Increases the hit area of the button on mobile.
|
||||||
"after:absolute after:-inset-2 md:after:hidden",
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupContent({
|
function SidebarGroupContent({
|
||||||
@@ -447,7 +447,7 @@ function SidebarGroupContent({
|
|||||||
className={cn("w-full text-sm", className)}
|
className={cn("w-full text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
@@ -458,7 +458,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
|||||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
@@ -469,7 +469,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
|||||||
className={cn("group/menu-item relative", className)}
|
className={cn("group/menu-item relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
const sidebarMenuButtonVariants = cva(
|
||||||
@@ -491,8 +491,8 @@ const sidebarMenuButtonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function SidebarMenuButton({
|
function SidebarMenuButton({
|
||||||
asChild = false,
|
asChild = false,
|
||||||
@@ -503,12 +503,12 @@ function SidebarMenuButton({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> & {
|
}: React.ComponentProps<"button"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
isActive?: boolean
|
isActive?: boolean;
|
||||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
const { isMobile, state } = useSidebar()
|
const { isMobile, state } = useSidebar();
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -519,16 +519,16 @@ function SidebarMenuButton({
|
|||||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
|
|
||||||
if (!tooltip) {
|
if (!tooltip) {
|
||||||
return button
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof tooltip === "string") {
|
if (typeof tooltip === "string") {
|
||||||
tooltip = {
|
tooltip = {
|
||||||
children: tooltip,
|
children: tooltip,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -541,7 +541,7 @@ function SidebarMenuButton({
|
|||||||
{...tooltip}
|
{...tooltip}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuAction({
|
function SidebarMenuAction({
|
||||||
@@ -550,10 +550,10 @@ function SidebarMenuAction({
|
|||||||
showOnHover = false,
|
showOnHover = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> & {
|
}: React.ComponentProps<"button"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
showOnHover?: boolean
|
showOnHover?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -569,11 +569,11 @@ function SidebarMenuAction({
|
|||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
showOnHover &&
|
showOnHover &&
|
||||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuBadge({
|
function SidebarMenuBadge({
|
||||||
@@ -591,11 +591,11 @@ function SidebarMenuBadge({
|
|||||||
"peer-data-[size=default]/menu-button:top-1.5",
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSkeleton({
|
function SidebarMenuSkeleton({
|
||||||
@@ -603,12 +603,12 @@ function SidebarMenuSkeleton({
|
|||||||
showIcon = false,
|
showIcon = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showIcon?: boolean
|
showIcon?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Random width between 50 to 90%.
|
// Random width between 50 to 90%.
|
||||||
const width = React.useMemo(() => {
|
const width = React.useMemo(() => {
|
||||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -633,7 +633,7 @@ function SidebarMenuSkeleton({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
@@ -644,11 +644,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSubItem({
|
function SidebarMenuSubItem({
|
||||||
@@ -662,7 +662,7 @@ function SidebarMenuSubItem({
|
|||||||
className={cn("group/menu-sub-item relative", className)}
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSubButton({
|
function SidebarMenuSubButton({
|
||||||
@@ -672,11 +672,11 @@ function SidebarMenuSubButton({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"a"> & {
|
}: React.ComponentProps<"a"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
size?: "sm" | "md"
|
size?: "sm" | "md";
|
||||||
isActive?: boolean
|
isActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "a"
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -690,11 +690,11 @@ function SidebarMenuSubButton({
|
|||||||
size === "sm" && "text-xs",
|
size === "sm" && "text-xs",
|
||||||
size === "md" && "text-sm",
|
size === "md" && "text-sm",
|
||||||
"group-data-[collapsible=icon]:hidden",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -722,4 +722,4 @@ export {
|
|||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
@@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton }
|
export { Skeleton };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes";
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme()
|
const { theme = "system" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
@@ -17,7 +17,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export { Toaster }
|
export { Toaster };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Switch({
|
function Switch({
|
||||||
className,
|
className,
|
||||||
@@ -12,18 +12,18 @@ function Switch({
|
|||||||
data-slot="switch"
|
data-slot="switch"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SwitchPrimitive.Thumb
|
<SwitchPrimitive.Thumb
|
||||||
data-slot="switch-thumb"
|
data-slot="switch-thumb"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitive.Root>
|
</SwitchPrimitive.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Switch }
|
export { Switch };
|
||||||
|
|||||||
+15
-15
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
return (
|
return (
|
||||||
@@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
@@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|||||||
className={cn("[&_tr]:border-b", className)}
|
className={cn("[&_tr]:border-b", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
@@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
@@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
|||||||
data-slot="table-footer"
|
data-slot="table-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
@@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
@@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
@@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCaption({
|
function TableCaption({
|
||||||
@@ -99,7 +99,7 @@ function TableCaption({
|
|||||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -111,4 +111,4 @@ export {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableCaption,
|
TableCaption,
|
||||||
}
|
};
|
||||||
|
|||||||
+10
-10
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Tabs({
|
function Tabs({
|
||||||
className,
|
className,
|
||||||
@@ -13,7 +13,7 @@ function Tabs({
|
|||||||
className={cn("flex flex-col gap-2", className)}
|
className={cn("flex flex-col gap-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsList({
|
function TabsList({
|
||||||
@@ -25,11 +25,11 @@ function TabsList({
|
|||||||
data-slot="tabs-list"
|
data-slot="tabs-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsTrigger({
|
function TabsTrigger({
|
||||||
@@ -41,11 +41,11 @@ function TabsTrigger({
|
|||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsContent({
|
function TabsContent({
|
||||||
@@ -58,7 +58,7 @@ function TabsContent({
|
|||||||
className={cn("flex-1 outline-none", className)}
|
className={cn("flex-1 outline-none", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface TextareaProps
|
export interface TextareaProps
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
@@ -11,14 +11,14 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Textarea.displayName = "Textarea"
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea };
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function TooltipProvider({
|
function TooltipProvider({
|
||||||
delayDuration = 0,
|
delayDuration = 0,
|
||||||
@@ -15,7 +15,7 @@ function TooltipProvider({
|
|||||||
delayDuration={delayDuration}
|
delayDuration={delayDuration}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tooltip({
|
function Tooltip({
|
||||||
@@ -25,13 +25,13 @@ function Tooltip({
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipTrigger({
|
function TooltipTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipContent({
|
function TooltipContent({
|
||||||
@@ -47,14 +47,14 @@ function TooltipContent({
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
|
|||||||
@@ -1,65 +1,68 @@
|
|||||||
import {useState} from 'react';
|
import { useState } from "react";
|
||||||
import {toast} from 'sonner';
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface ConfirmationOptions {
|
interface ConfirmationOptions {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
variant?: 'default' | 'destructive';
|
variant?: "default" | "destructive";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useConfirmation() {
|
export function useConfirmation() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [options, setOptions] = useState<ConfirmationOptions | null>(null);
|
const [options, setOptions] = useState<ConfirmationOptions | null>(null);
|
||||||
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
|
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
|
||||||
|
|
||||||
const confirm = (opts: ConfirmationOptions, callback: () => void) => {
|
const confirm = (opts: ConfirmationOptions, callback: () => void) => {
|
||||||
setOptions(opts);
|
setOptions(opts);
|
||||||
setOnConfirm(() => callback);
|
setOnConfirm(() => callback);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (onConfirm) {
|
if (onConfirm) {
|
||||||
onConfirm();
|
onConfirm();
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setOptions(null);
|
setOptions(null);
|
||||||
setOnConfirm(null);
|
setOnConfirm(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setOptions(null);
|
setOptions(null);
|
||||||
setOnConfirm(null);
|
setOnConfirm(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmWithToast = (message: string, callback: () => void, variant: 'default' | 'destructive' = 'default') => {
|
const confirmWithToast = (
|
||||||
const actionText = variant === 'destructive' ? 'Delete' : 'Confirm';
|
message: string,
|
||||||
const cancelText = 'Cancel';
|
callback: () => void,
|
||||||
|
variant: "default" | "destructive" = "default",
|
||||||
|
) => {
|
||||||
|
const actionText = variant === "destructive" ? "Delete" : "Confirm";
|
||||||
|
const cancelText = "Cancel";
|
||||||
|
|
||||||
toast(message, {
|
toast(message, {
|
||||||
action: {
|
action: {
|
||||||
label: actionText,
|
label: actionText,
|
||||||
onClick: callback
|
onClick: callback,
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: {
|
||||||
label: cancelText,
|
label: cancelText,
|
||||||
onClick: () => {
|
onClick: () => {},
|
||||||
}
|
},
|
||||||
},
|
duration: 10000,
|
||||||
duration: 10000,
|
className: variant === "destructive" ? "border-red-500" : "",
|
||||||
className: variant === 'destructive' ? 'border-red-500' : ''
|
});
|
||||||
});
|
};
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOpen,
|
isOpen,
|
||||||
options,
|
options,
|
||||||
confirm,
|
confirm,
|
||||||
handleConfirm,
|
handleConfirm,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
confirmWithToast
|
confirmWithToast,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-13
@@ -1,19 +1,21 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
export function useIsMobile() {
|
export function useIsMobile() {
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
const onChange = () => {
|
const onChange = () => {
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
}
|
};
|
||||||
mql.addEventListener("change", onChange)
|
mql.addEventListener("change", onChange);
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
return () => mql.removeEventListener("change", onChange)
|
return () => mql.removeEventListener("change", onChange);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return !!isMobile
|
return !!isMobile;
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-34
@@ -1,42 +1,42 @@
|
|||||||
import i18n from 'i18next';
|
import i18n from "i18next";
|
||||||
import {initReactI18next} from 'react-i18next';
|
import { initReactI18next } from "react-i18next";
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
|
||||||
import enTranslation from '../locales/en/translation.json';
|
import enTranslation from "../locales/en/translation.json";
|
||||||
import zhTranslation from '../locales/zh/translation.json';
|
import zhTranslation from "../locales/zh/translation.json";
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
supportedLngs: ['en', 'zh'],
|
supportedLngs: ["en", "zh"],
|
||||||
fallbackLng: 'en',
|
fallbackLng: "en",
|
||||||
debug: false,
|
debug: false,
|
||||||
|
|
||||||
detection: {
|
detection: {
|
||||||
order: ['localStorage', 'cookie'],
|
order: ["localStorage", "cookie"],
|
||||||
caches: ['localStorage', 'cookie'],
|
caches: ["localStorage", "cookie"],
|
||||||
lookupLocalStorage: 'i18nextLng',
|
lookupLocalStorage: "i18nextLng",
|
||||||
lookupCookie: 'i18nextLng',
|
lookupCookie: "i18nextLng",
|
||||||
checkWhitelist: true,
|
checkWhitelist: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
resources: {
|
resources: {
|
||||||
en: {
|
en: {
|
||||||
translation: enTranslation
|
translation: enTranslation,
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
translation: zhTranslation
|
translation: zhTranslation,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
react: {
|
react: {
|
||||||
useSuspense: false,
|
useSuspense: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
+157
-156
@@ -4,200 +4,201 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
color: rgba(255, 255, 255, 0.87);
|
||||||
background-color: #09090b;
|
background-color: #09090b;
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
--primary: oklch(0.21 0.006 285.885);
|
--primary: oklch(0.21 0.006 285.885);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.92 0.004 286.32);
|
--border: oklch(0.92 0.004 286.32);
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--input: oklch(0.92 0.004 286.32);
|
||||||
--ring: oklch(0.705 0.015 286.067);
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
--color-dark-bg: #18181b;
|
--color-dark-bg: #18181b;
|
||||||
--color-dark-bg-darker: #0e0e10;
|
--color-dark-bg-darker: #0e0e10;
|
||||||
--color-dark-bg-darkest: #09090b;
|
--color-dark-bg-darkest: #09090b;
|
||||||
--color-dark-bg-input: #222225;
|
--color-dark-bg-input: #222225;
|
||||||
--color-dark-bg-button: #23232a;
|
--color-dark-bg-button: #23232a;
|
||||||
--color-dark-bg-active: #1d1d1f;
|
--color-dark-bg-active: #1d1d1f;
|
||||||
--color-dark-bg-header: #131316;
|
--color-dark-bg-header: #131316;
|
||||||
--color-dark-border: #303032;
|
--color-dark-border: #303032;
|
||||||
--color-dark-border-active: #2d2d30;
|
--color-dark-border-active: #2d2d30;
|
||||||
--color-dark-border-hover: #434345;
|
--color-dark-border-hover: #434345;
|
||||||
--color-dark-hover: #2d2d30;
|
--color-dark-hover: #2d2d30;
|
||||||
--color-dark-active: #2a2a2c;
|
--color-dark-active: #2a2a2c;
|
||||||
--color-dark-pressed: #1a1a1c;
|
--color-dark-pressed: #1a1a1c;
|
||||||
--color-dark-hover-alt: #2a2a2d;
|
--color-dark-hover-alt: #2a2a2d;
|
||||||
--color-dark-border-light: #5a5a5d;
|
--color-dark-border-light: #5a5a5d;
|
||||||
--color-dark-bg-light: #141416;
|
--color-dark-bg-light: #141416;
|
||||||
--color-dark-border-medium: #373739;
|
--color-dark-border-medium: #373739;
|
||||||
--color-dark-bg-very-light: #101014;
|
--color-dark-bg-very-light: #101014;
|
||||||
--color-dark-bg-panel: #1b1b1e;
|
--color-dark-bg-panel: #1b1b1e;
|
||||||
--color-dark-border-panel: #222224;
|
--color-dark-border-panel: #222224;
|
||||||
--color-dark-bg-panel-hover: #232327;
|
--color-dark-bg-panel-hover: #232327;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.21 0.006 285.885);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.21 0.006 285.885);
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.92 0.004 286.32);
|
--primary: oklch(0.92 0.004 286.32);
|
||||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.274 0.006 286.033);
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
--accent: oklch(0.274 0.006 286.033);
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.552 0.016 285.938);
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.21 0.006 285.885);
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html, body {
|
html,
|
||||||
height: 100%;
|
body {
|
||||||
}
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar {
|
.thin-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #303032 transparent;
|
scrollbar-color: #303032 transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar {
|
.thin-scrollbar::-webkit-scrollbar {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar-track {
|
.thin-scrollbar::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background-color: #303032;
|
background-color: #303032;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar {
|
.thin-scrollbar::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar-track {
|
.thin-scrollbar::-webkit-scrollbar-track {
|
||||||
background: #18181b;
|
background: #18181b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #434345;
|
background: #434345;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
|
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: #5a5a5d;
|
background: #5a5a5d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thin-scrollbar {
|
.thin-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #434345 #18181b;
|
scrollbar-color: #434345 #18181b;
|
||||||
}
|
}
|
||||||
|
|||||||
+365
-307
@@ -1,330 +1,388 @@
|
|||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
|
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
||||||
|
|
||||||
export interface LogContext {
|
export interface LogContext {
|
||||||
operation?: string;
|
operation?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
hostId?: number;
|
hostId?: number;
|
||||||
tunnelName?: string;
|
tunnelName?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
method?: string;
|
method?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
statusText?: string;
|
statusText?: string;
|
||||||
responseTime?: number;
|
responseTime?: number;
|
||||||
retryCount?: number;
|
retryCount?: number;
|
||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
class FrontendLogger {
|
class FrontendLogger {
|
||||||
private serviceName: string;
|
private serviceName: string;
|
||||||
private serviceIcon: string;
|
private serviceIcon: string;
|
||||||
private serviceColor: string;
|
private serviceColor: string;
|
||||||
private isDevelopment: boolean;
|
private isDevelopment: boolean;
|
||||||
|
|
||||||
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||||
this.serviceName = serviceName;
|
this.serviceName = serviceName;
|
||||||
this.serviceIcon = serviceIcon;
|
this.serviceIcon = serviceIcon;
|
||||||
this.serviceColor = serviceColor;
|
this.serviceColor = serviceColor;
|
||||||
this.isDevelopment = process.env.NODE_ENV === 'development';
|
this.isDevelopment = process.env.NODE_ENV === "development";
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTimeStamp(): string {
|
||||||
|
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(", ")})`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTimeStamp(): string {
|
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
||||||
const now = new Date();
|
}
|
||||||
return `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}]`;
|
|
||||||
|
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 formatMessage(level: LogLevel, message: string, context?: LogContext): string {
|
private log(
|
||||||
const timestamp = this.getTimeStamp();
|
level: LogLevel,
|
||||||
const levelTag = this.getLevelTag(level);
|
message: string,
|
||||||
const serviceTag = this.getServiceTag();
|
context?: LogContext,
|
||||||
|
error?: unknown,
|
||||||
|
): void {
|
||||||
|
if (!this.shouldLog(level)) return;
|
||||||
|
|
||||||
let contextStr = '';
|
const formattedMessage = this.formatMessage(level, message, context);
|
||||||
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) {
|
switch (level) {
|
||||||
contextStr = ` (${contextParts.join(', ')})`;
|
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;
|
||||||
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
case "success":
|
||||||
|
console.log(formattedMessage);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getLevelTag(level: LogLevel): string {
|
debug(message: string, context?: LogContext): void {
|
||||||
const symbols = {
|
this.log("debug", message, context);
|
||||||
debug: '🔍',
|
}
|
||||||
info: 'ℹ️',
|
|
||||||
warn: '⚠️',
|
info(message: string, context?: LogContext): void {
|
||||||
error: '❌',
|
this.log("info", message, context);
|
||||||
success: '✅'
|
}
|
||||||
};
|
|
||||||
return `${symbols[level]} [${level.toUpperCase()}]`;
|
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 getServiceTag(): string {
|
private getStatusIcon(status: number): string {
|
||||||
return `${this.serviceIcon} [${this.serviceName}]`;
|
if (status >= 200 && status < 300) return "✅";
|
||||||
}
|
if (status >= 300 && status < 400) return "↩️";
|
||||||
|
if (status >= 400 && status < 500) return "⚠️";
|
||||||
private shouldLog(level: LogLevel): boolean {
|
if (status >= 500) return "❌";
|
||||||
if (level === 'debug' && !this.isDevelopment) {
|
return "❓";
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
return true;
|
private getPerformanceIcon(responseTime: number): string {
|
||||||
}
|
if (responseTime < 100) return "⚡";
|
||||||
|
if (responseTime < 500) return "🚀";
|
||||||
private log(level: LogLevel, message: string, context?: LogContext, error?: unknown): void {
|
if (responseTime < 1000) return "🏃";
|
||||||
if (!this.shouldLog(level)) return;
|
if (responseTime < 3000) return "🚶";
|
||||||
|
return "🐌";
|
||||||
const formattedMessage = this.formatMessage(level, message, context);
|
}
|
||||||
|
|
||||||
switch (level) {
|
private sanitizeUrl(url: string): string {
|
||||||
case 'debug':
|
try {
|
||||||
console.debug(formattedMessage);
|
const urlObj = new URL(url);
|
||||||
break;
|
if (
|
||||||
case 'info':
|
urlObj.searchParams.has("password") ||
|
||||||
console.log(formattedMessage);
|
urlObj.searchParams.has("token")
|
||||||
break;
|
) {
|
||||||
case 'warn':
|
urlObj.search = "";
|
||||||
console.warn(formattedMessage);
|
}
|
||||||
break;
|
return urlObj.toString();
|
||||||
case 'error':
|
} catch {
|
||||||
console.error(formattedMessage);
|
return url;
|
||||||
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 apiLogger = new FrontendLogger("API", "🌐", "#3b82f6");
|
||||||
export const authLogger = new FrontendLogger('AUTH', '🔐', '#dc2626');
|
export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
|
||||||
export const sshLogger = new FrontendLogger('SSH', '🖥️', '#1e3a8a');
|
export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
|
||||||
export const tunnelLogger = new FrontendLogger('TUNNEL', '📡', '#1e3a8a');
|
export const tunnelLogger = new FrontendLogger("TUNNEL", "📡", "#1e3a8a");
|
||||||
export const fileLogger = new FrontendLogger('FILE', '📁', '#1e3a8a');
|
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
|
||||||
export const statsLogger = new FrontendLogger('STATS', '📊', '#22c55e');
|
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
|
||||||
export const systemLogger = new FrontendLogger('SYSTEM', '🚀', '#1e3a8a');
|
export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
|
||||||
|
|
||||||
export const logger = systemLogger;
|
export const logger = systemLogger;
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import {clsx, type ClassValue} from "clsx"
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import {twMerge} from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1031,4 +1031,4 @@
|
|||||||
"selectHostToStart": "Select a host to start your terminal session",
|
"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."
|
"limitedSupportMessage": "Mobile support is currently limited. A dedicated mobile app is coming soon to enhance your experience."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1018,4 +1018,4 @@
|
|||||||
"selectHostToStart": "选择一个主机以开始您的终端会话",
|
"selectHostToStart": "选择一个主机以开始您的终端会话",
|
||||||
"limitedSupportMessage": "移动端支持目前有限。我们即将推出专门的移动应用以提升您的体验。"
|
"limitedSupportMessage": "移动端支持目前有限。我们即将推出专门的移动应用以提升您的体验。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-56
@@ -1,69 +1,72 @@
|
|||||||
import {StrictMode, useEffect, useState, useRef} from 'react'
|
import { StrictMode, useEffect, useState, useRef } from "react";
|
||||||
import {createRoot} from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
import DesktopApp from './ui/Desktop/DesktopApp.tsx'
|
import DesktopApp from "./ui/Desktop/DesktopApp.tsx";
|
||||||
import {MobileApp} from './ui/Mobile/MobileApp.tsx'
|
import { MobileApp } from "./ui/Mobile/MobileApp.tsx";
|
||||||
import {ThemeProvider} from "@/components/theme-provider"
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import './i18n/i18n'
|
import "./i18n/i18n";
|
||||||
import {isElectron} from './ui/main-axios.ts'
|
import { isElectron } from "./ui/main-axios.ts";
|
||||||
|
|
||||||
function useWindowWidth() {
|
function useWindowWidth() {
|
||||||
const [width, setWidth] = useState(window.innerWidth);
|
const [width, setWidth] = useState(window.innerWidth);
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
const lastSwitchTime = useRef(0);
|
const lastSwitchTime = useRef(0);
|
||||||
const isCurrentlyMobile = useRef(window.innerWidth < 768);
|
const isCurrentlyMobile = useRef(window.innerWidth < 768);
|
||||||
const hasSwitchedOnce = useRef(false);
|
const hasSwitchedOnce = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeoutId: NodeJS.Timeout;
|
let timeoutId: NodeJS.Timeout;
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
const newWidth = window.innerWidth;
|
const newWidth = window.innerWidth;
|
||||||
const newIsMobile = newWidth < 768;
|
const newIsMobile = newWidth < 768;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (hasSwitchedOnce.current && (now - lastSwitchTime.current) < 10000) {
|
if (hasSwitchedOnce.current && now - lastSwitchTime.current < 10000) {
|
||||||
setWidth(newWidth);
|
setWidth(newWidth);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newIsMobile !== isCurrentlyMobile.current && (now - lastSwitchTime.current) > 5000) {
|
if (
|
||||||
lastSwitchTime.current = now;
|
newIsMobile !== isCurrentlyMobile.current &&
|
||||||
isCurrentlyMobile.current = newIsMobile;
|
now - lastSwitchTime.current > 5000
|
||||||
hasSwitchedOnce.current = true;
|
) {
|
||||||
setWidth(newWidth);
|
lastSwitchTime.current = now;
|
||||||
setIsMobile(newIsMobile);
|
isCurrentlyMobile.current = newIsMobile;
|
||||||
} else {
|
hasSwitchedOnce.current = true;
|
||||||
setWidth(newWidth);
|
setWidth(newWidth);
|
||||||
}
|
setIsMobile(newIsMobile);
|
||||||
}, 2000);
|
} else {
|
||||||
};
|
setWidth(newWidth);
|
||||||
window.addEventListener("resize", handleResize);
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
window.removeEventListener("resize", handleResize);
|
window.removeEventListener("resize", handleResize);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return width;
|
return width;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RootApp() {
|
function RootApp() {
|
||||||
const width = useWindowWidth();
|
const width = useWindowWidth();
|
||||||
const isMobile = width < 768;
|
const isMobile = width < 768;
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
return <DesktopApp/>;
|
return <DesktopApp />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isMobile ? <MobileApp key="mobile"/> : <DesktopApp key="desktop"/>;
|
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||||
<RootApp/>
|
<RootApp />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
|||||||
+262
-234
@@ -4,56 +4,56 @@
|
|||||||
// This file contains all shared interfaces and types used across the application
|
// This file contains all shared interfaces and types used across the application
|
||||||
// to avoid duplication and ensure consistency.
|
// to avoid duplication and ensure consistency.
|
||||||
|
|
||||||
import type {Client} from 'ssh2';
|
import type { Client } from "ssh2";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SSH HOST TYPES
|
// SSH HOST TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface SSHHost {
|
export interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
pin: boolean;
|
pin: boolean;
|
||||||
authType: 'password' | 'key' | 'credential';
|
authType: "password" | "key" | "credential";
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
credentialId?: number;
|
credentialId?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
enableTerminal: boolean;
|
enableTerminal: boolean;
|
||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableFileManager: boolean;
|
enableFileManager: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: TunnelConnection[];
|
tunnelConnections: TunnelConnection[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHHostData {
|
export interface SSHHostData {
|
||||||
name?: string;
|
name?: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
pin?: boolean;
|
pin?: boolean;
|
||||||
authType: 'password' | 'key' | 'credential';
|
authType: "password" | "key" | "credential";
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: File | null;
|
key?: File | null;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
credentialId?: number | null;
|
credentialId?: number | null;
|
||||||
enableTerminal?: boolean;
|
enableTerminal?: boolean;
|
||||||
enableTunnel?: boolean;
|
enableTunnel?: boolean;
|
||||||
enableFileManager?: boolean;
|
enableFileManager?: boolean;
|
||||||
defaultPath?: string;
|
defaultPath?: string;
|
||||||
tunnelConnections?: any[];
|
tunnelConnections?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -61,34 +61,34 @@ export interface SSHHostData {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface Credential {
|
export interface Credential {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
authType: 'password' | 'key';
|
authType: "password" | "key";
|
||||||
username: string;
|
username: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
usageCount: number;
|
usageCount: number;
|
||||||
lastUsed?: string;
|
lastUsed?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CredentialData {
|
export interface CredentialData {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
authType: 'password' | 'key';
|
authType: "password" | "key";
|
||||||
username: string;
|
username: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -96,55 +96,55 @@ export interface CredentialData {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface TunnelConnection {
|
export interface TunnelConnection {
|
||||||
sourcePort: number;
|
sourcePort: number;
|
||||||
endpointPort: number;
|
endpointPort: number;
|
||||||
endpointHost: string;
|
endpointHost: string;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryInterval: number;
|
retryInterval: number;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TunnelConfig {
|
export interface TunnelConfig {
|
||||||
name: string;
|
name: string;
|
||||||
hostName: string;
|
hostName: string;
|
||||||
sourceIP: string;
|
sourceIP: string;
|
||||||
sourceSSHPort: number;
|
sourceSSHPort: number;
|
||||||
sourceUsername: string;
|
sourceUsername: string;
|
||||||
sourcePassword?: string;
|
sourcePassword?: string;
|
||||||
sourceAuthMethod: string;
|
sourceAuthMethod: string;
|
||||||
sourceSSHKey?: string;
|
sourceSSHKey?: string;
|
||||||
sourceKeyPassword?: string;
|
sourceKeyPassword?: string;
|
||||||
sourceKeyType?: string;
|
sourceKeyType?: string;
|
||||||
sourceCredentialId?: number;
|
sourceCredentialId?: number;
|
||||||
sourceUserId?: string;
|
sourceUserId?: string;
|
||||||
endpointIP: string;
|
endpointIP: string;
|
||||||
endpointSSHPort: number;
|
endpointSSHPort: number;
|
||||||
endpointUsername: string;
|
endpointUsername: string;
|
||||||
endpointPassword?: string;
|
endpointPassword?: string;
|
||||||
endpointAuthMethod: string;
|
endpointAuthMethod: string;
|
||||||
endpointSSHKey?: string;
|
endpointSSHKey?: string;
|
||||||
endpointKeyPassword?: string;
|
endpointKeyPassword?: string;
|
||||||
endpointKeyType?: string;
|
endpointKeyType?: string;
|
||||||
endpointCredentialId?: number;
|
endpointCredentialId?: number;
|
||||||
endpointUserId?: string;
|
endpointUserId?: string;
|
||||||
sourcePort: number;
|
sourcePort: number;
|
||||||
endpointPort: number;
|
endpointPort: number;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
retryInterval: number;
|
retryInterval: number;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TunnelStatus {
|
export interface TunnelStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
status: ConnectionState;
|
status: ConnectionState;
|
||||||
retryCount?: number;
|
retryCount?: number;
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
nextRetryIn?: number;
|
nextRetryIn?: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
errorType?: ErrorType;
|
errorType?: ErrorType;
|
||||||
manualDisconnect?: boolean;
|
manualDisconnect?: boolean;
|
||||||
retryExhausted?: boolean;
|
retryExhausted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -152,50 +152,50 @@ export interface TunnelStatus {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
title: string;
|
title: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
content: string;
|
content: string;
|
||||||
isSSH?: boolean;
|
isSSH?: boolean;
|
||||||
sshSessionId?: string;
|
sshSessionId?: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
dirty?: boolean;
|
dirty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileManagerFile {
|
export interface FileManagerFile {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
type?: 'file' | 'directory';
|
type?: "file" | "directory";
|
||||||
isSSH?: boolean;
|
isSSH?: boolean;
|
||||||
sshSessionId?: string;
|
sshSessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileManagerShortcut {
|
export interface FileManagerShortcut {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileItem {
|
export interface FileItem {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
type: 'file' | 'directory';
|
type: "file" | "directory";
|
||||||
sshSessionId?: string;
|
sshSessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortcutItem {
|
export interface ShortcutItem {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHConnection {
|
export interface SSHConnection {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -203,11 +203,11 @@ export interface SSHConnection {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface HostInfo {
|
export interface HostInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -215,14 +215,14 @@ export interface HostInfo {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface TermixAlert {
|
export interface TermixAlert {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
priority?: 'low' | 'medium' | 'high' | 'critical';
|
priority?: "low" | "medium" | "high" | "critical";
|
||||||
type?: 'info' | 'warning' | 'error' | 'success';
|
type?: "info" | "warning" | "error" | "success";
|
||||||
actionUrl?: string;
|
actionUrl?: string;
|
||||||
actionText?: string;
|
actionText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -230,11 +230,18 @@ export interface TermixAlert {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface TabContextTab {
|
export interface TabContextTab {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager' | 'user_profile';
|
type:
|
||||||
title: string;
|
| "home"
|
||||||
hostConfig?: any;
|
| "terminal"
|
||||||
terminalRef?: React.RefObject<any>;
|
| "ssh_manager"
|
||||||
|
| "server"
|
||||||
|
| "admin"
|
||||||
|
| "file_manager"
|
||||||
|
| "user_profile";
|
||||||
|
title: string;
|
||||||
|
hostConfig?: any;
|
||||||
|
terminalRef?: React.RefObject<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -242,38 +249,44 @@ export interface TabContextTab {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const CONNECTION_STATES = {
|
export const CONNECTION_STATES = {
|
||||||
DISCONNECTED: "disconnected",
|
DISCONNECTED: "disconnected",
|
||||||
CONNECTING: "connecting",
|
CONNECTING: "connecting",
|
||||||
CONNECTED: "connected",
|
CONNECTED: "connected",
|
||||||
VERIFYING: "verifying",
|
VERIFYING: "verifying",
|
||||||
FAILED: "failed",
|
FAILED: "failed",
|
||||||
UNSTABLE: "unstable",
|
UNSTABLE: "unstable",
|
||||||
RETRYING: "retrying",
|
RETRYING: "retrying",
|
||||||
WAITING: "waiting",
|
WAITING: "waiting",
|
||||||
DISCONNECTING: "disconnecting"
|
DISCONNECTING: "disconnecting",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES];
|
export type ConnectionState =
|
||||||
|
(typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES];
|
||||||
|
|
||||||
export type ErrorType = 'CONNECTION_FAILED' | 'AUTHENTICATION_FAILED' | 'TIMEOUT' | 'NETWORK_ERROR' | 'UNKNOWN';
|
export type ErrorType =
|
||||||
|
| "CONNECTION_FAILED"
|
||||||
|
| "AUTHENTICATION_FAILED"
|
||||||
|
| "TIMEOUT"
|
||||||
|
| "NETWORK_ERROR"
|
||||||
|
| "UNKNOWN";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// AUTHENTICATION TYPES
|
// AUTHENTICATION TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type AuthType = 'password' | 'key' | 'credential';
|
export type AuthType = "password" | "key" | "credential";
|
||||||
|
|
||||||
export type KeyType = 'rsa' | 'ecdsa' | 'ed25519';
|
export type KeyType = "rsa" | "ecdsa" | "ed25519";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API RESPONSE TYPES
|
// API RESPONSE TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = any> {
|
||||||
data?: T;
|
data?: T;
|
||||||
error?: string;
|
error?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -281,107 +294,122 @@ export interface ApiResponse<T = any> {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface CredentialsManagerProps {
|
export interface CredentialsManagerProps {
|
||||||
onEditCredential?: (credential: Credential) => void;
|
onEditCredential?: (credential: Credential) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CredentialEditorProps {
|
export interface CredentialEditorProps {
|
||||||
editingCredential?: Credential | null;
|
editingCredential?: Credential | null;
|
||||||
onFormSubmit?: () => void;
|
onFormSubmit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CredentialViewerProps {
|
export interface CredentialViewerProps {
|
||||||
credential: Credential;
|
credential: Credential;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CredentialSelectorProps {
|
export interface CredentialSelectorProps {
|
||||||
value?: number | null;
|
value?: number | null;
|
||||||
onValueChange: (value: number | null) => void;
|
onValueChange: (value: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostManagerProps {
|
export interface HostManagerProps {
|
||||||
onSelectView?: (view: string) => void;
|
onSelectView?: (view: string) => void;
|
||||||
isTopbarOpen?: boolean;
|
isTopbarOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHManagerHostEditorProps {
|
export interface SSHManagerHostEditorProps {
|
||||||
editingHost?: SSHHost | null;
|
editingHost?: SSHHost | null;
|
||||||
onFormSubmit?: () => void;
|
onFormSubmit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHManagerHostViewerProps {
|
export interface SSHManagerHostViewerProps {
|
||||||
onEditHost?: (host: SSHHost) => void;
|
onEditHost?: (host: SSHHost) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostProps {
|
export interface HostProps {
|
||||||
host: SSHHost;
|
host: SSHHost;
|
||||||
onHostConnect?: () => void;
|
onHostConnect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHTunnelProps {
|
export interface SSHTunnelProps {
|
||||||
filterHostKey?: string;
|
filterHostKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHTunnelViewerProps {
|
export interface SSHTunnelViewerProps {
|
||||||
hosts?: SSHHost[];
|
hosts?: SSHHost[];
|
||||||
tunnelStatuses?: Record<string, TunnelStatus>;
|
tunnelStatuses?: Record<string, TunnelStatus>;
|
||||||
tunnelActions?: Record<string, (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>>;
|
tunnelActions?: Record<
|
||||||
onTunnelAction?: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
string,
|
||||||
|
(
|
||||||
|
action: "connect" | "disconnect" | "cancel",
|
||||||
|
host: SSHHost,
|
||||||
|
tunnelIndex: number,
|
||||||
|
) => Promise<any>
|
||||||
|
>;
|
||||||
|
onTunnelAction?: (
|
||||||
|
action: "connect" | "disconnect" | "cancel",
|
||||||
|
host: SSHHost,
|
||||||
|
tunnelIndex: number,
|
||||||
|
) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileManagerProps {
|
export interface FileManagerProps {
|
||||||
onSelectView?: (view: string) => void;
|
onSelectView?: (view: string) => void;
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
initialHost?: SSHHost | null;
|
initialHost?: SSHHost | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileManagerLeftSidebarProps {
|
export interface FileManagerLeftSidebarProps {
|
||||||
onSelectView?: (view: string) => void;
|
onSelectView?: (view: string) => void;
|
||||||
onOpenFile: (file: any) => void;
|
onOpenFile: (file: any) => void;
|
||||||
tabs: Tab[];
|
tabs: Tab[];
|
||||||
host: SSHHost;
|
host: SSHHost;
|
||||||
onOperationComplete?: () => void;
|
onOperationComplete?: () => void;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
onSuccess?: (message: string) => void;
|
onSuccess?: (message: string) => void;
|
||||||
onPathChange?: (path: string) => void;
|
onPathChange?: (path: string) => void;
|
||||||
onDeleteItem?: (item: any) => void;
|
onDeleteItem?: (item: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileManagerOperationsProps {
|
export interface FileManagerOperationsProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
sshSessionId: string | null;
|
sshSessionId: string | null;
|
||||||
onOperationComplete?: () => void;
|
onOperationComplete?: () => void;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
onSuccess?: (message: string) => void;
|
onSuccess?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlertCardProps {
|
export interface AlertCardProps {
|
||||||
alert: TermixAlert;
|
alert: TermixAlert;
|
||||||
onDismiss: (alertId: string) => void;
|
onDismiss: (alertId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlertManagerProps {
|
export interface AlertManagerProps {
|
||||||
alerts: TermixAlert[];
|
alerts: TermixAlert[];
|
||||||
onDismiss: (alertId: string) => void;
|
onDismiss: (alertId: string) => void;
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHTunnelObjectProps {
|
export interface SSHTunnelObjectProps {
|
||||||
host: SSHHost;
|
host: SSHHost;
|
||||||
tunnelStatuses: Record<string, TunnelStatus>;
|
tunnelStatuses: Record<string, TunnelStatus>;
|
||||||
tunnelActions: Record<string, boolean>;
|
tunnelActions: Record<string, boolean>;
|
||||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
onTunnelAction: (
|
||||||
compact?: boolean;
|
action: "connect" | "disconnect" | "cancel",
|
||||||
bare?: boolean;
|
host: SSHHost,
|
||||||
|
tunnelIndex: number,
|
||||||
|
) => Promise<any>;
|
||||||
|
compact?: boolean;
|
||||||
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FolderStats {
|
export interface FolderStats {
|
||||||
totalHosts: number;
|
totalHosts: number;
|
||||||
hostsByType: Array<{
|
hostsByType: Array<{
|
||||||
type: string;
|
type: string;
|
||||||
count: number;
|
count: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -389,16 +417,16 @@ export interface FolderStats {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface HostConfig {
|
export interface HostConfig {
|
||||||
host: SSHHost;
|
host: SSHHost;
|
||||||
tunnels: TunnelConfig[];
|
tunnels: TunnelConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VerificationData {
|
export interface VerificationData {
|
||||||
conn: Client;
|
conn: Client;
|
||||||
timeout: NodeJS.Timeout;
|
timeout: NodeJS.Timeout;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
maxAttempts: number;
|
maxAttempts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -409,4 +437,4 @@ 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 RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||||
|
|
||||||
export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,202 +1,226 @@
|
|||||||
import React, {useState, useEffect, useRef} from 'react';
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {Input} from "@/components/ui/input.tsx";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import {FormControl, FormItem, FormLabel} from "@/components/ui/form.tsx";
|
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
|
||||||
import {getCredentials} from '@/ui/main-axios.ts';
|
import { getCredentials } from "@/ui/main-axios.ts";
|
||||||
import {useTranslation} from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {Credential} from '../../../../types';
|
import type { Credential } from "../../../../types";
|
||||||
|
|
||||||
interface CredentialSelectorProps {
|
interface CredentialSelectorProps {
|
||||||
value?: number | null;
|
value?: number | null;
|
||||||
onValueChange: (credentialId: number | null) => void;
|
onValueChange: (credentialId: number | null) => void;
|
||||||
onCredentialSelect?: (credential: Credential | null) => void;
|
onCredentialSelect?: (credential: Credential | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CredentialSelector({value, onValueChange, onCredentialSelect}: CredentialSelectorProps) {
|
export function CredentialSelector({
|
||||||
const {t} = useTranslation();
|
value,
|
||||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
onValueChange,
|
||||||
const [loading, setLoading] = useState(true);
|
onCredentialSelect,
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
}: CredentialSelectorProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const { t } = useTranslation();
|
||||||
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCredentials = async () => {
|
const fetchCredentials = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getCredentials();
|
const data = await getCredentials();
|
||||||
const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []);
|
const credentialsArray = Array.isArray(data)
|
||||||
setCredentials(credentialsArray);
|
? data
|
||||||
} catch (error) {
|
: data.credentials || data.data || [];
|
||||||
const {toast} = await import('sonner');
|
setCredentials(credentialsArray);
|
||||||
toast.error(t('credentials.failedToFetchCredentials'));
|
} catch (error) {
|
||||||
setCredentials([]);
|
const { toast } = await import("sonner");
|
||||||
} finally {
|
toast.error(t("credentials.failedToFetchCredentials"));
|
||||||
setLoading(false);
|
setCredentials([]);
|
||||||
}
|
} finally {
|
||||||
};
|
setLoading(false);
|
||||||
|
}
|
||||||
fetchCredentials();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
|
||||||
if (
|
|
||||||
dropdownRef.current &&
|
|
||||||
!dropdownRef.current.contains(event.target as Node) &&
|
|
||||||
buttonRef.current &&
|
|
||||||
!buttonRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dropdownOpen) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
} else {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [dropdownOpen]);
|
|
||||||
|
|
||||||
const selectedCredential = credentials.find(c => c.id === value);
|
|
||||||
|
|
||||||
const filteredCredentials = credentials.filter(credential => {
|
|
||||||
if (!searchQuery) return true;
|
|
||||||
const searchLower = searchQuery.toLowerCase();
|
|
||||||
return (
|
|
||||||
credential.name.toLowerCase().includes(searchLower) ||
|
|
||||||
credential.username.toLowerCase().includes(searchLower) ||
|
|
||||||
(credential.folder && credential.folder.toLowerCase().includes(searchLower))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCredentialSelect = (credential: Credential) => {
|
|
||||||
onValueChange(credential.id);
|
|
||||||
if (onCredentialSelect) {
|
|
||||||
onCredentialSelect(credential);
|
|
||||||
}
|
|
||||||
setDropdownOpen(false);
|
|
||||||
setSearchQuery('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
fetchCredentials();
|
||||||
onValueChange(null);
|
}, []);
|
||||||
if (onCredentialSelect) {
|
|
||||||
onCredentialSelect(null);
|
|
||||||
}
|
|
||||||
setDropdownOpen(false);
|
|
||||||
setSearchQuery('');
|
|
||||||
};
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropdownOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [dropdownOpen]);
|
||||||
|
|
||||||
|
const selectedCredential = credentials.find((c) => c.id === value);
|
||||||
|
|
||||||
|
const filteredCredentials = credentials.filter((credential) => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
const searchLower = searchQuery.toLowerCase();
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
credential.name.toLowerCase().includes(searchLower) ||
|
||||||
<FormLabel>{t('hosts.selectCredential')}</FormLabel>
|
credential.username.toLowerCase().includes(searchLower) ||
|
||||||
<FormControl>
|
(credential.folder &&
|
||||||
<div className="relative">
|
credential.folder.toLowerCase().includes(searchLower))
|
||||||
<Button
|
|
||||||
ref={buttonRef}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200"
|
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
t('common.loading')
|
|
||||||
) : value === "existing_credential" ? (
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">{t('hosts.existingCredential')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : selectedCredential ? (
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">{selectedCredential.name}</span>
|
|
||||||
<span className="text-sm text-muted-foreground ml-2">
|
|
||||||
({selectedCredential.username} • {selectedCredential.authType})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
t('hosts.selectCredentialPlaceholder')
|
|
||||||
)}
|
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7"/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{dropdownOpen && (
|
|
||||||
<div
|
|
||||||
ref={dropdownRef}
|
|
||||||
className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-80 overflow-hidden backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<div className="p-2 border-b border-border">
|
|
||||||
<Input
|
|
||||||
placeholder={t('credentials.searchCredentials')}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="h-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-60 overflow-y-auto p-2">
|
|
||||||
{loading ? (
|
|
||||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
|
||||||
{t('common.loading')}
|
|
||||||
</div>
|
|
||||||
) : filteredCredentials.length === 0 ? (
|
|
||||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
|
||||||
{searchQuery ? t('credentials.noCredentialsMatchFilters') : t('credentials.noCredentialsYet')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 gap-2.5">
|
|
||||||
{value && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
|
|
||||||
onClick={handleClear}
|
|
||||||
>
|
|
||||||
{t('common.clear')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{filteredCredentials.map((credential) => (
|
|
||||||
<Button
|
|
||||||
key={credential.id}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
|
|
||||||
credential.id === value ? 'bg-muted' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => handleCredentialSelect(credential)}
|
|
||||||
>
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">{credential.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{credential.username} • {credential.authType}
|
|
||||||
{credential.description && ` • ${credential.description}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
const handleCredentialSelect = (credential: Credential) => {
|
||||||
|
onValueChange(credential.id);
|
||||||
|
if (onCredentialSelect) {
|
||||||
|
onCredentialSelect(credential);
|
||||||
|
}
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onValueChange(null);
|
||||||
|
if (onCredentialSelect) {
|
||||||
|
onCredentialSelect(null);
|
||||||
|
}
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("hosts.selectCredential")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200"
|
||||||
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
t("common.loading")
|
||||||
|
) : value === "existing_credential" ? (
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">
|
||||||
|
{t("hosts.existingCredential")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedCredential ? (
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{selectedCredential.name}</span>
|
||||||
|
<span className="text-sm text-muted-foreground ml-2">
|
||||||
|
({selectedCredential.username} •{" "}
|
||||||
|
{selectedCredential.authType})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t("hosts.selectCredentialPlaceholder")
|
||||||
|
)}
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-80 overflow-hidden backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div className="p-2 border-b border-border">
|
||||||
|
<Input
|
||||||
|
placeholder={t("credentials.searchCredentials")}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-60 overflow-y-auto p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
) : filteredCredentials.length === 0 ? (
|
||||||
|
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||||
|
{searchQuery
|
||||||
|
? t("credentials.noCredentialsMatchFilters")
|
||||||
|
: t("credentials.noCredentialsYet")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-2.5">
|
||||||
|
{value && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
{t("common.clear")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{filteredCredentials.map((credential) => (
|
||||||
|
<Button
|
||||||
|
key={credential.id}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
|
||||||
|
credential.id === value ? "bg-muted" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleCredentialSelect(credential)}
|
||||||
|
>
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">
|
||||||
|
{credential.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{credential.username} • {credential.authType}
|
||||||
|
{credential.description &&
|
||||||
|
` • ${credential.description}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,465 +1,533 @@
|
|||||||
import React, {useState, useEffect} from 'react';
|
import React, { useState, useEffect } from "react";
|
||||||
import {Button} from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
|
||||||
import {Badge} from "@/components/ui/badge";
|
|
||||||
import {Separator} from "@/components/ui/separator";
|
|
||||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
|
||||||
import {Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet";
|
|
||||||
import {
|
import {
|
||||||
Key,
|
Card,
|
||||||
User,
|
CardContent,
|
||||||
Calendar,
|
CardDescription,
|
||||||
Hash,
|
CardHeader,
|
||||||
Folder,
|
CardTitle,
|
||||||
Edit3,
|
} from "@/components/ui/card";
|
||||||
Copy,
|
import { Badge } from "@/components/ui/badge";
|
||||||
Shield,
|
import { Separator } from "@/components/ui/separator";
|
||||||
Clock,
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
Server,
|
import {
|
||||||
Eye,
|
Sheet,
|
||||||
EyeOff,
|
SheetContent,
|
||||||
AlertTriangle,
|
SheetFooter,
|
||||||
CheckCircle,
|
SheetHeader,
|
||||||
FileText
|
SheetTitle,
|
||||||
} from 'lucide-react';
|
} from "@/components/ui/sheet";
|
||||||
import {getCredentialDetails, getCredentialHosts} from '@/ui/main-axios';
|
import {
|
||||||
import {toast} from 'sonner';
|
Key,
|
||||||
import {useTranslation} from 'react-i18next';
|
User,
|
||||||
import type {Credential, HostInfo, CredentialViewerProps} from '../../../types/index.js';
|
Calendar,
|
||||||
|
Hash,
|
||||||
|
Folder,
|
||||||
|
Edit3,
|
||||||
|
Copy,
|
||||||
|
Shield,
|
||||||
|
Clock,
|
||||||
|
Server,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type {
|
||||||
|
Credential,
|
||||||
|
HostInfo,
|
||||||
|
CredentialViewerProps,
|
||||||
|
} from "../../../types/index.js";
|
||||||
|
|
||||||
const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose, onEdit}) => {
|
const CredentialViewer: React.FC<CredentialViewerProps> = ({
|
||||||
const {t} = useTranslation();
|
credential,
|
||||||
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(null);
|
onClose,
|
||||||
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
|
onEdit,
|
||||||
const [loading, setLoading] = useState(true);
|
}) => {
|
||||||
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>({});
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'security' | 'usage'>('overview');
|
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [activeTab, setActiveTab] = useState<"overview" | "security" | "usage">(
|
||||||
|
"overview",
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCredentialDetails();
|
fetchCredentialDetails();
|
||||||
fetchHostsUsing();
|
fetchHostsUsing();
|
||||||
}, [credential.id]);
|
}, [credential.id]);
|
||||||
|
|
||||||
const fetchCredentialDetails = async () => {
|
const fetchCredentialDetails = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await getCredentialDetails(credential.id);
|
const response = await getCredentialDetails(credential.id);
|
||||||
setCredentialDetails(response);
|
setCredentialDetails(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t('credentials.failedToFetchCredentialDetails'));
|
toast.error(t("credentials.failedToFetchCredentialDetails"));
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchHostsUsing = async () => {
|
|
||||||
try {
|
|
||||||
const response = await getCredentialHosts(credential.id);
|
|
||||||
setHostsUsing(response);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t('credentials.failedToFetchHostsUsing'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSensitiveVisibility = (field: string) => {
|
|
||||||
setShowSensitive(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: !prev[field]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = async (text: string, fieldName: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
toast.success(t('copiedToClipboard', {field: fieldName}));
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t('credentials.failedToCopy'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAuthIcon = (authType: string) => {
|
|
||||||
return authType === 'password' ? (
|
|
||||||
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
|
||||||
) : (
|
|
||||||
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400"/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSensitiveField = (
|
|
||||||
value: string | undefined,
|
|
||||||
fieldName: string,
|
|
||||||
label: string,
|
|
||||||
isMultiline = false
|
|
||||||
) => {
|
|
||||||
if (!value) return null;
|
|
||||||
|
|
||||||
const isVisible = showSensitive[fieldName];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => toggleSensitiveVisibility(fieldName)}
|
|
||||||
>
|
|
||||||
{isVisible ? <EyeOff className="h-4 w-4"/> : <Eye className="h-4 w-4"/>}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => copyToClipboard(value, label)}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}>
|
|
||||||
{isVisible ? (
|
|
||||||
<pre
|
|
||||||
className={`text-sm ${isMultiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'} font-mono`}>
|
|
||||||
{value}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
|
||||||
{'•'.repeat(isMultiline ? 50 : 20)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading || !credentialDetails) {
|
|
||||||
return (
|
|
||||||
<Sheet open={true} onOpenChange={onClose}>
|
|
||||||
<SheetContent className="w-[600px] max-w-[50vw]">
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchHostsUsing = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getCredentialHosts(credential.id);
|
||||||
|
setHostsUsing(response);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("credentials.failedToFetchHostsUsing"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSensitiveVisibility = (field: string) => {
|
||||||
|
setShowSensitive((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: !prev[field],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, fieldName: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(t("copiedToClipboard", { field: fieldName }));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("credentials.failedToCopy"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAuthIcon = (authType: string) => {
|
||||||
|
return authType === "password" ? (
|
||||||
|
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
|
) : (
|
||||||
|
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSensitiveField = (
|
||||||
|
value: string | undefined,
|
||||||
|
fieldName: string,
|
||||||
|
label: string,
|
||||||
|
isMultiline = false,
|
||||||
|
) => {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const isVisible = showSensitive[fieldName];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={true} onOpenChange={onClose}>
|
<div className="space-y-2">
|
||||||
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
|
<div className="flex items-center justify-between">
|
||||||
<SheetHeader className="space-y-6 pb-8">
|
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
<SheetTitle className="flex items-center space-x-4">
|
{label}
|
||||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
</label>
|
||||||
{getAuthIcon(credentialDetails.authType)}
|
<div className="flex items-center space-x-2">
|
||||||
</div>
|
<Button
|
||||||
<div className="flex-1">
|
variant="ghost"
|
||||||
<div className="text-xl font-semibold">{credentialDetails.name}</div>
|
size="sm"
|
||||||
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
|
onClick={() => toggleSensitiveVisibility(fieldName)}
|
||||||
{credentialDetails.description}
|
>
|
||||||
</div>
|
{isVisible ? (
|
||||||
</div>
|
<EyeOff className="h-4 w-4" />
|
||||||
<div className="flex items-center space-x-2">
|
) : (
|
||||||
<Badge variant="outline"
|
<Eye className="h-4 w-4" />
|
||||||
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
|
)}
|
||||||
{credentialDetails.authType}
|
</Button>
|
||||||
</Badge>
|
<Button
|
||||||
{credentialDetails.keyType && (
|
variant="ghost"
|
||||||
<Badge variant="secondary"
|
size="sm"
|
||||||
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
|
onClick={() => copyToClipboard(value, label)}
|
||||||
{credentialDetails.keyType}
|
>
|
||||||
</Badge>
|
<Copy className="h-4 w-4" />
|
||||||
)}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SheetTitle>
|
</div>
|
||||||
</SheetHeader>
|
<div
|
||||||
|
className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? "" : "min-h-[2.5rem]"}`}
|
||||||
|
>
|
||||||
|
{isVisible ? (
|
||||||
|
<pre
|
||||||
|
className={`text-sm ${isMultiline ? "whitespace-pre-wrap" : "whitespace-nowrap"} font-mono`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{"•".repeat(isMultiline ? 50 : 20)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
<div className="space-y-10">
|
if (loading || !credentialDetails) {
|
||||||
{/* Tab Navigation */}
|
return (
|
||||||
<div
|
<Sheet open={true} onOpenChange={onClose}>
|
||||||
className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
<SheetContent className="w-[600px] max-w-[50vw]">
|
||||||
<Button
|
<div className="flex items-center justify-center h-64">
|
||||||
variant={activeTab === 'overview' ? 'default' : 'ghost'}
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
|
||||||
size="sm"
|
</div>
|
||||||
onClick={() => setActiveTab('overview')}
|
</SheetContent>
|
||||||
className="flex-1 h-10"
|
</Sheet>
|
||||||
>
|
);
|
||||||
<FileText className="h-4 w-4 mr-2"/>
|
}
|
||||||
{t('credentials.overview')}
|
|
||||||
</Button>
|
return (
|
||||||
<Button
|
<Sheet open={true} onOpenChange={onClose}>
|
||||||
variant={activeTab === 'security' ? 'default' : 'ghost'}
|
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
|
||||||
size="sm"
|
<SheetHeader className="space-y-6 pb-8">
|
||||||
onClick={() => setActiveTab('security')}
|
<SheetTitle className="flex items-center space-x-4">
|
||||||
className="flex-1 h-10"
|
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||||
>
|
{getAuthIcon(credentialDetails.authType)}
|
||||||
<Shield className="h-4 w-4 mr-2"/>
|
</div>
|
||||||
{t('credentials.security')}
|
<div className="flex-1">
|
||||||
</Button>
|
<div className="text-xl font-semibold">
|
||||||
<Button
|
{credentialDetails.name}
|
||||||
variant={activeTab === 'usage' ? 'default' : 'ghost'}
|
</div>
|
||||||
size="sm"
|
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
|
||||||
onClick={() => setActiveTab('usage')}
|
{credentialDetails.description}
|
||||||
className="flex-1 h-10"
|
</div>
|
||||||
>
|
</div>
|
||||||
<Server className="h-4 w-4 mr-2"/>
|
<div className="flex items-center space-x-2">
|
||||||
{t('credentials.usage')}
|
<Badge
|
||||||
</Button>
|
variant="outline"
|
||||||
|
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"
|
||||||
|
>
|
||||||
|
{credentialDetails.authType}
|
||||||
|
</Badge>
|
||||||
|
{credentialDetails.keyType && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{credentialDetails.keyType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="space-y-10">
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
||||||
|
<Button
|
||||||
|
variant={activeTab === "overview" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab("overview")}
|
||||||
|
className="flex-1 h-10"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
{t("credentials.overview")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activeTab === "security" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab("security")}
|
||||||
|
className="flex-1 h-10"
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4 mr-2" />
|
||||||
|
{t("credentials.security")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activeTab === "usage" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab("usage")}
|
||||||
|
className="flex-1 h-10"
|
||||||
|
>
|
||||||
|
<Server className="h-4 w-4 mr-2" />
|
||||||
|
{t("credentials.usage")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === "overview" && (
|
||||||
|
<div className="grid gap-10 lg:grid-cols-2">
|
||||||
|
<Card className="border-zinc-200 dark:border-zinc-700">
|
||||||
|
<CardHeader className="pb-8">
|
||||||
|
<CardTitle className="text-lg font-semibold">
|
||||||
|
{t("credentials.basicInformation")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
<div className="flex items-center space-x-5">
|
||||||
|
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{t("common.username")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{credentialDetails.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tab Content */}
|
{credentialDetails.folder && (
|
||||||
{activeTab === 'overview' && (
|
<div className="flex items-center space-x-4">
|
||||||
<div className="grid gap-10 lg:grid-cols-2">
|
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||||
<Card className="border-zinc-200 dark:border-zinc-700">
|
<div>
|
||||||
<CardHeader className="pb-8">
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
<CardTitle
|
{t("common.folder")}
|
||||||
className="text-lg font-semibold">{t('credentials.basicInformation')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-8">
|
|
||||||
<div className="flex items-center space-x-5">
|
|
||||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
|
||||||
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.username')}</div>
|
|
||||||
<div
|
|
||||||
className="font-medium text-zinc-800 dark:text-zinc-200">{credentialDetails.username}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{credentialDetails.folder && (
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.folder')}</div>
|
|
||||||
<div className="font-medium">{credentialDetails.folder}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{credentialDetails.tags.length > 0 && (
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1"/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div
|
|
||||||
className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">{t('hosts.tags')}</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{credentialDetails.tags.map((tag, index) => (
|
|
||||||
<Badge key={index} variant="outline" className="text-xs">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Separator/>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.created')}</div>
|
|
||||||
<div className="font-medium">{formatDate(credentialDetails.createdAt)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastModified')}</div>
|
|
||||||
<div className="font-medium">{formatDate(credentialDetails.updatedAt)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">{t('credentials.usageStatistics')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
|
||||||
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
|
|
||||||
{credentialDetails.usageCount}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
|
||||||
{t('credentials.timesUsed')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{credentialDetails.lastUsed && (
|
|
||||||
<div
|
|
||||||
className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
|
||||||
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastUsed')}</div>
|
|
||||||
<div
|
|
||||||
className="font-medium">{formatDate(credentialDetails.lastUsed)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
|
||||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.connectedHosts')}</div>
|
|
||||||
<div className="font-medium">{hostsUsing.length}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="font-medium">
|
||||||
|
{credentialDetails.folder}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'security' && (
|
{credentialDetails.tags.length > 0 && (
|
||||||
<Card>
|
<div className="flex items-start space-x-4">
|
||||||
<CardHeader>
|
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
|
||||||
<CardTitle className="text-lg flex items-center space-x-2">
|
<div className="flex-1">
|
||||||
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
<div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">
|
||||||
<span>{t('credentials.securityDetails')}</span>
|
{t("hosts.tags")}
|
||||||
</CardTitle>
|
</div>
|
||||||
<CardDescription>
|
<div className="flex flex-wrap gap-2">
|
||||||
{t('credentials.securityDetailsDescription')}
|
{credentialDetails.tags.map((tag, index) => (
|
||||||
</CardDescription>
|
<Badge
|
||||||
</CardHeader>
|
key={index}
|
||||||
<CardContent className="space-y-6">
|
variant="outline"
|
||||||
<div
|
className="text-xs"
|
||||||
className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
>
|
||||||
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400"/>
|
{tag}
|
||||||
<div>
|
</Badge>
|
||||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
))}
|
||||||
{t('credentials.credentialSecured')}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-zinc-700 dark:text-zinc-300">
|
</div>
|
||||||
{t('credentials.credentialSecuredDescription')}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{credentialDetails.authType === 'password' && (
|
<Separator />
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold mb-4">{t('credentials.passwordAuthentication')}</h3>
|
|
||||||
{renderSensitiveField(credentialDetails.password, 'password', t('common.password'))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{credentialDetails.authType === 'key' && (
|
<div className="flex items-center space-x-4">
|
||||||
<div className="space-y-6">
|
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||||
<h3 className="font-semibold mb-2">{t('credentials.keyAuthentication')}</h3>
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{t("credentials.created")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatDate(credentialDetails.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="flex items-center space-x-4">
|
||||||
<div>
|
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||||
<div
|
<div>
|
||||||
className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
{t('credentials.keyType')}
|
{t("credentials.lastModified")}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-sm">
|
<div className="font-medium">
|
||||||
{credentialDetails.keyType?.toUpperCase() || t('unknown').toUpperCase()}
|
{formatDate(credentialDetails.updatedAt)}
|
||||||
</Badge>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{renderSensitiveField(credentialDetails.key, 'key', t('credentials.privateKey'), true)}
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{t("credentials.usageStatistics")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||||
|
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
|
||||||
|
{credentialDetails.usageCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{t("credentials.timesUsed")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{credentialDetails.keyPassword && renderSensitiveField(
|
{credentialDetails.lastUsed && (
|
||||||
credentialDetails.keyPassword,
|
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||||
'keyPassword',
|
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
t('credentials.keyPassphrase')
|
<div>
|
||||||
)}
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
</div>
|
{t("credentials.lastUsed")}
|
||||||
)}
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatDate(credentialDetails.lastUsed)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||||
className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5"/>
|
<div>
|
||||||
<div className="text-sm">
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
|
{t("credentials.connectedHosts")}
|
||||||
{t('credentials.securityReminder')}
|
</div>
|
||||||
</div>
|
<div className="font-medium">{hostsUsing.length}</div>
|
||||||
<div className="text-zinc-700 dark:text-zinc-300">
|
</div>
|
||||||
{t('credentials.securityReminderText')}
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'usage' && (
|
{activeTab === "security" && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center space-x-2">
|
<CardTitle className="text-lg flex items-center space-x-2">
|
||||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
<span>{t('credentials.hostsUsingCredential')}</span>
|
<span>{t("credentials.securityDetails")}</span>
|
||||||
<Badge variant="secondary">{hostsUsing.length}</Badge>
|
</CardTitle>
|
||||||
</CardTitle>
|
<CardDescription>
|
||||||
</CardHeader>
|
{t("credentials.securityDetailsDescription")}
|
||||||
<CardContent>
|
</CardDescription>
|
||||||
{hostsUsing.length === 0 ? (
|
</CardHeader>
|
||||||
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
|
<CardContent className="space-y-6">
|
||||||
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600"/>
|
<div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||||
<p>{t('credentials.noHostsUsingCredential')}</p>
|
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
|
||||||
</div>
|
<div>
|
||||||
) : (
|
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
<ScrollArea className="h-64">
|
{t("credentials.credentialSecured")}
|
||||||
<div className="space-y-3">
|
</div>
|
||||||
{hostsUsing.map((host) => (
|
<div className="text-sm text-zinc-700 dark:text-zinc-300">
|
||||||
<div
|
{t("credentials.credentialSecuredDescription")}
|
||||||
key={host.id}
|
</div>
|
||||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
</div>
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
|
|
||||||
<Server
|
|
||||||
className="h-4 w-4 text-zinc-600 dark:text-zinc-400"/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{host.name || `${host.ip}:${host.port}`}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
|
||||||
{host.ip}:{host.port}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-right text-sm text-zinc-500 dark:text-zinc-400">
|
|
||||||
{formatDate(host.createdAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter>
|
{credentialDetails.authType === "password" && (
|
||||||
<Button variant="outline" onClick={onClose}>
|
<div>
|
||||||
{t('common.close')}
|
<h3 className="font-semibold mb-4">
|
||||||
</Button>
|
{t("credentials.passwordAuthentication")}
|
||||||
<Button onClick={onEdit}>
|
</h3>
|
||||||
<Edit3 className="h-4 w-4 mr-2"/>
|
{renderSensitiveField(
|
||||||
{t('credentials.editCredential')}
|
credentialDetails.password,
|
||||||
</Button>
|
"password",
|
||||||
</SheetFooter>
|
t("common.password"),
|
||||||
</SheetContent>
|
)}
|
||||||
</Sheet>
|
</div>
|
||||||
);
|
)}
|
||||||
|
|
||||||
|
{credentialDetails.authType === "key" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="font-semibold mb-2">
|
||||||
|
{t("credentials.keyAuthentication")}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||||
|
{t("credentials.keyType")}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-sm">
|
||||||
|
{credentialDetails.keyType?.toUpperCase() ||
|
||||||
|
t("unknown").toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderSensitiveField(
|
||||||
|
credentialDetails.key,
|
||||||
|
"key",
|
||||||
|
t("credentials.privateKey"),
|
||||||
|
true,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{credentialDetails.keyPassword &&
|
||||||
|
renderSensitiveField(
|
||||||
|
credentialDetails.keyPassword,
|
||||||
|
"keyPassword",
|
||||||
|
t("credentials.keyPassphrase"),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
|
||||||
|
{t("credentials.securityReminder")}
|
||||||
|
</div>
|
||||||
|
<div className="text-zinc-700 dark:text-zinc-300">
|
||||||
|
{t("credentials.securityReminderText")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "usage" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center space-x-2">
|
||||||
|
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||||
|
<span>{t("credentials.hostsUsingCredential")}</span>
|
||||||
|
<Badge variant="secondary">{hostsUsing.length}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{hostsUsing.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
|
||||||
|
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
|
||||||
|
<p>{t("credentials.noHostsUsingCredential")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{hostsUsing.map((host) => (
|
||||||
|
<div
|
||||||
|
key={host.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
|
||||||
|
<Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{host.name || `${host.ip}:${host.port}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{host.ip}:{host.port}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{formatDate(host.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t("common.close")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onEdit}>
|
||||||
|
<Edit3 className="h-4 w-4 mr-2" />
|
||||||
|
{t("credentials.editCredential")}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CredentialViewer;
|
export default CredentialViewer;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,26 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {FileManagerTabList} from "./FileManagerTabList.tsx";
|
import { FileManagerTabList } from "./FileManagerTabList.tsx";
|
||||||
|
|
||||||
interface FileManagerTopNavbarProps {
|
interface FileManagerTopNavbarProps {
|
||||||
tabs: { id: string | number, title: string }[];
|
tabs: { id: string | number; title: string }[];
|
||||||
activeTab: string | number;
|
activeTab: string | number;
|
||||||
setActiveTab: (tab: string | number) => void;
|
setActiveTab: (tab: string | number) => void;
|
||||||
closeTab: (tab: string | number) => void;
|
closeTab: (tab: string | number) => void;
|
||||||
onHomeClick: () => void;
|
onHomeClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement {
|
export function FIleManagerTopNavbar(
|
||||||
const {tabs, activeTab, setActiveTab, closeTab, onHomeClick} = props;
|
props: FileManagerTopNavbarProps,
|
||||||
|
): React.ReactElement {
|
||||||
|
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileManagerTabList
|
<FileManagerTabList
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
closeTab={closeTab}
|
closeTab={closeTab}
|
||||||
onHomeClick={onHomeClick}
|
onHomeClick={onHomeClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,335 +1,338 @@
|
|||||||
import React, {useEffect} from "react";
|
import React, { useEffect } from "react";
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
import {loadLanguage} from '@uiw/codemirror-extensions-langs';
|
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||||
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
|
import { hyperLink } from "@uiw/codemirror-extensions-hyper-link";
|
||||||
import {oneDark} from '@codemirror/theme-one-dark';
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
import {EditorView} from '@codemirror/view';
|
import { EditorView } from "@codemirror/view";
|
||||||
|
|
||||||
interface FileManagerCodeEditorProps {
|
interface FileManagerCodeEditorProps {
|
||||||
content: string;
|
content: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
onContentChange: (value: string) => void;
|
onContentChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileManagerFileEditor({content, fileName, onContentChange}: FileManagerCodeEditorProps) {
|
export function FileManagerFileEditor({
|
||||||
function getLanguageName(filename: string): string {
|
content,
|
||||||
if (!filename || typeof filename !== 'string') {
|
fileName,
|
||||||
return 'text';
|
onContentChange,
|
||||||
}
|
}: FileManagerCodeEditorProps) {
|
||||||
const lastDotIndex = filename.lastIndexOf('.');
|
function getLanguageName(filename: string): string {
|
||||||
if (lastDotIndex === -1) {
|
if (!filename || typeof filename !== "string") {
|
||||||
return 'text';
|
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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const lastDotIndex = filename.lastIndexOf(".");
|
||||||
|
if (lastDotIndex === -1) {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
|
||||||
|
|
||||||
useEffect(() => {
|
switch (ext) {
|
||||||
document.body.style.overflowX = 'hidden';
|
case "ng":
|
||||||
return () => {
|
return "angular";
|
||||||
document.body.style.overflowX = '';
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="w-full h-full relative overflow-hidden flex flex-col">
|
document.body.style.overflowX = "hidden";
|
||||||
<div
|
return () => {
|
||||||
className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper"
|
document.body.style.overflowX = "";
|
||||||
>
|
};
|
||||||
<CodeMirror
|
}, []);
|
||||||
value={content}
|
|
||||||
extensions={[
|
return (
|
||||||
loadLanguage(getLanguageName(fileName || 'untitled.txt') as any) || [],
|
<div className="w-full h-full relative overflow-hidden flex flex-col">
|
||||||
hyperLink,
|
<div className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper">
|
||||||
oneDark,
|
<CodeMirror
|
||||||
EditorView.theme({
|
value={content}
|
||||||
'&': {
|
extensions={[
|
||||||
backgroundColor: 'var(--color-dark-bg-darkest) !important',
|
loadLanguage(getLanguageName(fileName || "untitled.txt") as any) ||
|
||||||
},
|
[],
|
||||||
'.cm-gutters': {
|
hyperLink,
|
||||||
backgroundColor: 'var(--color-dark-bg) !important',
|
oneDark,
|
||||||
},
|
EditorView.theme({
|
||||||
})
|
"&": {
|
||||||
]}
|
backgroundColor: "var(--color-dark-bg-darkest) !important",
|
||||||
onChange={(value: any) => onContentChange(value)}
|
},
|
||||||
theme={undefined}
|
".cm-gutters": {
|
||||||
height="100%"
|
backgroundColor: "var(--color-dark-bg) !important",
|
||||||
basicSetup={{lineNumbers: true}}
|
},
|
||||||
className="min-h-full min-w-full flex-1"
|
}),
|
||||||
/>
|
]}
|
||||||
</div>
|
onChange={(value: any) => onContentChange(value)}
|
||||||
</div>
|
theme={undefined}
|
||||||
);
|
height="100%"
|
||||||
}
|
basicSetup={{ lineNumbers: true }}
|
||||||
|
className="min-h-full min-w-full flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,201 +1,234 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
|
import { Trash2, Folder, File, Plus, Pin } from "lucide-react";
|
||||||
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
|
import {
|
||||||
import {Input} from '@/components/ui/input.tsx';
|
Tabs,
|
||||||
import {useState} from 'react';
|
TabsList,
|
||||||
import {useTranslation} from 'react-i18next';
|
TabsTrigger,
|
||||||
import type {FileItem, ShortcutItem} from '../../../types/index';
|
TabsContent,
|
||||||
|
} from "@/components/ui/tabs.tsx";
|
||||||
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { FileItem, ShortcutItem } from "../../../types/index";
|
||||||
|
|
||||||
interface FileManagerHomeViewProps {
|
interface FileManagerHomeViewProps {
|
||||||
recent: FileItem[];
|
recent: FileItem[];
|
||||||
pinned: FileItem[];
|
pinned: FileItem[];
|
||||||
shortcuts: ShortcutItem[];
|
shortcuts: ShortcutItem[];
|
||||||
onOpenFile: (file: FileItem) => void;
|
onOpenFile: (file: FileItem) => void;
|
||||||
onRemoveRecent: (file: FileItem) => void;
|
onRemoveRecent: (file: FileItem) => void;
|
||||||
onPinFile: (file: FileItem) => void;
|
onPinFile: (file: FileItem) => void;
|
||||||
onUnpinFile: (file: FileItem) => void;
|
onUnpinFile: (file: FileItem) => void;
|
||||||
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
||||||
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
||||||
onAddShortcut: (path: string) => void;
|
onAddShortcut: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileManagerHomeView({
|
export function FileManagerHomeView({
|
||||||
recent,
|
recent,
|
||||||
pinned,
|
pinned,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
onRemoveRecent,
|
onRemoveRecent,
|
||||||
onPinFile,
|
onPinFile,
|
||||||
onUnpinFile,
|
onUnpinFile,
|
||||||
onOpenShortcut,
|
onOpenShortcut,
|
||||||
onRemoveShortcut,
|
onRemoveShortcut,
|
||||||
onAddShortcut
|
onAddShortcut,
|
||||||
}: FileManagerHomeViewProps) {
|
}: FileManagerHomeViewProps) {
|
||||||
const {t} = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
const [tab, setTab] = useState<"recent" | "pinned" | "shortcuts">("recent");
|
||||||
const [newShortcut, setNewShortcut] = useState('');
|
const [newShortcut, setNewShortcut] = useState("");
|
||||||
|
|
||||||
|
const renderFileCard = (
|
||||||
|
file: FileItem,
|
||||||
|
onRemove: () => void,
|
||||||
|
onPin?: () => void,
|
||||||
|
isPinned = false,
|
||||||
|
) => (
|
||||||
|
<div
|
||||||
|
key={file.path}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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-dark-bg-button hover:bg-dark-hover 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-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
|
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
||||||
<div key={file.path}
|
<div
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors">
|
key={shortcut.path}
|
||||||
<div
|
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
|
||||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
>
|
||||||
onClick={() => onOpenFile(file)}
|
<div
|
||||||
>
|
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||||
{file.type === 'directory' ?
|
onClick={() => onOpenShortcut(shortcut)}
|
||||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
>
|
||||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>
|
<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-dark-bg-button hover:bg-dark-hover 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-dark-bg-darkest">
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => setTab(v as "recent" | "pinned" | "shortcuts")}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
||||||
|
<TabsTrigger
|
||||||
|
value="recent"
|
||||||
|
className="data-[state=active]:bg-dark-bg-button"
|
||||||
|
>
|
||||||
|
{t("fileManager.recent")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="pinned"
|
||||||
|
className="data-[state=active]:bg-dark-bg-button"
|
||||||
|
>
|
||||||
|
{t("fileManager.pinned")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="shortcuts"
|
||||||
|
className="data-[state=active]:bg-dark-bg-button"
|
||||||
|
>
|
||||||
|
{t("fileManager.folderShortcuts")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<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-dark-bg border-2 border-dark-border rounded-lg">
|
||||||
|
<Input
|
||||||
|
placeholder={t("fileManager.enterFolderPath")}
|
||||||
|
value={newShortcut}
|
||||||
|
onChange={(e) => setNewShortcut(e.target.value)}
|
||||||
|
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && newShortcut.trim()) {
|
||||||
|
onAddShortcut(newShortcut.trim());
|
||||||
|
setNewShortcut("");
|
||||||
}
|
}
|
||||||
<div className="flex-1 min-w-0">
|
}}
|
||||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
/>
|
||||||
{file.name}
|
<Button
|
||||||
</div>
|
size="sm"
|
||||||
</div>
|
variant="ghost"
|
||||||
</div>
|
className="h-8 px-2 bg-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover rounded-md"
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
onClick={() => {
|
||||||
{onPin && (
|
if (newShortcut.trim()) {
|
||||||
<Button
|
onAddShortcut(newShortcut.trim());
|
||||||
size="sm"
|
setNewShortcut("");
|
||||||
variant="ghost"
|
}
|
||||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover 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-dark-bg-button hover:bg-dark-hover 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-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover 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"/>
|
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||||
<div className="flex-1 min-w-0">
|
{t("common.add")}
|
||||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
</Button>
|
||||||
{shortcut.path}
|
</div>
|
||||||
</div>
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
</div>
|
{shortcuts.length === 0 ? (
|
||||||
</div>
|
<div className="flex items-center justify-center py-4 col-span-full">
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<span className="text-sm text-muted-foreground">
|
||||||
<Button
|
{t("fileManager.noShortcuts")}
|
||||||
size="sm"
|
</span>
|
||||||
variant="ghost"
|
</div>
|
||||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
) : (
|
||||||
onClick={() => onRemoveShortcut(shortcut)}
|
shortcuts.map((shortcut) => renderShortcutCard(shortcut))
|
||||||
>
|
)}
|
||||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
</div>
|
||||||
</Button>
|
</TabsContent>
|
||||||
</div>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return (
|
|
||||||
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
|
|
||||||
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
|
||||||
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
|
||||||
<TabsTrigger value="recent"
|
|
||||||
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.recent')}</TabsTrigger>
|
|
||||||
<TabsTrigger value="pinned"
|
|
||||||
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.pinned')}</TabsTrigger>
|
|
||||||
<TabsTrigger value="shortcuts"
|
|
||||||
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.folderShortcuts')}</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<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-dark-bg border-2 border-dark-border rounded-lg">
|
|
||||||
<Input
|
|
||||||
placeholder={t('fileManager.enterFolderPath')}
|
|
||||||
value={newShortcut}
|
|
||||||
onChange={e => setNewShortcut(e.target.value)}
|
|
||||||
className="flex-1 bg-dark-bg-button border-2 border-dark-border 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-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +1,128 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {Card} from '@/components/ui/card.tsx';
|
import { Card } from "@/components/ui/card.tsx";
|
||||||
import {Folder, File, Trash2, Pin} from 'lucide-react';
|
import { Folder, File, Trash2, Pin } from "lucide-react";
|
||||||
import {useTranslation} from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SSHConnection {
|
interface SSHConnection {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'file' | 'directory' | 'link';
|
type: "file" | "directory" | "link";
|
||||||
path: string;
|
path: string;
|
||||||
isStarred?: boolean;
|
isStarred?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileManagerLeftSidebarVileViewerProps {
|
interface FileManagerLeftSidebarVileViewerProps {
|
||||||
sshConnections: SSHConnection[];
|
sshConnections: SSHConnection[];
|
||||||
onAddSSH: () => void;
|
onAddSSH: () => void;
|
||||||
onConnectSSH: (conn: SSHConnection) => void;
|
onConnectSSH: (conn: SSHConnection) => void;
|
||||||
onEditSSH: (conn: SSHConnection) => void;
|
onEditSSH: (conn: SSHConnection) => void;
|
||||||
onDeleteSSH: (conn: SSHConnection) => void;
|
onDeleteSSH: (conn: SSHConnection) => void;
|
||||||
onPinSSH: (conn: SSHConnection) => void;
|
onPinSSH: (conn: SSHConnection) => void;
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
files: FileItem[];
|
files: FileItem[];
|
||||||
onOpenFile: (file: FileItem) => void;
|
onOpenFile: (file: FileItem) => void;
|
||||||
onOpenFolder: (folder: FileItem) => void;
|
onOpenFolder: (folder: FileItem) => void;
|
||||||
onStarFile: (file: FileItem) => void;
|
onStarFile: (file: FileItem) => void;
|
||||||
onDeleteFile: (file: FileItem) => void;
|
onDeleteFile: (file: FileItem) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
isSSHMode: boolean;
|
isSSHMode: boolean;
|
||||||
onSwitchToLocal: () => void;
|
onSwitchToLocal: () => void;
|
||||||
onSwitchToSSH: (conn: SSHConnection) => void;
|
onSwitchToSSH: (conn: SSHConnection) => void;
|
||||||
currentSSH?: SSHConnection;
|
currentSSH?: SSHConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileManagerLeftSidebarFileViewer({
|
export function FileManagerLeftSidebarFileViewer({
|
||||||
currentPath,
|
currentPath,
|
||||||
files,
|
files,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onStarFile,
|
onStarFile,
|
||||||
onDeleteFile,
|
onDeleteFile,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
isSSHMode,
|
isSSHMode,
|
||||||
}: FileManagerLeftSidebarVileViewerProps) {
|
}: FileManagerLeftSidebarVileViewerProps) {
|
||||||
const {t} = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
|
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span
|
<span className="text-xs text-muted-foreground font-semibold">
|
||||||
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? t('common.sshPath') : t('common.localPath')}</span>
|
{isSSHMode ? t("common.sshPath") : t("common.localPath")}
|
||||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
</span>
|
||||||
</div>
|
<span className="text-xs text-white truncate">{currentPath}</span>
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-xs text-muted-foreground">{t('common.loading')}</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-xs text-red-500">{error}</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{files.map((item) => (
|
|
||||||
<Card key={item.path}
|
|
||||||
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded">
|
|
||||||
<div className="flex items-center gap-2 flex-1 cursor-pointer"
|
|
||||||
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
|
|
||||||
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :
|
|
||||||
<File className="w-4 h-4 text-muted-foreground"/>}
|
|
||||||
<span className="text-sm text-white truncate">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
|
||||||
onClick={() => onStarFile(item)}>
|
|
||||||
<Pin
|
|
||||||
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
|
||||||
onClick={() => onDeleteFile(item)}>
|
|
||||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
{files.length === 0 &&
|
|
||||||
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
{isLoading ? (
|
||||||
}
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-xs text-red-500">{error}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{files.map((item) => (
|
||||||
|
<Card
|
||||||
|
key={item.path}
|
||||||
|
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
item.type === "directory"
|
||||||
|
? onOpenFolder(item)
|
||||||
|
: onOpenFile(item)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.type === "directory" ? (
|
||||||
|
<Folder className="w-4 h-4 text-blue-400" />
|
||||||
|
) : (
|
||||||
|
<File className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-white truncate">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => onStarFile(item)}
|
||||||
|
>
|
||||||
|
<Pin
|
||||||
|
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => onDeleteFile(item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{files.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
No files or folders found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,62 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {X, Home} from 'lucide-react';
|
import { X, Home } from "lucide-react";
|
||||||
|
|
||||||
interface FileManagerTab {
|
interface FileManagerTab {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileManagerTabList {
|
interface FileManagerTabList {
|
||||||
tabs: FileManagerTab[];
|
tabs: FileManagerTab[];
|
||||||
activeTab: string | number;
|
activeTab: string | number;
|
||||||
setActiveTab: (tab: string | number) => void;
|
setActiveTab: (tab: string | number) => void;
|
||||||
closeTab: (tab: string | number) => void;
|
closeTab: (tab: string | number) => void;
|
||||||
onHomeClick: () => void;
|
onHomeClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) {
|
export function FileManagerTabList({
|
||||||
return (
|
tabs,
|
||||||
<div className="inline-flex items-center h-full gap-2">
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
closeTab,
|
||||||
|
onHomeClick,
|
||||||
|
}: FileManagerTabList) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center h-full gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={onHomeClick}
|
||||||
|
variant="outline"
|
||||||
|
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === "home" ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = tab.id === activeTab;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className="inline-flex rounded-md shadow-sm"
|
||||||
|
role="group"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={onHomeClick}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === 'home' ? '!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
|
||||||
>
|
>
|
||||||
<Home className="w-4 h-4"/>
|
{tab.title}
|
||||||
</Button>
|
</Button>
|
||||||
{tabs.map((tab) => {
|
|
||||||
const isActive = tab.id === activeTab;
|
|
||||||
return (
|
|
||||||
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
|
|
||||||
<Button
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
variant="outline"
|
|
||||||
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
|
||||||
>
|
|
||||||
{tab.title}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => closeTab(tab.id)}
|
onClick={() => closeTab(tab.id)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
|
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
|
||||||
>
|
>
|
||||||
<X className="!w-4 !h-4" strokeWidth={2}/>
|
<X className="!w-4 !h-4" strokeWidth={2} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,114 +1,142 @@
|
|||||||
import React, {useState} from "react";
|
import React, { useState } from "react";
|
||||||
import {HostManagerViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx"
|
import { HostManagerViewer } from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx";
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
import {
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
Tabs,
|
||||||
import {HostManagerEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx";
|
TabsContent,
|
||||||
import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
|
TabsList,
|
||||||
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
|
TabsTrigger,
|
||||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
} from "@/components/ui/tabs.tsx";
|
||||||
import {useTranslation} from "react-i18next";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import type {SSHHost, HostManagerProps} from '../../../types/index';
|
import { HostManagerEditor } from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx";
|
||||||
|
import { CredentialsManager } from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
|
||||||
|
import { CredentialEditor } from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
|
||||||
|
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { SSHHost, HostManagerProps } from "../../../types/index";
|
||||||
|
|
||||||
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
|
export function HostManager({
|
||||||
const {t} = useTranslation();
|
onSelectView,
|
||||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
isTopbarOpen,
|
||||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
}: HostManagerProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||||
|
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||||
|
|
||||||
const [editingCredential, setEditingCredential] = useState<any | null>(null);
|
const [editingCredential, setEditingCredential] = useState<any | null>(null);
|
||||||
const {state: sidebarState} = useSidebar();
|
const { state: sidebarState } = useSidebar();
|
||||||
|
|
||||||
const handleEditHost = (host: SSHHost) => {
|
const handleEditHost = (host: SSHHost) => {
|
||||||
setEditingHost(host);
|
setEditingHost(host);
|
||||||
setActiveTab("add_host");
|
setActiveTab("add_host");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = (updatedHost?: SSHHost) => {
|
const handleFormSubmit = (updatedHost?: SSHHost) => {
|
||||||
setEditingHost(null);
|
setEditingHost(null);
|
||||||
setActiveTab("host_viewer");
|
setActiveTab("host_viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditCredential = (credential: any) => {
|
const handleEditCredential = (credential: any) => {
|
||||||
setEditingCredential(credential);
|
setEditingCredential(credential);
|
||||||
setActiveTab("add_credential");
|
setActiveTab("add_credential");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCredentialFormSubmit = () => {
|
const handleCredentialFormSubmit = () => {
|
||||||
setEditingCredential(null);
|
setEditingCredential(null);
|
||||||
setActiveTab("credentials");
|
setActiveTab("credentials");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
setActiveTab(value);
|
||||||
|
if (value !== "add_host") {
|
||||||
|
setEditingHost(null);
|
||||||
|
}
|
||||||
|
if (value !== "add_credential") {
|
||||||
|
setEditingCredential(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTabChange = (value: string) => {
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
setActiveTab(value);
|
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||||
if (value !== "add_host") {
|
const bottomMarginPx = 8;
|
||||||
setEditingHost(null);
|
|
||||||
}
|
|
||||||
if (value !== "add_credential") {
|
|
||||||
setEditingCredential(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
return (
|
||||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
<div>
|
||||||
const bottomMarginPx = 8;
|
<div className="w-full">
|
||||||
|
<div
|
||||||
return (
|
className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden"
|
||||||
<div>
|
style={{
|
||||||
<div className="w-full">
|
marginLeft: leftMarginPx,
|
||||||
<div
|
marginRight: 17,
|
||||||
className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden"
|
marginTop: topMarginPx,
|
||||||
style={{
|
marginBottom: bottomMarginPx,
|
||||||
marginLeft: leftMarginPx,
|
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||||
marginRight: 17,
|
}}
|
||||||
marginTop: topMarginPx,
|
>
|
||||||
marginBottom: bottomMarginPx,
|
<Tabs
|
||||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
|
value={activeTab}
|
||||||
}}
|
onValueChange={handleTabChange}
|
||||||
>
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange}
|
>
|
||||||
className="flex-1 flex flex-col h-full min-h-0">
|
<TabsList className="bg-dark-bg border-2 border-dark-border mt-1.5">
|
||||||
<TabsList className="bg-dark-bg border-2 border-dark-border mt-1.5">
|
<TabsTrigger value="host_viewer">
|
||||||
<TabsTrigger value="host_viewer">{t('hosts.hostViewer')}</TabsTrigger>
|
{t("hosts.hostViewer")}
|
||||||
<TabsTrigger value="add_host">
|
</TabsTrigger>
|
||||||
{editingHost ? t('hosts.editHost') : t('hosts.addHost')}
|
<TabsTrigger value="add_host">
|
||||||
</TabsTrigger>
|
{editingHost ? t("hosts.editHost") : t("hosts.addHost")}
|
||||||
<div className="h-6 w-px bg-dark-border mx-1"></div>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="credentials">{t('credentials.credentialsViewer')}</TabsTrigger>
|
<div className="h-6 w-px bg-dark-border mx-1"></div>
|
||||||
<TabsTrigger value="add_credential">
|
<TabsTrigger value="credentials">
|
||||||
{editingCredential ? t('credentials.editCredential') : t('credentials.addCredential')}
|
{t("credentials.credentialsViewer")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="add_credential">
|
||||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
{editingCredential
|
||||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
? t("credentials.editCredential")
|
||||||
<HostManagerViewer onEditHost={handleEditHost}/>
|
: t("credentials.addCredential")}
|
||||||
</TabsContent>
|
</TabsTrigger>
|
||||||
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
|
</TabsList>
|
||||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
<TabsContent
|
||||||
<div className="flex flex-col h-full min-h-0">
|
value="host_viewer"
|
||||||
<HostManagerEditor
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
editingHost={editingHost}
|
>
|
||||||
onFormSubmit={handleFormSubmit}
|
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||||
/>
|
<HostManagerViewer onEditHost={handleEditHost} />
|
||||||
</div>
|
</TabsContent>
|
||||||
</TabsContent>
|
<TabsContent
|
||||||
<TabsContent value="credentials" className="flex-1 flex flex-col h-full min-h-0">
|
value="add_host"
|
||||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
<div className="flex flex-col h-full min-h-0 overflow-auto">
|
>
|
||||||
<CredentialsManager onEditCredential={handleEditCredential}/>
|
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||||
</div>
|
<div className="flex flex-col h-full min-h-0">
|
||||||
</TabsContent>
|
<HostManagerEditor
|
||||||
<TabsContent value="add_credential" className="flex-1 flex flex-col h-full min-h-0">
|
editingHost={editingHost}
|
||||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
onFormSubmit={handleFormSubmit}
|
||||||
<div className="flex flex-col h-full min-h-0">
|
/>
|
||||||
<CredentialEditor
|
</div>
|
||||||
editingCredential={editingCredential}
|
</TabsContent>
|
||||||
onFormSubmit={handleCredentialFormSubmit}
|
<TabsContent
|
||||||
/>
|
value="credentials"
|
||||||
</div>
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
</TabsContent>
|
>
|
||||||
</Tabs>
|
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||||
</div>
|
<div className="flex flex-col h-full min-h-0 overflow-auto">
|
||||||
</div>
|
<CredentialsManager onEditCredential={handleEditCredential} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value="add_credential"
|
||||||
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
|
>
|
||||||
|
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<CredentialEditor
|
||||||
|
editingCredential={editingCredential}
|
||||||
|
onFormSubmit={handleCredentialFormSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,418 +1,478 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {Progress} from "@/components/ui/progress.tsx"
|
import { Progress } from "@/components/ui/progress.tsx";
|
||||||
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
import { Cpu, HardDrive, MemoryStick } from "lucide-react";
|
||||||
import {Tunnel} from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
||||||
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts";
|
import {
|
||||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
getServerStatusById,
|
||||||
import {useTranslation} from 'react-i18next';
|
getServerMetricsById,
|
||||||
import {toast} from 'sonner';
|
type ServerMetrics,
|
||||||
|
} from "@/ui/main-axios.ts";
|
||||||
|
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface ServerProps {
|
interface ServerProps {
|
||||||
hostConfig?: any;
|
hostConfig?: any;
|
||||||
title?: string;
|
title?: string;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
isTopbarOpen?: boolean;
|
isTopbarOpen?: boolean;
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Server({
|
export function Server({
|
||||||
hostConfig,
|
hostConfig,
|
||||||
title,
|
title,
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
isTopbarOpen = true,
|
isTopbarOpen = true,
|
||||||
embedded = false
|
embedded = false,
|
||||||
}: ServerProps): React.ReactElement {
|
}: ServerProps): React.ReactElement {
|
||||||
const {t} = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {state: sidebarState} = useSidebar();
|
const { state: sidebarState } = useSidebar();
|
||||||
const {addTab, tabs} = useTabs() as any;
|
const { addTab, tabs } = useTabs() as any;
|
||||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
||||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
"offline",
|
||||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
);
|
||||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||||
|
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||||
|
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setCurrentHostConfig(hostConfig);
|
setCurrentHostConfig(hostConfig);
|
||||||
}, [hostConfig]);
|
}, [hostConfig]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const fetchLatestHostConfig = async () => {
|
const fetchLatestHostConfig = async () => {
|
||||||
if (hostConfig?.id) {
|
if (hostConfig?.id) {
|
||||||
try {
|
try {
|
||||||
const {getSSHHosts} = await import('@/ui/main-axios.ts');
|
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||||
const hosts = await getSSHHosts();
|
const hosts = await getSSHHosts();
|
||||||
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||||
if (updatedHost) {
|
if (updatedHost) {
|
||||||
setCurrentHostConfig(updatedHost);
|
setCurrentHostConfig(updatedHost);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t('serverStats.failedToFetchHostConfig'));
|
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchLatestHostConfig();
|
|
||||||
|
|
||||||
const handleHostsChanged = async () => {
|
|
||||||
if (hostConfig?.id) {
|
|
||||||
try {
|
|
||||||
const {getSSHHosts} = await import('@/ui/main-axios.ts');
|
|
||||||
const hosts = await getSSHHosts();
|
|
||||||
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
|
||||||
if (updatedHost) {
|
|
||||||
setCurrentHostConfig(updatedHost);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t('serverStats.failedToFetchHostConfig'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged);
|
|
||||||
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged);
|
|
||||||
}, [hostConfig?.id]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
let intervalId: number | undefined;
|
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
|
||||||
try {
|
|
||||||
const res = await getServerStatusById(currentHostConfig?.id);
|
|
||||||
if (!cancelled) {
|
|
||||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
if (!cancelled) {
|
|
||||||
if (error?.response?.status === 503) {
|
|
||||||
setServerStatus('offline');
|
|
||||||
} else if (error?.response?.status === 504) {
|
|
||||||
setServerStatus('offline');
|
|
||||||
} else if (error?.response?.status === 404) {
|
|
||||||
setServerStatus('offline');
|
|
||||||
} else {
|
|
||||||
setServerStatus('offline');
|
|
||||||
}
|
|
||||||
toast.error(t('serverStats.failedToFetchStatus'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
|
||||||
if (!currentHostConfig?.id) return;
|
|
||||||
try {
|
|
||||||
setIsLoadingMetrics(true);
|
|
||||||
const data = await getServerMetricsById(currentHostConfig.id);
|
|
||||||
if (!cancelled) {
|
|
||||||
setMetrics(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setMetrics(null);
|
|
||||||
toast.error(t('serverStats.failedToFetchMetrics'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoadingMetrics(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (currentHostConfig?.id && isVisible) {
|
|
||||||
fetchStatus();
|
|
||||||
fetchMetrics();
|
|
||||||
intervalId = window.setInterval(() => {
|
|
||||||
if (isVisible) {
|
|
||||||
fetchStatus();
|
|
||||||
fetchMetrics();
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return () => {
|
fetchLatestHostConfig();
|
||||||
cancelled = true;
|
|
||||||
if (intervalId) window.clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [currentHostConfig?.id, isVisible]);
|
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
const handleHostsChanged = async () => {
|
||||||
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
if (hostConfig?.id) {
|
||||||
const bottomMarginPx = 8;
|
try {
|
||||||
|
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||||
|
const hosts = await getSSHHosts();
|
||||||
|
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||||
|
if (updatedHost) {
|
||||||
|
setCurrentHostConfig(updatedHost);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||||
if (!currentHostConfig) return false;
|
return () =>
|
||||||
return tabs.some((tab: any) =>
|
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||||
tab.type === 'file_manager' &&
|
}, [hostConfig?.id]);
|
||||||
tab.hostConfig?.id === currentHostConfig.id
|
|
||||||
);
|
|
||||||
}, [tabs, currentHostConfig]);
|
|
||||||
|
|
||||||
const wrapperStyle: React.CSSProperties = embedded
|
React.useEffect(() => {
|
||||||
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
|
let cancelled = false;
|
||||||
: {
|
let intervalId: number | undefined;
|
||||||
opacity: isVisible ? 1 : 0,
|
|
||||||
marginLeft: leftMarginPx,
|
|
||||||
marginRight: 17,
|
|
||||||
marginTop: topMarginPx,
|
|
||||||
marginBottom: bottomMarginPx,
|
|
||||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const containerClass = embedded
|
const fetchStatus = async () => {
|
||||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
try {
|
||||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
const res = await getServerStatusById(currentHostConfig?.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
if (error?.response?.status === 503) {
|
||||||
|
setServerStatus("offline");
|
||||||
|
} else if (error?.response?.status === 504) {
|
||||||
|
setServerStatus("offline");
|
||||||
|
} else if (error?.response?.status === 404) {
|
||||||
|
setServerStatus("offline");
|
||||||
|
} else {
|
||||||
|
setServerStatus("offline");
|
||||||
|
}
|
||||||
|
toast.error(t("serverStats.failedToFetchStatus"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const fetchMetrics = async () => {
|
||||||
<div style={wrapperStyle} className={containerClass}>
|
if (!currentHostConfig?.id) return;
|
||||||
<div className="h-full w-full flex flex-col">
|
try {
|
||||||
|
setIsLoadingMetrics(true);
|
||||||
|
const data = await getServerMetricsById(currentHostConfig.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
setMetrics(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setMetrics(null);
|
||||||
|
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoadingMetrics(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
{/* Top Header */}
|
if (currentHostConfig?.id && isVisible) {
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
fetchStatus();
|
||||||
<div className="flex items-center gap-4 min-w-0">
|
fetchMetrics();
|
||||||
<div className="min-w-0">
|
intervalId = window.setInterval(() => {
|
||||||
<h1 className="font-bold text-lg truncate">
|
if (isVisible) {
|
||||||
{currentHostConfig?.folder} / {title}
|
fetchStatus();
|
||||||
</h1>
|
fetchMetrics();
|
||||||
</div>
|
}
|
||||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
}, 30000);
|
||||||
<StatusIndicator/>
|
}
|
||||||
</Status>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={isRefreshing}
|
|
||||||
onClick={async () => {
|
|
||||||
if (currentHostConfig?.id) {
|
|
||||||
try {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
const res = await getServerStatusById(currentHostConfig.id);
|
|
||||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
|
||||||
const data = await getServerMetricsById(currentHostConfig.id);
|
|
||||||
setMetrics(data);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.response?.status === 503) {
|
|
||||||
setServerStatus('offline');
|
|
||||||
} else if (error?.response?.status === 504) {
|
|
||||||
setServerStatus('offline');
|
|
||||||
} else if (error?.response?.status === 404) {
|
|
||||||
setServerStatus('offline');
|
|
||||||
} else {
|
|
||||||
setServerStatus('offline');
|
|
||||||
}
|
|
||||||
setMetrics(null);
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={t('serverStats.refreshStatusAndMetrics')}
|
|
||||||
>
|
|
||||||
{isRefreshing ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
{t('serverStats.refreshing')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
t('serverStats.refreshStatus')
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{currentHostConfig?.enableFileManager && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="font-semibold"
|
|
||||||
disabled={isFileManagerAlreadyOpen}
|
|
||||||
title={isFileManagerAlreadyOpen ? t('serverStats.fileManagerAlreadyOpen') : t('serverStats.openFileManager')}
|
|
||||||
onClick={() => {
|
|
||||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
|
||||||
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
|
||||||
? currentHostConfig.name.trim()
|
|
||||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
|
||||||
addTab({
|
|
||||||
type: 'file_manager',
|
|
||||||
title: titleBase,
|
|
||||||
hostConfig: currentHostConfig,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('nav.fileManager')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator className="p-0.25 w-full"/>
|
|
||||||
|
|
||||||
{/* Stats */}
|
return () => {
|
||||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
cancelled = true;
|
||||||
{isLoadingMetrics && !metrics ? (
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
<div className="flex items-center justify-center py-8">
|
};
|
||||||
<div className="flex items-center gap-3">
|
}, [currentHostConfig?.id, isVisible]);
|
||||||
<div
|
|
||||||
className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
<span className="text-gray-300">{t('serverStats.loadingMetrics')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : !metrics && serverStatus === 'offline' ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div
|
|
||||||
className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
|
||||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-300 mb-1">{t('serverStats.serverOffline')}</p>
|
|
||||||
<p className="text-sm text-gray-500">{t('serverStats.cannotFetchMetrics')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
|
||||||
{/* CPU Stats */}
|
|
||||||
<div
|
|
||||||
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Cpu className="h-5 w-5 text-blue-400"/>
|
|
||||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.cpuUsage')}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||||
<div className="flex justify-between items-center">
|
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||||
<span className="text-sm text-gray-300">
|
const bottomMarginPx = 8;
|
||||||
{(() => {
|
|
||||||
const pct = metrics?.cpu?.percent;
|
|
||||||
const cores = metrics?.cpu?.cores;
|
|
||||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
|
||||||
const coresText = (typeof cores === 'number') ? t('serverStats.cpuCores', {count: cores}) : t('serverStats.naCpus');
|
|
||||||
return `${pctText} ${t('serverStats.of')} ${coresText}`;
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
||||||
<Progress
|
if (!currentHostConfig) return false;
|
||||||
value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}
|
return tabs.some(
|
||||||
className="h-2"
|
(tab: any) =>
|
||||||
/>
|
tab.type === "file_manager" &&
|
||||||
</div>
|
tab.hostConfig?.id === currentHostConfig.id,
|
||||||
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{metrics?.cpu?.load ?
|
|
||||||
`Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}` :
|
|
||||||
'Load: N/A'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Memory Stats */}
|
|
||||||
<div
|
|
||||||
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<MemoryStick className="h-5 w-5 text-green-400"/>
|
|
||||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.memoryUsage')}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-300">
|
|
||||||
{(() => {
|
|
||||||
const pct = metrics?.memory?.percent;
|
|
||||||
const used = metrics?.memory?.usedGiB;
|
|
||||||
const total = metrics?.memory?.totalGiB;
|
|
||||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
|
||||||
const usedText = (typeof used === 'number') ? `${used.toFixed(1)} GiB` : 'N/A';
|
|
||||||
const totalText = (typeof total === 'number') ? `${total.toFixed(1)} GiB` : 'N/A';
|
|
||||||
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Progress
|
|
||||||
value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}
|
|
||||||
className="h-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{(() => {
|
|
||||||
const used = metrics?.memory?.usedGiB;
|
|
||||||
const total = metrics?.memory?.totalGiB;
|
|
||||||
const free = (typeof used === 'number' && typeof total === 'number') ? (total - used).toFixed(1) : 'N/A';
|
|
||||||
return `Free: ${free} GiB`;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Disk Stats */}
|
|
||||||
<div
|
|
||||||
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<HardDrive className="h-5 w-5 text-orange-400"/>
|
|
||||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.rootStorageSpace')}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-300">
|
|
||||||
{(() => {
|
|
||||||
const pct = metrics?.disk?.percent;
|
|
||||||
const used = metrics?.disk?.usedHuman;
|
|
||||||
const total = metrics?.disk?.totalHuman;
|
|
||||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
|
||||||
const usedText = used ?? 'N/A';
|
|
||||||
const totalText = total ?? 'N/A';
|
|
||||||
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Progress
|
|
||||||
value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}
|
|
||||||
className="h-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{(() => {
|
|
||||||
const used = metrics?.disk?.usedHuman;
|
|
||||||
const total = metrics?.disk?.totalHuman;
|
|
||||||
return used && total ? `Available: ${total}` : 'Available: N/A';
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SSH Tunnels */}
|
|
||||||
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
|
|
||||||
<Tunnel
|
|
||||||
filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
|
||||||
{t('serverStats.feedbackMessage')}{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/LukeGus/Termix/issues/new"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-500 hover:underline"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
}, [tabs, currentHostConfig]);
|
||||||
|
|
||||||
|
const wrapperStyle: React.CSSProperties = embedded
|
||||||
|
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||||
|
: {
|
||||||
|
opacity: isVisible ? 1 : 0,
|
||||||
|
marginLeft: leftMarginPx,
|
||||||
|
marginRight: 17,
|
||||||
|
marginTop: topMarginPx,
|
||||||
|
marginBottom: bottomMarginPx,
|
||||||
|
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerClass = embedded
|
||||||
|
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||||
|
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={wrapperStyle} className={containerClass}>
|
||||||
|
<div className="h-full w-full flex flex-col">
|
||||||
|
{/* Top Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="font-bold text-lg truncate">
|
||||||
|
{currentHostConfig?.folder} / {title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Status
|
||||||
|
status={serverStatus}
|
||||||
|
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<StatusIndicator />
|
||||||
|
</Status>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={isRefreshing}
|
||||||
|
onClick={async () => {
|
||||||
|
if (currentHostConfig?.id) {
|
||||||
|
try {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
const res = await getServerStatusById(currentHostConfig.id);
|
||||||
|
setServerStatus(
|
||||||
|
res?.status === "online" ? "online" : "offline",
|
||||||
|
);
|
||||||
|
const data = await getServerMetricsById(
|
||||||
|
currentHostConfig.id,
|
||||||
|
);
|
||||||
|
setMetrics(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 503) {
|
||||||
|
setServerStatus("offline");
|
||||||
|
} else if (error?.response?.status === 504) {
|
||||||
|
setServerStatus("offline");
|
||||||
|
} else if (error?.response?.status === 404) {
|
||||||
|
setServerStatus("offline");
|
||||||
|
} else {
|
||||||
|
setServerStatus("offline");
|
||||||
|
}
|
||||||
|
setMetrics(null);
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("serverStats.refreshStatusAndMetrics")}
|
||||||
|
>
|
||||||
|
{isRefreshing ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
{t("serverStats.refreshing")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t("serverStats.refreshStatus")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{currentHostConfig?.enableFileManager && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="font-semibold"
|
||||||
|
disabled={isFileManagerAlreadyOpen}
|
||||||
|
title={
|
||||||
|
isFileManagerAlreadyOpen
|
||||||
|
? t("serverStats.fileManagerAlreadyOpen")
|
||||||
|
: t("serverStats.openFileManager")
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||||
|
const titleBase =
|
||||||
|
currentHostConfig?.name &&
|
||||||
|
currentHostConfig.name.trim() !== ""
|
||||||
|
? currentHostConfig.name.trim()
|
||||||
|
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||||
|
addTab({
|
||||||
|
type: "file_manager",
|
||||||
|
title: titleBase,
|
||||||
|
hostConfig: currentHostConfig,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("nav.fileManager")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="p-0.25 w-full" />
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
||||||
|
{isLoadingMetrics && !metrics ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<span className="text-gray-300">
|
||||||
|
{t("serverStats.loadingMetrics")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !metrics && serverStatus === "offline" ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||||
|
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-300 mb-1">
|
||||||
|
{t("serverStats.serverOffline")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{t("serverStats.cannotFetchMetrics")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
||||||
|
{/* CPU Stats */}
|
||||||
|
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Cpu className="h-5 w-5 text-blue-400" />
|
||||||
|
<h3 className="font-semibold text-lg text-white">
|
||||||
|
{t("serverStats.cpuUsage")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{(() => {
|
||||||
|
const pct = metrics?.cpu?.percent;
|
||||||
|
const cores = metrics?.cpu?.cores;
|
||||||
|
const pctText =
|
||||||
|
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||||
|
const coresText =
|
||||||
|
typeof cores === "number"
|
||||||
|
? t("serverStats.cpuCores", { count: cores })
|
||||||
|
: t("serverStats.naCpus");
|
||||||
|
return `${pctText} ${t("serverStats.of")} ${coresText}`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
typeof metrics?.cpu?.percent === "number"
|
||||||
|
? metrics!.cpu!.percent!
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{metrics?.cpu?.load
|
||||||
|
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
|
||||||
|
: "Load: N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Memory Stats */}
|
||||||
|
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||||
|
<h3 className="font-semibold text-lg text-white">
|
||||||
|
{t("serverStats.memoryUsage")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{(() => {
|
||||||
|
const pct = metrics?.memory?.percent;
|
||||||
|
const used = metrics?.memory?.usedGiB;
|
||||||
|
const total = metrics?.memory?.totalGiB;
|
||||||
|
const pctText =
|
||||||
|
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||||
|
const usedText =
|
||||||
|
typeof used === "number"
|
||||||
|
? `${used.toFixed(1)} GiB`
|
||||||
|
: "N/A";
|
||||||
|
const totalText =
|
||||||
|
typeof total === "number"
|
||||||
|
? `${total.toFixed(1)} GiB`
|
||||||
|
: "N/A";
|
||||||
|
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
typeof metrics?.memory?.percent === "number"
|
||||||
|
? metrics!.memory!.percent!
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{(() => {
|
||||||
|
const used = metrics?.memory?.usedGiB;
|
||||||
|
const total = metrics?.memory?.totalGiB;
|
||||||
|
const free =
|
||||||
|
typeof used === "number" && typeof total === "number"
|
||||||
|
? (total - used).toFixed(1)
|
||||||
|
: "N/A";
|
||||||
|
return `Free: ${free} GiB`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disk Stats */}
|
||||||
|
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||||
|
<h3 className="font-semibold text-lg text-white">
|
||||||
|
{t("serverStats.rootStorageSpace")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{(() => {
|
||||||
|
const pct = metrics?.disk?.percent;
|
||||||
|
const used = metrics?.disk?.usedHuman;
|
||||||
|
const total = metrics?.disk?.totalHuman;
|
||||||
|
const pctText =
|
||||||
|
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||||
|
const usedText = used ?? "N/A";
|
||||||
|
const totalText = total ?? "N/A";
|
||||||
|
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
typeof metrics?.disk?.percent === "number"
|
||||||
|
? metrics!.disk!.percent!
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{(() => {
|
||||||
|
const used = metrics?.disk?.usedHuman;
|
||||||
|
const total = metrics?.disk?.totalHuman;
|
||||||
|
return used && total
|
||||||
|
? `Available: ${total}`
|
||||||
|
: "Available: N/A";
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSH Tunnels */}
|
||||||
|
{currentHostConfig?.tunnelConnections &&
|
||||||
|
currentHostConfig.tunnelConnections.length > 0 && (
|
||||||
|
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||||
|
<Tunnel
|
||||||
|
filterHostKey={
|
||||||
|
currentHostConfig?.name &&
|
||||||
|
currentHostConfig.name.trim() !== ""
|
||||||
|
? currentHostConfig.name
|
||||||
|
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||||
|
{t("serverStats.feedbackMessage")}{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/LukeGus/Termix/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-500 hover:underline"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,163 +1,206 @@
|
|||||||
import React, {useState, useEffect, useCallback} from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
|
import { TunnelViewer } from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
|
||||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
|
import {
|
||||||
import type {SSHHost, TunnelConnection, TunnelStatus, SSHTunnelProps} from '../../../types/index.js';
|
getSSHHosts,
|
||||||
|
getTunnelStatuses,
|
||||||
|
connectTunnel,
|
||||||
|
disconnectTunnel,
|
||||||
|
cancelTunnel,
|
||||||
|
} from "@/ui/main-axios.ts";
|
||||||
|
import type {
|
||||||
|
SSHHost,
|
||||||
|
TunnelConnection,
|
||||||
|
TunnelStatus,
|
||||||
|
SSHTunnelProps,
|
||||||
|
} from "../../../types/index.js";
|
||||||
|
|
||||||
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
|
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||||
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||||
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
|
const [tunnelStatuses, setTunnelStatuses] = useState<
|
||||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
|
Record<string, TunnelStatus>
|
||||||
|
>({});
|
||||||
|
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||||
|
|
||||||
const haveTunnelConnectionsChanged = (a: TunnelConnection[] = [], b: TunnelConnection[] = []): boolean => {
|
const haveTunnelConnectionsChanged = (
|
||||||
if (a.length !== b.length) return true;
|
a: TunnelConnection[] = [],
|
||||||
for (let i = 0; i < a.length; i++) {
|
b: TunnelConnection[] = [],
|
||||||
const x = a[i];
|
): boolean => {
|
||||||
const y = b[i];
|
if (a.length !== b.length) return true;
|
||||||
if (
|
for (let i = 0; i < a.length; i++) {
|
||||||
x.sourcePort !== y.sourcePort ||
|
const x = a[i];
|
||||||
x.endpointPort !== y.endpointPort ||
|
const y = b[i];
|
||||||
x.endpointHost !== y.endpointHost ||
|
if (
|
||||||
x.maxRetries !== y.maxRetries ||
|
x.sourcePort !== y.sourcePort ||
|
||||||
x.retryInterval !== y.retryInterval ||
|
x.endpointPort !== y.endpointPort ||
|
||||||
x.autoStart !== y.autoStart
|
x.endpointHost !== y.endpointHost ||
|
||||||
) {
|
x.maxRetries !== y.maxRetries ||
|
||||||
return true;
|
x.retryInterval !== y.retryInterval ||
|
||||||
}
|
x.autoStart !== y.autoStart
|
||||||
}
|
) {
|
||||||
return false;
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchHosts = useCallback(async () => {
|
||||||
|
const hostsData = await getSSHHosts();
|
||||||
|
setAllHosts(hostsData);
|
||||||
|
const nextVisible = filterHostKey
|
||||||
|
? hostsData.filter((h) => {
|
||||||
|
const key =
|
||||||
|
h.name && h.name.trim() !== "" ? h.name : `${h.username}@${h.ip}`;
|
||||||
|
return key === filterHostKey;
|
||||||
|
})
|
||||||
|
: hostsData;
|
||||||
|
|
||||||
|
const prev = prevVisibleHostRef.current;
|
||||||
|
const curr = nextVisible[0] ?? null;
|
||||||
|
let changed = false;
|
||||||
|
if (!prev && curr) changed = true;
|
||||||
|
else if (prev && !curr) changed = true;
|
||||||
|
else if (prev && curr) {
|
||||||
|
if (
|
||||||
|
prev.id !== curr.id ||
|
||||||
|
prev.name !== curr.name ||
|
||||||
|
prev.ip !== curr.ip ||
|
||||||
|
prev.port !== curr.port ||
|
||||||
|
prev.username !== curr.username ||
|
||||||
|
haveTunnelConnectionsChanged(
|
||||||
|
prev.tunnelConnections,
|
||||||
|
curr.tunnelConnections,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setVisibleHosts(nextVisible);
|
||||||
|
prevVisibleHostRef.current = curr;
|
||||||
|
}
|
||||||
|
}, [filterHostKey]);
|
||||||
|
|
||||||
|
const fetchTunnelStatuses = useCallback(async () => {
|
||||||
|
const statusData = await getTunnelStatuses();
|
||||||
|
setTunnelStatuses(statusData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHosts();
|
||||||
|
const interval = setInterval(fetchHosts, 5000);
|
||||||
|
|
||||||
|
const handleHostsChanged = () => {
|
||||||
|
fetchHosts();
|
||||||
};
|
};
|
||||||
|
window.addEventListener(
|
||||||
const fetchHosts = useCallback(async () => {
|
"ssh-hosts:changed",
|
||||||
const hostsData = await getSSHHosts();
|
handleHostsChanged as EventListener,
|
||||||
setAllHosts(hostsData);
|
|
||||||
const nextVisible = filterHostKey
|
|
||||||
? hostsData.filter(h => {
|
|
||||||
const key = (h.name && h.name.trim() !== '') ? h.name : `${h.username}@${h.ip}`;
|
|
||||||
return key === filterHostKey;
|
|
||||||
})
|
|
||||||
: hostsData;
|
|
||||||
|
|
||||||
const prev = prevVisibleHostRef.current;
|
|
||||||
const curr = nextVisible[0] ?? null;
|
|
||||||
let changed = false;
|
|
||||||
if (!prev && curr) changed = true;
|
|
||||||
else if (prev && !curr) changed = true;
|
|
||||||
else if (prev && curr) {
|
|
||||||
if (
|
|
||||||
prev.id !== curr.id ||
|
|
||||||
prev.name !== curr.name ||
|
|
||||||
prev.ip !== curr.ip ||
|
|
||||||
prev.port !== curr.port ||
|
|
||||||
prev.username !== curr.username ||
|
|
||||||
haveTunnelConnectionsChanged(prev.tunnelConnections, curr.tunnelConnections)
|
|
||||||
) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
setVisibleHosts(nextVisible);
|
|
||||||
prevVisibleHostRef.current = curr;
|
|
||||||
}
|
|
||||||
}, [filterHostKey]);
|
|
||||||
|
|
||||||
const fetchTunnelStatuses = useCallback(async () => {
|
|
||||||
const statusData = await getTunnelStatuses();
|
|
||||||
setTunnelStatuses(statusData);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchHosts();
|
|
||||||
const interval = setInterval(fetchHosts, 5000);
|
|
||||||
|
|
||||||
const handleHostsChanged = () => {
|
|
||||||
fetchHosts();
|
|
||||||
};
|
|
||||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
|
||||||
};
|
|
||||||
}, [fetchHosts]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchTunnelStatuses();
|
|
||||||
const interval = setInterval(fetchTunnelStatuses, 500);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [fetchTunnelStatuses]);
|
|
||||||
|
|
||||||
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => {
|
|
||||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
|
||||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
|
||||||
|
|
||||||
setTunnelActions(prev => ({...prev, [tunnelName]: true}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (action === 'connect') {
|
|
||||||
const endpointHost = allHosts.find(h =>
|
|
||||||
h.name === tunnel.endpointHost ||
|
|
||||||
`${h.username}@${h.ip}` === tunnel.endpointHost
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!endpointHost) {
|
|
||||||
throw new Error('Endpoint host not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tunnelConfig = {
|
|
||||||
name: tunnelName,
|
|
||||||
hostName: host.name || `${host.username}@${host.ip}`,
|
|
||||||
sourceIP: host.ip,
|
|
||||||
sourceSSHPort: host.port,
|
|
||||||
sourceUsername: host.username,
|
|
||||||
sourcePassword: host.authType === 'password' ? host.password : undefined,
|
|
||||||
sourceAuthMethod: host.authType,
|
|
||||||
sourceSSHKey: host.authType === 'key' ? host.key : undefined,
|
|
||||||
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined,
|
|
||||||
sourceKeyType: host.authType === 'key' ? host.keyType : undefined,
|
|
||||||
sourceCredentialId: host.credentialId,
|
|
||||||
sourceUserId: host.userId,
|
|
||||||
endpointIP: endpointHost.ip,
|
|
||||||
endpointSSHPort: endpointHost.port,
|
|
||||||
endpointUsername: endpointHost.username,
|
|
||||||
endpointPassword: endpointHost.authType === 'password' ? endpointHost.password : undefined,
|
|
||||||
endpointAuthMethod: endpointHost.authType,
|
|
||||||
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined,
|
|
||||||
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined,
|
|
||||||
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined,
|
|
||||||
endpointCredentialId: endpointHost.credentialId,
|
|
||||||
endpointUserId: endpointHost.userId,
|
|
||||||
sourcePort: tunnel.sourcePort,
|
|
||||||
endpointPort: tunnel.endpointPort,
|
|
||||||
maxRetries: tunnel.maxRetries,
|
|
||||||
retryInterval: tunnel.retryInterval * 1000,
|
|
||||||
autoStart: tunnel.autoStart,
|
|
||||||
isPinned: host.pin
|
|
||||||
};
|
|
||||||
|
|
||||||
await connectTunnel(tunnelConfig);
|
|
||||||
} else if (action === 'disconnect') {
|
|
||||||
await disconnectTunnel(tunnelName);
|
|
||||||
} else if (action === 'cancel') {
|
|
||||||
await cancelTunnel(tunnelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchTunnelStatuses();
|
|
||||||
} catch (err) {
|
|
||||||
} finally {
|
|
||||||
setTunnelActions(prev => ({...prev, [tunnelName]: false}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TunnelViewer
|
|
||||||
hosts={visibleHosts}
|
|
||||||
tunnelStatuses={tunnelStatuses}
|
|
||||||
tunnelActions={tunnelActions}
|
|
||||||
onTunnelAction={handleTunnelAction}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
window.removeEventListener(
|
||||||
|
"ssh-hosts:changed",
|
||||||
|
handleHostsChanged as EventListener,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [fetchHosts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTunnelStatuses();
|
||||||
|
const interval = setInterval(fetchTunnelStatuses, 500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchTunnelStatuses]);
|
||||||
|
|
||||||
|
const handleTunnelAction = async (
|
||||||
|
action: "connect" | "disconnect" | "cancel",
|
||||||
|
host: SSHHost,
|
||||||
|
tunnelIndex: number,
|
||||||
|
) => {
|
||||||
|
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||||
|
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||||
|
|
||||||
|
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === "connect") {
|
||||||
|
const endpointHost = allHosts.find(
|
||||||
|
(h) =>
|
||||||
|
h.name === tunnel.endpointHost ||
|
||||||
|
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!endpointHost) {
|
||||||
|
throw new Error("Endpoint host not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tunnelConfig = {
|
||||||
|
name: tunnelName,
|
||||||
|
hostName: host.name || `${host.username}@${host.ip}`,
|
||||||
|
sourceIP: host.ip,
|
||||||
|
sourceSSHPort: host.port,
|
||||||
|
sourceUsername: host.username,
|
||||||
|
sourcePassword:
|
||||||
|
host.authType === "password" ? host.password : undefined,
|
||||||
|
sourceAuthMethod: host.authType,
|
||||||
|
sourceSSHKey: host.authType === "key" ? host.key : undefined,
|
||||||
|
sourceKeyPassword:
|
||||||
|
host.authType === "key" ? host.keyPassword : undefined,
|
||||||
|
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
|
||||||
|
sourceCredentialId: host.credentialId,
|
||||||
|
sourceUserId: host.userId,
|
||||||
|
endpointIP: endpointHost.ip,
|
||||||
|
endpointSSHPort: endpointHost.port,
|
||||||
|
endpointUsername: endpointHost.username,
|
||||||
|
endpointPassword:
|
||||||
|
endpointHost.authType === "password"
|
||||||
|
? endpointHost.password
|
||||||
|
: undefined,
|
||||||
|
endpointAuthMethod: endpointHost.authType,
|
||||||
|
endpointSSHKey:
|
||||||
|
endpointHost.authType === "key" ? endpointHost.key : undefined,
|
||||||
|
endpointKeyPassword:
|
||||||
|
endpointHost.authType === "key"
|
||||||
|
? endpointHost.keyPassword
|
||||||
|
: undefined,
|
||||||
|
endpointKeyType:
|
||||||
|
endpointHost.authType === "key" ? endpointHost.keyType : undefined,
|
||||||
|
endpointCredentialId: endpointHost.credentialId,
|
||||||
|
endpointUserId: endpointHost.userId,
|
||||||
|
sourcePort: tunnel.sourcePort,
|
||||||
|
endpointPort: tunnel.endpointPort,
|
||||||
|
maxRetries: tunnel.maxRetries,
|
||||||
|
retryInterval: tunnel.retryInterval * 1000,
|
||||||
|
autoStart: tunnel.autoStart,
|
||||||
|
isPinned: host.pin,
|
||||||
|
};
|
||||||
|
|
||||||
|
await connectTunnel(tunnelConfig);
|
||||||
|
} else if (action === "disconnect") {
|
||||||
|
await disconnectTunnel(tunnelName);
|
||||||
|
} else if (action === "cancel") {
|
||||||
|
await cancelTunnel(tunnelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchTunnelStatuses();
|
||||||
|
} catch (err) {
|
||||||
|
} finally {
|
||||||
|
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TunnelViewer
|
||||||
|
hosts={visibleHosts}
|
||||||
|
tunnelStatuses={tunnelStatuses}
|
||||||
|
tunnelActions={tunnelActions}
|
||||||
|
onTunnelAction={handleTunnelAction}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,435 +1,533 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {Card} from "@/components/ui/card.tsx";
|
import { Card } from "@/components/ui/card.tsx";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import {useTranslation} from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Pin,
|
Pin,
|
||||||
Network,
|
Network,
|
||||||
Tag,
|
Tag,
|
||||||
Play,
|
Play,
|
||||||
Square,
|
Square,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Wifi,
|
Wifi,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
X
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {Badge} from "@/components/ui/badge.tsx";
|
import { Badge } from "@/components/ui/badge.tsx";
|
||||||
import type {TunnelStatus, SSHTunnelObjectProps} from '../../../types/index.js';
|
import type {
|
||||||
|
TunnelStatus,
|
||||||
|
SSHTunnelObjectProps,
|
||||||
|
} from "../../../types/index.js";
|
||||||
|
|
||||||
export function TunnelObject({
|
export function TunnelObject({
|
||||||
host,
|
host,
|
||||||
tunnelStatuses,
|
tunnelStatuses,
|
||||||
tunnelActions,
|
tunnelActions,
|
||||||
onTunnelAction,
|
onTunnelAction,
|
||||||
compact = false,
|
compact = false,
|
||||||
bare = false
|
bare = false,
|
||||||
}: SSHTunnelObjectProps): React.ReactElement {
|
}: SSHTunnelObjectProps): React.ReactElement {
|
||||||
const {t} = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||||
return tunnelStatuses[tunnelName];
|
return tunnelStatuses[tunnelName];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
|
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
|
||||||
if (!status) return {
|
if (!status)
|
||||||
icon: <WifiOff className="h-4 w-4"/>,
|
return {
|
||||||
text: t('tunnels.unknown'),
|
icon: <WifiOff className="h-4 w-4" />,
|
||||||
color: 'text-muted-foreground',
|
text: t("tunnels.unknown"),
|
||||||
bgColor: 'bg-muted/50',
|
color: "text-muted-foreground",
|
||||||
borderColor: 'border-border'
|
bgColor: "bg-muted/50",
|
||||||
|
borderColor: "border-border",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusValue = status.status || "DISCONNECTED";
|
||||||
|
|
||||||
|
switch (statusValue.toUpperCase()) {
|
||||||
|
case "CONNECTED":
|
||||||
|
return {
|
||||||
|
icon: <Wifi className="h-4 w-4" />,
|
||||||
|
text: t("tunnels.connected"),
|
||||||
|
color: "text-green-600 dark:text-green-400",
|
||||||
|
bgColor: "bg-green-500/10 dark:bg-green-400/10",
|
||||||
|
borderColor: "border-green-500/20 dark:border-green-400/20",
|
||||||
|
};
|
||||||
|
case "CONNECTING":
|
||||||
|
return {
|
||||||
|
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||||
|
text: t("tunnels.connecting"),
|
||||||
|
color: "text-blue-600 dark:text-blue-400",
|
||||||
|
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
|
||||||
|
borderColor: "border-blue-500/20 dark:border-blue-400/20",
|
||||||
|
};
|
||||||
|
case "DISCONNECTING":
|
||||||
|
return {
|
||||||
|
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||||
|
text: t("tunnels.disconnecting"),
|
||||||
|
color: "text-orange-600 dark:text-orange-400",
|
||||||
|
bgColor: "bg-orange-500/10 dark:bg-orange-400/10",
|
||||||
|
borderColor: "border-orange-500/20 dark:border-orange-400/20",
|
||||||
|
};
|
||||||
|
case "DISCONNECTED":
|
||||||
|
return {
|
||||||
|
icon: <WifiOff className="h-4 w-4" />,
|
||||||
|
text: t("tunnels.disconnected"),
|
||||||
|
color: "text-muted-foreground",
|
||||||
|
bgColor: "bg-muted/30",
|
||||||
|
borderColor: "border-border",
|
||||||
|
};
|
||||||
|
case "WAITING":
|
||||||
|
return {
|
||||||
|
icon: <Clock className="h-4 w-4" />,
|
||||||
|
color: "text-blue-600 dark:text-blue-400",
|
||||||
|
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
|
||||||
|
borderColor: "border-blue-500/20 dark:border-blue-400/20",
|
||||||
|
};
|
||||||
|
case "ERROR":
|
||||||
|
case "FAILED":
|
||||||
|
return {
|
||||||
|
icon: <AlertCircle className="h-4 w-4" />,
|
||||||
|
text: status.reason || t("tunnels.error"),
|
||||||
|
color: "text-red-600 dark:text-red-400",
|
||||||
|
bgColor: "bg-red-500/10 dark:bg-red-400/10",
|
||||||
|
borderColor: "border-red-500/20 dark:border-red-400/20",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
icon: <WifiOff className="h-4 w-4" />,
|
||||||
|
text: statusValue,
|
||||||
|
color: "text-muted-foreground",
|
||||||
|
bgColor: "bg-muted/30",
|
||||||
|
borderColor: "border-border",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusValue = status.status || 'DISCONNECTED';
|
|
||||||
|
|
||||||
switch (statusValue.toUpperCase()) {
|
|
||||||
case 'CONNECTED':
|
|
||||||
return {
|
|
||||||
icon: <Wifi className="h-4 w-4"/>,
|
|
||||||
text: t('tunnels.connected'),
|
|
||||||
color: 'text-green-600 dark:text-green-400',
|
|
||||||
bgColor: 'bg-green-500/10 dark:bg-green-400/10',
|
|
||||||
borderColor: 'border-green-500/20 dark:border-green-400/20'
|
|
||||||
};
|
|
||||||
case 'CONNECTING':
|
|
||||||
return {
|
|
||||||
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
|
|
||||||
text: t('tunnels.connecting'),
|
|
||||||
color: 'text-blue-600 dark:text-blue-400',
|
|
||||||
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
|
|
||||||
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
|
|
||||||
};
|
|
||||||
case 'DISCONNECTING':
|
|
||||||
return {
|
|
||||||
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
|
|
||||||
text: t('tunnels.disconnecting'),
|
|
||||||
color: 'text-orange-600 dark:text-orange-400',
|
|
||||||
bgColor: 'bg-orange-500/10 dark:bg-orange-400/10',
|
|
||||||
borderColor: 'border-orange-500/20 dark:border-orange-400/20'
|
|
||||||
};
|
|
||||||
case 'DISCONNECTED':
|
|
||||||
return {
|
|
||||||
icon: <WifiOff className="h-4 w-4"/>,
|
|
||||||
text: t('tunnels.disconnected'),
|
|
||||||
color: 'text-muted-foreground',
|
|
||||||
bgColor: 'bg-muted/30',
|
|
||||||
borderColor: 'border-border'
|
|
||||||
};
|
|
||||||
case 'WAITING':
|
|
||||||
return {
|
|
||||||
icon: <Clock className="h-4 w-4"/>,
|
|
||||||
color: 'text-blue-600 dark:text-blue-400',
|
|
||||||
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
|
|
||||||
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
|
|
||||||
};
|
|
||||||
case 'ERROR':
|
|
||||||
case 'FAILED':
|
|
||||||
return {
|
|
||||||
icon: <AlertCircle className="h-4 w-4"/>,
|
|
||||||
text: status.reason || t('tunnels.error'),
|
|
||||||
color: 'text-red-600 dark:text-red-400',
|
|
||||||
bgColor: 'bg-red-500/10 dark:bg-red-400/10',
|
|
||||||
borderColor: 'border-red-500/20 dark:border-red-400/20'
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
icon: <WifiOff className="h-4 w-4"/>,
|
|
||||||
text: statusValue,
|
|
||||||
color: 'text-muted-foreground',
|
|
||||||
bgColor: 'bg-muted/30',
|
|
||||||
borderColor: 'border-border'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (bare) {
|
|
||||||
return (
|
|
||||||
<div className="w-full min-w-0">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
|
||||||
const status = getTunnelStatus(tunnelIndex);
|
|
||||||
const statusDisplay = getTunnelStatusDisplay(status);
|
|
||||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
|
||||||
const isActionLoading = tunnelActions[tunnelName];
|
|
||||||
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
|
|
||||||
const isConnected = statusValue === 'CONNECTED';
|
|
||||||
const isConnecting = statusValue === 'CONNECTING';
|
|
||||||
const isDisconnecting = statusValue === 'DISCONNECTING';
|
|
||||||
const isRetrying = statusValue === 'RETRYING';
|
|
||||||
const isWaiting = statusValue === 'WAITING';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={tunnelIndex}
|
|
||||||
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
|
||||||
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
|
||||||
{statusDisplay.icon}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium break-words">
|
|
||||||
{t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}
|
|
||||||
</div>
|
|
||||||
<div className={`text-xs ${statusDisplay.color} font-medium`}>
|
|
||||||
{statusDisplay.text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
|
||||||
{!isActionLoading ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{isConnected ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
|
|
||||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
|
||||||
>
|
|
||||||
<Square className="h-3 w-3 mr-1"/>
|
|
||||||
{t('tunnels.disconnect')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : isRetrying || isWaiting ? (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
|
|
||||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3 mr-1"/>
|
|
||||||
{t('tunnels.cancel')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
|
|
||||||
disabled={isConnecting || isDisconnecting}
|
|
||||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
|
||||||
>
|
|
||||||
<Play className="h-3 w-3 mr-1"/>
|
|
||||||
{t('tunnels.connect')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled
|
|
||||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
|
||||||
>
|
|
||||||
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
|
|
||||||
{isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
|
||||||
<div
|
|
||||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
|
||||||
<div className="font-medium mb-1">{t('tunnels.error')}:</div>
|
|
||||||
{status.reason}
|
|
||||||
{status.reason && status.reason.includes('Max retries exhausted') && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
|
||||||
{t('tunnels.checkDockerLogs')} <a
|
|
||||||
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
|
|
||||||
create a <a
|
|
||||||
href="https://github.com/LukeGus/Termix/issues/new"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
className="underline text-blue-600 dark:text-blue-400">GitHub
|
|
||||||
issue</a> for help.
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
|
||||||
<div
|
|
||||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
|
||||||
<div className="font-medium mb-1">
|
|
||||||
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{t('tunnels.attempt', {
|
|
||||||
current: status.retryCount,
|
|
||||||
max: status.maxRetries
|
|
||||||
})}
|
|
||||||
{status.nextRetryIn && (
|
|
||||||
<span> • {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-4 text-muted-foreground">
|
|
||||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
|
|
||||||
<p className="text-sm">{t('tunnels.noTunnelConnections')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (bare) {
|
||||||
return (
|
return (
|
||||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
<div className="w-full min-w-0">
|
||||||
<div className="p-4">
|
<div className="space-y-3">
|
||||||
{!compact && (
|
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||||
<div className="flex items-center justify-between gap-2 mb-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||||
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
|
const status = getTunnelStatus(tunnelIndex);
|
||||||
<div className="flex-1 min-w-0">
|
const statusDisplay = getTunnelStatusDisplay(status);
|
||||||
<h3 className="font-semibold text-card-foreground truncate">
|
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||||
{host.name || `${host.username}@${host.ip}`}
|
const isActionLoading = tunnelActions[tunnelName];
|
||||||
</h3>
|
const statusValue =
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||||
{host.ip}:{host.port} • {host.username}
|
const isConnected = statusValue === "CONNECTED";
|
||||||
</p>
|
const isConnecting = statusValue === "CONNECTING";
|
||||||
</div>
|
const isDisconnecting = statusValue === "DISCONNECTING";
|
||||||
</div>
|
const isRetrying = statusValue === "RETRYING";
|
||||||
</div>
|
const isWaiting = statusValue === "WAITING";
|
||||||
)}
|
|
||||||
|
|
||||||
{!compact && host.tags && host.tags.length > 0 && (
|
return (
|
||||||
<div className="flex flex-wrap gap-1 mb-3">
|
<div
|
||||||
{host.tags.slice(0, 3).map((tag, index) => (
|
key={tunnelIndex}
|
||||||
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||||
<Tag className="h-2 w-2 mr-0.5"/>
|
>
|
||||||
{tag}
|
<div className="flex items-start justify-between gap-2">
|
||||||
</Badge>
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||||
))}
|
<span
|
||||||
{host.tags.length > 3 && (
|
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
|
||||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
>
|
||||||
+{host.tags.length - 3}
|
{statusDisplay.icon}
|
||||||
</Badge>
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium break-words">
|
||||||
|
{t("tunnels.port")} {tunnel.sourcePort} →{" "}
|
||||||
|
{tunnel.endpointHost}:{tunnel.endpointPort}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-xs ${statusDisplay.color} font-medium`}
|
||||||
|
>
|
||||||
|
{statusDisplay.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
||||||
|
{!isActionLoading ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{isConnected ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onTunnelAction(
|
||||||
|
"disconnect",
|
||||||
|
host,
|
||||||
|
tunnelIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3 mr-1" />
|
||||||
|
{t("tunnels.disconnect")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : isRetrying || isWaiting ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onTunnelAction("cancel", host, tunnelIndex)
|
||||||
|
}
|
||||||
|
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
{t("tunnels.cancel")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onTunnelAction("connect", host, tunnelIndex)
|
||||||
|
}
|
||||||
|
disabled={isConnecting || isDisconnecting}
|
||||||
|
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||||
|
>
|
||||||
|
<Play className="h-3 w-3 mr-1" />
|
||||||
|
{t("tunnels.connect")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled
|
||||||
|
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||||
|
>
|
||||||
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
{isConnected
|
||||||
|
? t("tunnels.disconnecting")
|
||||||
|
: isRetrying || isWaiting
|
||||||
|
? t("tunnels.canceling")
|
||||||
|
: t("tunnels.connecting")}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!compact && <Separator className="mb-3"/>}
|
{(statusValue === "ERROR" || statusValue === "FAILED") &&
|
||||||
|
status?.reason && (
|
||||||
|
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||||
|
<div className="font-medium mb-1">
|
||||||
|
{t("tunnels.error")}:
|
||||||
|
</div>
|
||||||
|
{status.reason}
|
||||||
|
{status.reason &&
|
||||||
|
status.reason.includes("Max retries exhausted") && (
|
||||||
|
<>
|
||||||
|
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||||
|
{t("tunnels.checkDockerLogs")}{" "}
|
||||||
|
<a
|
||||||
|
href="https://discord.com/invite/jVQGdvHDrf"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
Discord
|
||||||
|
</a>{" "}
|
||||||
|
or create a{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/LukeGus/Termix/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
GitHub issue
|
||||||
|
</a>{" "}
|
||||||
|
for help.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
{(statusValue === "RETRYING" ||
|
||||||
{!compact && (
|
statusValue === "WAITING") &&
|
||||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
status?.retryCount &&
|
||||||
<Network className="h-4 w-4"/>
|
status?.maxRetries && (
|
||||||
{t('tunnels.tunnelConnections')} ({host.tunnelConnections.length})
|
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||||
</h4>
|
<div className="font-medium mb-1">
|
||||||
)}
|
{statusValue === "WAITING"
|
||||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
? t("tunnels.waitingForRetry")
|
||||||
<div className="space-y-3">
|
: t("tunnels.retryingConnection")}
|
||||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
</div>
|
||||||
const status = getTunnelStatus(tunnelIndex);
|
<div>
|
||||||
const statusDisplay = getTunnelStatusDisplay(status);
|
{t("tunnels.attempt", {
|
||||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
current: status.retryCount,
|
||||||
const isActionLoading = tunnelActions[tunnelName];
|
max: status.maxRetries,
|
||||||
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
|
|
||||||
const isConnected = statusValue === 'CONNECTED';
|
|
||||||
const isConnecting = statusValue === 'CONNECTING';
|
|
||||||
const isDisconnecting = statusValue === 'DISCONNECTING';
|
|
||||||
const isRetrying = statusValue === 'RETRYING';
|
|
||||||
const isWaiting = statusValue === 'WAITING';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={tunnelIndex}
|
|
||||||
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
|
||||||
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
|
||||||
{statusDisplay.icon}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium break-words">
|
|
||||||
{t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}
|
|
||||||
</div>
|
|
||||||
<div className={`text-xs ${statusDisplay.color} font-medium`}>
|
|
||||||
{statusDisplay.text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{!isActionLoading && (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{isConnected ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
|
|
||||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
|
||||||
>
|
|
||||||
<Square className="h-3 w-3 mr-1"/>
|
|
||||||
{t('tunnels.disconnect')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : isRetrying || isWaiting ? (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
|
|
||||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3 mr-1"/>
|
|
||||||
{t('tunnels.cancel')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
|
|
||||||
disabled={isConnecting || isDisconnecting}
|
|
||||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
|
||||||
>
|
|
||||||
<Play className="h-3 w-3 mr-1"/>
|
|
||||||
{t('tunnels.connect')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isActionLoading && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled
|
|
||||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
|
||||||
>
|
|
||||||
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
|
|
||||||
{isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
|
||||||
<div
|
|
||||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
|
||||||
<div className="font-medium mb-1">{t('tunnels.error')}:</div>
|
|
||||||
{status.reason}
|
|
||||||
{status.reason && status.reason.includes('Max retries exhausted') && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
|
||||||
{t('tunnels.checkDockerLogs')} <a
|
|
||||||
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
|
|
||||||
create a <a
|
|
||||||
href="https://github.com/LukeGus/Termix/issues/new"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
className="underline text-blue-600 dark:text-blue-400">GitHub
|
|
||||||
issue</a> for help.
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
|
||||||
<div
|
|
||||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
|
||||||
<div className="font-medium mb-1">
|
|
||||||
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{t('tunnels.attempt', {
|
|
||||||
current: status.retryCount,
|
|
||||||
max: status.maxRetries
|
|
||||||
})}
|
|
||||||
{status.nextRetryIn && (
|
|
||||||
<span> • {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
|
{status.nextRetryIn && (
|
||||||
|
<span>
|
||||||
|
{" "}
|
||||||
|
•{" "}
|
||||||
|
{t("tunnels.nextRetryIn", {
|
||||||
|
seconds: status.nextRetryIn,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div className="text-center py-4 text-muted-foreground">
|
</div>
|
||||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
|
);
|
||||||
<p className="text-sm">{t('tunnels.noTunnelConnections')}</p>
|
})}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
) : (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||||
|
<div className="p-4">
|
||||||
|
{!compact && (
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{host.pin && (
|
||||||
|
<Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-card-foreground truncate">
|
||||||
|
{host.name || `${host.username}@${host.ip}`}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{host.ip}:{host.port} • {host.username}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!compact && host.tags && host.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
|
{host.tags.slice(0, 3).map((tag, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs px-1 py-0"
|
||||||
|
>
|
||||||
|
<Tag className="h-2 w-2 mr-0.5" />
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{host.tags.length > 3 && (
|
||||||
|
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||||
|
+{host.tags.length - 3}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!compact && <Separator className="mb-3" />}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{!compact && (
|
||||||
|
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||||
|
<Network className="h-4 w-4" />
|
||||||
|
{t("tunnels.tunnelConnections")} ({host.tunnelConnections.length})
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||||
|
const status = getTunnelStatus(tunnelIndex);
|
||||||
|
const statusDisplay = getTunnelStatusDisplay(status);
|
||||||
|
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||||
|
const isActionLoading = tunnelActions[tunnelName];
|
||||||
|
const statusValue =
|
||||||
|
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||||
|
const isConnected = statusValue === "CONNECTED";
|
||||||
|
const isConnecting = statusValue === "CONNECTING";
|
||||||
|
const isDisconnecting = statusValue === "DISCONNECTING";
|
||||||
|
const isRetrying = statusValue === "RETRYING";
|
||||||
|
const isWaiting = statusValue === "WAITING";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tunnelIndex}
|
||||||
|
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
|
||||||
|
>
|
||||||
|
{statusDisplay.icon}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium break-words">
|
||||||
|
{t("tunnels.port")} {tunnel.sourcePort} →{" "}
|
||||||
|
{tunnel.endpointHost}:{tunnel.endpointPort}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-xs ${statusDisplay.color} font-medium`}
|
||||||
|
>
|
||||||
|
{statusDisplay.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{!isActionLoading && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{isConnected ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onTunnelAction(
|
||||||
|
"disconnect",
|
||||||
|
host,
|
||||||
|
tunnelIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3 mr-1" />
|
||||||
|
{t("tunnels.disconnect")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : isRetrying || isWaiting ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onTunnelAction("cancel", host, tunnelIndex)
|
||||||
|
}
|
||||||
|
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
{t("tunnels.cancel")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onTunnelAction("connect", host, tunnelIndex)
|
||||||
|
}
|
||||||
|
disabled={isConnecting || isDisconnecting}
|
||||||
|
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||||
|
>
|
||||||
|
<Play className="h-3 w-3 mr-1" />
|
||||||
|
{t("tunnels.connect")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isActionLoading && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled
|
||||||
|
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||||
|
>
|
||||||
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
{isConnected
|
||||||
|
? t("tunnels.disconnecting")
|
||||||
|
: isRetrying || isWaiting
|
||||||
|
? t("tunnels.canceling")
|
||||||
|
: t("tunnels.connecting")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(statusValue === "ERROR" || statusValue === "FAILED") &&
|
||||||
|
status?.reason && (
|
||||||
|
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||||
|
<div className="font-medium mb-1">
|
||||||
|
{t("tunnels.error")}:
|
||||||
|
</div>
|
||||||
|
{status.reason}
|
||||||
|
{status.reason &&
|
||||||
|
status.reason.includes("Max retries exhausted") && (
|
||||||
|
<>
|
||||||
|
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||||
|
{t("tunnels.checkDockerLogs")}{" "}
|
||||||
|
<a
|
||||||
|
href="https://discord.com/invite/jVQGdvHDrf"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
Discord
|
||||||
|
</a>{" "}
|
||||||
|
or create a{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/LukeGus/Termix/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
GitHub issue
|
||||||
|
</a>{" "}
|
||||||
|
for help.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(statusValue === "RETRYING" ||
|
||||||
|
statusValue === "WAITING") &&
|
||||||
|
status?.retryCount &&
|
||||||
|
status?.maxRetries && (
|
||||||
|
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||||
|
<div className="font-medium mb-1">
|
||||||
|
{statusValue === "WAITING"
|
||||||
|
? t("tunnels.waitingForRetry")
|
||||||
|
: t("tunnels.retryingConnection")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("tunnels.attempt", {
|
||||||
|
current: status.retryCount,
|
||||||
|
max: status.maxRetries,
|
||||||
|
})}
|
||||||
|
{status.nextRetryIn && (
|
||||||
|
<span>
|
||||||
|
{" "}
|
||||||
|
•{" "}
|
||||||
|
{t("tunnels.nextRetryIn", {
|
||||||
|
seconds: status.nextRetryIn,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
|
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,56 +1,77 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {TunnelObject} from "./TunnelObject.tsx";
|
import { TunnelObject } from "./TunnelObject.tsx";
|
||||||
import {useTranslation} from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import type {SSHHost, TunnelConnection, TunnelStatus} from '../../../types/index.js';
|
import type {
|
||||||
|
SSHHost,
|
||||||
|
TunnelConnection,
|
||||||
|
TunnelStatus,
|
||||||
|
} from "../../../types/index.js";
|
||||||
|
|
||||||
interface SSHTunnelViewerProps {
|
interface SSHTunnelViewerProps {
|
||||||
hosts: SSHHost[];
|
hosts: SSHHost[];
|
||||||
tunnelStatuses: Record<string, TunnelStatus>;
|
tunnelStatuses: Record<string, TunnelStatus>;
|
||||||
tunnelActions: Record<string, boolean>;
|
tunnelActions: Record<string, boolean>;
|
||||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
onTunnelAction: (
|
||||||
|
action: "connect" | "disconnect" | "cancel",
|
||||||
|
host: SSHHost,
|
||||||
|
tunnelIndex: number,
|
||||||
|
) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TunnelViewer({
|
export function TunnelViewer({
|
||||||
hosts = [],
|
hosts = [],
|
||||||
tunnelStatuses = {},
|
tunnelStatuses = {},
|
||||||
tunnelActions = {},
|
tunnelActions = {},
|
||||||
onTunnelAction
|
onTunnelAction,
|
||||||
}: SSHTunnelViewerProps): React.ReactElement {
|
}: SSHTunnelViewerProps): React.ReactElement {
|
||||||
const {t} = useTranslation();
|
const { t } = useTranslation();
|
||||||
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
const activeHost: SSHHost | undefined =
|
||||||
|
Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||||
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">{t('tunnels.noSshTunnels')}</h3>
|
|
||||||
<p className="text-muted-foreground max-w-md">
|
|
||||||
{t('tunnels.createFirstTunnelMessage')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (
|
||||||
|
!activeHost ||
|
||||||
|
!activeHost.tunnelConnections ||
|
||||||
|
activeHost.tunnelConnections.length === 0
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||||
<div className="w-full flex-shrink-0 mb-2">
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
<h1 className="text-xl font-semibold text-foreground">{t('tunnels.title')}</h1>
|
{t("tunnels.noSshTunnels")}
|
||||||
</div>
|
</h3>
|
||||||
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
<p className="text-muted-foreground max-w-md">
|
||||||
<div
|
{t("tunnels.createFirstTunnelMessage")}
|
||||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
</p>
|
||||||
{activeHost.tunnelConnections.map((t, idx) => (
|
</div>
|
||||||
<TunnelObject
|
|
||||||
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
|
||||||
host={{...activeHost, tunnelConnections: [activeHost.tunnelConnections[idx]]}}
|
|
||||||
tunnelStatuses={tunnelStatuses}
|
|
||||||
tunnelActions={tunnelActions}
|
|
||||||
onTunnelAction={(action, _host, _index) => onTunnelAction(action, activeHost, idx)}
|
|
||||||
compact
|
|
||||||
bare
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||||
|
<div className="w-full flex-shrink-0 mb-2">
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
|
{t("tunnels.title")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
|
{activeHost.tunnelConnections.map((t, idx) => (
|
||||||
|
<TunnelObject
|
||||||
|
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
||||||
|
host={{
|
||||||
|
...activeHost,
|
||||||
|
tunnelConnections: [activeHost.tunnelConnections[idx]],
|
||||||
|
}}
|
||||||
|
tunnelStatuses={tunnelStatuses}
|
||||||
|
tunnelActions={tunnelActions}
|
||||||
|
onTunnelAction={(action, _host, _index) =>
|
||||||
|
onTunnelAction(action, activeHost, idx)
|
||||||
|
}
|
||||||
|
compact
|
||||||
|
bare
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+173
-151
@@ -1,88 +1,103 @@
|
|||||||
import React, {useState, useEffect} from "react"
|
import React, { useState, useEffect } from "react";
|
||||||
import {LeftSidebar} from "@/ui/Desktop/Navigation/LeftSidebar.tsx"
|
import { LeftSidebar } from "@/ui/Desktop/Navigation/LeftSidebar.tsx";
|
||||||
import {Homepage} from "@/ui/Desktop/Homepage/Homepage.tsx"
|
import { Homepage } from "@/ui/Desktop/Homepage/Homepage.tsx";
|
||||||
import {AppView} from "@/ui/Desktop/Navigation/AppView.tsx"
|
import { AppView } from "@/ui/Desktop/Navigation/AppView.tsx";
|
||||||
import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx"
|
import { HostManager } from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx";
|
||||||
import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"
|
import {
|
||||||
import {TopNavbar} from "@/ui/Desktop/Navigation/TopNavbar.tsx";
|
TabProvider,
|
||||||
import {AdminSettings} from "@/ui/Desktop/Admin/AdminSettings.tsx";
|
useTabs,
|
||||||
import {UserProfile} from "@/ui/Desktop/User/UserProfile.tsx";
|
} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||||
import {Toaster} from "@/components/ui/sonner.tsx";
|
import { TopNavbar } from "@/ui/Desktop/Navigation/TopNavbar.tsx";
|
||||||
import {getUserInfo, getCookie} from "@/ui/main-axios.ts";
|
import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
|
||||||
|
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
|
||||||
|
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||||
|
import { getUserInfo, getCookie } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const [view, setView] = useState<string>("homepage")
|
const [view, setView] = useState<string>("homepage");
|
||||||
const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"]))
|
const [mountedViews, setMountedViews] = useState<Set<string>>(
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
new Set(["homepage"]),
|
||||||
const [username, setUsername] = useState<string | null>(null)
|
);
|
||||||
const [isAdmin, setIsAdmin] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [authLoading, setAuthLoading] = useState(true)
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true)
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const {currentTab, tabs} = useTabs();
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
|
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
|
||||||
|
const { currentTab, tabs } = useTabs();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
getUserInfo()
|
getUserInfo()
|
||||||
.then((meRes) => {
|
.then((meRes) => {
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
setIsAdmin(!!meRes.is_admin);
|
setIsAdmin(!!meRes.is_admin);
|
||||||
setUsername(meRes.username || null);
|
setUsername(meRes.username || null);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
setUsername(null);
|
setUsername(null);
|
||||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
document.cookie =
|
||||||
})
|
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||||
.finally(() => setAuthLoading(false));
|
})
|
||||||
} else {
|
.finally(() => setAuthLoading(false));
|
||||||
setIsAuthenticated(false);
|
} else {
|
||||||
setIsAdmin(false);
|
setIsAuthenticated(false);
|
||||||
setUsername(null);
|
setIsAdmin(false);
|
||||||
setAuthLoading(false);
|
setUsername(null);
|
||||||
}
|
setAuthLoading(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
checkAuth()
|
checkAuth();
|
||||||
|
|
||||||
const handleStorageChange = () => checkAuth()
|
const handleStorageChange = () => checkAuth();
|
||||||
window.addEventListener('storage', handleStorageChange)
|
window.addEventListener("storage", handleStorageChange);
|
||||||
|
|
||||||
return () => window.removeEventListener('storage', handleStorageChange)
|
return () => window.removeEventListener("storage", handleStorageChange);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleSelectView = (nextView: string) => {
|
const handleSelectView = (nextView: string) => {
|
||||||
setMountedViews((prev) => {
|
setMountedViews((prev) => {
|
||||||
if (prev.has(nextView)) return prev
|
if (prev.has(nextView)) return prev;
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
next.add(nextView)
|
next.add(nextView);
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
setView(nextView)
|
setView(nextView);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => {
|
const handleAuthSuccess = (authData: {
|
||||||
setIsAuthenticated(true)
|
isAdmin: boolean;
|
||||||
setIsAdmin(authData.isAdmin)
|
username: string | null;
|
||||||
setUsername(authData.username)
|
userId: string | null;
|
||||||
}
|
}) => {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setIsAdmin(authData.isAdmin);
|
||||||
|
setUsername(authData.username);
|
||||||
|
};
|
||||||
|
|
||||||
const currentTabData = tabs.find(tab => tab.id === currentTab);
|
const currentTabData = tabs.find((tab) => tab.id === currentTab);
|
||||||
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
|
const showTerminalView =
|
||||||
const showHome = currentTabData?.type === 'home';
|
currentTabData?.type === "terminal" ||
|
||||||
const showSshManager = currentTabData?.type === 'ssh_manager';
|
currentTabData?.type === "server" ||
|
||||||
const showAdmin = currentTabData?.type === 'admin';
|
currentTabData?.type === "file_manager";
|
||||||
const showProfile = currentTabData?.type === 'user_profile';
|
const showHome = currentTabData?.type === "home";
|
||||||
|
const showSshManager = currentTabData?.type === "ssh_manager";
|
||||||
|
const showAdmin = currentTabData?.type === "admin";
|
||||||
|
const showProfile = currentTabData?.type === "user_profile";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
|
{!isAuthenticated && !authLoading && (
|
||||||
<div>
|
<div>
|
||||||
{!isAuthenticated && !authLoading && (
|
<div
|
||||||
<div>
|
className="absolute inset-0"
|
||||||
<div className="absolute inset-0" style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(
|
backgroundImage: `linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
transparent 49%,
|
transparent 49%,
|
||||||
@@ -91,86 +106,93 @@ function AppContent() {
|
|||||||
transparent 51%,
|
transparent 51%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
)`,
|
)`,
|
||||||
backgroundSize: '80px 80px'
|
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}
|
|
||||||
>
|
|
||||||
{showTerminalView && (
|
|
||||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
|
||||||
<AppView isTopbarOpen={isTopbarOpen}/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showHome && (
|
|
||||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
|
||||||
<Homepage
|
|
||||||
onSelectView={handleSelectView}
|
|
||||||
isAuthenticated={isAuthenticated}
|
|
||||||
authLoading={authLoading}
|
|
||||||
onAuthSuccess={handleAuthSuccess}
|
|
||||||
isTopbarOpen={isTopbarOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSshManager && (
|
|
||||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
|
||||||
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen}/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAdmin && (
|
|
||||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
|
||||||
<AdminSettings isTopbarOpen={isTopbarOpen}/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showProfile && (
|
|
||||||
<div className="h-screen w-full visible pointer-events-auto static overflow-auto">
|
|
||||||
<UserProfile isTopbarOpen={isTopbarOpen}/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
|
||||||
</LeftSidebar>
|
|
||||||
)}
|
|
||||||
<Toaster
|
|
||||||
position="bottom-right"
|
|
||||||
richColors={false}
|
|
||||||
closeButton
|
|
||||||
duration={5000}
|
|
||||||
offset={20}
|
|
||||||
/>
|
|
||||||
</div>
|
</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}
|
||||||
|
>
|
||||||
|
{showTerminalView && (
|
||||||
|
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||||
|
<AppView isTopbarOpen={isTopbarOpen} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showHome && (
|
||||||
|
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||||
|
<Homepage
|
||||||
|
onSelectView={handleSelectView}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
authLoading={authLoading}
|
||||||
|
onAuthSuccess={handleAuthSuccess}
|
||||||
|
isTopbarOpen={isTopbarOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSshManager && (
|
||||||
|
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||||
|
<HostManager
|
||||||
|
onSelectView={handleSelectView}
|
||||||
|
isTopbarOpen={isTopbarOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdmin && (
|
||||||
|
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||||
|
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showProfile && (
|
||||||
|
<div className="h-screen w-full visible pointer-events-auto static 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 DesktopApp() {
|
function DesktopApp() {
|
||||||
return (
|
return (
|
||||||
<TabProvider>
|
<TabProvider>
|
||||||
<AppContent/>
|
<AppContent />
|
||||||
</TabProvider>
|
</TabProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DesktopApp
|
export default DesktopApp;
|
||||||
|
|||||||
@@ -1,216 +1,233 @@
|
|||||||
import React, {useState, useEffect} from 'react';
|
import React, { useState, useEffect } from "react";
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {Input} from '@/components/ui/input.tsx';
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import {Label} from '@/components/ui/label.tsx';
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
import {Alert, AlertTitle, AlertDescription} from '@/components/ui/alert.tsx';
|
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
|
||||||
import {useTranslation} from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import {getServerConfig, saveServerConfig, testServerConnection, type ServerConfig} from '@/ui/main-axios.ts';
|
import {
|
||||||
import {CheckCircle, XCircle, Server, Wifi} from 'lucide-react';
|
getServerConfig,
|
||||||
|
saveServerConfig,
|
||||||
|
testServerConnection,
|
||||||
|
type ServerConfig,
|
||||||
|
} from "@/ui/main-axios.ts";
|
||||||
|
import { CheckCircle, XCircle, Server, Wifi } from "lucide-react";
|
||||||
|
|
||||||
interface ServerConfigProps {
|
interface ServerConfigProps {
|
||||||
onServerConfigured: (serverUrl: string) => void;
|
onServerConfigured: (serverUrl: string) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
isFirstTime?: boolean;
|
isFirstTime?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}: ServerConfigProps) {
|
export function ServerConfig({
|
||||||
const {t} = useTranslation();
|
onServerConfigured,
|
||||||
const [serverUrl, setServerUrl] = useState('');
|
onCancel,
|
||||||
const [loading, setLoading] = useState(false);
|
isFirstTime = false,
|
||||||
const [testing, setTesting] = useState(false);
|
}: ServerConfigProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const { t } = useTranslation();
|
||||||
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown');
|
const [serverUrl, setServerUrl] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<
|
||||||
|
"unknown" | "success" | "error"
|
||||||
|
>("unknown");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadServerConfig();
|
loadServerConfig();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadServerConfig = async () => {
|
const loadServerConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const config = await getServerConfig();
|
const config = await getServerConfig();
|
||||||
if (config?.serverUrl) {
|
if (config?.serverUrl) {
|
||||||
setServerUrl(config.serverUrl);
|
setServerUrl(config.serverUrl);
|
||||||
setConnectionStatus('success');
|
setConnectionStatus("success");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
if (!serverUrl.trim()) {
|
if (!serverUrl.trim()) {
|
||||||
setError(t('serverConfig.enterServerUrl'));
|
setError(t("serverConfig.enterServerUrl"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let normalizedUrl = serverUrl.trim();
|
let normalizedUrl = serverUrl.trim();
|
||||||
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
|
if (
|
||||||
normalizedUrl = `http://${normalizedUrl}`;
|
!normalizedUrl.startsWith("http://") &&
|
||||||
}
|
!normalizedUrl.startsWith("https://")
|
||||||
|
) {
|
||||||
|
normalizedUrl = `http://${normalizedUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await testServerConnection(normalizedUrl);
|
const result = await testServerConnection(normalizedUrl);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setConnectionStatus('success');
|
setConnectionStatus("success");
|
||||||
} else {
|
} else {
|
||||||
setConnectionStatus('error');
|
setConnectionStatus("error");
|
||||||
setError(result.error || t('serverConfig.connectionFailed'));
|
setError(result.error || t("serverConfig.connectionFailed"));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setConnectionStatus('error');
|
setConnectionStatus("error");
|
||||||
setError(t('serverConfig.connectionError'));
|
setError(t("serverConfig.connectionError"));
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveConfig = async () => {
|
const handleSaveConfig = async () => {
|
||||||
if (!serverUrl.trim()) {
|
if (!serverUrl.trim()) {
|
||||||
setError(t('serverConfig.enterServerUrl'));
|
setError(t("serverConfig.enterServerUrl"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connectionStatus !== 'success') {
|
if (connectionStatus !== "success") {
|
||||||
setError(t('serverConfig.testConnectionFirst'));
|
setError(t("serverConfig.testConnectionFirst"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let normalizedUrl = serverUrl.trim();
|
let normalizedUrl = serverUrl.trim();
|
||||||
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
|
if (
|
||||||
normalizedUrl = `http://${normalizedUrl}`;
|
!normalizedUrl.startsWith("http://") &&
|
||||||
}
|
!normalizedUrl.startsWith("https://")
|
||||||
|
) {
|
||||||
|
normalizedUrl = `http://${normalizedUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
const config: ServerConfig = {
|
const config: ServerConfig = {
|
||||||
serverUrl: normalizedUrl,
|
serverUrl: normalizedUrl,
|
||||||
lastUpdated: new Date().toISOString()
|
lastUpdated: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const success = await saveServerConfig(config);
|
const success = await saveServerConfig(config);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
onServerConfigured(normalizedUrl);
|
onServerConfigured(normalizedUrl);
|
||||||
} else {
|
} else {
|
||||||
setError(t('serverConfig.saveFailed'));
|
setError(t("serverConfig.saveFailed"));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(t('serverConfig.saveError'));
|
setError(t("serverConfig.saveError"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUrlChange = (value: string) => {
|
const handleUrlChange = (value: string) => {
|
||||||
setServerUrl(value);
|
setServerUrl(value);
|
||||||
setConnectionStatus('unknown');
|
setConnectionStatus("unknown");
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||||
<Server className="w-6 h-6 text-primary"/>
|
<Server className="w-6 h-6 text-primary" />
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold">{t('serverConfig.title')}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
|
||||||
{t('serverConfig.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="server-url">{t('serverConfig.serverUrl')}</Label>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Input
|
|
||||||
id="server-url"
|
|
||||||
type="text"
|
|
||||||
placeholder="http://localhost:8081 or https://your-server.com"
|
|
||||||
value={serverUrl}
|
|
||||||
onChange={(e) => handleUrlChange(e.target.value)}
|
|
||||||
className="flex-1 h-10"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleTestConnection}
|
|
||||||
disabled={testing || !serverUrl.trim() || loading}
|
|
||||||
className="w-10 h-10 p-0 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
{testing ? (
|
|
||||||
<div
|
|
||||||
className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"/>
|
|
||||||
) : (
|
|
||||||
<Wifi className="w-4 h-4"/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{connectionStatus !== 'unknown' && (
|
|
||||||
<div className="flex items-center space-x-2 text-sm">
|
|
||||||
{connectionStatus === 'success' ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-500"/>
|
|
||||||
<span className="text-green-600">{t('serverConfig.connected')}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<XCircle className="w-4 h-4 text-red-500"/>
|
|
||||||
<span className="text-red-600">{t('serverConfig.disconnected')}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{onCancel && !isFirstTime && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
|
|
||||||
onClick={handleSaveConfig}
|
|
||||||
disabled={loading || testing || connectionStatus !== 'success'}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"/>
|
|
||||||
<span>{t('serverConfig.saving')}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
t('serverConfig.saveConfig')
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
|
||||||
{t('serverConfig.helpText')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<h2 className="text-xl font-semibold">{t("serverConfig.title")}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{t("serverConfig.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Input
|
||||||
|
id="server-url"
|
||||||
|
type="text"
|
||||||
|
placeholder="http://localhost:8081 or https://your-server.com"
|
||||||
|
value={serverUrl}
|
||||||
|
onChange={(e) => handleUrlChange(e.target.value)}
|
||||||
|
className="flex-1 h-10"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={testing || !serverUrl.trim() || loading}
|
||||||
|
className="w-10 h-10 p-0 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Wifi className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connectionStatus !== "unknown" && (
|
||||||
|
<div className="flex items-center space-x-2 text-sm">
|
||||||
|
{connectionStatus === "success" ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-green-600">
|
||||||
|
{t("serverConfig.connected")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle className="w-4 h-4 text-red-500" />
|
||||||
|
<span className="text-red-600">
|
||||||
|
{t("serverConfig.disconnected")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{onCancel && !isFirstTime && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
|
||||||
|
onClick={handleSaveConfig}
|
||||||
|
disabled={loading || testing || connectionStatus !== "success"}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>{t("serverConfig.saving")}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t("serverConfig.saveConfig")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
|
{t("serverConfig.helpText")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +1,155 @@
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {HomepageAuth} from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
|
import { HomepageAuth } from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
|
||||||
import {HomepageUpdateLog} from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
|
import { HomepageUpdateLog } from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {getUserInfo, getDatabaseHealth, getCookie} from "@/ui/main-axios.ts";
|
import { getUserInfo, getDatabaseHealth, getCookie } from "@/ui/main-axios.ts";
|
||||||
import {useTranslation} from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface HomepageProps {
|
interface HomepageProps {
|
||||||
onSelectView: (view: string) => void;
|
onSelectView: (view: string) => void;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
authLoading: boolean;
|
authLoading: boolean;
|
||||||
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
|
onAuthSuccess: (authData: {
|
||||||
isTopbarOpen: boolean;
|
isAdmin: boolean;
|
||||||
|
username: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
}) => void;
|
||||||
|
isTopbarOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Homepage({
|
export function Homepage({
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
authLoading,
|
authLoading,
|
||||||
onAuthSuccess,
|
onAuthSuccess,
|
||||||
isTopbarOpen
|
isTopbarOpen,
|
||||||
}: HomepageProps): React.ReactElement {
|
}: HomepageProps): React.ReactElement {
|
||||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [username, setUsername] = useState<string | null>(null);
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
const [userId, setUserId] = useState<string | null>(null);
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
const [dbError, setDbError] = useState<string | null>(null);
|
const [dbError, setDbError] = useState<string | null>(null);
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
const leftMarginPx = 26;
|
const leftMarginPx = 26;
|
||||||
const bottomMarginPx = 8;
|
const bottomMarginPx = 8;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoggedIn(isAuthenticated);
|
setLoggedIn(isAuthenticated);
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
const jwt = getCookie("jwt");
|
const jwt = getCookie("jwt");
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
Promise.all([
|
Promise.all([getUserInfo(), getDatabaseHealth()])
|
||||||
getUserInfo(),
|
.then(([meRes]) => {
|
||||||
getDatabaseHealth()
|
setIsAdmin(!!meRes.is_admin);
|
||||||
])
|
setUsername(meRes.username || null);
|
||||||
.then(([meRes]) => {
|
setUserId(meRes.id || null);
|
||||||
setIsAdmin(!!meRes.is_admin);
|
setDbError(null);
|
||||||
setUsername(meRes.username || null);
|
})
|
||||||
setUserId(meRes.id || null);
|
.catch((err) => {
|
||||||
setDbError(null);
|
setIsAdmin(false);
|
||||||
})
|
setUsername(null);
|
||||||
.catch((err) => {
|
setUserId(null);
|
||||||
setIsAdmin(false);
|
if (err?.response?.data?.error?.includes("Database")) {
|
||||||
setUsername(null);
|
setDbError(
|
||||||
setUserId(null);
|
"Could not connect to the database. Please try again later.",
|
||||||
if (err?.response?.data?.error?.includes("Database")) {
|
);
|
||||||
setDbError("Could not connect to the database. Please try again later.");
|
} else {
|
||||||
} else {
|
setDbError(null);
|
||||||
setDbError(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}, [isAuthenticated]);
|
}
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!loggedIn ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<HomepageAuth
|
||||||
|
setLoggedIn={setLoggedIn}
|
||||||
|
setIsAdmin={setIsAdmin}
|
||||||
|
setUsername={setUsername}
|
||||||
|
setUserId={setUserId}
|
||||||
|
loggedIn={loggedIn}
|
||||||
|
authLoading={authLoading}
|
||||||
|
dbError={dbError}
|
||||||
|
setDbError={setDbError}
|
||||||
|
onAuthSuccess={onAuthSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-full h-full flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
marginLeft: leftMarginPx,
|
||||||
|
marginRight: 17,
|
||||||
|
marginTop: topMarginPx,
|
||||||
|
marginBottom: bottomMarginPx,
|
||||||
|
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center justify-center gap-8 relative z-10">
|
||||||
|
<div className="flex flex-col items-center gap-6 w-[400px]">
|
||||||
|
<HomepageUpdateLog loggedIn={loggedIn} />
|
||||||
|
|
||||||
return (
|
<div className="flex flex-row items-center gap-3">
|
||||||
<>
|
<Button
|
||||||
{!loggedIn ? (
|
variant="outline"
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
size="sm"
|
||||||
<HomepageAuth
|
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||||
setLoggedIn={setLoggedIn}
|
onClick={() =>
|
||||||
setIsAdmin={setIsAdmin}
|
window.open("https://github.com/LukeGus/Termix", "_blank")
|
||||||
setUsername={setUsername}
|
}
|
||||||
setUserId={setUserId}
|
|
||||||
loggedIn={loggedIn}
|
|
||||||
authLoading={authLoading}
|
|
||||||
dbError={dbError}
|
|
||||||
setDbError={setDbError}
|
|
||||||
onAuthSuccess={onAuthSuccess}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="w-full h-full flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
marginLeft: leftMarginPx,
|
|
||||||
marginRight: 17,
|
|
||||||
marginTop: topMarginPx,
|
|
||||||
marginBottom: bottomMarginPx,
|
|
||||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-center gap-8 relative z-10">
|
GitHub
|
||||||
<div className="flex flex-col items-center gap-6 w-[400px]">
|
</Button>
|
||||||
<HomepageUpdateLog
|
<div className="w-px h-4 bg-dark-border"></div>
|
||||||
loggedIn={loggedIn}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
<div className="flex flex-row items-center gap-3">
|
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||||
<Button
|
onClick={() =>
|
||||||
variant="outline"
|
window.open(
|
||||||
size="sm"
|
"https://github.com/LukeGus/Termix/issues/new",
|
||||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
"_blank",
|
||||||
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
|
)
|
||||||
>
|
}
|
||||||
GitHub
|
>
|
||||||
</Button>
|
Feedback
|
||||||
<div className="w-px h-4 bg-dark-border"></div>
|
</Button>
|
||||||
<Button
|
<div className="w-px h-4 bg-dark-border"></div>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
size="sm"
|
||||||
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
|
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||||
>
|
onClick={() =>
|
||||||
Feedback
|
window.open(
|
||||||
</Button>
|
"https://discord.com/invite/jVQGdvHDrf",
|
||||||
<div className="w-px h-4 bg-dark-border"></div>
|
"_blank",
|
||||||
<Button
|
)
|
||||||
variant="outline"
|
}
|
||||||
size="sm"
|
>
|
||||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
Discord
|
||||||
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
|
</Button>
|
||||||
>
|
<div className="w-px h-4 bg-dark-border"></div>
|
||||||
Discord
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<div className="w-px h-4 bg-dark-border"></div>
|
size="sm"
|
||||||
<Button
|
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||||
variant="outline"
|
onClick={() =>
|
||||||
size="sm"
|
window.open("https://github.com/sponsors/LukeGus", "_blank")
|
||||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
}
|
||||||
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
>
|
||||||
>
|
Donate
|
||||||
Donate
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,143 +1,157 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
import {
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
Card,
|
||||||
import {Badge} from "@/components/ui/badge.tsx";
|
CardContent,
|
||||||
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
|
CardFooter,
|
||||||
import {useTranslation} from "react-i18next";
|
CardHeader,
|
||||||
import type {TermixAlert} from '../../../types/index.js';
|
CardTitle,
|
||||||
|
} from "@/components/ui/card.tsx";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import { Badge } from "@/components/ui/badge.tsx";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
ExternalLink,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TermixAlert } from "../../../types/index.js";
|
||||||
|
|
||||||
interface AlertCardProps {
|
interface AlertCardProps {
|
||||||
alert: TermixAlert;
|
alert: TermixAlert;
|
||||||
onDismiss: (alertId: string) => void;
|
onDismiss: (alertId: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAlertIcon = (type?: string) => {
|
const getAlertIcon = (type?: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'warning':
|
case "warning":
|
||||||
return <AlertTriangle className="h-5 w-5 text-yellow-500"/>;
|
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||||
case 'error':
|
case "error":
|
||||||
return <AlertCircle className="h-5 w-5 text-red-500"/>;
|
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||||
case 'success':
|
case "success":
|
||||||
return <CheckCircle className="h-5 w-5 text-green-500"/>;
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
case 'info':
|
case "info":
|
||||||
default:
|
default:
|
||||||
return <Info className="h-5 w-5 text-blue-500"/>;
|
return <Info className="h-5 w-5 text-blue-500" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPriorityBadgeVariant = (priority?: string) => {
|
const getPriorityBadgeVariant = (priority?: string) => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case 'critical':
|
case "critical":
|
||||||
return 'destructive';
|
return "destructive";
|
||||||
case 'high':
|
case "high":
|
||||||
return 'destructive';
|
return "destructive";
|
||||||
case 'medium':
|
case "medium":
|
||||||
return 'secondary';
|
return "secondary";
|
||||||
case 'low':
|
case "low":
|
||||||
default:
|
default:
|
||||||
return 'outline';
|
return "outline";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTypeBadgeVariant = (type?: string) => {
|
const getTypeBadgeVariant = (type?: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'warning':
|
case "warning":
|
||||||
return 'secondary';
|
return "secondary";
|
||||||
case 'error':
|
case "error":
|
||||||
return 'destructive';
|
return "destructive";
|
||||||
case 'success':
|
case "success":
|
||||||
return 'default';
|
return "default";
|
||||||
case 'info':
|
case "info":
|
||||||
default:
|
default:
|
||||||
return 'outline';
|
return "outline";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement {
|
export function HomepageAlertCard({
|
||||||
const {t} = useTranslation();
|
alert,
|
||||||
|
onDismiss,
|
||||||
|
onClose,
|
||||||
|
}: AlertCardProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!alert) {
|
if (!alert) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDismiss = () => {
|
const handleDismiss = () => {
|
||||||
onDismiss(alert.id);
|
onDismiss(alert.id);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatExpiryDate = (expiryString: string) => {
|
const formatExpiryDate = (expiryString: string) => {
|
||||||
const expiryDate = new Date(expiryString);
|
const expiryDate = new Date(expiryString);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffTime = expiryDate.getTime() - now.getTime();
|
const diffTime = expiryDate.getTime() - now.getTime();
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (diffDays < 0) return t('common.expired');
|
if (diffDays < 0) return t("common.expired");
|
||||||
if (diffDays === 0) return t('common.expiresToday');
|
if (diffDays === 0) return t("common.expiresToday");
|
||||||
if (diffDays === 1) return t('common.expiresTomorrow');
|
if (diffDays === 1) return t("common.expiresTomorrow");
|
||||||
return t('common.expiresInDays', {days: diffDays});
|
return t("common.expiresInDays", { days: diffDays });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-2xl mx-auto">
|
<Card className="w-full max-w-2xl mx-auto">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{getAlertIcon(alert.type)}
|
{getAlertIcon(alert.type)}
|
||||||
<CardTitle className="text-xl font-bold">
|
<CardTitle className="text-xl font-bold">{alert.title}</CardTitle>
|
||||||
{alert.title}
|
</div>
|
||||||
</CardTitle>
|
<Button
|
||||||
</div>
|
variant="ghost"
|
||||||
<Button
|
size="icon"
|
||||||
variant="ghost"
|
onClick={onClose}
|
||||||
size="icon"
|
className="h-8 w-8 p-0"
|
||||||
onClick={onClose}
|
>
|
||||||
className="h-8 w-8 p-0"
|
<X className="h-4 w-4" />
|
||||||
>
|
</Button>
|
||||||
<X className="h-4 w-4"/>
|
</div>
|
||||||
</Button>
|
<div className="flex items-center gap-2 mt-2">
|
||||||
</div>
|
{alert.priority && (
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
|
||||||
{alert.priority && (
|
{alert.priority.toUpperCase()}
|
||||||
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
|
</Badge>
|
||||||
{alert.priority.toUpperCase()}
|
)}
|
||||||
</Badge>
|
{alert.type && (
|
||||||
)}
|
<Badge variant={getTypeBadgeVariant(alert.type)}>
|
||||||
{alert.type && (
|
{alert.type}
|
||||||
<Badge variant={getTypeBadgeVariant(alert.type)}>
|
</Badge>
|
||||||
{alert.type}
|
)}
|
||||||
</Badge>
|
<span className="text-sm text-muted-foreground">
|
||||||
)}
|
{formatExpiryDate(alert.expiresAt)}
|
||||||
<span className="text-sm text-muted-foreground">
|
</span>
|
||||||
{formatExpiryDate(alert.expiresAt)}
|
</div>
|
||||||
</span>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="pb-4">
|
||||||
</CardHeader>
|
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||||
<CardContent className="pb-4">
|
{alert.message}
|
||||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
</p>
|
||||||
{alert.message}
|
</CardContent>
|
||||||
</p>
|
<CardFooter className="flex items-center justify-between pt-0">
|
||||||
</CardContent>
|
<div className="flex gap-2">
|
||||||
<CardFooter className="flex items-center justify-between pt-0">
|
<Button variant="outline" onClick={handleDismiss}>
|
||||||
<div className="flex gap-2">
|
Dismiss
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
{alert.actionUrl && alert.actionText && (
|
||||||
onClick={handleDismiss}
|
<Button
|
||||||
>
|
variant="default"
|
||||||
Dismiss
|
onClick={() =>
|
||||||
</Button>
|
window.open(alert.actionUrl, "_blank", "noopener,noreferrer")
|
||||||
{alert.actionUrl && alert.actionText && (
|
}
|
||||||
<Button
|
className="gap-2"
|
||||||
variant="default"
|
>
|
||||||
onClick={() => window.open(alert.actionUrl, '_blank', 'noopener,noreferrer')}
|
{alert.actionText}
|
||||||
className="gap-2"
|
<ExternalLink className="h-4 w-4" />
|
||||||
>
|
</Button>
|
||||||
{alert.actionText}
|
)}
|
||||||
<ExternalLink className="h-4 w-4"/>
|
</div>
|
||||||
</Button>
|
</CardFooter>
|
||||||
)}
|
</Card>
|
||||||
</div>
|
);
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +1,179 @@
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
|
import { HomepageAlertCard } from "./HomepageAlertCard.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {getUserAlerts, dismissAlert} from "@/ui/main-axios.ts";
|
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
||||||
import {useTranslation} from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {TermixAlert} from '../../../types/index.js';
|
import type { TermixAlert } from "../../../types/index.js";
|
||||||
|
|
||||||
interface AlertManagerProps {
|
interface AlertManagerProps {
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement {
|
export function HomepageAlertManager({
|
||||||
const {t} = useTranslation();
|
userId,
|
||||||
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
loggedIn,
|
||||||
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
}: AlertManagerProps): React.ReactElement {
|
||||||
const [loading, setLoading] = useState(false);
|
const { t } = useTranslation();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
||||||
|
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loggedIn && userId) {
|
if (loggedIn && userId) {
|
||||||
fetchUserAlerts();
|
fetchUserAlerts();
|
||||||
}
|
|
||||||
}, [loggedIn, userId]);
|
|
||||||
|
|
||||||
const fetchUserAlerts = async () => {
|
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await getUserAlerts(userId);
|
|
||||||
|
|
||||||
const userAlerts = response.alerts || [];
|
|
||||||
|
|
||||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
|
||||||
const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
|
|
||||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
|
|
||||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
|
|
||||||
|
|
||||||
if (aPriority !== bPriority) {
|
|
||||||
return bPriority - aPriority;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
setAlerts(sortedAlerts);
|
|
||||||
setCurrentAlertIndex(0);
|
|
||||||
} catch (err) {
|
|
||||||
const {toast} = await import('sonner');
|
|
||||||
toast.error(t('homepage.failedToLoadAlerts'));
|
|
||||||
setError(t('homepage.failedToLoadAlerts'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDismissAlert = async (alertId: string) => {
|
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await dismissAlert(userId, alertId);
|
|
||||||
|
|
||||||
setAlerts(prev => {
|
|
||||||
const newAlerts = prev.filter(alert => alert.id !== alertId);
|
|
||||||
return newAlerts;
|
|
||||||
});
|
|
||||||
|
|
||||||
setCurrentAlertIndex(prevIndex => {
|
|
||||||
const newAlertsLength = alerts.length - 1;
|
|
||||||
if (newAlertsLength === 0) return 0;
|
|
||||||
if (prevIndex >= newAlertsLength) return Math.max(0, newAlertsLength - 1);
|
|
||||||
return prevIndex;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
setError(t('homepage.failedToDismissAlert'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseCurrentAlert = () => {
|
|
||||||
if (alerts.length === 0) return;
|
|
||||||
|
|
||||||
if (currentAlertIndex < alerts.length - 1) {
|
|
||||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
|
||||||
} else {
|
|
||||||
setAlerts([]);
|
|
||||||
setCurrentAlertIndex(0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreviousAlert = () => {
|
|
||||||
if (currentAlertIndex > 0) {
|
|
||||||
setCurrentAlertIndex(currentAlertIndex - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextAlert = () => {
|
|
||||||
if (currentAlertIndex < alerts.length - 1) {
|
|
||||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!loggedIn || !userId) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
}, [loggedIn, userId]);
|
||||||
|
|
||||||
if (alerts.length === 0) {
|
const fetchUserAlerts = async () => {
|
||||||
return null;
|
if (!userId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getUserAlerts(userId);
|
||||||
|
|
||||||
|
const userAlerts = response.alerts || [];
|
||||||
|
|
||||||
|
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||||
|
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||||
|
const aPriority =
|
||||||
|
priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
|
||||||
|
const bPriority =
|
||||||
|
priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
|
||||||
|
|
||||||
|
if (aPriority !== bPriority) {
|
||||||
|
return bPriority - aPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setAlerts(sortedAlerts);
|
||||||
|
setCurrentAlertIndex(0);
|
||||||
|
} catch (err) {
|
||||||
|
const { toast } = await import("sonner");
|
||||||
|
toast.error(t("homepage.failedToLoadAlerts"));
|
||||||
|
setError(t("homepage.failedToLoadAlerts"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const currentAlert = alerts[currentAlertIndex];
|
const handleDismissAlert = async (alertId: string) => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
if (!currentAlert) {
|
try {
|
||||||
return null;
|
await dismissAlert(userId, alertId);
|
||||||
|
|
||||||
|
setAlerts((prev) => {
|
||||||
|
const newAlerts = prev.filter((alert) => alert.id !== alertId);
|
||||||
|
return newAlerts;
|
||||||
|
});
|
||||||
|
|
||||||
|
setCurrentAlertIndex((prevIndex) => {
|
||||||
|
const newAlertsLength = alerts.length - 1;
|
||||||
|
if (newAlertsLength === 0) return 0;
|
||||||
|
if (prevIndex >= newAlertsLength)
|
||||||
|
return Math.max(0, newAlertsLength - 1);
|
||||||
|
return prevIndex;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(t("homepage.failedToDismissAlert"));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const priorityCounts = {critical: 0, high: 0, medium: 0, low: 0};
|
const handleCloseCurrentAlert = () => {
|
||||||
alerts.forEach(alert => {
|
if (alerts.length === 0) return;
|
||||||
const priority = alert.priority || 'low';
|
|
||||||
priorityCounts[priority as keyof typeof priorityCounts]++;
|
|
||||||
});
|
|
||||||
const hasMultipleAlerts = alerts.length > 1;
|
|
||||||
|
|
||||||
return (
|
if (currentAlertIndex < alerts.length - 1) {
|
||||||
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
|
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||||
<div className="relative w-full max-w-2xl mx-4">
|
} else {
|
||||||
<HomepageAlertCard
|
setAlerts([]);
|
||||||
alert={currentAlert}
|
setCurrentAlertIndex(0);
|
||||||
onDismiss={handleDismissAlert}
|
}
|
||||||
onClose={handleCloseCurrentAlert}
|
};
|
||||||
/>
|
|
||||||
|
|
||||||
{hasMultipleAlerts && (
|
const handlePreviousAlert = () => {
|
||||||
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
|
if (currentAlertIndex > 0) {
|
||||||
<Button
|
setCurrentAlertIndex(currentAlertIndex - 1);
|
||||||
variant="outline"
|
}
|
||||||
size="sm"
|
};
|
||||||
onClick={handlePreviousAlert}
|
|
||||||
disabled={currentAlertIndex === 0}
|
|
||||||
className="h-8 px-3"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{currentAlertIndex + 1} of {alerts.length}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleNextAlert}
|
|
||||||
disabled={currentAlertIndex === alerts.length - 1}
|
|
||||||
className="h-8 px-3"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
const handleNextAlert = () => {
|
||||||
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
|
if (currentAlertIndex < alerts.length - 1) {
|
||||||
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
|
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||||
{error}
|
}
|
||||||
</div>
|
};
|
||||||
</div>
|
|
||||||
)}
|
if (!loggedIn || !userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAlert = alerts[currentAlertIndex];
|
||||||
|
|
||||||
|
if (!currentAlert) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||||
|
alerts.forEach((alert) => {
|
||||||
|
const priority = alert.priority || "low";
|
||||||
|
priorityCounts[priority as keyof typeof priorityCounts]++;
|
||||||
|
});
|
||||||
|
const hasMultipleAlerts = alerts.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
|
||||||
|
<div className="relative w-full max-w-2xl mx-4">
|
||||||
|
<HomepageAlertCard
|
||||||
|
alert={currentAlert}
|
||||||
|
onDismiss={handleDismissAlert}
|
||||||
|
onClose={handleCloseCurrentAlert}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasMultipleAlerts && (
|
||||||
|
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePreviousAlert}
|
||||||
|
disabled={currentAlertIndex === 0}
|
||||||
|
className="h-8 px-3"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{currentAlertIndex + 1} of {alerts.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNextAlert}
|
||||||
|
disabled={currentAlertIndex === alerts.length - 1}
|
||||||
|
className="h-8 px-3"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
|
||||||
|
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
|
||||||
|
{error}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user