Compare commits
102 Commits
release-1.
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cd9de9ac5 | ||
|
|
d85fb26a5d | ||
|
|
7756dc6a4c | ||
|
|
ff76137339 | ||
|
|
61db35daad | ||
|
|
26c1cacc9d | ||
|
|
8dddbaa86d | ||
|
|
d46fafb421 | ||
|
|
25178928a0 | ||
|
|
2fe9c0f854 | ||
|
|
2f68dc018e | ||
|
|
8b8e77214c | ||
|
|
89589dcf9f | ||
|
|
250ad975d4 | ||
|
|
b649e73c80 | ||
|
|
839e36adb9 | ||
|
|
f02c0c3163 | ||
|
|
83c41751ea | ||
|
|
8058ffd217 | ||
|
|
a3db62b0f8 | ||
|
|
dc43bf1329 | ||
|
|
0a7b1b2bd0 | ||
|
|
94e6617638 | ||
|
|
76995dbc1b | ||
|
|
be6cda7d8a | ||
|
|
9130eb68a8 | ||
|
|
200428498f | ||
|
|
f60c9c72aa | ||
|
|
487919cedc | ||
|
|
b046aedcee | ||
|
|
0c5216933a | ||
|
|
191dc8ba24 | ||
|
|
d88c890ba7 | ||
|
|
a34c60947d | ||
|
|
56fddb6fcb | ||
|
|
b9bd00f86e | ||
|
|
d1ae7f659d | ||
|
|
b127c5ff4f | ||
|
|
75a87400eb | ||
|
|
d4dccdb574 | ||
|
|
a34421f5b2 | ||
|
|
7a5cf94d45 | ||
|
|
134f58b38b | ||
|
|
84dcda9363 | ||
|
|
d81cf20f5e | ||
|
|
86fd8574f1 | ||
|
|
6e175e2b36 | ||
|
|
c7fba8dbb1 | ||
|
|
0c2ac176a7 | ||
|
|
59a38dad0a | ||
|
|
6ac536ebad | ||
|
|
12418eb5b2 | ||
|
|
e364e9b38e | ||
|
|
402f9fa909 | ||
|
|
5ac25e2a26 | ||
|
|
5fbac89e67 | ||
|
|
f2fb938e5f | ||
|
|
cef420d1d8 | ||
|
|
94f69cee3f | ||
|
|
23e72aedfd | ||
|
|
7957ed06e4 | ||
|
|
5f1a2d9a17 | ||
|
|
dbe12ce681 | ||
|
|
d66efdb74f | ||
|
|
c3d1855c65 | ||
|
|
4f4e5296a4 | ||
|
|
61da21c507 | ||
|
|
347e8c40df | ||
|
|
f0ae53c41b | ||
|
|
53f9e888df | ||
|
|
c1d06028c3 | ||
|
|
fa64e98ef9 | ||
|
|
2df2c4e73d | ||
|
|
7d904c4a2c | ||
|
|
cf945e3665 | ||
|
|
880907cc93 | ||
|
|
22162e5b9b | ||
|
|
981705e81d | ||
|
|
5445cb2b78 | ||
|
|
2667af9437 | ||
|
|
58947f4455 | ||
|
|
b7f52d4d73 | ||
|
|
b854a4956c | ||
|
|
07367b24b6 | ||
|
|
1b076cc612 | ||
|
|
81d1db09e4 | ||
|
|
f62bb7f773 | ||
|
|
0906ddfe6e | ||
|
|
96864dbeb4 | ||
|
|
3a663f1b47 | ||
|
|
b9d5965aa6 | ||
|
|
47f8d3f23b | ||
|
|
c71b8b4211 | ||
|
|
07a8fc3e50 | ||
|
|
c90c45ec61 | ||
|
|
4e93ac7d88 | ||
|
|
9ce69a80d7 | ||
|
|
602f21b475 | ||
|
|
3b347f7ae5 | ||
|
|
1f83fb68f0 | ||
|
|
a2481362a2 | ||
|
|
f49c32d26f |
578
.coderabbit.yaml
Normal file
@@ -0,0 +1,578 @@
|
||||
language: "en"
|
||||
early_access: false
|
||||
reviews:
|
||||
request_changes_workflow: false
|
||||
high_level_summary: true
|
||||
poem: false
|
||||
review_status: true
|
||||
collapse_walkthrough: false
|
||||
path_filters:
|
||||
- "!**/.xml"
|
||||
- "!**/__generated__/**"
|
||||
- "!**/generated/**"
|
||||
- "!**/*.json"
|
||||
- "!**/*.svg"
|
||||
- "!**/*.png"
|
||||
- "!**/*.jpg"
|
||||
- "!**/*.gif"
|
||||
- "!**/*.lock"
|
||||
- "!**/node_modules/**"
|
||||
- "!**/dist/**"
|
||||
- "!**/public/locales/**"
|
||||
- "!**/repo-images/**"
|
||||
path_instructions:
|
||||
- path: "**/*.{ts,tsx}"
|
||||
instructions: |
|
||||
Review TypeScript and React code for Termix server management platform. Key considerations:
|
||||
|
||||
**Architecture & Patterns:**
|
||||
- Follow the established multi-port backend architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
||||
- Use proper separation between Desktop and Mobile UI components
|
||||
- Maintain consistent state management patterns with React hooks and context
|
||||
- Follow the established tab-based navigation system
|
||||
|
||||
**Database & Backend:**
|
||||
- Use Drizzle ORM with SQLite for database operations
|
||||
- Implement proper JWT authentication middleware patterns
|
||||
- Follow the established API error handling patterns in main-axios.ts
|
||||
- Use proper logging with the structured logger system (apiLogger, authLogger, sshLogger, etc.)
|
||||
- Maintain proper input validation and sanitization
|
||||
|
||||
**UI/UX Guidelines:**
|
||||
- Use Shadcn/UI components with Tailwind CSS for consistent styling
|
||||
- Follow the established theme system with dark/light mode support
|
||||
- Use proper responsive design patterns for Desktop/Mobile views
|
||||
- Implement proper loading states and error handling
|
||||
- Use the established confirmation patterns with useConfirmation hook
|
||||
- Use CSS variables and classes from index.css instead of hardcoding colors
|
||||
- Follow the established color token system (--primary, --secondary, --background, etc.)
|
||||
- Use proper Tailwind CSS classes instead of inline styles
|
||||
- Implement proper focus states and accessibility indicators
|
||||
|
||||
**SSH & Security:**
|
||||
- Implement proper SSH connection management with session handling
|
||||
- Use secure credential storage and management patterns
|
||||
- Follow the established authentication flow (password, key, credential-based)
|
||||
- Implement proper file operation security and validation
|
||||
|
||||
**Code Quality:**
|
||||
- Use proper TypeScript types from the centralized types/index.ts
|
||||
- Follow the established API patterns in main-axios.ts
|
||||
- Implement proper error boundaries and fallback UI
|
||||
- Use proper React patterns (hooks, context, refs)
|
||||
- Maintain consistent naming conventions and file organization
|
||||
- All API interactions should go through main-axios.ts functions, not direct axios calls
|
||||
- Use proper component interaction patterns through props and callbacks
|
||||
- Follow the established state management patterns with useState and useEffect
|
||||
- Use proper event handling and form submission patterns
|
||||
|
||||
**Bug Detection & Fixes:**
|
||||
- Identify and fix memory leaks in useEffect cleanup functions
|
||||
- Fix missing dependency arrays in useEffect hooks
|
||||
- Resolve infinite re-render loops caused by object/array dependencies
|
||||
- Fix race conditions in async operations and API calls
|
||||
- Identify and fix potential null/undefined access errors
|
||||
- Fix improper state updates that cause stale closures
|
||||
- Resolve event handler memory leaks and proper cleanup
|
||||
- Fix improper error handling that could crash the application
|
||||
- Identify and fix accessibility issues and keyboard navigation problems
|
||||
- Fix responsive design issues and mobile compatibility problems
|
||||
- Resolve TypeScript type errors and missing type definitions
|
||||
- Fix improper form validation and submission handling
|
||||
- Identify and fix performance issues and unnecessary re-renders
|
||||
- Fix improper API error handling and user feedback
|
||||
- Resolve authentication state inconsistencies and token management issues
|
||||
|
||||
**Internationalization:**
|
||||
- Use the i18next translation system with proper t() function calls
|
||||
- Support both English and Chinese locales
|
||||
- Use proper translation keys and fallbacks
|
||||
|
||||
**Performance:**
|
||||
- Implement proper cleanup in useEffect hooks
|
||||
- Use proper memoization where appropriate
|
||||
- Follow the established polling and refresh patterns
|
||||
- Implement proper connection pooling and resource management
|
||||
|
||||
**Specific to Termix:**
|
||||
- Maintain compatibility with Electron and web versions
|
||||
- Follow the established terminal integration patterns with xterm.js
|
||||
- Use proper file manager operations and SSH session management
|
||||
- Implement proper tunnel management and status tracking
|
||||
- Follow the established alert and notification system patterns
|
||||
|
||||
Highlight any deviations from these patterns and suggest improvements for maintainability, security, and user experience.
|
||||
|
||||
**General Bug Detection & Fixes:**
|
||||
- Identify and fix common React bugs (missing keys, improper state updates, memory leaks)
|
||||
- Fix TypeScript errors and type safety issues
|
||||
- Resolve accessibility violations and keyboard navigation problems
|
||||
- Fix responsive design issues and mobile compatibility problems
|
||||
- Identify and fix performance bottlenecks and unnecessary re-renders
|
||||
- Fix improper error handling that could crash the application
|
||||
- Resolve security vulnerabilities and improper data handling
|
||||
- Fix improper form validation and user input handling
|
||||
- Identify and fix race conditions and async operation issues
|
||||
- Fix improper cleanup and resource management
|
||||
- Resolve improper authentication and authorization issues
|
||||
- Fix improper API error handling and user feedback
|
||||
- Identify and fix potential null/undefined access errors
|
||||
- Fix improper event handling and memory leaks
|
||||
- Resolve improper state management and data flow issues
|
||||
|
||||
- path: "**/backend/**/*.{ts,js}"
|
||||
instructions: |
|
||||
Review backend code for Termix server management platform. Key considerations:
|
||||
|
||||
**Backend Architecture:**
|
||||
- Follow the multi-port microservice architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
||||
- Use Express.js with proper middleware patterns
|
||||
- Implement proper CORS and security headers
|
||||
- Use proper request/response logging with structured logging
|
||||
|
||||
**Database Operations:**
|
||||
- Use Drizzle ORM with proper schema definitions
|
||||
- Implement proper database migrations and schema updates
|
||||
- Use proper transaction handling for critical operations
|
||||
- Follow the established database connection patterns
|
||||
|
||||
**Authentication & Security:**
|
||||
- Implement proper JWT token validation and refresh
|
||||
- Use bcryptjs for password hashing with proper salt rounds
|
||||
- Implement proper input validation and sanitization
|
||||
- Use proper CORS configuration for security
|
||||
- Implement proper rate limiting and security headers
|
||||
|
||||
**SSH Operations:**
|
||||
- Use ssh2 library with proper connection management
|
||||
- Implement proper SSH key handling and validation
|
||||
- Use proper session management and cleanup
|
||||
- Implement proper error handling for SSH operations
|
||||
- Use proper file operation security and validation
|
||||
|
||||
**API Design:**
|
||||
- Follow RESTful API patterns with proper HTTP status codes
|
||||
- Implement proper error response formatting
|
||||
- Use proper request/response validation
|
||||
- Implement proper API versioning and backward compatibility
|
||||
- All API routes should be defined in main-axios.ts, not scattered across components
|
||||
- Use the established multi-port API architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
||||
- Follow the established error handling patterns with handleApiError function
|
||||
- Use proper structured logging with service-specific loggers (apiLogger, authLogger, sshLogger, etc.)
|
||||
|
||||
**Logging & Monitoring:**
|
||||
- Use the structured logging system with proper context
|
||||
- Implement proper error tracking and reporting
|
||||
- Use proper performance monitoring and metrics
|
||||
- Implement proper health checks and status endpoints
|
||||
|
||||
Highlight any security vulnerabilities, performance issues, or architectural deviations.
|
||||
|
||||
- path: "**/components/**/*.{ts,tsx}"
|
||||
instructions: |
|
||||
Review UI components for Termix server management platform. Key considerations:
|
||||
|
||||
**Component Design:**
|
||||
- Use Shadcn/UI components as the foundation
|
||||
- Implement proper component composition and reusability
|
||||
- Use proper TypeScript interfaces and prop types
|
||||
- Follow the established component naming conventions
|
||||
|
||||
**Styling & Theming:**
|
||||
- Use Tailwind CSS with proper responsive design
|
||||
- Implement proper dark/light theme support
|
||||
- Use proper color tokens and design system consistency
|
||||
- Implement proper accessibility features (ARIA labels, keyboard navigation)
|
||||
- Use CSS variables from index.css instead of hardcoding colors (--primary, --secondary, --background, etc.)
|
||||
- Follow the established color scheme and design tokens
|
||||
- Use proper Tailwind CSS utility classes instead of custom CSS
|
||||
- Implement proper focus states and hover effects
|
||||
|
||||
**State Management:**
|
||||
- Use proper React hooks and context patterns
|
||||
- Implement proper state lifting and prop drilling avoidance
|
||||
- Use proper memoization with useMemo and useCallback
|
||||
- Implement proper cleanup in useEffect hooks
|
||||
|
||||
**Form Handling:**
|
||||
- Use react-hook-form with proper validation
|
||||
- Implement proper form state management
|
||||
- Use proper error handling and user feedback
|
||||
- Implement proper accessibility for form elements
|
||||
|
||||
**SSH Integration:**
|
||||
- Implement proper SSH connection status indicators
|
||||
- Use proper terminal integration with xterm.js
|
||||
- Implement proper file manager operations
|
||||
- Use proper tunnel status and management UI
|
||||
|
||||
Highlight any UI/UX issues, accessibility problems, or performance concerns.
|
||||
|
||||
- path: "**/types/**/*.{ts,js}"
|
||||
instructions: |
|
||||
Review type definitions for Termix server management platform. Key considerations:
|
||||
|
||||
**Type Design:**
|
||||
- Use proper TypeScript interfaces and type definitions
|
||||
- Implement proper type safety and validation
|
||||
- Use proper generic types and utility types
|
||||
- Follow the established type naming conventions
|
||||
|
||||
**API Types:**
|
||||
- Define proper request/response types for all API endpoints
|
||||
- Use proper error types and status codes
|
||||
- Implement proper validation types and schemas
|
||||
- Use proper pagination and filtering types
|
||||
|
||||
**SSH Types:**
|
||||
- Define proper SSH connection and configuration types
|
||||
- Use proper tunnel and credential types
|
||||
- Implement proper file operation types
|
||||
- Use proper authentication and security types
|
||||
|
||||
**Type Safety:**
|
||||
- Ensure proper type coverage and completeness
|
||||
- Use proper strict type checking
|
||||
- Implement proper type narrowing and guards
|
||||
- Use proper type assertions and casting
|
||||
|
||||
Highlight any type safety issues, missing types, or type inconsistencies.
|
||||
|
||||
- path: "**/hooks/**/*.{ts,tsx}"
|
||||
instructions: |
|
||||
Review custom hooks for Termix server management platform. Key considerations:
|
||||
|
||||
**Hook Design:**
|
||||
- Use proper React hooks patterns and conventions
|
||||
- Implement proper hook composition and reusability
|
||||
- Use proper TypeScript types for hook parameters and return values
|
||||
- Follow the established hook naming conventions
|
||||
|
||||
**State Management:**
|
||||
- Implement proper state management with useState and useReducer
|
||||
- Use proper context and provider patterns
|
||||
- Implement proper state persistence and synchronization
|
||||
- Use proper state cleanup and memory management
|
||||
|
||||
**Side Effects:**
|
||||
- Use proper useEffect patterns with proper dependencies
|
||||
- Implement proper cleanup functions and resource management
|
||||
- Use proper async operations and error handling
|
||||
- Implement proper polling and refresh patterns
|
||||
|
||||
**Performance:**
|
||||
- Use proper memoization with useMemo and useCallback
|
||||
- Implement proper debouncing and throttling
|
||||
- Use proper lazy loading and code splitting
|
||||
- Implement proper optimization patterns
|
||||
|
||||
**SSH Integration:**
|
||||
- Implement proper SSH connection management hooks
|
||||
- Use proper terminal integration hooks
|
||||
- Implement proper file manager operation hooks
|
||||
- Use proper tunnel management hooks
|
||||
|
||||
**Hook-Specific Bug Detection:**
|
||||
- Fix missing cleanup functions in useEffect hooks that cause memory leaks
|
||||
- Resolve infinite loops caused by incorrect dependency arrays
|
||||
- Fix stale closure issues in event handlers and async operations
|
||||
- Identify and fix improper state updates that cause unnecessary re-renders
|
||||
- Fix race conditions in async hooks and API calls
|
||||
- Resolve improper ref usage and null reference errors
|
||||
- Fix improper context usage and provider nesting issues
|
||||
- Identify and fix custom hook dependency issues
|
||||
- Resolve improper memoization that causes stale data
|
||||
- Fix improper error handling in custom hooks
|
||||
|
||||
Highlight any hook design issues, performance problems, or reusability concerns.
|
||||
|
||||
- path: "**/lib/**/*.{ts,js}"
|
||||
instructions: |
|
||||
Review utility libraries and helper functions for Termix server management platform. Key considerations:
|
||||
|
||||
**Utility Functions:**
|
||||
- Implement proper utility functions with clear purposes
|
||||
- Use proper TypeScript types and JSDoc documentation
|
||||
- Implement proper error handling and validation
|
||||
- Follow the established utility naming conventions
|
||||
|
||||
**Logging System:**
|
||||
- Use proper structured logging with context and metadata
|
||||
- Implement proper log levels and filtering
|
||||
- Use proper log formatting and output
|
||||
- Implement proper log rotation and cleanup
|
||||
|
||||
**API Utilities:**
|
||||
- Implement proper API client configuration and management
|
||||
- Use proper request/response interceptors
|
||||
- Implement proper error handling and retry logic
|
||||
- Use proper authentication and authorization handling
|
||||
- All API functions should be centralized in main-axios.ts
|
||||
- Use proper service-specific API instances (sshHostApi, tunnelApi, fileManagerApi, statsApi, authApi)
|
||||
- Follow the established error handling patterns with handleApiError function
|
||||
- Use proper structured logging with service-specific loggers
|
||||
|
||||
**Security Utilities:**
|
||||
- Implement proper input validation and sanitization
|
||||
- Use proper encryption and decryption functions
|
||||
- Implement proper secure random generation
|
||||
- Use proper security headers and CORS handling
|
||||
|
||||
**SSH Utilities:**
|
||||
- Implement proper SSH connection utilities
|
||||
- Use proper SSH key handling and validation
|
||||
- Implement proper SSH command execution
|
||||
- Use proper SSH file operation utilities
|
||||
|
||||
**Utility Bug Detection:**
|
||||
- Fix improper error handling in utility functions that could crash the application
|
||||
- Resolve null/undefined access errors in utility functions
|
||||
- Fix improper input validation that could cause security vulnerabilities
|
||||
- Identify and fix memory leaks in utility functions
|
||||
- Fix improper async/await usage and promise handling
|
||||
- Resolve improper type checking and validation errors
|
||||
- Fix improper logging that could expose sensitive information
|
||||
- Identify and fix performance bottlenecks in utility functions
|
||||
- Fix improper data transformation and serialization issues
|
||||
- Resolve improper configuration and environment variable handling
|
||||
|
||||
Highlight any utility design issues, performance problems, or security concerns.
|
||||
|
||||
- path: "**/main-axios.ts"
|
||||
instructions: |
|
||||
Review main-axios.ts API client configuration for Termix server management platform. Key considerations:
|
||||
|
||||
**API Client Architecture:**
|
||||
- Maintain the multi-port API architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
|
||||
- Use proper service-specific API instances (sshHostApi, tunnelApi, fileManagerApi, statsApi, authApi)
|
||||
- Implement proper API instance creation with createApiInstance function
|
||||
- Use proper base URL configuration for different environments (dev, production, Electron)
|
||||
|
||||
**Error Handling:**
|
||||
- Use the centralized handleApiError function for consistent error handling
|
||||
- Implement proper error classification (auth, network, validation, server errors)
|
||||
- Use proper error logging with service-specific loggers
|
||||
- Implement proper error response formatting and user-friendly messages
|
||||
|
||||
**Request/Response Interceptors:**
|
||||
- Implement proper JWT token handling in request interceptors
|
||||
- Use proper request timing and performance logging
|
||||
- Implement proper response logging and error tracking
|
||||
- Use proper authentication token refresh and cleanup
|
||||
|
||||
**API Function Organization:**
|
||||
- Group API functions by service (SSH Host Management, Tunnel Management, File Manager, etc.)
|
||||
- Use proper TypeScript types for all API functions
|
||||
- Implement proper parameter validation and sanitization
|
||||
- Use proper return type definitions and error handling
|
||||
|
||||
**Authentication:**
|
||||
- Implement proper JWT token management and refresh
|
||||
- Use proper cookie handling for web and Electron environments
|
||||
- Implement proper authentication state management
|
||||
- Use proper token expiration and cleanup
|
||||
|
||||
**Logging:**
|
||||
- Use proper structured logging with context and metadata
|
||||
- Implement proper request/response logging with performance metrics
|
||||
- Use proper error logging with appropriate log levels
|
||||
- Implement proper service-specific logger selection
|
||||
|
||||
**Performance:**
|
||||
- Implement proper request timeout and retry logic
|
||||
- Use proper connection pooling and resource management
|
||||
- Implement proper request deduplication and caching
|
||||
- Use proper performance monitoring and metrics
|
||||
|
||||
**Security:**
|
||||
- Implement proper input validation and sanitization
|
||||
- Use proper CORS and security header handling
|
||||
- Implement proper authentication and authorization
|
||||
- Use proper secure communication and data handling
|
||||
|
||||
**API Bug Detection:**
|
||||
- Fix improper error handling that could expose sensitive information
|
||||
- Resolve race conditions in concurrent API calls
|
||||
- Fix improper token management and authentication state issues
|
||||
- Identify and fix memory leaks in API interceptors
|
||||
- Fix improper request/response validation that could cause crashes
|
||||
- Resolve improper timeout handling and retry logic
|
||||
- Fix improper error response formatting and user feedback
|
||||
- Identify and fix performance issues in API calls
|
||||
- Fix improper request deduplication and caching issues
|
||||
- Resolve improper authentication token refresh and cleanup
|
||||
- Fix improper CORS and security header configuration
|
||||
- Identify and fix potential security vulnerabilities in API handling
|
||||
|
||||
Highlight any API design issues, error handling problems, or security concerns.
|
||||
|
||||
- path: "**/electron/**/*.{ts,js,cjs}"
|
||||
instructions: |
|
||||
Review Electron application code for Termix server management platform. Key considerations:
|
||||
|
||||
**Electron Architecture:**
|
||||
- Use proper Electron main and renderer process separation
|
||||
- Implement proper IPC (Inter-Process Communication) patterns
|
||||
- Use proper security and sandboxing configurations
|
||||
- Follow the established Electron best practices
|
||||
|
||||
**Security:**
|
||||
- Implement proper security policies and configurations
|
||||
- Use proper context isolation and node integration
|
||||
- Implement proper CSP and security headers
|
||||
- Use proper authentication and authorization handling
|
||||
|
||||
**Performance:**
|
||||
- Implement proper memory management and cleanup
|
||||
- Use proper resource optimization and caching
|
||||
- Implement proper background processing and threading
|
||||
- Use proper performance monitoring and profiling
|
||||
|
||||
**Electron Bug Detection:**
|
||||
- Fix improper IPC communication that could cause crashes
|
||||
- Resolve memory leaks in Electron main and renderer processes
|
||||
- Fix improper window management and lifecycle issues
|
||||
- Identify and fix security vulnerabilities in Electron configuration
|
||||
- Fix improper context isolation and node integration issues
|
||||
- Resolve improper event handling and cleanup in Electron
|
||||
- Fix improper file system access and permission issues
|
||||
- Identify and fix performance issues in Electron processes
|
||||
- Fix improper auto-updater and version management
|
||||
- Resolve improper tray and menu functionality issues
|
||||
- Fix improper security policies and CSP configuration
|
||||
- Identify and fix potential security vulnerabilities in Electron setup
|
||||
|
||||
Highlight any Electron-specific issues, security vulnerabilities, or performance problems.
|
||||
|
||||
- path: "**/docker/**/*"
|
||||
instructions: |
|
||||
Review Docker configuration files for Termix server management platform. Key considerations:
|
||||
|
||||
**Dockerfile Design:**
|
||||
- Use proper multi-stage builds for optimization
|
||||
- Implement proper layer caching and optimization
|
||||
- Use proper security and minimal base images
|
||||
- Follow the established Docker best practices
|
||||
|
||||
**Security:**
|
||||
- Implement proper user and permission management
|
||||
- Use proper security scanning and vulnerability assessment
|
||||
- Implement proper secrets and credential management
|
||||
- Use proper network security and isolation
|
||||
|
||||
**Performance:**
|
||||
- Implement proper resource optimization and allocation
|
||||
- Use proper caching and build optimization
|
||||
- Implement proper monitoring and logging
|
||||
- Use proper health checks and status monitoring
|
||||
|
||||
**Docker Bug Detection:**
|
||||
- Fix improper multi-stage build optimization that causes large images
|
||||
- Resolve security vulnerabilities in base images and dependencies
|
||||
- Fix improper volume and data persistence configuration
|
||||
- Identify and fix resource limit and constraint issues
|
||||
- Fix improper networking and port configuration
|
||||
- Resolve improper environment variable and secret management
|
||||
- Fix improper health check and status monitoring configuration
|
||||
- Identify and fix performance issues in container startup
|
||||
- Fix improper logging and monitoring configuration
|
||||
- Resolve improper backup and recovery procedures
|
||||
- Fix improper scaling and load balancing configuration
|
||||
- Identify and fix potential security vulnerabilities in Docker setup
|
||||
|
||||
Highlight any Docker configuration issues, security vulnerabilities, or performance problems.
|
||||
|
||||
- path: "**/*.md"
|
||||
instructions: |
|
||||
Review documentation files for Termix server management platform. Key considerations:
|
||||
|
||||
**Documentation Quality:**
|
||||
- Ensure proper grammar, spelling, and clarity
|
||||
- Use proper formatting and structure
|
||||
- Implement proper code examples and snippets
|
||||
- Follow the established documentation standards
|
||||
|
||||
**Content Accuracy:**
|
||||
- Ensure proper technical accuracy and completeness
|
||||
- Use proper up-to-date information and examples
|
||||
- Implement proper cross-references and links
|
||||
- Use proper version and compatibility information
|
||||
|
||||
**User Experience:**
|
||||
- Ensure proper user-friendly language and explanations
|
||||
- Use proper step-by-step instructions and guides
|
||||
- Implement proper troubleshooting and FAQ sections
|
||||
- Use proper visual aids and diagrams where appropriate
|
||||
|
||||
Highlight any documentation issues, inaccuracies, or missing information.
|
||||
|
||||
- path: "**/index.css"
|
||||
instructions: |
|
||||
Review index.css styling configuration for Termix server management platform. Key considerations:
|
||||
|
||||
**CSS Variable System:**
|
||||
- Define proper CSS custom properties for colors, spacing, and typography
|
||||
- Use consistent naming conventions for CSS variables (--primary, --secondary, --background, etc.)
|
||||
- Implement proper dark/light theme variable definitions
|
||||
- Use proper semantic color naming (--destructive, --muted, --accent, etc.)
|
||||
|
||||
**Design System:**
|
||||
- Follow the established design token system
|
||||
- Use proper color palette definitions with proper contrast ratios
|
||||
- Implement proper typography scale and font family definitions
|
||||
- Use proper spacing and sizing scale definitions
|
||||
|
||||
**Theme Support:**
|
||||
- Implement proper dark and light theme variable definitions
|
||||
- Use proper CSS custom property fallbacks
|
||||
- Implement proper theme switching support
|
||||
- Use proper color scheme media queries
|
||||
|
||||
**Component Styling:**
|
||||
- Define proper base styles for common components
|
||||
- Use proper utility classes and helper styles
|
||||
- Implement proper responsive design utilities
|
||||
- Use proper accessibility-focused styling
|
||||
|
||||
**Color Management:**
|
||||
- Avoid hardcoded color values, use CSS variables instead
|
||||
- Implement proper color contrast and accessibility
|
||||
- Use proper semantic color definitions
|
||||
- Implement proper color state variations (hover, focus, active)
|
||||
|
||||
**Typography:**
|
||||
- Define proper font family and weight definitions
|
||||
- Use proper line height and letter spacing
|
||||
- Implement proper text size and hierarchy
|
||||
- Use proper font loading and fallback strategies
|
||||
|
||||
**Layout Utilities:**
|
||||
- Define proper spacing and margin utilities
|
||||
- Use proper flexbox and grid utilities
|
||||
- Implement proper responsive breakpoint utilities
|
||||
- Use proper container and layout helpers
|
||||
|
||||
**Accessibility:**
|
||||
- Implement proper focus indicators and states
|
||||
- Use proper color contrast ratios
|
||||
- Implement proper reduced motion support
|
||||
- Use proper screen reader friendly styling
|
||||
|
||||
**Performance:**
|
||||
- Use efficient CSS selectors and properties
|
||||
- Implement proper CSS organization and structure
|
||||
- Use proper CSS custom property optimization
|
||||
- Implement proper critical CSS and loading strategies
|
||||
|
||||
Highlight any styling issues, accessibility problems, or design system inconsistencies.
|
||||
auto_review:
|
||||
enabled: true
|
||||
ignore_title_keywords:
|
||||
- "WIP"
|
||||
- "DO NOT MERGE"
|
||||
- "DRAFT"
|
||||
- "EXPERIMENTAL"
|
||||
- "TEST"
|
||||
drafts: false
|
||||
chat:
|
||||
auto_reply: true
|
||||
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
||||
github: [LukeGus]
|
||||
github: [LukeGus]
|
||||
|
||||
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help Termix improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots/Logs**
|
||||
If applicable, add screenshots or console/Docker logs to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 1.6.0]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for Termix
|
||||
title: "[FEATURE]"
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
40
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
groups:
|
||||
dev-patch-updates:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "patch"
|
||||
dev-minor-updates:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "minor"
|
||||
prod-patch-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "patch"
|
||||
prod-minor-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
groups:
|
||||
patch-updates:
|
||||
update-types:
|
||||
- "patch"
|
||||
minor-updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
14
.github/workflows/docker-image.yml
vendored
@@ -5,8 +5,8 @@ on:
|
||||
branches:
|
||||
- development
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
- "**.md"
|
||||
- ".gitignore"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
network=host
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
|
||||
@@ -73,12 +73,12 @@ jobs:
|
||||
elif [ "${{ github.ref }}" == "refs/heads/development" ]; then
|
||||
IMAGE_TAG="development-latest"
|
||||
else
|
||||
IMAGE_TAG="${{ github.ref_name }}-development-latest"
|
||||
IMAGE_TAG="${{ github.ref_name }}"
|
||||
fi
|
||||
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and Push Multi-Arch Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
|
||||
100
.github/workflows/electron-build.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
name: Build Electron App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
- 'docker/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_type:
|
||||
description: "Build type to run"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- linux
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == ''
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Windows Portable
|
||||
run: npm run build:win-portable
|
||||
|
||||
- name: Build Windows Installer
|
||||
run: npm run build:win-installer
|
||||
|
||||
- name: Create Windows Portable zip
|
||||
run: |
|
||||
Compress-Archive -Path "release/win-unpacked/*" -DestinationPath "Termix-Windows-Portable.zip"
|
||||
|
||||
- name: Upload Windows Portable Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Termix-Windows-Portable
|
||||
path: Termix-Windows-Portable.zip
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows Installer Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Termix-Windows-Installer
|
||||
path: release/*.exe
|
||||
retention-days: 30
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == ''
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Linux Portable
|
||||
run: npm run build:linux-portable
|
||||
|
||||
- name: Create Linux Portable zip
|
||||
run: |
|
||||
cd release/linux-unpacked
|
||||
zip -r ../../Termix-Linux-Portable.zip *
|
||||
cd ../..
|
||||
|
||||
- name: Upload Linux Portable Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Termix-Linux-Portable
|
||||
path: Termix-Linux-Portable.zip
|
||||
retention-days: 30
|
||||
2
.gitignore
vendored
@@ -23,3 +23,5 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
/db/
|
||||
/release/
|
||||
/.claude/
|
||||
|
||||
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
1
.prettierrc
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
107
CONTRIBUTING.md
Normal file
@@ -0,0 +1,107 @@
|
||||
\_# Contributing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) (built with v24)
|
||||
- [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```sh
|
||||
git clone https://github.com/LukeGus/Termix
|
||||
```
|
||||
2. Install the dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
## Running the development server
|
||||
|
||||
Run the following commands:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
npm run dev:backend
|
||||
```
|
||||
|
||||
a
|
||||
This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. **Fork the repository**: Click the "Fork" button at the top right of
|
||||
the [repository page](https://github.com/LukeGus/Termix).
|
||||
2. **Create a new branch**:
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
3. **Make your changes**: Implement your feature, fix, or improvement.
|
||||
4. **Commit your changes**:
|
||||
```sh
|
||||
git commit -m "Feature request my new feature"
|
||||
```
|
||||
5. **Push to your fork**:
|
||||
```sh
|
||||
git push origin feature/my-feature-request
|
||||
```
|
||||
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
|
||||
|
||||
## 📝 Guidelines
|
||||
|
||||
- Follow the existing code style. Use Tailwind CSS with shadcn components.
|
||||
- Use the below color scheme with the respective CSS variable placed in the `className` of a div/component.
|
||||
- Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded.
|
||||
- Include meaningful commit messages.
|
||||
- Link related issues when applicable.
|
||||
- `MobileApp.tsx` renders when the users screen width is less than 768px, otherwise it loads the usual `DesktopApp.tsx`.
|
||||
|
||||
## Color Scheme
|
||||
|
||||
### Background Colors
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
|
||||
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
|
||||
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
|
||||
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
|
||||
| `--color-dark-bg-light` | `#141416` | Light dark backgrounds | Lighter variant of dark background |
|
||||
| `--color-dark-bg-very-light` | `#101014` | Very light dark backgrounds | Very light variant of dark background |
|
||||
| `--color-dark-bg-panel` | `#1b1b1e` | Panel backgrounds | Background for panels and cards |
|
||||
| `--color-dark-bg-panel-hover` | `#232327` | Panel hover states | Background for panels on hover |
|
||||
|
||||
### Element-Specific Backgrounds
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ------------------------ | ----------- | ------------------ | --------------------------------------------- |
|
||||
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
|
||||
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
|
||||
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
|
||||
| `--color-dark-bg-header` | `#131316` | Header backgrounds | Background for headers and navigation bars |
|
||||
|
||||
### Border Colors
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ---------------------------- | ----------- | --------------- | ---------------------------------------- |
|
||||
| `--color-dark-border` | `#303032` | Default borders | Standard border color |
|
||||
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
|
||||
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
|
||||
| `--color-dark-border-light` | `#5a5a5d` | Light borders | Lighter border color for subtle elements |
|
||||
| `--color-dark-border-medium` | `#373739` | Medium borders | Medium weight border color |
|
||||
| `--color-dark-border-panel` | `#222224` | Panel borders | Border color for panels and cards |
|
||||
|
||||
### Interactive States
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ------------------------ | ----------- | ----------------- | --------------------------------------------- |
|
||||
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
|
||||
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
|
||||
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |
|
||||
| `--color-dark-hover-alt` | `#2a2a2d` | Alternative hover | Alternative hover state color |
|
||||
|
||||
## Support
|
||||
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||
repo.
|
||||
109
README-CN.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# 仓库统计
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> 英文</a> |
|
||||
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文
|
||||
</p>
|
||||
|
||||

|
||||

|
||||

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

|
||||

|
||||

|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||
|
||||
#### Top Technologies
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
@@ -13,36 +21,50 @@
|
||||
[](#)
|
||||
[](#)
|
||||
|
||||
|
||||
<br />
|
||||
<p align="center">
|
||||
<a href="https://github.com/LukeGus/Termix">
|
||||
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: auto;"> </a>
|
||||
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
If you would like, you can support the project here!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Overview
|
||||
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, SSH tunneling capabilities, and remote file configuration editing, with many more tools to come.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/LukeGus/Termix">
|
||||
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based
|
||||
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
|
||||
access, SSH tunneling capabilities, and remote file editing, with many more tools to come.
|
||||
|
||||
# Features
|
||||
|
||||
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
|
||||
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
|
||||
- **Remote Config Editor** - Edit files directly on remote servers with syntax highlighting and file management
|
||||
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (
|
||||
uploading, removing, renaming, deleting files)
|
||||
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC support with more auth types planned
|
||||
- **Modern UI** - Clean interface built with React, Tailwind CSS, and the amazing Shadcn
|
||||
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
|
||||
- **Modern UI** - Clean desktop/mobile friendly (in progress) interface built with React, Tailwind CSS, and Shadcn
|
||||
- **Languages** - Built-in support for English and Chinese
|
||||
- **Improved Platform Support** - Now includes an installable Electron app (in progress) for desktop, with a dedicated
|
||||
mobile app also planned.
|
||||
|
||||
# Planned Features
|
||||
- **Improved Admin Control** - Ability to manage admins, and give more fine-grained control over their permissions, share hosts, reset passwords, delete accounts, etc
|
||||
- **More auth types** - Add 2FA, TOTP, etc
|
||||
- **Theming** - Modify themeing for all tools
|
||||
- **Improved SFTP Support** - Ability to manage files easier with the config editor by uploading, creating, and removing files
|
||||
- **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue)
|
||||
|
||||
See [Projects](https://github.com/users/LukeGus/projects/3). If you are looking to contribute,
|
||||
see [Contributing](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md),
|
||||
|
||||
# Installation
|
||||
Visit the Termix [Docs](https://docs.termix.site/docs) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
|
||||
|
||||
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix. Otherwise, view
|
||||
a sample docker-compose file here:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
@@ -58,11 +80,18 @@ services:
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
driver: local
|
||||
```
|
||||
|
||||
Pre-built binaries are now available for download, including a Windows installer/portable app and a Linux portable app (
|
||||
built with Electron). See [Docs](http://localhost:5174/install#pre-built-binaries) for details. A native iOS/Android app
|
||||
is planned.
|
||||
|
||||
# Support
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo.
|
||||
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||
repo.
|
||||
|
||||
# Show-off
|
||||
|
||||
@@ -78,10 +107,11 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/0f95495d-c5db-48f5-b18b-9ab48bb10d31" width="800" controls>
|
||||
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</p>
|
||||
|
||||
# License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See LICENSE for more information.
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# Stage 1: Install dependencies and build frontend
|
||||
FROM node:18-alpine AS deps
|
||||
FROM node:24-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci --force && \
|
||||
ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
RUN npm ci --force --ignore-scripts && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 2: Build frontend
|
||||
@@ -23,30 +27,46 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
RUN npm rebuild better-sqlite3 --force
|
||||
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Production dependencies
|
||||
FROM node:18-alpine AS production-deps
|
||||
FROM node:24-alpine AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
RUN npm ci --only=production --ignore-scripts --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 5: Build native modules
|
||||
FROM node:18-alpine AS native-builder
|
||||
FROM node:24-alpine AS native-builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
|
||||
ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
# Install native modules and compile them properly
|
||||
RUN npm ci --only=production --force && \
|
||||
npm rebuild better-sqlite3 bcryptjs --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 6: Final image
|
||||
FROM node:18-alpine
|
||||
FROM node:24-alpine
|
||||
ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
@@ -57,13 +77,12 @@ RUN apk add --no-cache nginx gettext su-exec && \
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
|
||||
COPY --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --from=native-builder /app/node_modules/bcryptjs /app/node_modules/bcryptjs
|
||||
COPY --from=native-builder /app/node_modules/better-sqlite3 /app/node_modules/better-sqlite3
|
||||
COPY --from=native-builder /app/node_modules /app/node_modules
|
||||
COPY --from=backend-builder /app/dist/backend ./dist/backend
|
||||
|
||||
COPY package.json ./
|
||||
@@ -72,8 +91,8 @@ RUN chown -R node:node /app
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE ${PORT} 8081 8082 8083 8084
|
||||
EXPOSE ${PORT} 8081 8082 8083 8084 8085
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
CMD ["/entrypoint.sh"]
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
@@ -12,4 +12,4 @@ services:
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
driver: local
|
||||
|
||||
@@ -19,9 +19,9 @@ cd /app
|
||||
export NODE_ENV=production
|
||||
|
||||
if command -v su-exec > /dev/null 2>&1; then
|
||||
su-exec node node dist/backend/starter.js
|
||||
su-exec node node dist/backend/backend/starter.js
|
||||
else
|
||||
su -s /bin/sh node -c "node dist/backend/starter.js"
|
||||
su -s /bin/sh node -c "node dist/backend/backend/starter.js"
|
||||
fi
|
||||
|
||||
echo "All services started"
|
||||
|
||||
@@ -18,7 +18,7 @@ http {
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
location /users/ {
|
||||
location ~ ^/users(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -27,7 +27,7 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /version/ {
|
||||
location ~ ^/version(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -36,7 +36,7 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /releases/ {
|
||||
location ~ ^/releases(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -45,7 +45,25 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/db/ {
|
||||
location ~ ^/alerts(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -62,6 +80,14 @@ http {
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -76,7 +102,34 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/config_editor/ {
|
||||
location /ssh/file_manager/recent {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/pinned {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/shortcuts {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/ssh/ {
|
||||
proxy_pass http://127.0.0.1:8084;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -85,7 +138,7 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/ssh/config_editor/(recent|pinned|shortcuts) {
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -94,6 +147,24 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/status(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8085;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/metrics(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:8085;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
44
electron-builder.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"appId": "com.termix.app",
|
||||
"productName": "Termix",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"public/**/*",
|
||||
"!**/node_modules/**/*",
|
||||
"!src/**/*",
|
||||
"!*.md",
|
||||
"!tsconfig*.json",
|
||||
"!vite.config.ts",
|
||||
"!eslint.config.js"
|
||||
],
|
||||
"asarUnpack": ["node_modules/node-fetch/**/*"],
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.cjs"
|
||||
},
|
||||
"buildDependenciesFromSource": false,
|
||||
"nodeGypRebuild": false,
|
||||
"npmRebuild": false,
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "public/icon.ico",
|
||||
"executableName": "Termix"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"artifactName": "${productName}-Setup-${version}.${ext}",
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Termix",
|
||||
"uninstallDisplayName": "Termix"
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage",
|
||||
"icon": "public/icon.png",
|
||||
"category": "Development"
|
||||
}
|
||||
}
|
||||
334
electron/main.cjs
Normal file
@@ -0,0 +1,334 @@
|
||||
const { app, BrowserWindow, shell, ipcMain } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
let mainWindow = null;
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
console.log("Another instance is already running, quitting...");
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
} else {
|
||||
app.on("second-instance", (event, commandLine, workingDirectory) => {
|
||||
console.log("Second instance detected, focusing existing window...");
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: "Termix",
|
||||
icon: isDev
|
||||
? path.join(__dirname, "..", "public", "icon.png")
|
||||
: path.join(process.resourcesPath, "public", "icon.png"),
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: !isDev,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
},
|
||||
show: false,
|
||||
});
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.loadURL("http://localhost:5173");
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
const indexPath = path.join(__dirname, "..", "dist", "index.html");
|
||||
console.log("Loading frontend from:", indexPath);
|
||||
mainWindow.loadFile(indexPath);
|
||||
}
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
console.log("Window ready to show");
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(event, errorCode, errorDescription, validatedURL) => {
|
||||
console.error(
|
||||
"Failed to load:",
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.webContents.on("did-finish-load", () => {
|
||||
console.log("Frontend loaded successfully");
|
||||
});
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
if (process.platform === "darwin") {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("get-app-version", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle("get-platform", () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-server-config", () => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const configPath = path.join(userDataPath, "server-config.json");
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
const configData = fs.readFileSync(configPath, "utf8");
|
||||
return JSON.parse(configData);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error reading server config:", error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("save-server-config", (event, config) => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const configPath = path.join(userDataPath, "server-config.json");
|
||||
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error saving server config:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
try {
|
||||
let fetch;
|
||||
try {
|
||||
fetch = globalThis.fetch || require("node:fetch");
|
||||
} catch (e) {
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { URL } = require("url");
|
||||
|
||||
fetch = (url, options = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === "https:";
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const req = client.request(
|
||||
url,
|
||||
{
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 5000,
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
text: () => Promise.resolve(data),
|
||||
json: () => Promise.resolve(JSON.parse(data)),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timeout"));
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(options.body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
||||
|
||||
const healthUrl = `${normalizedServerUrl}/health`;
|
||||
|
||||
try {
|
||||
const response = await fetch(healthUrl, {
|
||||
method: "GET",
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
|
||||
if (
|
||||
data.includes("<html") ||
|
||||
data.includes("<!DOCTYPE") ||
|
||||
data.includes("<head>") ||
|
||||
data.includes("<body>")
|
||||
) {
|
||||
console.log(
|
||||
"Health endpoint returned HTML instead of JSON - not a Termix server",
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const healthData = JSON.parse(data);
|
||||
if (
|
||||
healthData &&
|
||||
(healthData.status === "ok" ||
|
||||
healthData.status === "healthy" ||
|
||||
healthData.healthy === true ||
|
||||
healthData.database === "connected")
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
testedUrl: healthUrl,
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log("Health endpoint did not return valid JSON");
|
||||
}
|
||||
}
|
||||
} catch (urlError) {
|
||||
console.error("Health check failed:", urlError);
|
||||
}
|
||||
|
||||
try {
|
||||
const versionUrl = `${normalizedServerUrl}/version`;
|
||||
const response = await fetch(versionUrl, {
|
||||
method: "GET",
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
|
||||
if (
|
||||
data.includes("<html") ||
|
||||
data.includes("<!DOCTYPE") ||
|
||||
data.includes("<head>") ||
|
||||
data.includes("<body>")
|
||||
) {
|
||||
console.log(
|
||||
"Version endpoint returned HTML instead of JSON - not a Termix server",
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const versionData = JSON.parse(data);
|
||||
if (
|
||||
versionData &&
|
||||
(versionData.status === "up_to_date" ||
|
||||
versionData.status === "requires_update" ||
|
||||
(versionData.localVersion &&
|
||||
versionData.version &&
|
||||
versionData.latest_release))
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
testedUrl: versionUrl,
|
||||
warning:
|
||||
"Health endpoint not available, but server appears to be running",
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log("Version endpoint did not return valid JSON");
|
||||
}
|
||||
}
|
||||
} catch (versionError) {
|
||||
console.error("Version check failed:", versionError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.",
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
console.log("Termix started successfully");
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
} else if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
console.log("App is quitting...");
|
||||
});
|
||||
|
||||
app.on("will-quit", () => {
|
||||
console.log("App will quit...");
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("Uncaught Exception:", error);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
29
electron/preload.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||
|
||||
getServerConfig: () => ipcRenderer.invoke("get-server-config"),
|
||||
saveServerConfig: (config) =>
|
||||
ipcRenderer.invoke("save-server-config", config),
|
||||
testServerConnection: (serverUrl) =>
|
||||
ipcRenderer.invoke("test-server-connection", serverUrl),
|
||||
|
||||
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
||||
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
||||
|
||||
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
|
||||
onUpdateDownloaded: (callback) =>
|
||||
ipcRenderer.on("update-downloaded", callback),
|
||||
|
||||
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
|
||||
isElectron: true,
|
||||
isDev: process.env.NODE_ENV === "development",
|
||||
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
});
|
||||
|
||||
window.IS_ELECTRON = true;
|
||||
|
||||
console.log("electronAPI exposed to window");
|
||||
@@ -1,18 +1,18 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
@@ -20,4 +20,4 @@ export default tseslint.config([
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
2305
openapi.json
Normal file
9886
package-lock.json
generated
52
package.json
@@ -1,15 +1,24 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.6.0",
|
||||
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "npx prettier . --write",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vite build && tsc -p tsconfig.node.json",
|
||||
"build:backend": "tsc -p tsconfig.node.json",
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/starter.js",
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"electron": "electron .",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron .\"",
|
||||
"build:win-portable": "npm run build && electron-builder --win --dir",
|
||||
"build:win-installer": "npm run build && electron-builder --win --publish=never",
|
||||
"build:linux-portable": "npm run build && electron-builder --linux --dir"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
@@ -17,10 +26,11 @@
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
@@ -28,10 +38,12 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-themes": "^4.24.1",
|
||||
@@ -54,17 +66,26 @@
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"express": "^5.1.0",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jose": "^5.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"multer": "^2.0.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^3.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"react-simple-keyboard": "^3.8.120",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
@@ -73,26 +94,33 @@
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.30.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^38.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-packager": "^17.1.2",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"prettier": "3.6.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.0.4"
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vite": "^7.1.5",
|
||||
"wait-on": "^8.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 168 KiB |
BIN
public/icon.icns
Normal file
BIN
public/icon.ico
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
public/icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.1 KiB |
BIN
public/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 483 B |
BIN
public/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
public/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 986 B |
BIN
public/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/icons/icon.icns
Normal file
BIN
public/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
repo-images/HeaderImage.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 614 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 115 KiB |
56
src/App.tsx
@@ -1,56 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
import {Homepage} from "@/apps/Homepage/Homepage.tsx"
|
||||
import {Terminal} from "@/apps/SSH/Terminal/Terminal.tsx"
|
||||
import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx";
|
||||
import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx";
|
||||
import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx"
|
||||
|
||||
function App() {
|
||||
const [view, setView] = React.useState<string>("homepage")
|
||||
const [mountedViews, setMountedViews] = React.useState<Set<string>>(new Set(["homepage"]))
|
||||
|
||||
const handleSelectView = (nextView: string) => {
|
||||
setMountedViews((prev) => {
|
||||
if (prev.has(nextView)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(nextView)
|
||||
return next
|
||||
})
|
||||
setView(nextView)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh w-full">
|
||||
<main className="flex-1 w-full">
|
||||
{mountedViews.has("homepage") && (
|
||||
<div style={{display: view === "homepage" ? "block" : "none"}}>
|
||||
<Homepage onSelectView={handleSelectView} />
|
||||
</div>
|
||||
)}
|
||||
{mountedViews.has("ssh_manager") && (
|
||||
<div style={{display: view === "ssh_manager" ? "block" : "none"}}>
|
||||
<SSHManager onSelectView={handleSelectView} />
|
||||
</div>
|
||||
)}
|
||||
{mountedViews.has("terminal") && (
|
||||
<div style={{display: view === "terminal" ? "block" : "none"}}>
|
||||
<Terminal onSelectView={handleSelectView} />
|
||||
</div>
|
||||
)}
|
||||
{mountedViews.has("tunnel") && (
|
||||
<div style={{display: view === "tunnel" ? "block" : "none"}}>
|
||||
<SSHTunnel onSelectView={handleSelectView} />
|
||||
</div>
|
||||
)}
|
||||
{mountedViews.has("config_editor") && (
|
||||
<div style={{display: view === "config_editor" ? "block" : "none"}}>
|
||||
<ConfigEditor onSelectView={handleSelectView} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,109 +0,0 @@
|
||||
import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx";
|
||||
import axios from "axios";
|
||||
import {HomepageUpdateLog} from "@/apps/Homepage/HompageUpdateLog.tsx";
|
||||
import {HomepageWelcomeCard} from "@/apps/Homepage/HomepageWelcomeCard.tsx";
|
||||
|
||||
interface HomepageProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
function setCookie(name: string, value: string, days = 7) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
}
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
const [showWelcomeCard, setShowWelcomeCard] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const jwt = getCookie("jwt");
|
||||
const welcomeHidden = getCookie("welcome_hidden");
|
||||
|
||||
if (jwt) {
|
||||
setAuthLoading(true);
|
||||
Promise.all([
|
||||
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}),
|
||||
API.get("/db-health")
|
||||
])
|
||||
.then(([meRes]) => {
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setDbError(null);
|
||||
setShowWelcomeCard(welcomeHidden !== "true");
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setCookie("jwt", "", -1);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
})
|
||||
.finally(() => setAuthLoading(false));
|
||||
} else {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleHideWelcomeCard = () => {
|
||||
setShowWelcomeCard(false);
|
||||
setCookie("welcome_hidden", "true", 365 * 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<HomepageSidebar
|
||||
onSelectView={onSelectView}
|
||||
disabled={!loggedIn || authLoading}
|
||||
isAdmin={isAdmin}
|
||||
username={loggedIn ? username : null}
|
||||
>
|
||||
<div className="w-full min-h-svh grid place-items-center">
|
||||
<div className="flex flex-row items-center justify-center gap-8">
|
||||
<HomepageAuth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
dbError={dbError}
|
||||
setDbError={setDbError}
|
||||
/>
|
||||
<HomepageUpdateLog
|
||||
loggedIn={loggedIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loggedIn && !authLoading && showWelcomeCard && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-10">
|
||||
<HomepageWelcomeCard onHidePermanently={handleHideWelcomeCard}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HomepageSidebar>
|
||||
);
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Label} from "@/components/ui/label";
|
||||
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import axios from "axios";
|
||||
|
||||
function setCookie(name: string, value: string, days = 7) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
}
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
interface HomepageAuthProps extends React.ComponentProps<"div"> {
|
||||
setLoggedIn: (loggedIn: boolean) => void;
|
||||
setIsAdmin: (isAdmin: boolean) => void;
|
||||
setUsername: (username: string | null) => void;
|
||||
loggedIn: boolean;
|
||||
authLoading: boolean;
|
||||
dbError: string | null;
|
||||
setDbError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
export function HomepageAuth({
|
||||
className,
|
||||
setLoggedIn,
|
||||
setIsAdmin,
|
||||
setUsername,
|
||||
loggedIn,
|
||||
authLoading,
|
||||
dbError,
|
||||
setDbError,
|
||||
...props
|
||||
}: HomepageAuthProps) {
|
||||
const [tab, setTab] = useState<"login" | "signup" | "external">("login");
|
||||
const [localUsername, setLocalUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalLoggedIn(loggedIn);
|
||||
}, [loggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
API.get("/registration-allowed").then(res => {
|
||||
setRegistrationAllowed(res.data.allowed);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
API.get("/oidc-config").then((response) => {
|
||||
if (response.data) {
|
||||
setOidcConfigured(true);
|
||||
} else {
|
||||
setOidcConfigured(false);
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (error.response?.status === 404) {
|
||||
setOidcConfigured(false);
|
||||
} else {
|
||||
setOidcConfigured(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
API.get("/count").then(res => {
|
||||
if (res.data.count === 0) {
|
||||
setFirstUser(true);
|
||||
setTab("signup");
|
||||
} else {
|
||||
setFirstUser(false);
|
||||
}
|
||||
setDbError(null);
|
||||
}).catch(() => {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
});
|
||||
}, [setDbError]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
let res, meRes;
|
||||
if (tab === "login") {
|
||||
res = await API.post("/login", {username: localUsername, password});
|
||||
} else {
|
||||
await API.post("/create", {username: localUsername, password});
|
||||
res = await API.post("/login", {username: localUsername, password});
|
||||
}
|
||||
setCookie("jwt", res.data.token);
|
||||
[meRes] = await Promise.all([
|
||||
API.get("/me", {headers: {Authorization: `Bearer ${res.data.token}`}}),
|
||||
API.get("/db-health")
|
||||
]);
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setDbError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Unknown error");
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setCookie("jwt", "", -1);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOIDCLogin() {
|
||||
setError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
const authResponse = await API.get("/oidc/authorize");
|
||||
const {auth_url: authUrl} = authResponse.data;
|
||||
|
||||
if (!authUrl || authUrl === 'undefined') {
|
||||
throw new Error('Invalid authorization URL received from backend');
|
||||
}
|
||||
|
||||
window.location.replace(authUrl);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Failed to start OIDC login");
|
||||
setOidcLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const success = urlParams.get('success');
|
||||
const token = urlParams.get('token');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
setError(`OIDC authentication failed: ${error}`);
|
||||
setOidcLoading(false);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
if (success && token) {
|
||||
setOidcLoading(true);
|
||||
setError(null);
|
||||
|
||||
setCookie("jwt", token);
|
||||
API.get("/me", {headers: {Authorization: `Bearer ${token}`}})
|
||||
.then(meRes => {
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setDbError(null);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
})
|
||||
.catch(err => {
|
||||
setError("Failed to get user info after OIDC login");
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setCookie("jwt", "", -1);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
})
|
||||
.finally(() => {
|
||||
setOidcLoading(false);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const Spinner = (
|
||||
<svg className="animate-spin mr-2 h-4 w-4 text-white inline-block" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}>
|
||||
{dbError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{dbError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{firstUser && !dbError && !internalLoggedIn && (
|
||||
<Alert variant="default" className="mb-4">
|
||||
<AlertTitle>First User</AlertTitle>
|
||||
<AlertDescription className="inline">
|
||||
You are the first user and will be made an admin. You can view admin settings in the sidebar
|
||||
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline hover:text-blue-800 inline"
|
||||
>
|
||||
GitHub issue
|
||||
</a>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!registrationAllowed && !internalLoggedIn && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Registration Disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
New account registration is currently disabled by an admin. Please log in or contact an
|
||||
administrator.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{(internalLoggedIn || (authLoading && getCookie("jwt"))) && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Alert className="my-2">
|
||||
<AlertTitle>Logged in!</AlertTitle>
|
||||
<AlertDescription>
|
||||
You are logged in! Use the sidebar to access all available tools. To get started,
|
||||
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
|
||||
host using the other apps in the sidebar.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
|
||||
>
|
||||
Discord
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
||||
>
|
||||
Fund
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTab("login")}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTab("signup")}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "external"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTab("external")}
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
External
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login" ? "Login to your account" :
|
||||
tab === "signup" ? "Create a new account" :
|
||||
"Login with external provider"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{tab === "external" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Login using your configured external identity provider</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading ? Spinner : "Login with External Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={e => setLocalUsername(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required className="h-11 text-base"
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}>
|
||||
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Computer,
|
||||
Server,
|
||||
File,
|
||||
Hammer, ChevronUp, User2, HardDrive
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent, SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem, SidebarProvider, SidebarInset,
|
||||
} from "@/components/ui/sidebar.tsx"
|
||||
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetClose
|
||||
} from "@/components/ui/sheet";
|
||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Label} from "@/components/ui/label.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
|
||||
import axios from "axios";
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
getView?: () => string;
|
||||
disabled?: boolean;
|
||||
isAdmin?: boolean;
|
||||
username?: string | null;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
export function HomepageSidebar({
|
||||
onSelectView,
|
||||
getView,
|
||||
disabled,
|
||||
isAdmin,
|
||||
username,
|
||||
children,
|
||||
}: SidebarProps): React.ReactElement {
|
||||
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
const [regLoading, setRegLoading] = React.useState(false);
|
||||
const [oidcConfig, setOidcConfig] = React.useState({
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
issuer_url: '',
|
||||
authorization_url: '',
|
||||
token_url: '',
|
||||
identifier_path: 'sub',
|
||||
name_path: 'name',
|
||||
scopes: 'openid email profile'
|
||||
});
|
||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (adminSheetOpen) {
|
||||
API.get("/registration-allowed").then(res => {
|
||||
setAllowRegistration(res.data.allowed);
|
||||
});
|
||||
|
||||
API.get("/oidc-config").then(res => {
|
||||
if (res.data) {
|
||||
setOidcConfig(res.data);
|
||||
}
|
||||
}).catch((error) => {
|
||||
});
|
||||
}
|
||||
}, [adminSheetOpen]);
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
setRegLoading(true);
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.patch(
|
||||
"/registration-allowed",
|
||||
{allowed: checked},
|
||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
||||
);
|
||||
setAllowRegistration(checked);
|
||||
} catch (e) {
|
||||
} finally {
|
||||
setRegLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setOidcLoading(true);
|
||||
setOidcError(null);
|
||||
setOidcSuccess(null);
|
||||
|
||||
const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
||||
const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
setOidcError(`Missing required fields: ${missingFields.join(', ')}`);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post(
|
||||
"/oidc-config",
|
||||
oidcConfig,
|
||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
||||
);
|
||||
setOidcSuccess("OIDC configuration updated successfully!");
|
||||
} catch (err: any) {
|
||||
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCConfigChange = (field: string, value: string) => {
|
||||
setOidcConfig(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem key={"SSH Manager"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")}
|
||||
disabled={disabled}>
|
||||
<HardDrive/>
|
||||
<span>SSH Manager</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<div className="ml-5">
|
||||
<SidebarMenuItem key={"Terminal"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("terminal")}
|
||||
disabled={disabled}>
|
||||
<Computer/>
|
||||
<span>Terminal</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem key={"Tunnel"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("tunnel")}
|
||||
disabled={disabled}>
|
||||
<Server/>
|
||||
<span>Tunnel</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem key={"Config Editor"}>
|
||||
<SidebarMenuButton onClick={() => onSelectView("config_editor")}
|
||||
disabled={disabled}>
|
||||
<File/>
|
||||
<span>Config Editor</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</div>
|
||||
<SidebarMenuItem key={"Tools"}>
|
||||
<SidebarMenuButton onClick={() => window.open("https://dashix.dev", "_blank")} disabled={disabled}>
|
||||
<Hammer/>
|
||||
<span>Tools</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className="data-[state=open]:opacity-90 w-full"
|
||||
style={{width: '100%'}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<User2/> {username ? username : 'Signed out'}
|
||||
<ChevronUp className="ml-auto"/>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
||||
>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onSelect={() => setAdminSheetOpen(true)}>
|
||||
<span>Admin Settings</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onSelect={handleLogout}>
|
||||
<span>Sign out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
|
||||
{isAdmin && (
|
||||
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
|
||||
<SheetContent side="left" className="w-[400px] max-h-screen overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Admin Settings</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="pt-1 pb-4 px-4 flex flex-col gap-6">
|
||||
{/* Registration Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">User Registration</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
|
||||
disabled={regLoading}/>
|
||||
Allow new account registration
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Separator className="p-0.25 mt-2 mb-2"/>
|
||||
|
||||
{/* OIDC Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure external identity provider for OIDC/OAuth2 authentication.
|
||||
Users will see an "External" login option once configured.
|
||||
</p>
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{oidcError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">Client ID</Label>
|
||||
<Input
|
||||
id="client_id"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
||||
placeholder="your-client-id"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">Client Secret</Label>
|
||||
<Input
|
||||
id="client_secret"
|
||||
type="password"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||
placeholder="your-client-secret"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">Authorization URL</Label>
|
||||
<Input
|
||||
id="authorization_url"
|
||||
value={oidcConfig.authorization_url}
|
||||
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/authorize/"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">Issuer URL</Label>
|
||||
<Input
|
||||
id="issuer_url"
|
||||
value={oidcConfig.issuer_url}
|
||||
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/termix/"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">Token URL</Label>
|
||||
<Input
|
||||
id="token_url"
|
||||
value={oidcConfig.token_url}
|
||||
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
||||
placeholder="http://100.98.3.50:9000/application/o/token/"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
||||
<Input
|
||||
id="identifier_path"
|
||||
value={oidcConfig.identifier_path}
|
||||
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
||||
placeholder="sub"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
JSON path to extract user ID from JWT (e.g., "sub", "email", "preferred_username")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_path">Display Name Path</Label>
|
||||
<Input
|
||||
id="name_path"
|
||||
value={oidcConfig.name_path}
|
||||
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
||||
placeholder="name"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
JSON path to extract display name from JWT (e.g., "name", "preferred_username")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">Scopes</Label>
|
||||
<Input
|
||||
id="scopes"
|
||||
value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
|
||||
placeholder="openid email profile"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Space-separated list of OAuth2 scopes to request
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{oidcLoading ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setOidcConfig({
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
issuer_url: '',
|
||||
authorization_url: '',
|
||||
token_url: '',
|
||||
identifier_path: 'sub',
|
||||
name_path: 'name',
|
||||
scopes: 'openid email profile'
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oidcSuccess && (
|
||||
<Alert>
|
||||
<AlertTitle>Success</AlertTitle>
|
||||
<AlertDescription>{oidcSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter className="px-4 pt-1 pb-4">
|
||||
<Separator className="p-0.25 mt-2 mb-2"/>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from "react";
|
||||
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {Button} from "@/components/ui/button";
|
||||
|
||||
interface HomepageWelcomeCardProps {
|
||||
onHidePermanently: () => void;
|
||||
}
|
||||
|
||||
export function HomepageWelcomeCard({onHidePermanently}: HomepageWelcomeCardProps): React.ReactElement {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold text-center">
|
||||
The Future of Termix
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-center leading-relaxed">
|
||||
Please checkout the linked survey{" "}
|
||||
<a
|
||||
href="https://docs.google.com/forms/d/e/1FAIpQLSeGvnQODFtnpjmJsMKgASbaQ87CLQEBCcnzK_Vuw5TdfbfIyA/viewform?usp=sharing&ouid=107601685503825301492"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline hover:text-primary/80 transition-colors"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
. The purpose of this survey is to gather feedback from users on what the future UI of Termix could
|
||||
look like to optimize server management. Please take a minute or two to read the survey questions
|
||||
and answer them to the best of your ability. Thank you!
|
||||
</p>
|
||||
<p className="text-muted-foreground text-center leading-relaxed mt-6">
|
||||
A special thanks to those in Asia who recently joined Termix through various forum posts, keep
|
||||
sharing it! A Chinese translation is planned for Termix, but since I don’t speak Chinese, I’ll need
|
||||
to hire someone to help with the translation. If you’d like to support me financially, you can do
|
||||
so{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/LukeGus"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline hover:text-primary/80 transition-colors"
|
||||
>
|
||||
here.
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onHidePermanently}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
Hide Permanently
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import axios from "axios";
|
||||
|
||||
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
interface ReleaseItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
pubDate: string;
|
||||
version: string;
|
||||
isPrerelease: boolean;
|
||||
isDraft: boolean;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
download_url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RSSResponse {
|
||||
feed: {
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
updated: string;
|
||||
};
|
||||
items: ReleaseItem[];
|
||||
total_count: number;
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
interface VersionResponse {
|
||||
status: 'up_to_date' | 'requires_update';
|
||||
version: string;
|
||||
latest_release: {
|
||||
name: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
};
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
const apiBase = import.meta.env.DEV ? "http://localhost:8081" : "";
|
||||
|
||||
const API = axios.create({
|
||||
baseURL: apiBase,
|
||||
});
|
||||
|
||||
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn) {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
API.get('/releases/rss?per_page=100'),
|
||||
API.get('/version/')
|
||||
])
|
||||
.then(([releasesRes, versionRes]) => {
|
||||
setReleases(releasesRes.data);
|
||||
setVersionInfo(versionRes.data);
|
||||
setError(null);
|
||||
})
|
||||
.catch(err => {
|
||||
setError('Failed to fetch update information');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [loggedIn]);
|
||||
|
||||
if (!loggedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatDescription = (description: string) => {
|
||||
const firstLine = description.split('\n')[0];
|
||||
return firstLine
|
||||
.replace(/[#*`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.substring(0, 100) + (firstLine.length > 100 ? '...' : '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[400px] h-[600px] flex flex-col border border-border rounded-lg bg-card p-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Updates & Releases</h3>
|
||||
|
||||
<Separator className="p-0.25 mt-3 mb-3"/>
|
||||
|
||||
{versionInfo && versionInfo.status === 'requires_update' && (
|
||||
<Alert>
|
||||
<AlertTitle>Update Available</AlertTitle>
|
||||
<AlertDescription>
|
||||
A new version ({versionInfo.version}) is available.
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto underline ml-1"
|
||||
onClick={() => window.open("https://docs.termix.site/docs", '_blank')}
|
||||
>
|
||||
Update now
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{versionInfo && versionInfo.status === 'requires_update' && (
|
||||
<Separator className="p-0.25 mt-3 mb-3"/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-3">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{releases?.items.map((release) => (
|
||||
<div
|
||||
key={release.id}
|
||||
className="border border-border rounded-lg p-3 hover:bg-accent transition-colors cursor-pointer"
|
||||
onClick={() => window.open(release.link, '_blank')}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-medium text-sm leading-tight flex-1">
|
||||
{release.title}
|
||||
</h4>
|
||||
{release.isPrerelease && (
|
||||
<span
|
||||
className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded ml-2 flex-shrink-0">
|
||||
Pre-release
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mb-2 leading-relaxed">
|
||||
{formatDescription(release.description)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
|
||||
{release.assets.length > 0 && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{release.assets.length} asset{release.assets.length !== 1 ? 's' : ''}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{releases && releases.items.length === 0 && !loading && (
|
||||
<Alert>
|
||||
<AlertTitle>No Releases</AlertTitle>
|
||||
<AlertDescription>
|
||||
No releases found.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import {loadLanguage} from '@uiw/codemirror-extensions-langs';
|
||||
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
|
||||
import {oneDark} from '@codemirror/theme-one-dark';
|
||||
import {EditorView} from '@codemirror/view';
|
||||
|
||||
interface ConfigCodeEditorProps {
|
||||
content: string;
|
||||
fileName: string;
|
||||
onContentChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
|
||||
function getLanguageName(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return 'text';
|
||||
}
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex === -1) {
|
||||
return 'text';
|
||||
}
|
||||
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case 'ng':
|
||||
return 'angular';
|
||||
case 'apl':
|
||||
return 'apl';
|
||||
case 'asc':
|
||||
return 'asciiArmor';
|
||||
case 'ast':
|
||||
return 'asterisk';
|
||||
case 'bf':
|
||||
return 'brainfuck';
|
||||
case 'c':
|
||||
return 'c';
|
||||
case 'ceylon':
|
||||
return 'ceylon';
|
||||
case 'clj':
|
||||
return 'clojure';
|
||||
case 'cmake':
|
||||
return 'cmake';
|
||||
case 'cob':
|
||||
case 'cbl':
|
||||
return 'cobol';
|
||||
case 'coffee':
|
||||
return 'coffeescript';
|
||||
case 'lisp':
|
||||
return 'commonLisp';
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx':
|
||||
return 'cpp';
|
||||
case 'cr':
|
||||
return 'crystal';
|
||||
case 'cs':
|
||||
return 'csharp';
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'cypher':
|
||||
return 'cypher';
|
||||
case 'd':
|
||||
return 'd';
|
||||
case 'dart':
|
||||
return 'dart';
|
||||
case 'diff':
|
||||
case 'patch':
|
||||
return 'diff';
|
||||
case 'dockerfile':
|
||||
return 'dockerfile';
|
||||
case 'dtd':
|
||||
return 'dtd';
|
||||
case 'dylan':
|
||||
return 'dylan';
|
||||
case 'ebnf':
|
||||
return 'ebnf';
|
||||
case 'ecl':
|
||||
return 'ecl';
|
||||
case 'eiffel':
|
||||
return 'eiffel';
|
||||
case 'elm':
|
||||
return 'elm';
|
||||
case 'erl':
|
||||
return 'erlang';
|
||||
case 'factor':
|
||||
return 'factor';
|
||||
case 'fcl':
|
||||
return 'fcl';
|
||||
case 'fs':
|
||||
return 'forth';
|
||||
case 'f90':
|
||||
case 'for':
|
||||
return 'fortran';
|
||||
case 's':
|
||||
return 'gas';
|
||||
case 'feature':
|
||||
return 'gherkin';
|
||||
case 'go':
|
||||
return 'go';
|
||||
case 'groovy':
|
||||
return 'groovy';
|
||||
case 'hs':
|
||||
return 'haskell';
|
||||
case 'hx':
|
||||
return 'haxe';
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return 'html';
|
||||
case 'http':
|
||||
return 'http';
|
||||
case 'idl':
|
||||
return 'idl';
|
||||
case 'java':
|
||||
return 'java';
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return 'javascript';
|
||||
case 'jinja2':
|
||||
case 'j2':
|
||||
return 'jinja2';
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'jsx':
|
||||
return 'jsx';
|
||||
case 'jl':
|
||||
return 'julia';
|
||||
case 'kt':
|
||||
case 'kts':
|
||||
return 'kotlin';
|
||||
case 'less':
|
||||
return 'less';
|
||||
case 'lezer':
|
||||
return 'lezer';
|
||||
case 'liquid':
|
||||
return 'liquid';
|
||||
case 'litcoffee':
|
||||
return 'livescript';
|
||||
case 'lua':
|
||||
return 'lua';
|
||||
case 'md':
|
||||
return 'markdown';
|
||||
case 'nb':
|
||||
case 'mat':
|
||||
return 'mathematica';
|
||||
case 'mbox':
|
||||
return 'mbox';
|
||||
case 'mmd':
|
||||
return 'mermaid';
|
||||
case 'mrc':
|
||||
return 'mirc';
|
||||
case 'moo':
|
||||
return 'modelica';
|
||||
case 'mscgen':
|
||||
return 'mscgen';
|
||||
case 'm':
|
||||
return 'mumps';
|
||||
case 'sql':
|
||||
return 'mysql';
|
||||
case 'nc':
|
||||
return 'nesC';
|
||||
case 'nginx':
|
||||
return 'nginx';
|
||||
case 'nix':
|
||||
return 'nix';
|
||||
case 'nsi':
|
||||
return 'nsis';
|
||||
case 'nt':
|
||||
return 'ntriples';
|
||||
case 'mm':
|
||||
return 'objectiveCpp';
|
||||
case 'octave':
|
||||
return 'octave';
|
||||
case 'oz':
|
||||
return 'oz';
|
||||
case 'pas':
|
||||
return 'pascal';
|
||||
case 'pl':
|
||||
case 'pm':
|
||||
return 'perl';
|
||||
case 'pgsql':
|
||||
return 'pgsql';
|
||||
case 'php':
|
||||
return 'php';
|
||||
case 'pig':
|
||||
return 'pig';
|
||||
case 'ps1':
|
||||
return 'powershell';
|
||||
case 'properties':
|
||||
return 'properties';
|
||||
case 'proto':
|
||||
return 'protobuf';
|
||||
case 'pp':
|
||||
return 'puppet';
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'q':
|
||||
return 'q';
|
||||
case 'r':
|
||||
return 'r';
|
||||
case 'rb':
|
||||
return 'ruby';
|
||||
case 'rs':
|
||||
return 'rust';
|
||||
case 'sas':
|
||||
return 'sas';
|
||||
case 'sass':
|
||||
case 'scss':
|
||||
return 'sass';
|
||||
case 'scala':
|
||||
return 'scala';
|
||||
case 'scm':
|
||||
return 'scheme';
|
||||
case 'shader':
|
||||
return 'shader';
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
return 'shell';
|
||||
case 'siv':
|
||||
return 'sieve';
|
||||
case 'st':
|
||||
return 'smalltalk';
|
||||
case 'sol':
|
||||
return 'solidity';
|
||||
case 'solr':
|
||||
return 'solr';
|
||||
case 'rq':
|
||||
return 'sparql';
|
||||
case 'xlsx':
|
||||
case 'ods':
|
||||
case 'csv':
|
||||
return 'spreadsheet';
|
||||
case 'nut':
|
||||
return 'squirrel';
|
||||
case 'tex':
|
||||
return 'stex';
|
||||
case 'styl':
|
||||
return 'stylus';
|
||||
case 'svelte':
|
||||
return 'svelte';
|
||||
case 'swift':
|
||||
return 'swift';
|
||||
case 'tcl':
|
||||
return 'tcl';
|
||||
case 'textile':
|
||||
return 'textile';
|
||||
case 'tiddlywiki':
|
||||
return 'tiddlyWiki';
|
||||
case 'tiki':
|
||||
return 'tiki';
|
||||
case 'toml':
|
||||
return 'toml';
|
||||
case 'troff':
|
||||
return 'troff';
|
||||
case 'tsx':
|
||||
return 'tsx';
|
||||
case 'ttcn':
|
||||
return 'ttcn';
|
||||
case 'ttl':
|
||||
case 'turtle':
|
||||
return 'turtle';
|
||||
case 'ts':
|
||||
return 'typescript';
|
||||
case 'vb':
|
||||
return 'vb';
|
||||
case 'vbs':
|
||||
return 'vbscript';
|
||||
case 'vm':
|
||||
return 'velocity';
|
||||
case 'v':
|
||||
return 'verilog';
|
||||
case 'vhd':
|
||||
case 'vhdl':
|
||||
return 'vhdl';
|
||||
case 'vue':
|
||||
return 'vue';
|
||||
case 'wat':
|
||||
return 'wast';
|
||||
case 'webidl':
|
||||
return 'webIDL';
|
||||
case 'xq':
|
||||
case 'xquery':
|
||||
return 'xQuery';
|
||||
case 'xml':
|
||||
return 'xml';
|
||||
case 'yacas':
|
||||
return 'yacas';
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return 'yaml';
|
||||
case 'z80':
|
||||
return 'z80';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflowX = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflowX = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
className="config-codemirror-scroll-wrapper"
|
||||
>
|
||||
<CodeMirror
|
||||
value={content}
|
||||
extensions={[
|
||||
loadLanguage(getLanguageName(fileName || 'untitled.txt') as any) || [],
|
||||
hyperLink,
|
||||
oneDark,
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: '#09090b !important',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: '#18181b !important',
|
||||
},
|
||||
})
|
||||
]}
|
||||
onChange={(value: any) => onContentChange(value)}
|
||||
theme={undefined}
|
||||
height="100%"
|
||||
basicSetup={{lineNumbers: true}}
|
||||
style={{minHeight: '100%', minWidth: '100%', flex: 1}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,608 +0,0 @@
|
||||
import React, {useState, useEffect, useRef} from "react";
|
||||
import {ConfigEditorSidebar} from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
|
||||
import {ConfigTabList} from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
|
||||
import {ConfigHomeView} from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
|
||||
import {ConfigCodeEditor} from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {ConfigTopbar} from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {
|
||||
getConfigEditorRecent,
|
||||
getConfigEditorPinned,
|
||||
getConfigEditorShortcuts,
|
||||
addConfigEditorRecent,
|
||||
removeConfigEditorRecent,
|
||||
addConfigEditorPinned,
|
||||
removeConfigEditorPinned,
|
||||
addConfigEditorShortcut,
|
||||
removeConfigEditorShortcut,
|
||||
readSSHFile,
|
||||
writeSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH
|
||||
} from '@/apps/SSH/ssh-axios.ts';
|
||||
|
||||
interface Tab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
isSSH?: boolean;
|
||||
sshSessionId?: string;
|
||||
filePath?: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
success?: string;
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => void }): React.ReactElement {
|
||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<string | number>('home');
|
||||
const [recent, setRecent] = useState<any[]>([]);
|
||||
const [pinned, setPinned] = useState<any[]>([]);
|
||||
const [shortcuts, setShortcuts] = useState<any[]>([]);
|
||||
|
||||
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const sidebarRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
} else {
|
||||
setRecent([]);
|
||||
setPinned([]);
|
||||
setShortcuts([]);
|
||||
}
|
||||
}, [currentHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'home' && currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
}, [activeTab, currentHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'home' && currentHost) {
|
||||
const interval = setInterval(() => {
|
||||
fetchHomeData();
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [activeTab, currentHost]);
|
||||
|
||||
async function fetchHomeData() {
|
||||
if (!currentHost) return;
|
||||
|
||||
try {
|
||||
const homeDataPromise = Promise.all([
|
||||
getConfigEditorRecent(currentHost.id),
|
||||
getConfigEditorPinned(currentHost.id),
|
||||
getConfigEditorShortcuts(currentHost.id),
|
||||
]);
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000)
|
||||
);
|
||||
|
||||
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]);
|
||||
|
||||
const recentWithPinnedStatus = (recentRes || []).map(file => ({
|
||||
...file,
|
||||
type: 'file',
|
||||
isPinned: (pinnedRes || []).some(pinnedFile =>
|
||||
pinnedFile.path === file.path && pinnedFile.name === file.name
|
||||
)
|
||||
}));
|
||||
|
||||
const pinnedWithType = (pinnedRes || []).map(file => ({
|
||||
...file,
|
||||
type: 'file'
|
||||
}));
|
||||
|
||||
setRecent(recentWithPinnedStatus);
|
||||
setPinned(pinnedWithType);
|
||||
setShortcuts((shortcutsRes || []).map(shortcut => ({
|
||||
...shortcut,
|
||||
type: 'directory'
|
||||
})));
|
||||
} catch (err: any) {
|
||||
}
|
||||
}
|
||||
|
||||
const formatErrorMessage = (err: any, defaultMessage: string): string => {
|
||||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||||
const axiosErr = err as any;
|
||||
if (axiosErr.response?.status === 403) {
|
||||
return `Permission denied. ${defaultMessage}. Check the Docker logs for detailed error information.`;
|
||||
} else if (axiosErr.response?.status === 500) {
|
||||
const backendError = axiosErr.response?.data?.error || 'Internal server error occurred';
|
||||
return `Server Error (500): ${backendError}. Check the Docker logs for detailed error information.`;
|
||||
} else if (axiosErr.response?.data?.error) {
|
||||
const backendError = axiosErr.response.data.error;
|
||||
return `${axiosErr.response?.status ? `Error ${axiosErr.response.status}: ` : ''}${backendError}. Check the Docker logs for detailed error information.`;
|
||||
} else {
|
||||
return `Request failed with status code ${axiosErr.response?.status || 'unknown'}. Check the Docker logs for detailed error information.`;
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
return `${err.message}. Check the Docker logs for detailed error information.`;
|
||||
} else {
|
||||
return `${defaultMessage}. Check the Docker logs for detailed error information.`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFile = async (file: any) => {
|
||||
const tabId = file.path;
|
||||
|
||||
if (!tabs.find(t => t.id === tabId)) {
|
||||
const currentSshSessionId = currentHost?.id.toString();
|
||||
|
||||
setTabs([...tabs, {
|
||||
id: tabId,
|
||||
title: file.name,
|
||||
fileName: file.name,
|
||||
content: '',
|
||||
filePath: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentSshSessionId,
|
||||
loading: true
|
||||
}]);
|
||||
try {
|
||||
const res = await readSSHFile(currentSshSessionId, file.path);
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {
|
||||
...t,
|
||||
content: res.content,
|
||||
loading: false,
|
||||
error: undefined
|
||||
} : t));
|
||||
await addConfigEditorRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentSshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err: any) {
|
||||
const errorMessage = formatErrorMessage(err, 'Cannot read file');
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false, error: errorMessage} : t));
|
||||
}
|
||||
}
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
const handleRemoveRecent = async (file: any) => {
|
||||
try {
|
||||
await removeConfigEditorRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinFile = async (file: any) => {
|
||||
try {
|
||||
await addConfigEditorPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpinFile = async (file: any) => {
|
||||
try {
|
||||
await removeConfigEditorPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenShortcut = async (shortcut: any) => {
|
||||
if (sidebarRef.current?.isOpeningShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
||||
try {
|
||||
sidebarRef.current.isOpeningShortcut = true;
|
||||
|
||||
const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`;
|
||||
|
||||
await sidebarRef.current.openFolder(currentHost, normalizedPath);
|
||||
} catch (err) {
|
||||
} finally {
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.isOpeningShortcut = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddShortcut = async (folderPath: string) => {
|
||||
try {
|
||||
const name = folderPath.split('/').pop() || folderPath;
|
||||
await addConfigEditorShortcut({
|
||||
name,
|
||||
path: folderPath,
|
||||
isSSH: true,
|
||||
sshSessionId: currentHost?.id.toString(),
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveShortcut = async (shortcut: any) => {
|
||||
try {
|
||||
await removeConfigEditorShortcut({
|
||||
name: shortcut.name,
|
||||
path: shortcut.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentHost?.id.toString(),
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const closeTab = (tabId: string | number) => {
|
||||
const idx = tabs.findIndex(t => t.id === tabId);
|
||||
const newTabs = tabs.filter(t => t.id !== tabId);
|
||||
setTabs(newTabs);
|
||||
if (activeTab === tabId) {
|
||||
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
|
||||
else setActiveTab('home');
|
||||
}
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
};
|
||||
|
||||
const setTabContent = (tabId: string | number, content: string) => {
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {
|
||||
...t,
|
||||
content,
|
||||
dirty: true,
|
||||
error: undefined,
|
||||
success: undefined
|
||||
} : t));
|
||||
};
|
||||
|
||||
const handleSave = async (tab: Tab) => {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (!tab.sshSessionId) {
|
||||
throw new Error('No SSH session ID available');
|
||||
}
|
||||
|
||||
if (!tab.filePath) {
|
||||
throw new Error('No file path available');
|
||||
}
|
||||
|
||||
if (!currentHost?.id) {
|
||||
throw new Error('No current host available');
|
||||
}
|
||||
|
||||
try {
|
||||
const statusPromise = getSSHStatus(tab.sshSessionId);
|
||||
const statusTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
|
||||
);
|
||||
|
||||
const status = await Promise.race([statusPromise, statusTimeoutPromise]);
|
||||
|
||||
if (!status.connected) {
|
||||
const connectPromise = connectSSH(tab.sshSessionId, {
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
password: currentHost.password,
|
||||
sshKey: currentHost.key,
|
||||
keyPassword: currentHost.keyPassword
|
||||
});
|
||||
const connectTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000)
|
||||
);
|
||||
|
||||
await Promise.race([connectPromise, connectTimeoutPromise]);
|
||||
}
|
||||
} catch (statusErr) {
|
||||
}
|
||||
|
||||
const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => {
|
||||
reject(new Error('Save operation timed out'));
|
||||
}, 30000)
|
||||
);
|
||||
|
||||
const result = await Promise.race([savePromise, timeoutPromise]);
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
dirty: false,
|
||||
success: 'File saved successfully'
|
||||
} : t));
|
||||
|
||||
setTimeout(() => {
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t));
|
||||
}, 3000);
|
||||
|
||||
Promise.allSettled([
|
||||
(async () => {
|
||||
try {
|
||||
await addConfigEditorRecent({
|
||||
name: tab.fileName,
|
||||
path: tab.filePath,
|
||||
isSSH: true,
|
||||
sshSessionId: tab.sshSessionId,
|
||||
hostId: currentHost.id
|
||||
});
|
||||
} catch (recentErr) {
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
await fetchHomeData();
|
||||
} catch (refreshErr) {
|
||||
}
|
||||
})()
|
||||
]).then(() => {
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
let errorMessage = formatErrorMessage(err, 'Cannot save file');
|
||||
|
||||
if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) {
|
||||
errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`;
|
||||
}
|
||||
|
||||
setTabs(tabs => {
|
||||
const updatedTabs = tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
error: `Failed to save file: ${errorMessage}`
|
||||
} : t);
|
||||
return updatedTabs;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setTabs(currentTabs => [...currentTabs]);
|
||||
}, 100);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHostChange = (host: SSHHost | null) => {
|
||||
setCurrentHost(host);
|
||||
setTabs([]);
|
||||
setActiveTab('home');
|
||||
};
|
||||
|
||||
if (!currentHost) {
|
||||
return (
|
||||
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
|
||||
<ConfigEditorSidebar
|
||||
onSelectView={onSelectView}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
onHostChange={handleHostChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 256,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#09090b'
|
||||
}}>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Connect to a Server</h2>
|
||||
<p className="text-muted-foreground">Select a server from the sidebar to start editing files</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}>
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}>
|
||||
<ConfigEditorSidebar
|
||||
onSelectView={onSelectView}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
onHostChange={handleHostChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30}}>
|
||||
<div className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative px-4"
|
||||
style={{height: 44}}>
|
||||
{/* Tab list scrollable area */}
|
||||
<div className="flex-1 min-w-0 h-full flex items-center">
|
||||
<div
|
||||
className="h-9 w-full bg-[#09090b] border border-[#23232a] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
|
||||
style={{minWidth: 0}}>
|
||||
<ConfigTopbar
|
||||
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
closeTab={closeTab}
|
||||
onHomeClick={() => {
|
||||
setActiveTab('home');
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Save button - always visible */}
|
||||
<Button
|
||||
className={cn(
|
||||
'ml-4 px-4 py-1.5 border rounded-md text-sm font-medium transition-colors',
|
||||
'border-[#2d2d30] text-white bg-transparent hover:bg-[#23232a] active:bg-[#23232a] focus:bg-[#23232a]',
|
||||
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : 'hover:border-[#2d2d30]'
|
||||
)}
|
||||
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
|
||||
onClick={() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (tab && !isSaving) handleSave(tab);
|
||||
}}
|
||||
type="button"
|
||||
style={{height: 36, alignSelf: 'center'}}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 44,
|
||||
left: 256,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
zIndex: 10,
|
||||
background: '#101014',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{activeTab === 'home' ? (
|
||||
<ConfigHomeView
|
||||
recent={recent}
|
||||
pinned={pinned}
|
||||
shortcuts={shortcuts}
|
||||
onOpenFile={handleOpenFile}
|
||||
onRemoveRecent={handleRemoveRecent}
|
||||
onPinFile={handlePinFile}
|
||||
onUnpinFile={handleUnpinFile}
|
||||
onOpenShortcut={handleOpenShortcut}
|
||||
onRemoveShortcut={handleRemoveShortcut}
|
||||
onAddShortcut={handleAddShortcut}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
||||
{/* Error display */}
|
||||
{tab.error && (
|
||||
<div
|
||||
className="bg-red-900/20 border border-red-500/30 text-red-300 px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-400">⚠️</span>
|
||||
<span>{tab.error}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
error: undefined
|
||||
} : t))}
|
||||
className="text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Success display */}
|
||||
{tab.success && (
|
||||
<div
|
||||
className="bg-green-900/20 border border-green-500/30 text-green-300 px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-400">✓</span>
|
||||
<span>{tab.success}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
success: undefined
|
||||
} : t))}
|
||||
className="text-green-400 hover:text-green-300 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ConfigCodeEditor
|
||||
content={tab.content}
|
||||
fileName={tab.fileName}
|
||||
onContentChange={content => setTabContent(tab.id, content)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel, SidebarMenu, SidebarMenuItem,
|
||||
SidebarProvider
|
||||
} from '@/components/ui/sidebar.tsx';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react';
|
||||
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
|
||||
import {
|
||||
getSSHHosts,
|
||||
listSSHFiles,
|
||||
connectSSH,
|
||||
getSSHStatus,
|
||||
getConfigEditorPinned,
|
||||
addConfigEditorPinned,
|
||||
removeConfigEditorPinned
|
||||
} from '@/apps/SSH/ssh-axios.ts';
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
{onSelectView, onOpenFile, tabs, onHostChange}: {
|
||||
onSelectView: (view: string) => void;
|
||||
onOpenFile: (file: any) => void;
|
||||
tabs: any[];
|
||||
onHostChange?: (host: SSHHost | null) => void;
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const [sshConnections, setSSHConnections] = useState<SSHHost[]>([]);
|
||||
const [loadingSSH, setLoadingSSH] = useState(false);
|
||||
const [errorSSH, setErrorSSH] = useState<string | undefined>(undefined);
|
||||
const [view, setView] = useState<'servers' | 'files'>('servers');
|
||||
const [activeServer, setActiveServer] = useState<SSHHost | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [fileSearch, setFileSearch] = useState('');
|
||||
const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedFileSearch(fileSearch), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [fileSearch]);
|
||||
|
||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [filesError, setFilesError] = useState<string | null>(null);
|
||||
const [connectingSSH, setConnectingSSH] = useState(false);
|
||||
const [connectionCache, setConnectionCache] = useState<Record<string, {
|
||||
sessionId: string;
|
||||
timestamp: number
|
||||
}>>({});
|
||||
const [fetchingFiles, setFetchingFiles] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSSH();
|
||||
}, []);
|
||||
|
||||
async function fetchSSH() {
|
||||
setLoadingSSH(true);
|
||||
setErrorSSH(undefined);
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const configEditorHosts = hosts.filter(host => host.enableConfigEditor);
|
||||
|
||||
if (configEditorHosts.length > 0) {
|
||||
const firstHost = configEditorHosts[0];
|
||||
}
|
||||
|
||||
setSSHConnections(configEditorHosts);
|
||||
} catch (err: any) {
|
||||
setErrorSSH('Failed to load SSH connections');
|
||||
} finally {
|
||||
setLoadingSSH(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
||||
const sessionId = server.id.toString();
|
||||
|
||||
const cached = connectionCache[sessionId];
|
||||
if (cached && Date.now() - cached.timestamp < 30000) {
|
||||
setSshSessionId(cached.sessionId);
|
||||
return cached.sessionId;
|
||||
}
|
||||
|
||||
if (connectingSSH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setConnectingSSH(true);
|
||||
|
||||
try {
|
||||
if (!server.password && !server.key) {
|
||||
setFilesError('No authentication credentials available for this SSH host');
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionConfig = {
|
||||
ip: server.ip,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
sshKey: server.key,
|
||||
keyPassword: server.keyPassword,
|
||||
};
|
||||
|
||||
await connectSSH(sessionId, connectionConfig);
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
setConnectionCache(prev => ({
|
||||
...prev,
|
||||
[sessionId]: {sessionId, timestamp: Date.now()}
|
||||
}));
|
||||
|
||||
return sessionId;
|
||||
} catch (err: any) {
|
||||
setFilesError(err?.response?.data?.error || 'Failed to connect to SSH');
|
||||
setSshSessionId(null);
|
||||
return null;
|
||||
} finally {
|
||||
setConnectingSSH(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFiles() {
|
||||
if (fetchingFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(true);
|
||||
setFiles([]);
|
||||
setFilesLoading(true);
|
||||
setFilesError(null);
|
||||
|
||||
try {
|
||||
let pinnedFiles: any[] = [];
|
||||
try {
|
||||
if (activeServer) {
|
||||
pinnedFiles = await getConfigEditorPinned(activeServer.id);
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
|
||||
if (activeServer && sshSessionId) {
|
||||
let res: any[] = [];
|
||||
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
if (!status.connected) {
|
||||
const newSessionId = await connectToSSH(activeServer);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
} else {
|
||||
throw new Error('Failed to reconnect SSH session');
|
||||
}
|
||||
} else {
|
||||
res = await listSSHFiles(sshSessionId, currentPath);
|
||||
}
|
||||
} catch (sessionErr) {
|
||||
const newSessionId = await connectToSSH(activeServer);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
} else {
|
||||
throw sessionErr;
|
||||
}
|
||||
}
|
||||
|
||||
const processedFiles = (res || []).map((f: any) => {
|
||||
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
|
||||
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
|
||||
return {
|
||||
...f,
|
||||
path: filePath,
|
||||
isPinned,
|
||||
isSSH: true,
|
||||
sshSessionId: sshSessionId
|
||||
};
|
||||
});
|
||||
|
||||
setFiles(processedFiles);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setFiles([]);
|
||||
setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files');
|
||||
} finally {
|
||||
setFilesLoading(false);
|
||||
setFetchingFiles(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
fetchFiles();
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [currentPath, view, activeServer, sshSessionId]);
|
||||
|
||||
async function handleSelectServer(server: SSHHost) {
|
||||
if (connectingSSH) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(false);
|
||||
setFilesLoading(false);
|
||||
setFilesError(null);
|
||||
setFiles([]);
|
||||
|
||||
setActiveServer(server);
|
||||
setCurrentPath(server.defaultPath || '/');
|
||||
setView('files');
|
||||
|
||||
const sessionId = await connectToSSH(server);
|
||||
if (sessionId) {
|
||||
setSshSessionId(sessionId);
|
||||
if (onHostChange) {
|
||||
onHostChange(server);
|
||||
}
|
||||
} else {
|
||||
w
|
||||
setView('servers');
|
||||
setActiveServer(null);
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openFolder: async (server: SSHHost, path: string) => {
|
||||
if (connectingSSH || fetchingFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeServer?.id === server.id && currentPath === path) {
|
||||
setTimeout(() => fetchFiles(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(false);
|
||||
setFilesLoading(false);
|
||||
setFilesError(null);
|
||||
setFiles([]);
|
||||
|
||||
setActiveServer(server);
|
||||
setCurrentPath(path);
|
||||
setView('files');
|
||||
|
||||
if (!sshSessionId || activeServer?.id !== server.id) {
|
||||
const sessionId = await connectToSSH(server);
|
||||
if (sessionId) {
|
||||
setSshSessionId(sessionId);
|
||||
if (onHostChange && activeServer?.id !== server.id) {
|
||||
onHostChange(server);
|
||||
}
|
||||
} else {
|
||||
setView('servers');
|
||||
setActiveServer(null);
|
||||
}
|
||||
} else {
|
||||
if (onHostChange && activeServer?.id !== server.id) {
|
||||
onHostChange(server);
|
||||
}
|
||||
}
|
||||
},
|
||||
fetchFiles: () => {
|
||||
if (activeServer && sshSessionId) {
|
||||
fetchFiles();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (pathInputRef.current) {
|
||||
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
const sshByFolder: Record<string, SSHHost[]> = {};
|
||||
sshConnections.forEach(conn => {
|
||||
const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder';
|
||||
if (!sshByFolder[folder]) sshByFolder[folder] = [];
|
||||
sshByFolder[folder].push(conn);
|
||||
});
|
||||
|
||||
const sortedFolders = Object.keys(sshByFolder);
|
||||
if (sortedFolders.includes('No Folder')) {
|
||||
sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1);
|
||||
sortedFolders.unshift('No Folder');
|
||||
}
|
||||
|
||||
const filteredSshByFolder: Record<string, SSHHost[]> = {};
|
||||
Object.entries(sshByFolder).forEach(([folder, hosts]) => {
|
||||
filteredSshByFolder[folder] = hosts.filter(conn => {
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
|
||||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
|
||||
(conn.tags || []).join(' ').toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const filteredFiles = files.filter(file => {
|
||||
const q = debouncedFileSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return file.name.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
|
||||
<SidebarContent style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}>
|
||||
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / Config
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent className="flex flex-col flex-grow min-h-0">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem key={"Homepage"}>
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
||||
variant="outline">
|
||||
<CornerDownLeft/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<div
|
||||
className="flex-1 w-full flex flex-col rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 relative min-h-0 mt-1">
|
||||
{view === 'servers' && (
|
||||
<>
|
||||
<div
|
||||
className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
className="w-full h-8 text-sm bg-[#18181b] border border-[#23232a] text-white placeholder:text-muted-foreground rounded"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 w-full h-full"
|
||||
style={{height: '100%', maxHeight: '100%'}}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div
|
||||
className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
||||
<div style={{display: 'flex', justifyContent: 'center'}}>
|
||||
<Separator className="w-full h-px bg-[#434345] my-2"
|
||||
style={{maxWidth: 213, margin: '0 auto'}}/>
|
||||
</div>
|
||||
<div className="mx-auto" style={{maxWidth: '213px', width: '100%'}}>
|
||||
<div className="flex-1 min-h-0">
|
||||
<Accordion type="multiple" className="w-full"
|
||||
value={sortedFolders}>
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<React.Fragment key={folder}>
|
||||
<AccordionItem value={folder}
|
||||
className="mt-0 w-full !border-b-transparent">
|
||||
<AccordionTrigger
|
||||
className="text-base font-semibold rounded-t-none py-2 w-full">{folder}</AccordionTrigger>
|
||||
<AccordionContent
|
||||
className="flex flex-col gap-1 pb-2 pt-1 w-full">
|
||||
{filteredSshByFolder[folder].map(conn => (
|
||||
<Button
|
||||
key={conn.id}
|
||||
variant="outline"
|
||||
className="w-full h-10 px-2 bg-[#18181b] border border-[#434345] hover:bg-[#2d2d30] transition-colors text-left justify-start"
|
||||
onClick={() => handleSelectServer(conn)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center w-full">
|
||||
{conn.pin && <Pin
|
||||
className="w-0.5 h-0.5 text-yellow-400 mr-1 flex-shrink-0"/>}
|
||||
<span
|
||||
className="font-medium truncate">{conn.name || conn.ip}</span>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
{idx < sortedFolders.length - 1 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<Separator
|
||||
className="h-px bg-[#434345] my-1"
|
||||
style={{width: 213}}/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
{view === 'files' && activeServer && (
|
||||
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20"
|
||||
style={{maxWidth: 260}}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 bg-[#18181b] border border-[#23232a] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onClick={() => {
|
||||
let path = currentPath;
|
||||
if (path && path !== '/' && path !== '') {
|
||||
if (path.endsWith('/')) path = path.slice(0, -1);
|
||||
const lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
setCurrentPath(path.slice(0, lastSlash));
|
||||
} else {
|
||||
setCurrentPath('/');
|
||||
}
|
||||
} else {
|
||||
setView('servers');
|
||||
if (onHostChange) {
|
||||
onHostChange(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4"/>
|
||||
</Button>
|
||||
<Input ref={pathInputRef} value={currentPath}
|
||||
onChange={e => setCurrentPath(e.target.value)}
|
||||
className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-2 border-b border-[#23232a] bg-[#18181b]">
|
||||
<Input
|
||||
placeholder="Search files and folders..."
|
||||
className="w-full h-7 text-sm bg-[#23232a] border border-[#434345] text-white placeholder:text-muted-foreground rounded"
|
||||
autoComplete="off"
|
||||
value={fileSearch}
|
||||
onChange={e => setFileSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 w-full h-full bg-[#09090b] border-t border-[#23232a]">
|
||||
<ScrollArea className="w-full h-full bg-[#09090b]" style={{
|
||||
height: '100%',
|
||||
maxHeight: '100%',
|
||||
paddingRight: 8,
|
||||
scrollbarGutter: 'stable',
|
||||
background: '#09090b'
|
||||
}}>
|
||||
<div className="p-2 pr-2">
|
||||
{connectingSSH || filesLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||
) : filesError ? (
|
||||
<div className="text-xs text-red-500">{filesError}</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No files or
|
||||
folders found.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredFiles.map((item: any) => {
|
||||
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded group max-w-full",
|
||||
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
|
||||
)}
|
||||
style={{maxWidth: 220, marginBottom: 8}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => !isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
isSSH: item.isSSH,
|
||||
sshSessionId: item.sshSessionId
|
||||
}))}
|
||||
>
|
||||
{item.type === 'directory' ?
|
||||
<Folder
|
||||
className="w-4 h-4 text-blue-400"/> :
|
||||
<File
|
||||
className="w-4 h-4 text-muted-foreground"/>}
|
||||
<span
|
||||
className="text-sm text-white truncate max-w-[120px]">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{item.type === 'file' && (
|
||||
<Button size="icon" variant="ghost"
|
||||
className="h-7 w-7"
|
||||
disabled={isOpen}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (item.isPinned) {
|
||||
await removeConfigEditorPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: activeServer?.id,
|
||||
isSSH: true,
|
||||
sshSessionId: activeServer?.id.toString()
|
||||
});
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? {
|
||||
...f,
|
||||
isPinned: false
|
||||
} : f
|
||||
));
|
||||
} else {
|
||||
await addConfigEditorPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: activeServer?.id,
|
||||
isSSH: true,
|
||||
sshSessionId: activeServer?.id.toString()
|
||||
});
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? {
|
||||
...f,
|
||||
isPinned: true
|
||||
} : f
|
||||
));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to pin/unpin file:', err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pin
|
||||
className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
);
|
||||
});
|
||||
export {ConfigEditorSidebar};
|
||||
@@ -1,149 +0,0 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Card} from '@/components/ui/card.tsx';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
|
||||
|
||||
interface SSHConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'link';
|
||||
path: string;
|
||||
isStarred?: boolean;
|
||||
}
|
||||
|
||||
interface ConfigFileSidebarViewerProps {
|
||||
sshConnections: SSHConnection[];
|
||||
onAddSSH: () => void;
|
||||
onConnectSSH: (conn: SSHConnection) => void;
|
||||
onEditSSH: (conn: SSHConnection) => void;
|
||||
onDeleteSSH: (conn: SSHConnection) => void;
|
||||
onPinSSH: (conn: SSHConnection) => void;
|
||||
currentPath: string;
|
||||
files: FileItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onOpenFolder: (folder: FileItem) => void;
|
||||
onStarFile: (file: FileItem) => void;
|
||||
onDeleteFile: (file: FileItem) => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
isSSHMode: boolean;
|
||||
onSwitchToLocal: () => void;
|
||||
onSwitchToSSH: (conn: SSHConnection) => void;
|
||||
currentSSH?: SSHConnection;
|
||||
}
|
||||
|
||||
export function ConfigFileSidebarViewer({
|
||||
sshConnections,
|
||||
onAddSSH,
|
||||
onConnectSSH,
|
||||
onEditSSH,
|
||||
onDeleteSSH,
|
||||
onPinSSH,
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
onSwitchToLocal,
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: ConfigFileSidebarViewerProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* SSH Connections */}
|
||||
<div className="p-2 bg-[#18181b] border-b border-[#23232a]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
|
||||
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
|
||||
<Plus className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant={!isSSHMode ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start text-left px-2 py-1.5 rounded"
|
||||
onClick={onSwitchToLocal}
|
||||
>
|
||||
<Server className="w-4 h-4 mr-2"/> Local Files
|
||||
</Button>
|
||||
{sshConnections.map((conn) => (
|
||||
<div key={conn.id} className="flex items-center gap-1 group">
|
||||
<Button
|
||||
variant={isSSHMode && currentSSH?.id === conn.id ? 'secondary' : 'ghost'}
|
||||
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
|
||||
onClick={() => onSwitchToSSH(conn)}
|
||||
>
|
||||
<Link2 className="w-4 h-4 mr-2"/>
|
||||
{conn.name || conn.ip}
|
||||
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
|
||||
<Pin
|
||||
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
|
||||
<Edit className="w-4 h-4"/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* File/Folder Viewer */}
|
||||
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span>
|
||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500">{error}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{files.map((item) => (
|
||||
<Card key={item.path}
|
||||
className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded">
|
||||
<div className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
|
||||
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground"/>}
|
||||
<span className="text-sm text-white truncate">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
onClick={() => onStarFile(item)}>
|
||||
<Pin
|
||||
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
onClick={() => onDeleteFile(item)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{files.length === 0 &&
|
||||
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
|
||||
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {useState} from 'react';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isPinned?: boolean;
|
||||
type: 'file' | 'directory';
|
||||
sshSessionId?: string;
|
||||
}
|
||||
|
||||
interface ShortcutItem {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ConfigHomeViewProps {
|
||||
recent: FileItem[];
|
||||
pinned: FileItem[];
|
||||
shortcuts: ShortcutItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onRemoveRecent: (file: FileItem) => void;
|
||||
onPinFile: (file: FileItem) => void;
|
||||
onUnpinFile: (file: FileItem) => void;
|
||||
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
||||
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
||||
onAddShortcut: (path: string) => void;
|
||||
}
|
||||
|
||||
export function ConfigHomeView({
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: ConfigHomeViewProps) {
|
||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||
const [newShortcut, setNewShortcut] = useState('');
|
||||
|
||||
|
||||
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
|
||||
<div key={file.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenFile(file)}
|
||||
>
|
||||
{file.type === 'directory' ?
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>
|
||||
}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onPin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={onPin}
|
||||
>
|
||||
<Pin
|
||||
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
||||
<div key={shortcut.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenShortcut(shortcut)}
|
||||
>
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{shortcut.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={() => onRemoveShortcut(shortcut)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
|
||||
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
||||
<TabsList className="mb-4 bg-[#18181b] border border-[#23232a]">
|
||||
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
|
||||
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
|
||||
Shortcuts</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No recent files.</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-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{pinned.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
||||
</div>
|
||||
) : pinned.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
undefined,
|
||||
() => onUnpinFile(file),
|
||||
true
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shortcuts" className="mt-0">
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border border-[#23232a] rounded-lg">
|
||||
<Input
|
||||
placeholder="Enter folder path"
|
||||
value={newShortcut}
|
||||
onChange={e => setNewShortcut(e.target.value)}
|
||||
className="flex-1 bg-[#23232a] border-[#434345] text-white placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2 bg-[#23232a] border-[#434345] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={() => {
|
||||
if (newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1"/>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{shortcuts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No shortcuts.</span>
|
||||
</div>
|
||||
) : shortcuts.map((shortcut) =>
|
||||
renderShortcutCard(shortcut)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {X, Home} from 'lucide-react';
|
||||
|
||||
interface ConfigTab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ConfigTabListProps {
|
||||
tabs: ConfigTab[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
onHomeClick: () => void;
|
||||
}
|
||||
|
||||
export function ConfigTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: ConfigTabListProps) {
|
||||
return (
|
||||
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
|
||||
<Button
|
||||
onClick={onHomeClick}
|
||||
variant="outline"
|
||||
className={`h-7 mr-[0.5rem] rounded-md flex items-center ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
>
|
||||
<Home className="w-4 h-4"/>
|
||||
</Button>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={index < tabs.length - 1 ? "mr-[0.5rem]" : ""}
|
||||
>
|
||||
<div className="inline-flex rounded-md shadow-sm" role="group">
|
||||
<Button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
variant="outline"
|
||||
className={`h-7 rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => closeTab(tab.id)}
|
||||
variant="outline"
|
||||
className="h-7 rounded-l-none p-0 !w-9"
|
||||
>
|
||||
<X className="!w-5 !h-5" strokeWidth={2.5}/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import React from "react";
|
||||
import { ConfigTabList } from "./ConfigTabList.tsx";
|
||||
|
||||
export function ConfigTopbar(props: any): React.ReactElement {
|
||||
return (
|
||||
<ConfigTabList {...props} />
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import React, {useState} from "react";
|
||||
import {SSHManagerSidebar} from "@/apps/SSH/Manager/SSHManagerSidebar.tsx";
|
||||
import {SSHManagerHostViewer} from "@/apps/SSH/Manager/SSHManagerHostViewer.tsx"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {SSHManagerHostEditor} from "@/apps/SSH/Manager/SSHManagerHostEditor.tsx";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
setActiveTab("add_host");
|
||||
};
|
||||
|
||||
const handleFormSubmit = () => {
|
||||
setEditingHost(null);
|
||||
setActiveTab("host_viewer");
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
if (value === "host_viewer") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SSHManagerSidebar
|
||||
onSelectView={onSelectView}
|
||||
/>
|
||||
|
||||
<div className="flex w-screen h-screen overflow-hidden">
|
||||
<div className="w-[256px]"/>
|
||||
|
||||
<div
|
||||
className="flex-1 bg-[#18181b] m-[35px] text-white p-4 rounded-md w-[1200px] border h-[calc(100vh-70px)] flex flex-col min-h-0">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col h-full min-h-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
|
||||
<TabsTrigger value="add_host">
|
||||
{editingHost ? "Edit Host" : "Add Host"}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SSHManagerHostViewer onEditHost={handleEditHost}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<SSHManagerHostEditor
|
||||
editingHost={editingHost}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
import React, {useState, useEffect, useMemo} from "react";
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
|
||||
import {getSSHHosts, deleteSSHHost} from "@/apps/SSH/ssh-axios";
|
||||
import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SSHManagerHostViewerProps {
|
||||
onEditHost?: (host: SSHHost) => void;
|
||||
}
|
||||
|
||||
export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
}, []);
|
||||
|
||||
const fetchHosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getSSHHosts();
|
||||
setHosts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load hosts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (hostId: number, hostName: string) => {
|
||||
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
|
||||
try {
|
||||
await deleteSSHHost(hostId);
|
||||
await fetchHosts();
|
||||
} catch (err) {
|
||||
alert('Failed to delete host');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (host: SSHHost) => {
|
||||
if (onEditHost) {
|
||||
onEditHost(host);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAndSortedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = hosts.filter(host => {
|
||||
const searchableText = [
|
||||
host.name || '',
|
||||
host.username,
|
||||
host.ip,
|
||||
host.folder || '',
|
||||
...(host.tags || []),
|
||||
host.authType,
|
||||
host.defaultPath || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchableText.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (a.pin && !b.pin) return -1;
|
||||
if (!a.pin && b.pin) return 1;
|
||||
|
||||
const aName = a.name || a.username;
|
||||
const bName = b.name || b.username;
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}, [hosts, searchQuery]);
|
||||
|
||||
const hostsByFolder = useMemo(() => {
|
||||
const grouped: { [key: string]: SSHHost[] } = {};
|
||||
|
||||
filteredAndSortedHosts.forEach(host => {
|
||||
const folder = host.folder || 'Uncategorized';
|
||||
if (!grouped[folder]) {
|
||||
grouped[folder] = [];
|
||||
}
|
||||
grouped[folder].push(host);
|
||||
});
|
||||
|
||||
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === 'Uncategorized') return -1;
|
||||
if (b === 'Uncategorized') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const sortedGrouped: { [key: string]: SSHHost[] } = {};
|
||||
sortedFolders.forEach(folder => {
|
||||
sortedGrouped[folder] = grouped[folder];
|
||||
});
|
||||
|
||||
return sortedGrouped;
|
||||
}, [filteredAndSortedHosts]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading hosts...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={fetchHosts} variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||
<h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
You haven't added any SSH hosts yet. Click "Add Host" to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">SSH Hosts</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{filteredAndSortedHosts.length} hosts
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
||||
<div key={folder} className="border rounded-md">
|
||||
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger
|
||||
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4"/>
|
||||
<span className="font-medium">{folder}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderHosts.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{folderHosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
||||
onClick={() => handleEdit(host)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{host.pin && <Pin
|
||||
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>}
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.username}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<Edit className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.tags.slice(0, 6).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 > 6 && (
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{host.enableTerminal && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Terminal className="h-2 w-2 mr-0.5"/>
|
||||
Terminal
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Network className="h-2 w-2 mr-0.5"/>
|
||||
Tunnel
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
|
||||
<span
|
||||
className="ml-0.5">({host.tunnelConnections.length})</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableConfigEditor && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<FileEdit className="h-2 w-2 mr-0.5"/>
|
||||
Config
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
CornerDownLeft
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Button
|
||||
} from "@/components/ui/button.tsx"
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem, SidebarProvider,
|
||||
} from "@/components/ui/sidebar.tsx"
|
||||
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function SSHManagerSidebar({onSelectView}: SidebarProps): React.ReactElement {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / SSH Manager
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
<SidebarMenu>
|
||||
|
||||
{/* Sidebar Items */}
|
||||
<SidebarMenuItem key={"Homepage"}>
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
||||
variant="outline">
|
||||
<CornerDownLeft/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,784 +0,0 @@
|
||||
import React, {useState, useRef, useEffect} from "react";
|
||||
import {TerminalSidebar} from "@/apps/SSH/Terminal/TerminalSidebar.tsx";
|
||||
import {TerminalComponent} from "./TerminalComponent.tsx";
|
||||
import {TerminalTopbar} from "@/apps/SSH/Terminal/TerminalTopbar.tsx";
|
||||
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
import {ChevronDown, ChevronRight} from "lucide-react";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
type Tab = {
|
||||
id: number;
|
||||
title: string;
|
||||
hostConfig: any;
|
||||
terminalRef: React.RefObject<any>;
|
||||
};
|
||||
|
||||
export function Terminal({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
const [allTabs, setAllTabs] = useState<Tab[]>([]);
|
||||
const [currentTab, setCurrentTab] = useState<number | null>(null);
|
||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||
const nextTabId = useRef(1);
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
|
||||
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
|
||||
const SIDEBAR_WIDTH = 256;
|
||||
const HANDLE_THICKNESS = 10;
|
||||
|
||||
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({});
|
||||
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const panelGroupRefs = useRef<{ [key: string]: any }>({});
|
||||
|
||||
const setActiveTab = (tabId: number) => {
|
||||
setCurrentTab(tabId);
|
||||
};
|
||||
|
||||
const fitVisibleTerminals = () => {
|
||||
allTabs.forEach((terminal) => {
|
||||
const isVisible =
|
||||
(allSplitScreenTab.length === 0 && terminal.id === currentTab) ||
|
||||
(allSplitScreenTab.length > 0 && (terminal.id === currentTab || allSplitScreenTab.includes(terminal.id)));
|
||||
if (isVisible && terminal.terminalRef && terminal.terminalRef.current && typeof terminal.terminalRef.current.fit === 'function') {
|
||||
terminal.terminalRef.current.fit();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setSplitScreenTab = (tabId: number) => {
|
||||
fitVisibleTerminals();
|
||||
setAllSplitScreenTab((prev) => {
|
||||
let next;
|
||||
if (prev.includes(tabId)) {
|
||||
next = prev.filter((id) => id !== tabId);
|
||||
} else if (prev.length < 3) {
|
||||
next = [...prev, tabId];
|
||||
} else {
|
||||
next = prev;
|
||||
}
|
||||
setTimeout(() => fitVisibleTerminals(), 0);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const setCloseTab = (tabId: number) => {
|
||||
const tab = allTabs.find((t) => t.id === tabId);
|
||||
if (tab && tab.terminalRef && tab.terminalRef.current && typeof tab.terminalRef.current.disconnect === "function") {
|
||||
tab.terminalRef.current.disconnect();
|
||||
}
|
||||
setAllTabs((prev) => prev.filter((tab) => tab.id !== tabId));
|
||||
setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId));
|
||||
if (currentTab === tabId) {
|
||||
const remainingTabs = allTabs.filter((tab) => tab.id !== tabId);
|
||||
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : null);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePanelRects = () => {
|
||||
setPanelRects((prev) => {
|
||||
const next: Record<string, DOMRect | null> = {...prev};
|
||||
Object.entries(panelRefs.current).forEach(([id, ref]) => {
|
||||
if (ref) {
|
||||
next[id] = ref.getBoundingClientRect();
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const observers: ResizeObserver[] = [];
|
||||
Object.entries(panelRefs.current).forEach(([id, ref]) => {
|
||||
if (ref) {
|
||||
const observer = new ResizeObserver(() => updatePanelRects());
|
||||
observer.observe(ref);
|
||||
observers.push(observer);
|
||||
}
|
||||
});
|
||||
updatePanelRects();
|
||||
return () => {
|
||||
observers.forEach((observer) => observer.disconnect());
|
||||
};
|
||||
}, [allSplitScreenTab, currentTab, allTabs.length]);
|
||||
|
||||
const renderAllTerminals = () => {
|
||||
const layoutStyles: Record<number, React.CSSProperties> = {};
|
||||
const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id));
|
||||
const mainTab = allTabs.find((tab) => tab.id === currentTab);
|
||||
const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t);
|
||||
if (allSplitScreenTab.length === 0 && mainTab) {
|
||||
layoutStyles[mainTab.id] = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 20,
|
||||
display: 'block',
|
||||
pointerEvents: 'auto',
|
||||
};
|
||||
} else {
|
||||
layoutTabs.forEach((tab) => {
|
||||
const rect = panelRects[String(tab.id)];
|
||||
if (rect) {
|
||||
const parentRect = panelRefs.current['parent']?.getBoundingClientRect();
|
||||
let top = rect.top, left = rect.left, width = rect.width, height = rect.height;
|
||||
if (parentRect) {
|
||||
top = rect.top - parentRect.top;
|
||||
left = rect.left - parentRect.left;
|
||||
}
|
||||
layoutStyles[tab.id] = {
|
||||
position: 'absolute',
|
||||
top: top + 28,
|
||||
left,
|
||||
width,
|
||||
height: height - 28,
|
||||
zIndex: 20,
|
||||
display: 'block',
|
||||
pointerEvents: 'auto',
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div ref={el => {
|
||||
panelRefs.current['parent'] = el;
|
||||
}} style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 1,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{allTabs.map((tab) => {
|
||||
const style = layoutStyles[tab.id]
|
||||
? {...layoutStyles[tab.id], overflow: 'hidden'}
|
||||
: {display: 'none', overflow: 'hidden'};
|
||||
const isVisible = !!layoutStyles[tab.id];
|
||||
return (
|
||||
<div key={tab.id} style={style} data-terminal-id={tab.id}>
|
||||
<TerminalComponent
|
||||
key={tab.id}
|
||||
ref={tab.terminalRef}
|
||||
hostConfig={tab.hostConfig}
|
||||
isVisible={isVisible}
|
||||
title={tab.title}
|
||||
showTitle={false}
|
||||
splitScreen={allSplitScreenTab.length > 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSplitOverlays = () => {
|
||||
const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id));
|
||||
const mainTab = allTabs.find((tab) => tab.id === currentTab);
|
||||
const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t);
|
||||
if (allSplitScreenTab.length === 0) return null;
|
||||
|
||||
if (layoutTabs.length === 2) {
|
||||
const [tab1, tab2] = layoutTabs;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<ResizablePrimitive.PanelGroup
|
||||
ref={el => {
|
||||
panelGroupRefs.current['main'] = el;
|
||||
}}
|
||||
direction="horizontal"
|
||||
className="h-full w-full"
|
||||
id="main-horizontal"
|
||||
>
|
||||
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(tab1.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{tab1.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(tab2.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{tab2.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (layoutTabs.length === 3) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<ResizablePrimitive.PanelGroup
|
||||
ref={el => {
|
||||
panelGroupRefs.current['main'] = el;
|
||||
}}
|
||||
direction="vertical"
|
||||
className="h-full w-full"
|
||||
id="main-vertical"
|
||||
>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="top-panel" order={1}>
|
||||
<ResizablePanelGroup ref={el => {
|
||||
panelGroupRefs.current['top'] = el;
|
||||
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[0].id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[0].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[0].title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[1].id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[1].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[1].title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="bottom-panel" order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[2].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[2].title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (layoutTabs.length === 4) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<ResizablePrimitive.PanelGroup
|
||||
ref={el => {
|
||||
panelGroupRefs.current['main'] = el;
|
||||
}}
|
||||
direction="vertical"
|
||||
className="h-full w-full"
|
||||
id="main-vertical"
|
||||
>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="top-panel" order={1}>
|
||||
<ResizablePanelGroup ref={el => {
|
||||
panelGroupRefs.current['top'] = el;
|
||||
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
|
||||
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[0].id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[0].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[0].title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[1].id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[1].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[1].title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="bottom-panel" order={2}>
|
||||
<ResizablePanelGroup ref={el => {
|
||||
panelGroupRefs.current['bottom'] = el;
|
||||
}} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
|
||||
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[2].id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[2].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[2].title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
|
||||
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20}
|
||||
className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${layoutTabs[3].id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(layoutTabs[3].id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: 28,
|
||||
lineHeight: '28px',
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
}}>{layoutTabs[3].title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const onAddHostSubmit = (data: any) => {
|
||||
const id = nextTabId.current++;
|
||||
const title = `${data.ip || "Host"}:${data.port || 22}`;
|
||||
const terminalRef = React.createRef<any>();
|
||||
const newTab: Tab = {
|
||||
id,
|
||||
title,
|
||||
hostConfig: data,
|
||||
terminalRef,
|
||||
};
|
||||
setAllTabs((prev) => [...prev, newTab]);
|
||||
setCurrentTab(id);
|
||||
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
|
||||
};
|
||||
|
||||
const getUniqueTabTitle = (baseTitle: string) => {
|
||||
let title = baseTitle;
|
||||
let count = 1;
|
||||
const existingTitles = allTabs.map(t => t.title);
|
||||
while (existingTitles.includes(title)) {
|
||||
title = `${baseTitle} (${count})`;
|
||||
count++;
|
||||
}
|
||||
return title;
|
||||
};
|
||||
|
||||
const onHostConnect = (hostConfig: any) => {
|
||||
const baseTitle = hostConfig.name?.trim() ? hostConfig.name : `${hostConfig.ip || "Host"}:${hostConfig.port || 22}`;
|
||||
const title = getUniqueTabTitle(baseTitle);
|
||||
const terminalRef = React.createRef<any>();
|
||||
const id = nextTabId.current++;
|
||||
const newTab: Tab = {
|
||||
id,
|
||||
title,
|
||||
hostConfig,
|
||||
terminalRef,
|
||||
};
|
||||
setAllTabs((prev) => [...prev, newTab]);
|
||||
setCurrentTab(id);
|
||||
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden', position: 'relative'}}>
|
||||
<div
|
||||
style={{
|
||||
width: isSidebarOpen ? SIDEBAR_WIDTH : 0,
|
||||
flexShrink: 0,
|
||||
height: '100vh',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
overflow: 'hidden',
|
||||
transition: 'width 240ms ease-in-out',
|
||||
willChange: 'width',
|
||||
}}
|
||||
>
|
||||
<TerminalSidebar
|
||||
onSelectView={onSelectView}
|
||||
onHostConnect={onHostConnect}
|
||||
allTabs={allTabs}
|
||||
runCommandOnTabs={(tabIds: number[], command: string) => {
|
||||
allTabs.forEach(tab => {
|
||||
if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) {
|
||||
tab.terminalRef.current.sendInput(command);
|
||||
}
|
||||
});
|
||||
}}
|
||||
onCloseSidebar={() => setIsSidebarOpen(false)}
|
||||
open={isSidebarOpen}
|
||||
onOpenChange={setIsSidebarOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="terminal-container"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '100vh',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
paddingLeft: isSidebarOpen ? 0 : HANDLE_THICKNESS,
|
||||
paddingTop: isTopbarOpen ? 0 : HANDLE_THICKNESS,
|
||||
border: 'none',
|
||||
transition: 'padding-left 240ms ease-in-out, padding-top 240ms ease-in-out',
|
||||
willChange: 'padding',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: isTopbarOpen ? 46 : 0,
|
||||
overflow: 'hidden',
|
||||
zIndex: 10,
|
||||
transition: 'height 240ms ease-in-out',
|
||||
willChange: 'height',
|
||||
}}
|
||||
>
|
||||
<TerminalTopbar
|
||||
allTabs={allTabs}
|
||||
currentTab={currentTab ?? -1}
|
||||
setActiveTab={setActiveTab}
|
||||
allSplitScreenTab={allSplitScreenTab}
|
||||
setSplitScreenTab={setSplitScreenTab}
|
||||
setCloseTab={setCloseTab}
|
||||
onHideTopbar={() => setIsTopbarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
{!isTopbarOpen && (
|
||||
<div
|
||||
onClick={() => setIsTopbarOpen(true)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: HANDLE_THICKNESS,
|
||||
background: '#222224',
|
||||
cursor: 'pointer',
|
||||
zIndex: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="Show top bar">
|
||||
<ChevronDown size={HANDLE_THICKNESS} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: isTopbarOpen ? 'calc(100% - 46px)' : '100%',
|
||||
marginTop: isTopbarOpen ? 46 : 0,
|
||||
position: 'relative',
|
||||
transition: 'margin-top 240ms ease-in-out, height 240ms ease-in-out',
|
||||
}}
|
||||
>
|
||||
{allTabs.length === 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: '#18181b',
|
||||
border: '1px solid #434345',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
color: '#f7f7f7',
|
||||
maxWidth: '400px',
|
||||
zIndex: 30
|
||||
}}>
|
||||
<div style={{fontSize: '18px', fontWeight: 'bold', marginBottom: '12px'}}>
|
||||
Welcome to Termix SSH
|
||||
</div>
|
||||
<div style={{fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5'}}>
|
||||
Click on any host title in the sidebar to open a terminal connection, or use the "Add
|
||||
Host" button to create a new connection.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{allSplitScreenTab.length > 0 && (
|
||||
<div style={{position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28}}>
|
||||
<button
|
||||
style={{
|
||||
background: '#18181b',
|
||||
color: '#fff',
|
||||
borderLeft: '1px solid #222224',
|
||||
borderRight: '1px solid #222224',
|
||||
borderTop: 'none',
|
||||
borderBottom: '1px solid #222224',
|
||||
borderRadius: 0,
|
||||
padding: '2px 10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
margin: 0,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (allSplitScreenTab.length === 1) {
|
||||
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
||||
} else if (allSplitScreenTab.length === 2) {
|
||||
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
||||
panelGroupRefs.current['top']?.setLayout([50, 50]);
|
||||
} else if (allSplitScreenTab.length === 3) {
|
||||
panelGroupRefs.current['main']?.setLayout([50, 50]);
|
||||
panelGroupRefs.current['top']?.setLayout([50, 50]);
|
||||
panelGroupRefs.current['bottom']?.setLayout([50, 50]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Reset Split Sizes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{renderAllTerminals()}
|
||||
{renderSplitOverlays()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSidebarOpen && (
|
||||
<div
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: HANDLE_THICKNESS,
|
||||
height: '100%',
|
||||
background: '#222224',
|
||||
cursor: 'pointer',
|
||||
zIndex: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="Show sidebar">
|
||||
<ChevronRight size={HANDLE_THICKNESS} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react';
|
||||
import {useXTerm} from 'react-xtermjs';
|
||||
import {FitAddon} from '@xterm/addon-fit';
|
||||
import {ClipboardAddon} from '@xterm/addon-clipboard';
|
||||
import {Unicode11Addon} from '@xterm/addon-unicode11';
|
||||
import {WebLinksAddon} from '@xterm/addon-web-links';
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: any;
|
||||
isVisible: boolean;
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
splitScreen?: boolean;
|
||||
}
|
||||
|
||||
export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
{hostConfig, isVisible, splitScreen = false},
|
||||
ref
|
||||
) {
|
||||
const {instance: terminal, ref: xtermRef} = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
},
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
|
||||
}
|
||||
}
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
return () => window.removeEventListener('resize', handleWindowResize);
|
||||
}, []);
|
||||
|
||||
function handleWindowResize() {
|
||||
fitAddonRef.current?.fit();
|
||||
}
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
}
|
||||
|
||||
function getUseRightClickCopyPaste() {
|
||||
return getCookie("rightClickCopyPaste") === "true"
|
||||
}
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
async function readTextFromClipboard(): Promise<string> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||
|
||||
terminal.options = {
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
scrollback: 10000,
|
||||
fontSize: 14,
|
||||
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#09090b',
|
||||
foreground: '#f7f7f7',
|
||||
},
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: false,
|
||||
fastScrollModifier: 'alt',
|
||||
fastScrollSensitivity: 5,
|
||||
allowProposedApi: true,
|
||||
};
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.open(xtermRef.current);
|
||||
|
||||
const element = xtermRef.current;
|
||||
const handleContextMenu = async (e: MouseEvent) => {
|
||||
if (!getUseRightClickCopyPaste()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (terminal.hasSelection()) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
}
|
||||
} else {
|
||||
const pasteText = await readTextFromClipboard();
|
||||
if (pasteText) {
|
||||
terminal.paste(pasteText);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
};
|
||||
if (element) {
|
||||
element.addEventListener('contextmenu', handleContextMenu);
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(JSON.stringify({type: 'resize', data: {cols, rows}}));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
setVisible(true);
|
||||
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
const wsUrl = window.location.hostname === 'localhost'
|
||||
? 'ws://localhost:8082'
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||
terminal.onData((data) => {
|
||||
ws.send(JSON.stringify({type: 'input', data}));
|
||||
});
|
||||
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({type: 'ping'}));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'data') {
|
||||
terminal.write(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||
} else if (msg.type === 'connected') {
|
||||
} else if (msg.type === 'disconnected') {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln('\r\n[Connection closed]');
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln('\r\n[Connection error]');
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (element) {
|
||||
element.removeEventListener('contextmenu', handleContextMenu);
|
||||
}
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={xtermRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
marginLeft: 2,
|
||||
opacity: visible && isVisible ? 1 : 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
|
||||
|
||||
/* Load NerdFonts locally */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('/fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(180,180,180,0.7);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(120,120,120,0.9);
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(180,180,180,0.7) transparent;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', Consolas, "Courier New", monospace !important;
|
||||
font-variant-ligatures: contextual;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] {
|
||||
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
@@ -1,440 +0,0 @@
|
||||
import React, {useState} from 'react';
|
||||
|
||||
import {
|
||||
CornerDownLeft,
|
||||
Hammer, Pin, Menu
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Button
|
||||
} from "@/components/ui/button.tsx"
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem, SidebarProvider,
|
||||
} from "@/components/ui/sidebar.tsx"
|
||||
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger
|
||||
} from "@/components/ui/sheet.tsx";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion.tsx";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {getSSHHosts} from "@/apps/SSH/ssh-axios";
|
||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
onHostConnect: (hostConfig: any) => void;
|
||||
allTabs: { id: number; title: string; terminalRef: React.RefObject<any> }[];
|
||||
runCommandOnTabs: (tabIds: number[], command: string) => void;
|
||||
onCloseSidebar?: () => void;
|
||||
onAddHostSubmit?: (data: any) => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function TerminalSidebar({
|
||||
onSelectView,
|
||||
onHostConnect,
|
||||
allTabs,
|
||||
runCommandOnTabs,
|
||||
onCloseSidebar,
|
||||
open,
|
||||
onOpenChange
|
||||
}: SidebarProps): React.ReactElement {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [hostsLoading, setHostsLoading] = useState(false);
|
||||
const [hostsError, setHostsError] = useState<string | null>(null);
|
||||
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
||||
|
||||
const fetchHosts = React.useCallback(async () => {
|
||||
setHostsLoading(true);
|
||||
setHostsError(null);
|
||||
try {
|
||||
const newHosts = await getSSHHosts();
|
||||
const terminalHosts = newHosts.filter(host => host.enableTerminal);
|
||||
|
||||
const prevHosts = prevHostsRef.current;
|
||||
const isSame =
|
||||
terminalHosts.length === prevHosts.length &&
|
||||
terminalHosts.every((h: SSHHost, i: number) => {
|
||||
const prev = prevHosts[i];
|
||||
if (!prev) return false;
|
||||
return (
|
||||
h.id === prev.id &&
|
||||
h.name === prev.name &&
|
||||
h.folder === prev.folder &&
|
||||
h.ip === prev.ip &&
|
||||
h.port === prev.port &&
|
||||
h.username === prev.username &&
|
||||
h.password === prev.password &&
|
||||
h.authType === prev.authType &&
|
||||
h.key === prev.key &&
|
||||
h.pin === prev.pin &&
|
||||
JSON.stringify(h.tags) === JSON.stringify(prev.tags)
|
||||
);
|
||||
});
|
||||
if (!isSame) {
|
||||
setHosts(terminalHosts);
|
||||
prevHostsRef.current = terminalHosts;
|
||||
}
|
||||
} catch (err: any) {
|
||||
setHostsError('Failed to load hosts');
|
||||
} finally {
|
||||
setHostsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHosts]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [search]);
|
||||
|
||||
const filteredHosts = React.useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return hosts;
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
return hosts.filter(h => {
|
||||
const searchableText = [
|
||||
h.name || '',
|
||||
h.username,
|
||||
h.ip,
|
||||
h.folder || '',
|
||||
...(h.tags || []),
|
||||
h.authType,
|
||||
h.defaultPath || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchableText.includes(q);
|
||||
});
|
||||
}, [hosts, debouncedSearch]);
|
||||
|
||||
const hostsByFolder = React.useMemo(() => {
|
||||
const map: Record<string, SSHHost[]> = {};
|
||||
filteredHosts.forEach(h => {
|
||||
const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder';
|
||||
if (!map[folder]) map[folder] = [];
|
||||
map[folder].push(h);
|
||||
});
|
||||
return map;
|
||||
}, [filteredHosts]);
|
||||
|
||||
const sortedFolders = React.useMemo(() => {
|
||||
const folders = Object.keys(hostsByFolder);
|
||||
folders.sort((a, b) => {
|
||||
if (a === 'No Folder') return -1;
|
||||
if (b === 'No Folder') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return folders;
|
||||
}, [hostsByFolder]);
|
||||
|
||||
const getSortedHosts = (arr: SSHHost[]) => {
|
||||
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
return [...pinned, ...rest];
|
||||
};
|
||||
|
||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||
const [toolsCommand, setToolsCommand] = useState("");
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||
|
||||
const handleTabToggle = (tabId: number) => {
|
||||
setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]);
|
||||
};
|
||||
|
||||
const handleRunCommand = () => {
|
||||
if (selectedTabIds.length && toolsCommand.trim()) {
|
||||
let cmd = toolsCommand;
|
||||
if (!cmd.endsWith("\n")) cmd += "\n";
|
||||
runCommandOnTabs(selectedTabIds, cmd);
|
||||
setToolsCommand("");
|
||||
}
|
||||
};
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
}
|
||||
|
||||
const updateRightClickCopyPaste = (checked) => {
|
||||
document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider open={open} onOpenChange={onOpenChange}>
|
||||
<Sidebar className="h-full flex flex-col overflow-hidden">
|
||||
<SidebarContent className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarGroupLabel
|
||||
className="text-lg font-bold text-white flex items-center justify-between gap-2 w-full">
|
||||
<span>Termix / Terminal</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCloseSidebar?.()}
|
||||
title="Hide sidebar"
|
||||
style={{
|
||||
height: 28,
|
||||
width: 28,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'hsl(240 5% 9%)',
|
||||
color: 'hsl(240 5% 64.9%)',
|
||||
border: '1px solid hsl(240 3.7% 15.9%)',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Menu className="h-4 w-4"/>
|
||||
</button>
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
<SidebarMenu className="flex flex-col flex-grow h-full overflow-hidden">
|
||||
|
||||
<SidebarMenuItem key="Homepage">
|
||||
<Button
|
||||
className="w-full mt-2 mb-2 h-8"
|
||||
onClick={() => onSelectView("homepage")}
|
||||
variant="outline"
|
||||
>
|
||||
<CornerDownLeft/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden">
|
||||
<div
|
||||
className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
||||
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
className="w-full h-8 text-sm bg-background border border-border rounded"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div style={{display: 'flex', justifyContent: 'center'}}>
|
||||
<Separator className="w-full h-px bg-[#434345] my-2"
|
||||
style={{maxWidth: 213, margin: '0 auto'}}/>
|
||||
</div>
|
||||
{hostsError && (
|
||||
<div className="px-2 py-1 mt-2">
|
||||
<div
|
||||
className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">{hostsError}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ScrollArea className="w-full h-full">
|
||||
<Accordion key={`host-accordion-${sortedFolders.length}`}
|
||||
type="multiple" className="w-full"
|
||||
defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<React.Fragment key={folder}>
|
||||
<AccordionItem value={folder}
|
||||
className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
|
||||
<AccordionTrigger
|
||||
className="text-base font-semibold rounded-t-none px-3 py-2"
|
||||
style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger>
|
||||
<AccordionContent
|
||||
className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
||||
{getSortedHosts(hostsByFolder[folder]).map(host => (
|
||||
<div key={host.id}
|
||||
className="w-full overflow-hidden">
|
||||
<HostMenuItem
|
||||
host={host}
|
||||
onHostConnect={onHostConnect}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
{idx < sortedFolders.length - 1 && (
|
||||
<div
|
||||
style={{display: 'flex', justifyContent: 'center'}}>
|
||||
<Separator className="h-px bg-[#434345] my-1"
|
||||
style={{width: 213}}/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
<div className="bg-sidebar">
|
||||
<Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
className="w-full h-8 mt-2"
|
||||
variant="outline"
|
||||
onClick={() => setToolsSheetOpen(true)}
|
||||
>
|
||||
<Hammer className="mr-2 h-4 w-4"/>
|
||||
Tools
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left"
|
||||
className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
|
||||
<SheetHeader className="pb-0.5">
|
||||
<SheetTitle>Tools</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-2 pt-2">
|
||||
<Accordion type="single" collapsible defaultValue="multiwindow">
|
||||
<AccordionItem value="multiwindow">
|
||||
<AccordionTrigger className="text-base font-semibold">Run multiwindow
|
||||
commands</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<textarea
|
||||
className="w-full min-h-[120px] max-h-48 rounded-md border border-input text-foreground p-2 text-sm font-mono resize-vertical focus:outline-none focus:ring-0"
|
||||
placeholder="Enter command(s) to run on selected tabs..."
|
||||
value={toolsCommand}
|
||||
onChange={e => setToolsCommand(e.target.value)}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: 8,
|
||||
background: '#141416'
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{allTabs.map(tab => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
variant={selectedTabIds.includes(tab.id) ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
className="rounded-full px-3 py-1 text-xs flex items-center gap-1"
|
||||
onClick={() => handleTabToggle(tab.id)}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={handleRunCommand}
|
||||
disabled={!toolsCommand.trim() || !selectedTabIds.length}
|
||||
>
|
||||
Run Command
|
||||
</Button>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<Separator className="p-0.25"/>
|
||||
|
||||
<div className="flex items-center space-x-2 mt-5">
|
||||
<Checkbox id="enable-copy-paste" onCheckedChange={updateRightClickCopyPaste}
|
||||
defaultChecked={getCookie("rightClickCopyPaste") === "true"}/>
|
||||
<label
|
||||
htmlFor="enable-paste"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable right‑click copy/paste
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const HostMenuItem = React.memo(function HostMenuItem({host, onHostConnect}: {
|
||||
host: SSHHost;
|
||||
onHostConnect: (hostConfig: any) => void
|
||||
}) {
|
||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||
const hasTags = tags.length > 0;
|
||||
return (
|
||||
<div className="relative group flex flex-col mb-1 w-full overflow-hidden">
|
||||
<div className={`flex flex-col w-full rounded overflow-hidden border border-[#434345] bg-[#18181b] h-full`}>
|
||||
<div className="flex w-full h-10">
|
||||
<div
|
||||
className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
|
||||
onClick={() => onHostConnect(host)}
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
{host.pin &&
|
||||
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0"/>
|
||||
}
|
||||
<span className="font-medium truncate">{host.name || host.ip}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasTags && (
|
||||
<div
|
||||
className="border-t border-border bg-[#18181b] flex items-center gap-1 px-2 py-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
|
||||
style={{height: 30}}>
|
||||
{tags.map((tag: string) => (
|
||||
<span key={tag}
|
||||
className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from "react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {X, SeparatorVertical} from "lucide-react"
|
||||
|
||||
interface TerminalTab {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface SSHTabListProps {
|
||||
allTabs: TerminalTab[];
|
||||
currentTab: number;
|
||||
setActiveTab: (tab: number) => void;
|
||||
allSplitScreenTab: number[];
|
||||
setSplitScreenTab: (tab: number) => void;
|
||||
setCloseTab: (tab: number) => void;
|
||||
}
|
||||
|
||||
export function TerminalTabList({
|
||||
allTabs,
|
||||
currentTab,
|
||||
setActiveTab,
|
||||
allSplitScreenTab = [],
|
||||
setSplitScreenTab,
|
||||
setCloseTab,
|
||||
}: SSHTabListProps): React.ReactElement {
|
||||
const isSplitScreenActive = allSplitScreenTab.length > 0;
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
|
||||
{allTabs.map((terminal, index) => {
|
||||
const isActive = terminal.id === currentTab;
|
||||
const isSplit = allSplitScreenTab.includes(terminal.id);
|
||||
const isSplitButtonDisabled =
|
||||
(isActive && !isSplitScreenActive) ||
|
||||
(allSplitScreenTab.length >= 3 && !isSplit);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={terminal.id}
|
||||
className={index < allTabs.length - 1 ? "mr-[0.5rem]" : ""}
|
||||
>
|
||||
<div className="inline-flex rounded-md shadow-sm" role="group">
|
||||
<Button
|
||||
onClick={() => setActiveTab(terminal.id)}
|
||||
disabled={isSplit}
|
||||
variant="outline"
|
||||
className={`rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
>
|
||||
{terminal.title}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setSplitScreenTab(terminal.id)}
|
||||
disabled={isSplitButtonDisabled || isActive}
|
||||
variant="outline"
|
||||
className="rounded-none p-0 !w-9 !h-9"
|
||||
>
|
||||
<SeparatorVertical className="!w-5 !h-5" strokeWidth={2.5}/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setCloseTab(terminal.id)}
|
||||
disabled={(isSplitScreenActive && isActive) || isSplit}
|
||||
variant="outline"
|
||||
className="rounded-l-none p-0 !w-9 !h-9"
|
||||
>
|
||||
<X className="!w-5 !h-5" strokeWidth={2.5}/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import {TerminalTabList} from "@/apps/SSH/Terminal/TerminalTabList.tsx";
|
||||
import React from "react";
|
||||
import {ChevronUp} from "lucide-react";
|
||||
|
||||
interface TerminalTab {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface SSHTopbarProps {
|
||||
allTabs: TerminalTab[];
|
||||
currentTab: number;
|
||||
setActiveTab: (tab: number) => void;
|
||||
allSplitScreenTab: number[];
|
||||
setSplitScreenTab: (tab: number) => void;
|
||||
setCloseTab: (tab: number) => void;
|
||||
onHideTopbar?: () => void;
|
||||
}
|
||||
|
||||
export function TerminalTopbar({
|
||||
allTabs,
|
||||
currentTab,
|
||||
setActiveTab,
|
||||
allSplitScreenTab,
|
||||
setSplitScreenTab,
|
||||
setCloseTab,
|
||||
onHideTopbar
|
||||
}: SSHTopbarProps): React.ReactElement {
|
||||
return (
|
||||
<div className="flex h-11.5 z-100" style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
width: '100%',
|
||||
backgroundColor: '#18181b',
|
||||
borderBottom: '1px solid #222224',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<div style={{flex: 1, minWidth: 0, height: '100%', overflowX: 'auto'}}>
|
||||
<div style={{minWidth: 'max-content', height: '100%', paddingLeft: 8, overflowY: 'hidden'}}>
|
||||
<TerminalTabList
|
||||
allTabs={allTabs}
|
||||
currentTab={currentTab}
|
||||
setActiveTab={setActiveTab}
|
||||
allSplitScreenTab={allSplitScreenTab}
|
||||
setSplitScreenTab={setSplitScreenTab}
|
||||
setCloseTab={setCloseTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{flex: '0 0 auto', paddingRight: 8, paddingLeft: 16}}>
|
||||
<button
|
||||
onClick={() => onHideTopbar?.()}
|
||||
style={{
|
||||
height: 28,
|
||||
width: 28,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'hsl(240 5% 9%)',
|
||||
color: 'hsl(240 5% 64.9%)',
|
||||
border: '1px solid hsl(240 3.7% 15.9%)',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Hide top bar"
|
||||
>
|
||||
<ChevronUp size={16} strokeWidth={2}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import React, {useState, useEffect, useCallback} from "react";
|
||||
import {SSHTunnelSidebar} from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx";
|
||||
import {SSHTunnelViewer} from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx";
|
||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/apps/SSH/ssh-axios";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
endpointHost: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TunnelStatus {
|
||||
status: string;
|
||||
reason?: string;
|
||||
errorType?: string;
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
nextRetryIn?: number;
|
||||
retryExhausted?: boolean;
|
||||
}
|
||||
|
||||
export function SSHTunnel({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchHosts = useCallback(async () => {
|
||||
try {
|
||||
const hostsData = await getSSHHosts();
|
||||
setHosts(hostsData);
|
||||
} catch (err) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchTunnelStatuses = useCallback(async () => {
|
||||
try {
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
} catch (err) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [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 = hosts.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,
|
||||
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,
|
||||
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 (
|
||||
<div className="flex h-screen w-full">
|
||||
<div className="w-64 flex-shrink-0">
|
||||
<SSHTunnelSidebar
|
||||
onSelectView={onSelectView}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<SSHTunnelViewer
|
||||
hosts={hosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={handleTunnelAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
import React from "react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {
|
||||
Loader2,
|
||||
Pin,
|
||||
Terminal,
|
||||
Network,
|
||||
FileEdit,
|
||||
Tag,
|
||||
Play,
|
||||
Square,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Zap,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
|
||||
const CONNECTION_STATES = {
|
||||
DISCONNECTED: "disconnected",
|
||||
CONNECTING: "connecting",
|
||||
CONNECTED: "connected",
|
||||
VERIFYING: "verifying",
|
||||
FAILED: "failed",
|
||||
UNSTABLE: "unstable",
|
||||
RETRYING: "retrying",
|
||||
WAITING: "waiting",
|
||||
DISCONNECTING: "disconnecting"
|
||||
};
|
||||
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
endpointHost: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TunnelStatus {
|
||||
status: string;
|
||||
reason?: string;
|
||||
errorType?: string;
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
nextRetryIn?: number;
|
||||
retryExhausted?: boolean;
|
||||
}
|
||||
|
||||
interface SSHTunnelObjectProps {
|
||||
host: SSHHost;
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||
}
|
||||
|
||||
export function SSHTunnelObject({
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
|
||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
return tunnelStatuses[tunnelName];
|
||||
};
|
||||
|
||||
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
|
||||
if (!status) return {
|
||||
icon: <WifiOff className="h-4 w-4"/>,
|
||||
text: 'Unknown',
|
||||
color: 'text-muted-foreground',
|
||||
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: '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: '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: '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: '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 || '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'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||
<div className="p-4">
|
||||
{/* Host Header */}
|
||||
<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>
|
||||
|
||||
{/* Tags */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Separator className="mb-3"/>
|
||||
|
||||
{/* Tunnel Connections */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4"/>
|
||||
Tunnel Connections ({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}`}>
|
||||
{/* Tunnel Header */}
|
||||
<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">
|
||||
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">
|
||||
{tunnel.autoStart && (
|
||||
<Badge variant="outline" className="text-xs px-2 py-1">
|
||||
<Zap className="h-3 w-3 mr-1"/>
|
||||
Auto
|
||||
</Badge>
|
||||
)}
|
||||
{/* Action Buttons */}
|
||||
{!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"/>
|
||||
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"/>
|
||||
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"/>
|
||||
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 ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error/Status Reason */}
|
||||
{(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">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">
|
||||
Check your Docker logs for the error reason, join the <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>
|
||||
)}
|
||||
|
||||
{/* Retry Info */}
|
||||
{(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' ? 'Waiting for retry' : 'Retrying connection'}
|
||||
</div>
|
||||
<div>
|
||||
Attempt {status.retryCount} of {status.maxRetries}
|
||||
{status.nextRetryIn && (
|
||||
<span> • Next retry in {status.nextRetryIn} seconds</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">No tunnel connections configured</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
CornerDownLeft,
|
||||
Settings
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Button
|
||||
} from "@/components/ui/button.tsx"
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem, SidebarProvider,
|
||||
} from "@/components/ui/sidebar.tsx"
|
||||
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function SSHTunnelSidebar({onSelectView}: SidebarProps): React.ReactElement {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / Tunnel
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
<SidebarMenu>
|
||||
|
||||
<SidebarMenuItem key={"Homepage"}>
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
||||
variant="outline">
|
||||
<CornerDownLeft className="h-4 w-4 mr-2"/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import React from "react";
|
||||
import {SSHTunnelObject} from "./SSHTunnelObject.tsx";
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Search} from "lucide-react";
|
||||
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
endpointHost: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TunnelStatus {
|
||||
status: string;
|
||||
reason?: string;
|
||||
errorType?: string;
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
nextRetryIn?: number;
|
||||
retryExhausted?: boolean;
|
||||
}
|
||||
|
||||
interface SSHTunnelViewerProps {
|
||||
hosts: SSHHost[];
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||
}
|
||||
|
||||
export function SSHTunnelViewer({
|
||||
hosts = [],
|
||||
tunnelStatuses = {},
|
||||
tunnelActions = {},
|
||||
onTunnelAction
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(searchQuery), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
const filteredHosts = React.useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return hosts;
|
||||
|
||||
const query = debouncedSearch.trim().toLowerCase();
|
||||
return hosts.filter(host => {
|
||||
const searchableText = [
|
||||
host.name || '',
|
||||
host.username,
|
||||
host.ip,
|
||||
host.folder || '',
|
||||
...(host.tags || []),
|
||||
host.authType,
|
||||
host.defaultPath || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchableText.includes(query);
|
||||
});
|
||||
}, [hosts, debouncedSearch]);
|
||||
|
||||
const tunnelHosts = React.useMemo(() => {
|
||||
return filteredHosts.filter(host =>
|
||||
host.enableTunnel &&
|
||||
host.tunnelConnections &&
|
||||
host.tunnelConnections.length > 0
|
||||
);
|
||||
}, [filteredHosts]);
|
||||
|
||||
const hostsByFolder = React.useMemo(() => {
|
||||
const map: Record<string, SSHHost[]> = {};
|
||||
tunnelHosts.forEach(host => {
|
||||
const folder = host.folder && host.folder.trim() ? host.folder : 'Uncategorized';
|
||||
if (!map[folder]) map[folder] = [];
|
||||
map[folder].push(host);
|
||||
});
|
||||
return map;
|
||||
}, [tunnelHosts]);
|
||||
|
||||
const sortedFolders = React.useMemo(() => {
|
||||
const folders = Object.keys(hostsByFolder);
|
||||
folders.sort((a, b) => {
|
||||
if (a === 'Uncategorized') return -1;
|
||||
if (b === 'Uncategorized') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return folders;
|
||||
}, [hostsByFolder]);
|
||||
|
||||
const getSortedHosts = (arr: SSHHost[]) => {
|
||||
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
return [...pinned, ...rest];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full p-6" style={{width: 'calc(100vw - 256px)', maxWidth: 'none'}}>
|
||||
<div className="w-full min-w-0" style={{width: '100%', maxWidth: 'none'}}>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">
|
||||
SSH Tunnels
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your SSH tunnel connections
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tunnelHosts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No SSH Tunnels
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{searchQuery.trim() ?
|
||||
"No hosts match your search criteria." :
|
||||
"Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Accordion type="multiple" className="w-full" defaultValue={sortedFolders}>
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<AccordionItem value={folder} key={`folder-${folder}`}
|
||||
className={idx === 0 ? "mt-0" : "mt-2"}>
|
||||
<AccordionTrigger className="text-base font-semibold rounded-t-none px-3 py-2"
|
||||
style={{marginTop: idx === 0 ? 0 : undefined}}>
|
||||
{folder}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
||||
<div className="grid grid-cols-4 gap-6 w-full">
|
||||
{getSortedHosts(hostsByFolder[folder]).map((host, hostIndex) => (
|
||||
<div key={host.id} className="w-full">
|
||||
<SSHTunnelObject
|
||||
host={host}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={onTunnelAction}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
interface SSHHostData {
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder?: string;
|
||||
tags?: string[];
|
||||
pin?: boolean;
|
||||
authType: 'password' | 'key';
|
||||
password?: string;
|
||||
key?: File | null;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal?: boolean;
|
||||
enableTunnel?: boolean;
|
||||
enableConfigEditor?: boolean;
|
||||
defaultPath?: string;
|
||||
tunnelConnections?: any[];
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TunnelConfig {
|
||||
name: string;
|
||||
hostName: string;
|
||||
sourceIP: string;
|
||||
sourceSSHPort: number;
|
||||
sourceUsername: string;
|
||||
sourcePassword?: string;
|
||||
sourceAuthMethod: string;
|
||||
sourceSSHKey?: string;
|
||||
sourceKeyPassword?: string;
|
||||
sourceKeyType?: string;
|
||||
endpointIP: string;
|
||||
endpointSSHPort: number;
|
||||
endpointUsername: string;
|
||||
endpointPassword?: string;
|
||||
endpointAuthMethod: string;
|
||||
endpointSSHKey?: string;
|
||||
endpointKeyPassword?: string;
|
||||
endpointKeyType?: string;
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
isPinned: boolean;
|
||||
}
|
||||
|
||||
interface TunnelStatus {
|
||||
status: string;
|
||||
reason?: string;
|
||||
errorType?: string;
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
nextRetryIn?: number;
|
||||
retryExhausted?: boolean;
|
||||
}
|
||||
|
||||
interface ConfigEditorFile {
|
||||
name: string;
|
||||
path: string;
|
||||
type?: 'file' | 'directory';
|
||||
isSSH?: boolean;
|
||||
sshSessionId?: string;
|
||||
}
|
||||
|
||||
interface ConfigEditorShortcut {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
|
||||
const sshHostApi = axios.create({
|
||||
baseURL: isLocalhost ? 'http://localhost:8081' : window.location.origin,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const tunnelApi = axios.create({
|
||||
baseURL: isLocalhost ? 'http://localhost:8083' : window.location.origin,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const configEditorApi = axios.create({
|
||||
baseURL: isLocalhost ? 'http://localhost:8084' : window.location.origin,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
|
||||
function getCookie(name: string): string | undefined {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
||||
}
|
||||
|
||||
sshHostApi.interceptors.request.use((config) => {
|
||||
const token = getCookie('jwt');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
tunnelApi.interceptors.request.use((config) => {
|
||||
const token = getCookie('jwt');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
configEditorApi.interceptors.request.use((config) => {
|
||||
const token = getCookie('jwt');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export async function getSSHHosts(): Promise<SSHHost[]> {
|
||||
try {
|
||||
const response = await sshHostApi.get('/ssh/db/host');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
try {
|
||||
const submitData = {
|
||||
name: hostData.name || '',
|
||||
ip: hostData.ip,
|
||||
port: parseInt(hostData.port.toString()) || 22,
|
||||
username: hostData.username,
|
||||
folder: hostData.folder || '',
|
||||
tags: hostData.tags || [],
|
||||
pin: hostData.pin || false,
|
||||
authMethod: hostData.authType,
|
||||
password: hostData.authType === 'password' ? hostData.password : '',
|
||||
key: hostData.authType === 'key' ? hostData.key : null,
|
||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||
enableTerminal: hostData.enableTerminal !== false,
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableConfigEditor: hostData.enableConfigEditor !== false,
|
||||
defaultPath: hostData.defaultPath || '/',
|
||||
tunnelConnections: hostData.tunnelConnections || [],
|
||||
};
|
||||
|
||||
if (!submitData.enableTunnel) {
|
||||
submitData.tunnelConnections = [];
|
||||
}
|
||||
|
||||
if (!submitData.enableConfigEditor) {
|
||||
submitData.defaultPath = '';
|
||||
}
|
||||
|
||||
if (hostData.authType === 'key' && hostData.key instanceof File) {
|
||||
const formData = new FormData();
|
||||
formData.append('key', hostData.key);
|
||||
|
||||
const dataWithoutFile = {...submitData};
|
||||
delete dataWithoutFile.key;
|
||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||
|
||||
const response = await sshHostApi.post('/ssh/db/host', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} else {
|
||||
const response = await sshHostApi.post('/ssh/db/host', submitData);
|
||||
return response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSSHHost(hostId: number, hostData: SSHHostData): Promise<SSHHost> {
|
||||
try {
|
||||
const submitData = {
|
||||
name: hostData.name || '',
|
||||
ip: hostData.ip,
|
||||
port: parseInt(hostData.port.toString()) || 22,
|
||||
username: hostData.username,
|
||||
folder: hostData.folder || '',
|
||||
tags: hostData.tags || [],
|
||||
pin: hostData.pin || false,
|
||||
authMethod: hostData.authType,
|
||||
password: hostData.authType === 'password' ? hostData.password : '',
|
||||
key: hostData.authType === 'key' ? hostData.key : null,
|
||||
keyPassword: hostData.authType === 'key' ? hostData.keyPassword : '',
|
||||
keyType: hostData.authType === 'key' ? hostData.keyType : '',
|
||||
enableTerminal: hostData.enableTerminal !== false,
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableConfigEditor: hostData.enableConfigEditor !== false,
|
||||
defaultPath: hostData.defaultPath || '/',
|
||||
tunnelConnections: hostData.tunnelConnections || [],
|
||||
};
|
||||
|
||||
if (!submitData.enableTunnel) {
|
||||
submitData.tunnelConnections = [];
|
||||
}
|
||||
if (!submitData.enableConfigEditor) {
|
||||
submitData.defaultPath = '';
|
||||
}
|
||||
|
||||
if (hostData.authType === 'key' && hostData.key instanceof File) {
|
||||
const formData = new FormData();
|
||||
formData.append('key', hostData.key);
|
||||
|
||||
const dataWithoutFile = {...submitData};
|
||||
delete dataWithoutFile.key;
|
||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||
|
||||
const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} else {
|
||||
const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, submitData);
|
||||
return response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSSHHost(hostId: number): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
||||
try {
|
||||
const response = await sshHostApi.get(`/ssh/db/host/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
||||
try {
|
||||
const response = await tunnelApi.get('/ssh/tunnel/status');
|
||||
return response.data || {};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelStatus | undefined> {
|
||||
const statuses = await getTunnelStatuses();
|
||||
return statuses[tunnelName];
|
||||
}
|
||||
|
||||
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
||||
try {
|
||||
const response = await tunnelApi.post('/ssh/tunnel/connect', tunnelConfig);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
||||
try {
|
||||
const response = await tunnelApi.post('/ssh/tunnel/disconnect', {tunnelName});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelTunnel(tunnelName: string): Promise<any> {
|
||||
try {
|
||||
const response = await tunnelApi.post('/ssh/tunnel/cancel', {tunnelName});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfigEditorRecent(hostId: number): Promise<ConfigEditorFile[]> {
|
||||
try {
|
||||
const response = await sshHostApi.get(`/ssh/config_editor/recent?hostId=${hostId}`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addConfigEditorRecent(file: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.post('/ssh/config_editor/recent', file);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeConfigEditorRecent(file: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.delete('/ssh/config_editor/recent', {data: file});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfigEditorPinned(hostId: number): Promise<ConfigEditorFile[]> {
|
||||
try {
|
||||
const response = await sshHostApi.get(`/ssh/config_editor/pinned?hostId=${hostId}`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addConfigEditorPinned(file: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.post('/ssh/config_editor/pinned', file);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeConfigEditorPinned(file: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.delete('/ssh/config_editor/pinned', {data: file});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfigEditorShortcuts(hostId: number): Promise<ConfigEditorShortcut[]> {
|
||||
try {
|
||||
const response = await sshHostApi.get(`/ssh/config_editor/shortcuts?hostId=${hostId}`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addConfigEditorShortcut(shortcut: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.post('/ssh/config_editor/shortcuts', shortcut);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeConfigEditorShortcut(shortcut: {
|
||||
name: string;
|
||||
path: string;
|
||||
isSSH: boolean;
|
||||
sshSessionId?: string;
|
||||
hostId: number
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.delete('/ssh/config_editor/shortcuts', {data: shortcut});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectSSH(sessionId: string, config: {
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const response = await configEditorApi.post('/ssh/config_editor/ssh/connect', {
|
||||
sessionId,
|
||||
...config
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectSSH(sessionId: string): Promise<any> {
|
||||
try {
|
||||
const response = await configEditorApi.post('/ssh/config_editor/ssh/disconnect', {sessionId});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSSHStatus(sessionId: string): Promise<{ connected: boolean }> {
|
||||
try {
|
||||
const response = await configEditorApi.get('/ssh/config_editor/ssh/status', {
|
||||
params: {sessionId}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSSHFiles(sessionId: string, path: string): Promise<any[]> {
|
||||
try {
|
||||
const response = await configEditorApi.get('/ssh/config_editor/ssh/listFiles', {
|
||||
params: {sessionId, path}
|
||||
});
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSSHFile(sessionId: string, path: string): Promise<{ content: string; path: string }> {
|
||||
try {
|
||||
const response = await configEditorApi.get('/ssh/config_editor/ssh/readFile', {
|
||||
params: {sessionId, path}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeSSHFile(sessionId: string, path: string, content: string): Promise<any> {
|
||||
try {
|
||||
const response = await configEditorApi.post('/ssh/config_editor/ssh/writeFile', {
|
||||
sessionId,
|
||||
path,
|
||||
content
|
||||
});
|
||||
|
||||
if (response.data && (response.data.message === 'File written successfully' || response.status === 200)) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('File write operation did not return success status');
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {sshHostApi, tunnelApi, configEditorApi};
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from "react";
|
||||
import {TemplateSidebar} from "@/apps/Template/TemplateSidebar.tsx";
|
||||
|
||||
interface ConfigEditorProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function Template({onSelectView}: ConfigEditorProps): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<TemplateSidebar
|
||||
onSelectView={onSelectView}
|
||||
/>
|
||||
|
||||
Template
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
CornerDownLeft
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Button
|
||||
} from "@/components/ui/button.tsx"
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem, SidebarProvider,
|
||||
} from "@/components/ui/sidebar.tsx"
|
||||
|
||||
import {
|
||||
Separator,
|
||||
} from "@/components/ui/separator.tsx"
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
}
|
||||
|
||||
export function TemplateSidebar({onSelectView}: SidebarProps): React.ReactElement {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Termix / Template
|
||||
</SidebarGroupLabel>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
<SidebarGroupContent className="flex flex-col flex-grow">
|
||||
<SidebarMenu>
|
||||
|
||||
<SidebarMenuItem key={"Homepage"}>
|
||||
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
|
||||
variant="outline">
|
||||
<CornerDownLeft/>
|
||||
Return
|
||||
</Button>
|
||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
||||
</SidebarMenuItem>
|
||||
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,439 +0,0 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import {Client as SSHClient} from 'ssh2';
|
||||
import chalk from "chalk";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
const sshIconSymbol = '📁';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
}
|
||||
clearTimeout(session.timeout);
|
||||
delete sshSessions[sessionId];
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSessionCleanup(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
session.timeout = setTimeout(() => cleanupSession(sessionId), SESSION_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
app.post('/ssh/config_editor/ssh/connect', (req, res) => {
|
||||
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
|
||||
if (!sessionId || !ip || !username || !port) {
|
||||
return res.status(400).json({error: 'Missing SSH connection parameters'});
|
||||
}
|
||||
|
||||
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
|
||||
const client = new SSHClient();
|
||||
const config: any = {
|
||||
host: ip,
|
||||
port: port || 22,
|
||||
username,
|
||||
readyTimeout: 20000,
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 3,
|
||||
algorithms: {
|
||||
kex: [
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group-exchange-sha1',
|
||||
'ecdh-sha2-nistp256',
|
||||
'ecdh-sha2-nistp384',
|
||||
'ecdh-sha2-nistp521'
|
||||
],
|
||||
cipher: [
|
||||
'aes128-ctr',
|
||||
'aes192-ctr',
|
||||
'aes256-ctr',
|
||||
'aes128-gcm@openssh.com',
|
||||
'aes256-gcm@openssh.com',
|
||||
'aes128-cbc',
|
||||
'aes192-cbc',
|
||||
'aes256-cbc',
|
||||
'3des-cbc'
|
||||
],
|
||||
hmac: [
|
||||
'hmac-sha2-256',
|
||||
'hmac-sha2-512',
|
||||
'hmac-sha1',
|
||||
'hmac-md5'
|
||||
],
|
||||
compress: [
|
||||
'none',
|
||||
'zlib@openssh.com',
|
||||
'zlib'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
if (sshKey && sshKey.trim()) {
|
||||
config.privateKey = sshKey;
|
||||
if (keyPassword) config.passphrase = keyPassword;
|
||||
} else if (password && password.trim()) {
|
||||
config.password = password;
|
||||
} else {
|
||||
return res.status(400).json({error: 'Either password or SSH key must be provided'});
|
||||
}
|
||||
|
||||
let responseSent = false;
|
||||
|
||||
client.on('ready', () => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({status: 'success', message: 'SSH connection established'});
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
logger.error(`SSH connection error for session ${sessionId}:`, err.message);
|
||||
res.status(500).json({status: 'error', message: err.message});
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
|
||||
cleanupSession(sessionId);
|
||||
});
|
||||
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
app.post('/ssh/config_editor/ssh/disconnect', (req, res) => {
|
||||
const {sessionId} = req.body;
|
||||
cleanupSession(sessionId);
|
||||
res.json({status: 'success', message: 'SSH connection disconnected'});
|
||||
});
|
||||
|
||||
app.get('/ssh/config_editor/ssh/status', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const isConnected = !!sshSessions[sessionId]?.isConnected;
|
||||
res.json({status: 'success', connected: isConnected});
|
||||
});
|
||||
|
||||
app.get('/ssh/config_editor/ssh/listFiles', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const sshPath = decodeURIComponent((req.query.path as string) || '/');
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({error: 'Session ID is required'});
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({error: 'SSH connection not established'});
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('SSH listFiles error:', err);
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
|
||||
let data = '';
|
||||
let errorData = '';
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
|
||||
const lines = data.split('\n').filter(line => line.trim());
|
||||
const files = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 9) {
|
||||
const permissions = parts[0];
|
||||
const name = parts.slice(8).join(' ');
|
||||
const isDirectory = permissions.startsWith('d');
|
||||
const isLink = permissions.startsWith('l');
|
||||
|
||||
if (name === '.' || name === '..') continue;
|
||||
|
||||
files.push({
|
||||
name,
|
||||
type: isDirectory ? 'directory' : (isLink ? 'link' : 'file')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json(files);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/ssh/config_editor/ssh/readFile', (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const filePath = decodeURIComponent(req.query.path as string);
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({error: 'Session ID is required'});
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({error: 'SSH connection not established'});
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({error: 'File path is required'});
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('SSH readFile error:', err);
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
|
||||
let data = '';
|
||||
let errorData = '';
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
|
||||
res.json({content: data, path: filePath});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/ssh/config_editor/ssh/writeFile', (req, res) => {
|
||||
const {sessionId, path: filePath, content} = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({error: 'Session ID is required'});
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({error: 'SSH connection not established'});
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({error: 'File path is required'});
|
||||
}
|
||||
|
||||
if (content === undefined) {
|
||||
return res.status(400).json({error: 'File content is required'});
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
||||
const escapedFilePath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const base64Content = Buffer.from(content, 'utf8').toString('base64');
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: 'SSH command timed out'});
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const checkCommand = `ls -la '${escapedFilePath}' 2>/dev/null || echo "File does not exist"`;
|
||||
|
||||
sshConn.client.exec(checkCommand, (checkErr, checkStream) => {
|
||||
if (checkErr) {
|
||||
return res.status(500).json({error: `File check failed: ${checkErr.message}`});
|
||||
}
|
||||
|
||||
let checkResult = '';
|
||||
checkStream.on('data', (chunk: Buffer) => {
|
||||
checkResult += chunk.toString();
|
||||
});
|
||||
|
||||
checkStream.on('close', (checkCode) => {
|
||||
const writeCommand = `echo '${base64Content}' > '${escapedTempFile}' && base64 -d '${escapedTempFile}' > '${escapedFilePath}' && rm -f '${escapedTempFile}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH writeFile error:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: err.message});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = '';
|
||||
let errorData = '';
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes('Permission denied')) {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error(`Permission denied writing to file: ${filePath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot write to ${filePath}. Check file ownership and permissions. Use 'ls -la ${filePath}' to verify.`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (outputData.includes('SUCCESS')) {
|
||||
const verifyCommand = `ls -la '${escapedFilePath}' 2>/dev/null | awk '{print $5}'`;
|
||||
|
||||
sshConn.client.exec(verifyCommand, (verifyErr, verifyStream) => {
|
||||
if (verifyErr) {
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let verifyResult = '';
|
||||
verifyStream.on('data', (chunk: Buffer) => {
|
||||
verifyResult += chunk.toString();
|
||||
});
|
||||
|
||||
verifyStream.on('close', (verifyCode) => {
|
||||
const fileSize = Number(verifyResult.trim());
|
||||
|
||||
if (fileSize > 0) {
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
} else {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: 'File write operation may have failed - file appears empty'});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(`SSH writeFile command failed with code ${code}: ${errorData.replace(/\n/g, ' ').trim()}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({error: `Command failed: ${errorData}`});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({message: 'File written successfully', path: filePath});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
logger.error('SSH writeFile stream error:', streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
Object.keys(sshSessions).forEach(cleanupSession);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
Object.keys(sshSessions).forEach(cleanupSession);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
const PORT = 8084;
|
||||
app.listen(PORT, () => {
|
||||
});
|
||||
@@ -1,243 +1,295 @@
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import userRoutes from './routes/users.js';
|
||||
import sshRoutes from './routes/ssh.js';
|
||||
import chalk from 'chalk';
|
||||
import cors from 'cors';
|
||||
import fetch from 'node-fetch';
|
||||
import 'dotenv/config';
|
||||
import express from "express";
|
||||
import bodyParser from "body-parser";
|
||||
import userRoutes from "./routes/users.js";
|
||||
import sshRoutes from "./routes/ssh.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import cors from "cors";
|
||||
import fetch from "node-fetch";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import "dotenv/config";
|
||||
import { databaseLogger, apiLogger } from "../utils/logger.js";
|
||||
|
||||
const app = express();
|
||||
app.use(cors({
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
app.use(
|
||||
cors({
|
||||
origin: "*",
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class GitHubCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 30 * 60 * 1000;
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 30 * 60 * 1000;
|
||||
|
||||
set(key: string, data: any): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION
|
||||
});
|
||||
set(key: string, data: any): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION,
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): any | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
get(key: string): any | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
}
|
||||
|
||||
const githubCache = new GitHubCache();
|
||||
|
||||
const GITHUB_API_BASE = 'https://api.github.com';
|
||||
const REPO_OWNER = 'LukeGus';
|
||||
const REPO_NAME = 'Termix';
|
||||
const GITHUB_API_BASE = "https://api.github.com";
|
||||
const REPO_OWNER = "LukeGus";
|
||||
const REPO_NAME = "Termix";
|
||||
|
||||
interface GitHubRelease {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
assets: Array<{
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
assets: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
browser_download_url: string;
|
||||
}>;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
size: number;
|
||||
download_count: number;
|
||||
browser_download_url: string;
|
||||
}>;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
|
||||
const cachedData = githubCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return {
|
||||
data: cachedData,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cachedData.timestamp
|
||||
};
|
||||
async function fetchGitHubAPI(
|
||||
endpoint: string,
|
||||
cacheKey: string,
|
||||
): Promise<any> {
|
||||
const cachedData = githubCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return {
|
||||
data: cachedData,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cachedData.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "TermixUpdateChecker/1.0",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': 'TermixUpdateChecker/1.0',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
githubCache.set(cacheKey, data);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
githubCache.set(cacheKey, data);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
cached: false
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch from GitHub API: ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
data: data,
|
||||
cached: false,
|
||||
};
|
||||
} catch (error) {
|
||||
databaseLogger.error(`Failed to fetch from GitHub API`, error, {
|
||||
operation: "github_api",
|
||||
endpoint,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({status: 'ok'});
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
app.get('/version', async (req, res) => {
|
||||
const localVersion = process.env.VERSION;
|
||||
|
||||
if (!localVersion) {
|
||||
return res.status(401).send('Local Version Not Set');
|
||||
}
|
||||
app.get("/version", async (req, res) => {
|
||||
let localVersion = process.env.VERSION;
|
||||
|
||||
if (!localVersion) {
|
||||
try {
|
||||
const cacheKey = 'latest_release';
|
||||
const releaseData = await fetchGitHubAPI(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
||||
cacheKey
|
||||
);
|
||||
|
||||
const rawTag = releaseData.data.tag_name || releaseData.data.name || '';
|
||||
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
|
||||
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
||||
|
||||
if (!remoteVersion) {
|
||||
return res.status(401).send('Remote Version Not Found');
|
||||
}
|
||||
|
||||
const response = {
|
||||
status: localVersion === remoteVersion ? 'up_to_date' : 'requires_update',
|
||||
version: remoteVersion,
|
||||
latest_release: {
|
||||
tag_name: releaseData.data.tag_name,
|
||||
name: releaseData.data.name,
|
||||
published_at: releaseData.data.published_at,
|
||||
html_url: releaseData.data.html_url
|
||||
},
|
||||
cached: releaseData.cached,
|
||||
cache_age: releaseData.cache_age
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (err) {
|
||||
logger.error('Version check failed', err);
|
||||
res.status(500).send('Fetch Error');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/releases/rss', async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
|
||||
const cacheKey = `releases_rss_${page}_${per_page}`;
|
||||
|
||||
const releasesData = await fetchGitHubAPI(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
|
||||
cacheKey
|
||||
);
|
||||
|
||||
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
|
||||
id: release.id,
|
||||
title: release.name || release.tag_name,
|
||||
description: release.body,
|
||||
link: release.html_url,
|
||||
pubDate: release.published_at,
|
||||
version: release.tag_name,
|
||||
isPrerelease: release.prerelease,
|
||||
isDraft: release.draft,
|
||||
assets: release.assets.map(asset => ({
|
||||
name: asset.name,
|
||||
size: asset.size,
|
||||
download_count: asset.download_count,
|
||||
download_url: asset.browser_download_url
|
||||
}))
|
||||
}));
|
||||
|
||||
const response = {
|
||||
feed: {
|
||||
title: `${REPO_NAME} Releases`,
|
||||
description: `Latest releases from ${REPO_NAME} repository`,
|
||||
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
|
||||
updated: new Date().toISOString()
|
||||
},
|
||||
items: rssItems,
|
||||
total_count: rssItems.length,
|
||||
cached: releasesData.cached,
|
||||
cache_age: releasesData.cache_age
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
const packagePath = path.resolve(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
localVersion = packageJson.version;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate RSS format', error)
|
||||
res.status(500).json({ error: 'Failed to generate RSS format', details: error instanceof Error ? error.message : 'Unknown error' });
|
||||
databaseLogger.error("Failed to read version from package.json", error, {
|
||||
operation: "version_check",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!localVersion) {
|
||||
databaseLogger.error("No version information available", undefined, {
|
||||
operation: "version_check",
|
||||
});
|
||||
return res.status(404).send("Local Version Not Set");
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = "latest_release";
|
||||
const releaseData = await fetchGitHubAPI(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
|
||||
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
|
||||
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
||||
|
||||
if (!remoteVersion) {
|
||||
databaseLogger.warn("Remote version not found in GitHub response", {
|
||||
operation: "version_check",
|
||||
rawTag,
|
||||
});
|
||||
return res.status(401).send("Remote Version Not Found");
|
||||
}
|
||||
|
||||
const isUpToDate = localVersion === remoteVersion;
|
||||
|
||||
const response = {
|
||||
status: isUpToDate ? "up_to_date" : "requires_update",
|
||||
localVersion: localVersion,
|
||||
version: remoteVersion,
|
||||
latest_release: {
|
||||
tag_name: releaseData.data.tag_name,
|
||||
name: releaseData.data.name,
|
||||
published_at: releaseData.data.published_at,
|
||||
html_url: releaseData.data.html_url,
|
||||
},
|
||||
cached: releaseData.cached,
|
||||
cache_age: releaseData.cache_age,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Version check failed", err, {
|
||||
operation: "version_check",
|
||||
});
|
||||
res.status(500).send("Fetch Error");
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/users', userRoutes);
|
||||
app.use('/ssh', sshRoutes);
|
||||
app.get("/releases/rss", async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const per_page = Math.min(
|
||||
parseInt(req.query.per_page as string) || 20,
|
||||
100,
|
||||
);
|
||||
const cacheKey = `releases_rss_${page}_${per_page}`;
|
||||
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({error: 'Internal Server Error'});
|
||||
const releasesData = await fetchGitHubAPI(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
|
||||
id: release.id,
|
||||
title: release.name || release.tag_name,
|
||||
description: release.body,
|
||||
link: release.html_url,
|
||||
pubDate: release.published_at,
|
||||
version: release.tag_name,
|
||||
isPrerelease: release.prerelease,
|
||||
isDraft: release.draft,
|
||||
assets: release.assets.map((asset) => ({
|
||||
name: asset.name,
|
||||
size: asset.size,
|
||||
download_count: asset.download_count,
|
||||
download_url: asset.browser_download_url,
|
||||
})),
|
||||
}));
|
||||
|
||||
const response = {
|
||||
feed: {
|
||||
title: `${REPO_NAME} Releases`,
|
||||
description: `Latest releases from ${REPO_NAME} repository`,
|
||||
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
|
||||
updated: new Date().toISOString(),
|
||||
},
|
||||
items: rssItems,
|
||||
total_count: rssItems.length,
|
||||
cached: releasesData.cached,
|
||||
cache_age: releasesData.cache_age,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to generate RSS format", error, {
|
||||
operation: "rss_releases",
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to generate RSS format",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.use("/users", userRoutes);
|
||||
app.use("/ssh", sshRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
|
||||
app.use(
|
||||
(
|
||||
err: unknown,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
apiLogger.error("Unhandled error in request", err, {
|
||||
operation: "error_handler",
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
userAgent: req.get("User-Agent"),
|
||||
});
|
||||
res.status(500).json({ error: "Internal Server Error" });
|
||||
},
|
||||
);
|
||||
|
||||
const PORT = 8081;
|
||||
app.listen(PORT, () => {});
|
||||
app.listen(PORT, () => {
|
||||
databaseLogger.success(`Database API server started on port ${PORT}`, {
|
||||
operation: "server_start",
|
||||
port: PORT,
|
||||
routes: [
|
||||
"/users",
|
||||
"/ssh",
|
||||
"/alerts",
|
||||
"/credentials",
|
||||
"/health",
|
||||
"/version",
|
||||
"/releases/rss",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,43 +1,25 @@
|
||||
import {drizzle} from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import * as schema from "./schema.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dataDir = process.env.DATA_DIR || './db/data';
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const dbDir = path.resolve(dataDir);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, {recursive: true});
|
||||
databaseLogger.info(`Creating database directory`, {
|
||||
operation: "db_init",
|
||||
path: dbDir,
|
||||
});
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'db.sqlite');
|
||||
const dbPath = path.join(dataDir, "db.sqlite");
|
||||
databaseLogger.info(`Initializing SQLite database`, {
|
||||
operation: "db_init",
|
||||
path: dbPath,
|
||||
});
|
||||
const sqlite = new Database(dbPath);
|
||||
|
||||
sqlite.exec(`
|
||||
@@ -46,10 +28,9 @@ sqlite.exec(`
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
client_id TEXT NOT NULL,
|
||||
client_secret TEXT NOT NULL,
|
||||
client_secret TEXT NOT NULL,
|
||||
issuer_url TEXT NOT NULL,
|
||||
authorization_url TEXT NOT NULL,
|
||||
token_url TEXT NOT NULL,
|
||||
@@ -82,116 +63,244 @@ sqlite.exec(`
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_config_editor INTEGER NOT NULL DEFAULT 1,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
default_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_recent (
|
||||
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_pinned (
|
||||
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_editor_shortcuts (
|
||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id)
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
`);
|
||||
|
||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
||||
const addColumnIfNotExists = (
|
||||
table: string,
|
||||
column: string,
|
||||
definition: string,
|
||||
) => {
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`,
|
||||
)
|
||||
.get();
|
||||
} catch (e) {
|
||||
try {
|
||||
sqlite.prepare(`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`).get();
|
||||
} catch (e) {
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
databaseLogger.debug(`Adding column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
});
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
} catch (alterError) {
|
||||
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
|
||||
}
|
||||
databaseLogger.success(`Column ${column} added to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
});
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
logger.info('Checking for schema updates...');
|
||||
databaseLogger.info("Checking for schema updates...", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists('users', 'is_oidc', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('users', 'oidc_identifier', 'TEXT');
|
||||
addColumnIfNotExists('users', 'client_id', 'TEXT');
|
||||
addColumnIfNotExists('users', 'client_secret', 'TEXT');
|
||||
addColumnIfNotExists('users', 'issuer_url', 'TEXT');
|
||||
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
|
||||
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
||||
try {
|
||||
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
||||
logger.info('Removed redirect_uri column from users table');
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'name_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'scopes', 'TEXT');
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
||||
addColumnIfNotExists("users", "client_id", "TEXT");
|
||||
addColumnIfNotExists("users", "client_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
||||
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
||||
addColumnIfNotExists("users", "token_url", "TEXT");
|
||||
|
||||
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"');
|
||||
addColumnIfNotExists('ssh_data', 'password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'enable_config_editor', '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("users", "identifier_path", "TEXT");
|
||||
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||
|
||||
addColumnIfNotExists('config_editor_recent', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('config_editor_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('config_editor_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists("users", "totp_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
logger.success('Schema migration completed');
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"auth_type",
|
||||
'TEXT NOT NULL DEFAULT "password"',
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_terminal",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_tunnel",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_file_manager",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"created_at",
|
||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"updated_at",
|
||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"credential_id",
|
||||
"INTEGER REFERENCES ssh_credentials(id)",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
};
|
||||
|
||||
migrateSchema();
|
||||
const initializeDatabase = async () => {
|
||||
migrateSchema();
|
||||
|
||||
try {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
|
||||
databaseLogger.info("Initializing default settings", {
|
||||
operation: "db_init",
|
||||
setting: "allow_registration",
|
||||
});
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||
)
|
||||
.run();
|
||||
databaseLogger.success("Default settings initialized", {
|
||||
operation: "db_init",
|
||||
});
|
||||
} else {
|
||||
databaseLogger.debug("Default settings already exist", {
|
||||
operation: "db_init",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Could not initialize default settings');
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize default settings", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const db = drizzle(sqlite, {schema});
|
||||
initializeDatabase().catch((error) => {
|
||||
databaseLogger.error("Failed to initialize database", error, {
|
||||
operation: "db_init",
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
databaseLogger.success("Database connection established", {
|
||||
operation: "db_init",
|
||||
path: dbPath,
|
||||
});
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
@@ -1,76 +1,167 @@
|
||||
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
|
||||
import {sql} from 'drizzle-orm';
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
username: text('username').notNull(),
|
||||
password_hash: text('password_hash').notNull(),
|
||||
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
password_hash: text("password_hash").notNull(),
|
||||
is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
|
||||
is_oidc: integer('is_oidc', {mode: 'boolean'}).notNull().default(false),
|
||||
oidc_identifier: text('oidc_identifier'),
|
||||
client_id: text('client_id'),
|
||||
client_secret: text('client_secret'),
|
||||
issuer_url: text('issuer_url'),
|
||||
authorization_url: text('authorization_url'),
|
||||
token_url: text('token_url'),
|
||||
identifier_path: text('identifier_path'),
|
||||
name_path: text('name_path'),
|
||||
scopes: text().default("openid email profile"),
|
||||
is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
|
||||
oidc_identifier: text("oidc_identifier"),
|
||||
client_id: text("client_id"),
|
||||
client_secret: text("client_secret"),
|
||||
issuer_url: text("issuer_url"),
|
||||
authorization_url: text("authorization_url"),
|
||||
token_url: text("token_url"),
|
||||
identifier_path: text("identifier_path"),
|
||||
name_path: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
totp_secret: text("totp_secret"),
|
||||
totp_enabled: integer("totp_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
totp_backup_codes: text("totp_backup_codes"),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable('ssh_data', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name'),
|
||||
ip: text('ip').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
username: text('username').notNull(),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'),
|
||||
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
|
||||
authType: text('auth_type').notNull(),
|
||||
password: text('password'),
|
||||
key: text('key', {length: 8192}),
|
||||
keyPassword: text('key_password'),
|
||||
keyType: text('key_type'),
|
||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
||||
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
||||
tunnelConnections: text('tunnel_connections'),
|
||||
enableConfigEditor: integer('enable_config_editor', {mode: 'boolean'}).notNull().default(true),
|
||||
defaultPath: text('default_path'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const sshData = sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name"),
|
||||
ip: text("ip").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
username: text("username").notNull(),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
||||
authType: text("auth_type").notNull(),
|
||||
|
||||
password: text("password"),
|
||||
key: text("key", { length: 8192 }),
|
||||
keyPassword: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id),
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
tunnelConnections: text("tunnel_connections"),
|
||||
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
defaultPath: text("default_path"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const configEditorRecent = sqliteTable('config_editor_recent', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
lastOpened: text("last_opened")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const configEditorPinned = sqliteTable('config_editor_pinned', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const fileManagerPinned = sqliteTable("file_manager_pinned", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
pinnedAt: text("pinned_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const dismissedAlerts = sqliteTable("dismissed_alerts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
alertId: text("alert_id").notNull(),
|
||||
dismissedAt: text("dismissed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
authType: text("auth_type").notNull(),
|
||||
username: text("username").notNull(),
|
||||
password: text("password"),
|
||||
key: text("key", { length: 16384 }),
|
||||
keyPassword: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
usedAt: text("used_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
261
src/backend/database/routes/alerts.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import fetch from "node-fetch";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class AlertCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||
|
||||
set(key: string, data: any): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION,
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): any | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
}
|
||||
|
||||
const alertCache = new AlertCache();
|
||||
|
||||
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
|
||||
const REPO_OWNER = "LukeGus";
|
||||
const REPO_NAME = "Termix-Docs";
|
||||
const ALERTS_FILE = "main/termix-alerts.json";
|
||||
|
||||
interface TermixAlert {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt: string;
|
||||
priority?: "low" | "medium" | "high" | "critical";
|
||||
type?: "info" | "warning" | "error" | "success";
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
}
|
||||
|
||||
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
const cacheKey = "termix_alerts";
|
||||
const cachedData = alertCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const validAlerts = alerts.filter((alert) => {
|
||||
const expiryDate = new Date(alert.expiresAt);
|
||||
const isValid = expiryDate > now;
|
||||
return isValid;
|
||||
});
|
||||
|
||||
alertCache.set(cacheKey, validAlerts);
|
||||
return validAlerts;
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to fetch alerts from GitHub", {
|
||||
operation: "alerts_fetch",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Route: Get all active alerts
|
||||
// GET /alerts
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const alerts = await fetchAlertsFromGitHub();
|
||||
res.json({
|
||||
alerts,
|
||||
cached: alertCache.get("termix_alerts") !== null,
|
||||
total_count: alerts.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get alerts for a specific user (excluding dismissed ones)
|
||||
// GET /alerts/user/:userId
|
||||
router.get("/user/:userId", async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "User ID is required" });
|
||||
}
|
||||
|
||||
const allAlerts = await fetchAlertsFromGitHub();
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
.select({ alertId: dismissedAlerts.alertId })
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
const dismissedAlertIds = new Set(
|
||||
dismissedAlertRecords.map((record) => record.alertId),
|
||||
);
|
||||
|
||||
const userAlerts = allAlerts.filter(
|
||||
(alert) => !dismissedAlertIds.has(alert.id),
|
||||
);
|
||||
|
||||
res.json({
|
||||
alerts: userAlerts,
|
||||
total_count: userAlerts.length,
|
||||
dismissed_count: dismissedAlertIds.size,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get user alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch user alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Dismiss an alert for a user
|
||||
// POST /alerts/dismiss
|
||||
router.post("/dismiss", async (req, res) => {
|
||||
try {
|
||||
const { userId, alertId } = req.body;
|
||||
|
||||
if (!userId || !alertId) {
|
||||
authLogger.warn("Missing userId or alertId in dismiss request");
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "User ID and Alert ID are required" });
|
||||
}
|
||||
|
||||
const existingDismissal = await db
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (existingDismissal.length > 0) {
|
||||
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
|
||||
return res.status(409).json({ error: "Alert already dismissed" });
|
||||
}
|
||||
|
||||
const result = await db.insert(dismissedAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
});
|
||||
|
||||
res.json({ message: "Alert dismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to dismiss alert", error);
|
||||
res.status(500).json({ error: "Failed to dismiss alert" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get dismissed alerts for a user
|
||||
// GET /alerts/dismissed/:userId
|
||||
router.get("/dismissed/:userId", async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "User ID is required" });
|
||||
}
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
.select({
|
||||
alertId: dismissedAlerts.alertId,
|
||||
dismissedAt: dismissedAlerts.dismissedAt,
|
||||
})
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
res.json({
|
||||
dismissed_alerts: dismissedAlertRecords,
|
||||
total_count: dismissedAlertRecords.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get dismissed alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Undismiss an alert for a user (remove from dismissed list)
|
||||
// DELETE /alerts/dismiss
|
||||
router.delete("/dismiss", async (req, res) => {
|
||||
try {
|
||||
const { userId, alertId } = req.body;
|
||||
|
||||
if (!userId || !alertId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "User ID and Alert ID are required" });
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.delete(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Dismissed alert not found" });
|
||||
}
|
||||
res.json({ message: "Alert undismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to undismiss alert", error);
|
||||
res.status(500).json({ error: "Failed to undismiss alert" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
664
src/backend/database/routes/credentials.ts
Normal file
@@ -0,0 +1,664 @@
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
function isNonEmptyString(val: any): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
authLogger.warn("Missing or invalid Authorization header");
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Missing or invalid Authorization header" });
|
||||
}
|
||||
const token = authHeader.split(" ")[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || "secret";
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
(req as any).userId = payload.userId;
|
||||
next();
|
||||
} catch (err) {
|
||||
authLogger.warn("Invalid or expired token");
|
||||
return res.status(401).json({ error: "Invalid or expired token" });
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new credential
|
||||
// POST /credentials
|
||||
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
folder,
|
||||
tags,
|
||||
authType,
|
||||
username,
|
||||
password,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
} = req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
!isNonEmptyString(name) ||
|
||||
!isNonEmptyString(username)
|
||||
) {
|
||||
authLogger.warn("Invalid credential creation data validation failed", {
|
||||
operation: "credential_create",
|
||||
userId,
|
||||
hasName: !!name,
|
||||
hasUsername: !!username,
|
||||
});
|
||||
return res.status(400).json({ error: "Name and username are required" });
|
||||
}
|
||||
|
||||
if (!["password", "key"].includes(authType)) {
|
||||
authLogger.warn("Invalid auth type provided", {
|
||||
operation: "credential_create",
|
||||
userId,
|
||||
name,
|
||||
authType,
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Auth type must be "password" or "key"' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (authType === "password" && !password) {
|
||||
authLogger.warn("Password required for password authentication", {
|
||||
operation: "credential_create",
|
||||
userId,
|
||||
name,
|
||||
authType,
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Password is required for password authentication" });
|
||||
}
|
||||
if (authType === "key" && !key) {
|
||||
authLogger.warn("SSH key required for key authentication", {
|
||||
operation: "credential_create",
|
||||
userId,
|
||||
name,
|
||||
authType,
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH key is required for key authentication" });
|
||||
}
|
||||
const plainPassword = authType === "password" && password ? password : null;
|
||||
const plainKey = authType === "key" && key ? key : null;
|
||||
const plainKeyPassword =
|
||||
authType === "key" && keyPassword ? keyPassword : null;
|
||||
|
||||
const credentialData = {
|
||||
userId,
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
folder: folder?.trim() || null,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
authType,
|
||||
username: username.trim(),
|
||||
password: plainPassword,
|
||||
key: plainKey,
|
||||
keyPassword: plainKeyPassword,
|
||||
keyType: keyType || null,
|
||||
usageCount: 0,
|
||||
lastUsed: null,
|
||||
};
|
||||
|
||||
const result = await db
|
||||
.insert(sshCredentials)
|
||||
.values(credentialData)
|
||||
.returning();
|
||||
const created = result[0];
|
||||
|
||||
authLogger.success(
|
||||
`SSH credential created: ${name} (${authType}) by user ${userId}`,
|
||||
{
|
||||
operation: "credential_create_success",
|
||||
userId,
|
||||
credentialId: created.id,
|
||||
name,
|
||||
authType,
|
||||
username,
|
||||
},
|
||||
);
|
||||
|
||||
res.status(201).json(formatCredentialOutput(created));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create credential in database", err, {
|
||||
operation: "credential_create",
|
||||
userId,
|
||||
name,
|
||||
authType,
|
||||
username,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to create credential",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get all credentials for the authenticated user
|
||||
// GET /credentials
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
authLogger.warn("Invalid userId for credential fetch");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId))
|
||||
.orderBy(desc(sshCredentials.updatedAt));
|
||||
|
||||
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch credentials", err);
|
||||
res.status(500).json({ error: "Failed to fetch credentials" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all unique credential folders for the authenticated user
|
||||
// GET /credentials/folders
|
||||
router.get("/folders", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
authLogger.warn("Invalid userId for credential folder fetch");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select({ folder: sshCredentials.folder })
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
|
||||
const folderCounts: Record<string, number> = {};
|
||||
result.forEach((r) => {
|
||||
if (r.folder && r.folder.trim() !== "") {
|
||||
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
const folders = Object.keys(folderCounts).filter(
|
||||
(folder) => folderCounts[folder] > 0,
|
||||
);
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch credential folders", err);
|
||||
res.status(500).json({ error: "Failed to fetch credential folders" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a specific credential by ID (with plain text secrets)
|
||||
// GET /credentials/:id
|
||||
router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
authLogger.warn("Invalid request for credential fetch");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, parseInt(id)),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (credentials.length === 0) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
const credential = credentials[0];
|
||||
const output = formatCredentialOutput(credential);
|
||||
|
||||
if (credential.password) {
|
||||
(output as any).password = credential.password;
|
||||
}
|
||||
if (credential.key) {
|
||||
(output as any).key = credential.key;
|
||||
}
|
||||
if (credential.keyPassword) {
|
||||
(output as any).keyPassword = credential.keyPassword;
|
||||
}
|
||||
|
||||
res.json(output);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch credential", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to fetch credential",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update a credential
|
||||
// PUT /credentials/:id
|
||||
router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
authLogger.warn("Invalid request for credential update");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, parseInt(id)),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
const updateFields: any = {};
|
||||
|
||||
if (updateData.name !== undefined)
|
||||
updateFields.name = updateData.name.trim();
|
||||
if (updateData.description !== undefined)
|
||||
updateFields.description = updateData.description?.trim() || null;
|
||||
if (updateData.folder !== undefined)
|
||||
updateFields.folder = updateData.folder?.trim() || null;
|
||||
if (updateData.tags !== undefined) {
|
||||
updateFields.tags = Array.isArray(updateData.tags)
|
||||
? updateData.tags.join(",")
|
||||
: updateData.tags || "";
|
||||
}
|
||||
if (updateData.username !== undefined)
|
||||
updateFields.username = updateData.username.trim();
|
||||
if (updateData.authType !== undefined)
|
||||
updateFields.authType = updateData.authType;
|
||||
if (updateData.keyType !== undefined)
|
||||
updateFields.keyType = updateData.keyType;
|
||||
|
||||
if (updateData.password !== undefined) {
|
||||
updateFields.password = updateData.password || null;
|
||||
}
|
||||
if (updateData.key !== undefined) {
|
||||
updateFields.key = updateData.key || null;
|
||||
}
|
||||
if (updateData.keyPassword !== undefined) {
|
||||
updateFields.keyPassword = updateData.keyPassword || null;
|
||||
}
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, parseInt(id)));
|
||||
|
||||
return res.json(formatCredentialOutput(existing[0]));
|
||||
}
|
||||
|
||||
await db
|
||||
.update(sshCredentials)
|
||||
.set(updateFields)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, parseInt(id)),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
const updated = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, parseInt(id)));
|
||||
|
||||
const credential = updated[0];
|
||||
authLogger.success(
|
||||
`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
|
||||
{
|
||||
operation: "credential_update_success",
|
||||
userId,
|
||||
credentialId: parseInt(id),
|
||||
name: credential.name,
|
||||
authType: credential.authType,
|
||||
username: credential.username,
|
||||
},
|
||||
);
|
||||
|
||||
res.json(formatCredentialOutput(updated[0]));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update credential", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to update credential",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a credential
|
||||
// DELETE /credentials/:id
|
||||
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
authLogger.warn("Invalid request for credential deletion");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const credentialToDelete = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, parseInt(id)),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (credentialToDelete.length === 0) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
const hostsUsingCredential = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(eq(sshData.credentialId, parseInt(id)), eq(sshData.userId, userId)),
|
||||
);
|
||||
|
||||
if (hostsUsingCredential.length > 0) {
|
||||
await db
|
||||
.update(sshData)
|
||||
.set({
|
||||
credentialId: null,
|
||||
password: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
authType: "password",
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.credentialId, parseInt(id)),
|
||||
eq(sshData.userId, userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentialUsage.credentialId, parseInt(id)),
|
||||
eq(sshCredentialUsage.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, parseInt(id)),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
const credential = credentialToDelete[0];
|
||||
authLogger.success(
|
||||
`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`,
|
||||
{
|
||||
operation: "credential_delete_success",
|
||||
userId,
|
||||
credentialId: parseInt(id),
|
||||
name: credential.name,
|
||||
authType: credential.authType,
|
||||
username: credential.username,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({ message: "Credential deleted successfully" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete credential", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to delete credential",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Apply a credential to an SSH host (for quick application)
|
||||
// POST /credentials/:id/apply-to-host/:hostId
|
||||
router.post(
|
||||
"/:id/apply-to-host/:hostId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id: credentialId, hostId } = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
|
||||
authLogger.warn("Invalid request for credential application");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, parseInt(credentialId)),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (credentials.length === 0) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
const credential = credentials[0];
|
||||
|
||||
await db
|
||||
.update(sshData)
|
||||
.set({
|
||||
credentialId: parseInt(credentialId),
|
||||
username: credential.username,
|
||||
authType: credential.authType,
|
||||
password: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(
|
||||
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
|
||||
);
|
||||
|
||||
await db.insert(sshCredentialUsage).values({
|
||||
credentialId: parseInt(credentialId),
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(sshCredentials)
|
||||
.set({
|
||||
usageCount: sql`${sshCredentials.usageCount}
|
||||
+ 1`,
|
||||
lastUsed: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(sshCredentials.id, parseInt(credentialId)));
|
||||
res.json({ message: "Credential applied to host successfully" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to apply credential to host", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to apply credential to host",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get hosts using a specific credential
|
||||
// GET /credentials/:id/hosts
|
||||
router.get(
|
||||
"/:id/hosts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { id: credentialId } = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !credentialId) {
|
||||
authLogger.warn("Invalid request for credential hosts fetch");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const hosts = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.credentialId, parseInt(credentialId)),
|
||||
eq(sshData.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
res.json(hosts.map((host) => formatSSHHostOutput(host)));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch hosts using credential", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to fetch hosts using credential",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function formatCredentialOutput(credential: any): any {
|
||||
return {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
description: credential.description,
|
||||
folder: credential.folder,
|
||||
tags:
|
||||
typeof credential.tags === "string"
|
||||
? credential.tags
|
||||
? credential.tags.split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
authType: credential.authType,
|
||||
username: credential.username,
|
||||
keyType: credential.keyType,
|
||||
usageCount: credential.usageCount || 0,
|
||||
lastUsed: credential.lastUsed,
|
||||
createdAt: credential.createdAt,
|
||||
updatedAt: credential.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function formatSSHHostOutput(host: any): any {
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags:
|
||||
typeof host.tags === "string"
|
||||
? host.tags
|
||||
? host.tags.split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
authType: host.authType,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [],
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
defaultPath: host.defaultPath,
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Rename a credential folder
|
||||
// PUT /credentials/folders/rename
|
||||
router.put(
|
||||
"/folders/rename",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { oldName, newName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Both oldName and newName are required" });
|
||||
}
|
||||
|
||||
if (oldName === newName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Old name and new name cannot be the same" });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(sshCredentials)
|
||||
.set({ folder: newName })
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.folder, oldName),
|
||||
),
|
||||
);
|
||||
|
||||
res.json({ success: true, message: "Folder renamed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Error renaming credential folder:", error);
|
||||
res.status(500).json({ error: "Failed to rename folder" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
1288
src/backend/ssh/file-manager.ts
Normal file
909
src/backend/ssh/server-stats.ts
Normal file
@@ -0,0 +1,909 @@
|
||||
import express from "express";
|
||||
import net from "net";
|
||||
import cors from "cors";
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { sshData, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
|
||||
interface PooledConnection {
|
||||
client: Client;
|
||||
lastUsed: number;
|
||||
inUse: boolean;
|
||||
hostKey: string;
|
||||
}
|
||||
|
||||
class SSHConnectionPool {
|
||||
private connections = new Map<string, PooledConnection[]>();
|
||||
private maxConnectionsPerHost = 3;
|
||||
private connectionTimeout = 30000;
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
constructor() {
|
||||
this.cleanupInterval = setInterval(
|
||||
() => {
|
||||
this.cleanup();
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
private getHostKey(host: SSHHostWithCredentials): string {
|
||||
return `${host.ip}:${host.port}:${host.username}`;
|
||||
}
|
||||
|
||||
async getConnection(host: SSHHostWithCredentials): Promise<Client> {
|
||||
const hostKey = this.getHostKey(host);
|
||||
const connections = this.connections.get(hostKey) || [];
|
||||
|
||||
const available = connections.find((conn) => !conn.inUse);
|
||||
if (available) {
|
||||
available.inUse = true;
|
||||
available.lastUsed = Date.now();
|
||||
return available.client;
|
||||
}
|
||||
|
||||
if (connections.length < this.maxConnectionsPerHost) {
|
||||
const client = await this.createConnection(host);
|
||||
const pooled: PooledConnection = {
|
||||
client,
|
||||
lastUsed: Date.now(),
|
||||
inUse: true,
|
||||
hostKey,
|
||||
};
|
||||
connections.push(pooled);
|
||||
this.connections.set(hostKey, connections);
|
||||
return client;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkAvailable = () => {
|
||||
const available = connections.find((conn) => !conn.inUse);
|
||||
if (available) {
|
||||
available.inUse = true;
|
||||
available.lastUsed = Date.now();
|
||||
resolve(available.client);
|
||||
} else {
|
||||
setTimeout(checkAvailable, 100);
|
||||
}
|
||||
};
|
||||
checkAvailable();
|
||||
});
|
||||
}
|
||||
|
||||
private async createConnection(
|
||||
host: SSHHostWithCredentials,
|
||||
): Promise<Client> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Client();
|
||||
const timeout = setTimeout(() => {
|
||||
client.end();
|
||||
reject(new Error("SSH connection timeout"));
|
||||
}, this.connectionTimeout);
|
||||
|
||||
client.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(client);
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
try {
|
||||
client.connect(buildSshConfig(host));
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
releaseConnection(host: SSHHostWithCredentials, client: Client): void {
|
||||
const hostKey = this.getHostKey(host);
|
||||
const connections = this.connections.get(hostKey) || [];
|
||||
const pooled = connections.find((conn) => conn.client === client);
|
||||
if (pooled) {
|
||||
pooled.inUse = false;
|
||||
pooled.lastUsed = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
const maxAge = 10 * 60 * 1000;
|
||||
|
||||
for (const [hostKey, connections] of this.connections.entries()) {
|
||||
const activeConnections = connections.filter((conn) => {
|
||||
if (!conn.inUse && now - conn.lastUsed > maxAge) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (activeConnections.length === 0) {
|
||||
this.connections.delete(hostKey);
|
||||
} else {
|
||||
this.connections.set(hostKey, activeConnections);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
clearInterval(this.cleanupInterval);
|
||||
for (const connections of this.connections.values()) {
|
||||
for (const conn of connections) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
this.connections.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class RequestQueue {
|
||||
private queues = new Map<number, Array<() => Promise<any>>>();
|
||||
private processing = new Set<number>();
|
||||
|
||||
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const queue = this.queues.get(hostId) || [];
|
||||
queue.push(async () => {
|
||||
try {
|
||||
const result = await request();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
this.queues.set(hostId, queue);
|
||||
this.processQueue(hostId);
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue(hostId: number): Promise<void> {
|
||||
if (this.processing.has(hostId)) return;
|
||||
|
||||
this.processing.add(hostId);
|
||||
const queue = this.queues.get(hostId) || [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const request = queue.shift();
|
||||
if (request) {
|
||||
try {
|
||||
await request();
|
||||
} catch (error) {}
|
||||
}
|
||||
}
|
||||
|
||||
this.processing.delete(hostId);
|
||||
if (queue.length > 0) {
|
||||
this.processQueue(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CachedMetrics {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
class MetricsCache {
|
||||
private cache = new Map<number, CachedMetrics>();
|
||||
private ttl = 30000;
|
||||
|
||||
get(hostId: number): any | null {
|
||||
const cached = this.cache.get(hostId);
|
||||
if (cached && Date.now() - cached.timestamp < this.ttl) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
set(hostId: number, data: any): void {
|
||||
this.cache.set(hostId, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
hostId,
|
||||
});
|
||||
}
|
||||
|
||||
clear(hostId?: number): void {
|
||||
if (hostId) {
|
||||
this.cache.delete(hostId);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connectionPool = new SSHConnectionPool();
|
||||
const requestQueue = new RequestQueue();
|
||||
const metricsCache = new MetricsCache();
|
||||
|
||||
type HostStatus = "online" | "offline";
|
||||
|
||||
interface SSHHostWithCredentials {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
type StatusEntry = {
|
||||
status: HostStatus;
|
||||
lastChecked: string;
|
||||
};
|
||||
|
||||
function validateHostId(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) {
|
||||
const id = Number(req.params.id);
|
||||
if (!id || !Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
cors({
|
||||
origin: "*",
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use((req, res, next) => {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
res.header(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization, User-Agent, X-Electron-App",
|
||||
);
|
||||
res.header(
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
);
|
||||
if (req.method === "OPTIONS") {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||
|
||||
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
|
||||
try {
|
||||
const hosts = await db.select().from(sshData);
|
||||
|
||||
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
||||
for (const host of hosts) {
|
||||
try {
|
||||
const hostWithCreds = await resolveHostCredentials(host);
|
||||
if (hostWithCreds) {
|
||||
hostsWithCredentials.push(hostWithCreds);
|
||||
}
|
||||
} catch (err) {
|
||||
statsLogger.warn(
|
||||
`Failed to resolve credentials for host ${host.id}: ${err instanceof Error ? err.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return hostsWithCredentials.filter((h) => !!h.id && !!h.ip && !!h.port);
|
||||
} catch (err) {
|
||||
statsLogger.error("Failed to fetch hosts from database", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHostById(
|
||||
id: number,
|
||||
): Promise<SSHHostWithCredentials | undefined> {
|
||||
try {
|
||||
const hosts = await db.select().from(sshData).where(eq(sshData.id, id));
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const host = hosts[0];
|
||||
return await resolveHostCredentials(host);
|
||||
} catch (err) {
|
||||
statsLogger.error(`Failed to fetch host ${id}`, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveHostCredentials(
|
||||
host: any,
|
||||
): Promise<SSHHostWithCredentials | undefined> {
|
||||
try {
|
||||
const baseHost: any = {
|
||||
id: host.id,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder || "",
|
||||
tags:
|
||||
typeof host.tags === "string"
|
||||
? host.tags
|
||||
? host.tags.split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
authType: host.authType,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
defaultPath: host.defaultPath || "/",
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [],
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
userId: host.userId,
|
||||
};
|
||||
|
||||
if (host.credentialId) {
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId),
|
||||
eq(sshCredentials.userId, host.userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
baseHost.credentialId = credential.id;
|
||||
baseHost.username = credential.username;
|
||||
baseHost.authType = credential.authType;
|
||||
|
||||
if (credential.password) {
|
||||
baseHost.password = credential.password;
|
||||
}
|
||||
if (credential.key) {
|
||||
baseHost.key = credential.key;
|
||||
}
|
||||
if (credential.keyPassword) {
|
||||
baseHost.keyPassword = credential.keyPassword;
|
||||
}
|
||||
if (credential.keyType) {
|
||||
baseHost.keyType = credential.keyType;
|
||||
}
|
||||
} else {
|
||||
statsLogger.warn(
|
||||
`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`,
|
||||
);
|
||||
addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.warn(
|
||||
`Failed to resolve credential ${host.credentialId} for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
} else {
|
||||
addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
|
||||
return baseHost;
|
||||
} catch (error) {
|
||||
statsLogger.error(
|
||||
`Failed to resolve host credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function addLegacyCredentials(baseHost: any, host: any): void {
|
||||
baseHost.password = host.password || null;
|
||||
baseHost.key = host.key || null;
|
||||
baseHost.keyPassword = host.keyPassword || null;
|
||||
baseHost.keyType = host.keyType;
|
||||
}
|
||||
|
||||
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||
const base: ConnectConfig = {
|
||||
host: host.ip,
|
||||
port: host.port || 22,
|
||||
username: host.username || "root",
|
||||
readyTimeout: 10_000,
|
||||
algorithms: {},
|
||||
} as ConnectConfig;
|
||||
|
||||
if (host.authType === "password") {
|
||||
if (!host.password) {
|
||||
throw new Error(`No password available for host ${host.ip}`);
|
||||
}
|
||||
(base as any).password = host.password;
|
||||
} else if (host.authType === "key") {
|
||||
if (!host.key) {
|
||||
throw new Error(`No SSH key available for host ${host.ip}`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
|
||||
throw new Error("Invalid private key format");
|
||||
}
|
||||
|
||||
const cleanKey = host.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
|
||||
(base as any).privateKey = Buffer.from(cleanKey, "utf8");
|
||||
|
||||
if (host.keyPassword) {
|
||||
(base as any).passphrase = host.keyPassword;
|
||||
}
|
||||
} catch (keyError) {
|
||||
statsLogger.error(
|
||||
`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
|
||||
);
|
||||
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported authentication type '${host.authType}' for host ${host.ip}`,
|
||||
);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
async function withSshConnection<T>(
|
||||
host: SSHHostWithCredentials,
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await connectionPool.getConnection(host);
|
||||
try {
|
||||
const result = await fn(client);
|
||||
return result;
|
||||
} finally {
|
||||
connectionPool.releaseConnection(host, client);
|
||||
}
|
||||
}
|
||||
|
||||
function execCommand(
|
||||
client: Client,
|
||||
command: string,
|
||||
): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.exec(command, { pty: false }, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let exitCode: number | null = null;
|
||||
stream
|
||||
.on("close", (code: number | undefined) => {
|
||||
exitCode = typeof code === "number" ? code : null;
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf8");
|
||||
})
|
||||
.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf8");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseCpuLine(
|
||||
cpuLine: string,
|
||||
): { total: number; idle: number } | undefined {
|
||||
const parts = cpuLine.trim().split(/\s+/);
|
||||
if (parts[0] !== "cpu") return undefined;
|
||||
const nums = parts
|
||||
.slice(1)
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
if (nums.length < 4) return undefined;
|
||||
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
||||
const total = nums.reduce((a, b) => a + b, 0);
|
||||
return { total, idle };
|
||||
}
|
||||
|
||||
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
}
|
||||
|
||||
function kibToGiB(kib: number): number {
|
||||
return kib / (1024 * 1024);
|
||||
}
|
||||
|
||||
async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
cpu: {
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
load: [number, number, number] | null;
|
||||
};
|
||||
memory: {
|
||||
percent: number | null;
|
||||
usedGiB: number | null;
|
||||
totalGiB: number | null;
|
||||
};
|
||||
disk: {
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
totalHuman: string | null;
|
||||
};
|
||||
}> {
|
||||
const cached = metricsCache.get(host.id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return requestQueue.queueRequest(host.id, async () => {
|
||||
return withSshConnection(host, async (client) => {
|
||||
let cpuPercent: number | null = null;
|
||||
let cores: number | null = null;
|
||||
let loadTriplet: [number, number, number] | null = null;
|
||||
|
||||
try {
|
||||
const [stat1, loadAvgOut, coresOut] = await Promise.all([
|
||||
execCommand(client, "cat /proc/stat"),
|
||||
execCommand(client, "cat /proc/loadavg"),
|
||||
execCommand(
|
||||
client,
|
||||
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
|
||||
),
|
||||
]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const stat2 = await execCommand(client, "cat /proc/stat");
|
||||
|
||||
const cpuLine1 = (
|
||||
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
||||
).trim();
|
||||
const cpuLine2 = (
|
||||
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
||||
).trim();
|
||||
const a = parseCpuLine(cpuLine1);
|
||||
const b = parseCpuLine(cpuLine2);
|
||||
if (a && b) {
|
||||
const totalDiff = b.total - a.total;
|
||||
const idleDiff = b.idle - a.idle;
|
||||
const used = totalDiff - idleDiff;
|
||||
if (totalDiff > 0)
|
||||
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
|
||||
}
|
||||
|
||||
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
|
||||
if (laParts.length >= 3) {
|
||||
loadTriplet = [
|
||||
Number(laParts[0]),
|
||||
Number(laParts[1]),
|
||||
Number(laParts[2]),
|
||||
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
|
||||
const coresNum = Number((coresOut.stdout || "").trim());
|
||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||
} catch (e) {
|
||||
statsLogger.warn(
|
||||
`Failed to collect CPU metrics for host ${host.id}`,
|
||||
e,
|
||||
);
|
||||
cpuPercent = null;
|
||||
cores = null;
|
||||
loadTriplet = null;
|
||||
}
|
||||
|
||||
let memPercent: number | null = null;
|
||||
let usedGiB: number | null = null;
|
||||
let totalGiB: number | null = null;
|
||||
try {
|
||||
const memInfo = await execCommand(client, "cat /proc/meminfo");
|
||||
const lines = memInfo.stdout.split("\n");
|
||||
const getVal = (key: string) => {
|
||||
const line = lines.find((l) => l.startsWith(key));
|
||||
if (!line) return null;
|
||||
const m = line.match(/\d+/);
|
||||
return m ? Number(m[0]) : null;
|
||||
};
|
||||
const totalKb = getVal("MemTotal:");
|
||||
const availKb = getVal("MemAvailable:");
|
||||
if (totalKb && availKb && totalKb > 0) {
|
||||
const usedKb = totalKb - availKb;
|
||||
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
||||
usedGiB = kibToGiB(usedKb);
|
||||
totalGiB = kibToGiB(totalKb);
|
||||
}
|
||||
} catch (e) {
|
||||
statsLogger.warn(
|
||||
`Failed to collect memory metrics for host ${host.id}`,
|
||||
e,
|
||||
);
|
||||
memPercent = null;
|
||||
usedGiB = null;
|
||||
totalGiB = null;
|
||||
}
|
||||
|
||||
let diskPercent: number | null = null;
|
||||
let usedHuman: string | null = null;
|
||||
let totalHuman: string | null = null;
|
||||
try {
|
||||
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
||||
execCommand(client, "df -h -P / | tail -n +2"),
|
||||
execCommand(client, "df -B1 -P / | tail -n +2"),
|
||||
]);
|
||||
|
||||
const humanLine =
|
||||
diskOutHuman.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)[0] || "";
|
||||
const bytesLine =
|
||||
diskOutBytes.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)[0] || "";
|
||||
|
||||
const humanParts = humanLine.split(/\s+/);
|
||||
const bytesParts = bytesLine.split(/\s+/);
|
||||
|
||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
||||
totalHuman = humanParts[1] || null;
|
||||
usedHuman = humanParts[2] || null;
|
||||
|
||||
const totalBytes = Number(bytesParts[1]);
|
||||
const usedBytes = Number(bytesParts[2]);
|
||||
|
||||
if (
|
||||
Number.isFinite(totalBytes) &&
|
||||
Number.isFinite(usedBytes) &&
|
||||
totalBytes > 0
|
||||
) {
|
||||
diskPercent = Math.max(
|
||||
0,
|
||||
Math.min(100, (usedBytes / totalBytes) * 100),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
statsLogger.warn(
|
||||
`Failed to collect disk metrics for host ${host.id}`,
|
||||
e,
|
||||
);
|
||||
diskPercent = null;
|
||||
usedHuman = null;
|
||||
totalHuman = null;
|
||||
}
|
||||
|
||||
const result = {
|
||||
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
|
||||
memory: {
|
||||
percent: toFixedNum(memPercent, 0),
|
||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
||||
},
|
||||
disk: { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman },
|
||||
};
|
||||
|
||||
metricsCache.set(host.id, result);
|
||||
return result;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function tcpPing(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
let settled = false;
|
||||
|
||||
const onDone = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
socket.setTimeout(timeoutMs);
|
||||
|
||||
socket.once("connect", () => onDone(true));
|
||||
socket.once("timeout", () => onDone(false));
|
||||
socket.once("error", () => onDone(false));
|
||||
socket.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
async function pollStatusesOnce(): Promise<void> {
|
||||
const hosts = await fetchAllHosts();
|
||||
if (hosts.length === 0) {
|
||||
statsLogger.warn("No hosts retrieved for status polling", {
|
||||
operation: "status_poll",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const checks = hosts.map(async (h) => {
|
||||
const isOnline = await tcpPing(h.ip, h.port, 5000);
|
||||
const now = new Date().toISOString();
|
||||
const statusEntry: StatusEntry = {
|
||||
status: isOnline ? "online" : "offline",
|
||||
lastChecked: now,
|
||||
};
|
||||
hostStatuses.set(h.id, statusEntry);
|
||||
return isOnline;
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(checks);
|
||||
const onlineCount = results.filter(
|
||||
(r) => r.status === "fulfilled" && r.value === true,
|
||||
).length;
|
||||
const offlineCount = hosts.length - onlineCount;
|
||||
statsLogger.success("Status polling completed", {
|
||||
operation: "status_poll",
|
||||
totalHosts: hosts.length,
|
||||
onlineCount,
|
||||
offlineCount,
|
||||
});
|
||||
}
|
||||
|
||||
app.get("/status", async (req, res) => {
|
||||
if (hostStatuses.size === 0) {
|
||||
await pollStatusesOnce();
|
||||
}
|
||||
const result: Record<number, StatusEntry> = {};
|
||||
for (const [id, entry] of hostStatuses.entries()) {
|
||||
result[id] = entry;
|
||||
}
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
|
||||
try {
|
||||
const host = await fetchHostById(id);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
||||
const now = new Date().toISOString();
|
||||
const statusEntry: StatusEntry = {
|
||||
status: isOnline ? "online" : "offline",
|
||||
lastChecked: now,
|
||||
};
|
||||
|
||||
hostStatuses.set(id, statusEntry);
|
||||
res.json(statusEntry);
|
||||
} catch (err) {
|
||||
statsLogger.error("Failed to check host status", err);
|
||||
res.status(500).json({ error: "Failed to check host status" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/refresh", async (req, res) => {
|
||||
await pollStatusesOnce();
|
||||
res.json({ message: "Refreshed" });
|
||||
});
|
||||
|
||||
app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
|
||||
try {
|
||||
const host = await fetchHostById(id);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
||||
if (!isOnline) {
|
||||
return res.status(503).json({
|
||||
error: "Host is offline",
|
||||
cpu: { percent: null, cores: null, load: null },
|
||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||
disk: { percent: null, usedHuman: null, totalHuman: null },
|
||||
lastChecked: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = await collectMetrics(host);
|
||||
res.json({ ...metrics, lastChecked: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
statsLogger.error("Failed to collect metrics", err);
|
||||
|
||||
if (err instanceof Error && err.message.includes("timeout")) {
|
||||
return res.status(504).json({
|
||||
error: "Metrics collection timeout",
|
||||
cpu: { percent: null, cores: null, load: null },
|
||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||
disk: { percent: null, usedHuman: null, totalHuman: null },
|
||||
lastChecked: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: "Failed to collect metrics",
|
||||
cpu: { percent: null, cores: null, load: null },
|
||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||
disk: { percent: null, usedHuman: null, totalHuman: null },
|
||||
lastChecked: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
statsLogger.info("Received SIGINT, shutting down gracefully");
|
||||
connectionPool.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
statsLogger.info("Received SIGTERM, shutting down gracefully");
|
||||
connectionPool.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
const PORT = 8085;
|
||||
app.listen(PORT, async () => {
|
||||
statsLogger.success("Server Stats API server started", {
|
||||
operation: "server_start",
|
||||
port: PORT,
|
||||
});
|
||||
try {
|
||||
await pollStatusesOnce();
|
||||
} catch (err) {
|
||||
statsLogger.error("Initial poll failed", err, {
|
||||
operation: "initial_poll",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,363 +0,0 @@
|
||||
import {WebSocketServer, WebSocket, type RawData} from 'ws';
|
||||
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const wss = new WebSocketServer({port: 8082});
|
||||
|
||||
const sshIconSymbol = '🖥️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
ws.on('close', () => {
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
ws.on('message', (msg: RawData) => {
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString());
|
||||
} catch (e) {
|
||||
logger.error('Invalid JSON received: ' + msg.toString());
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'}));
|
||||
return;
|
||||
}
|
||||
|
||||
const {type, data} = parsed;
|
||||
|
||||
switch (type) {
|
||||
case 'connectToHost':
|
||||
handleConnectToHost(data);
|
||||
break;
|
||||
|
||||
case 'resize':
|
||||
handleResize(data);
|
||||
break;
|
||||
|
||||
case 'disconnect':
|
||||
cleanupSSH();
|
||||
break;
|
||||
|
||||
case 'input':
|
||||
if (sshStream) {
|
||||
if (data === '\t') {
|
||||
sshStream.write(data);
|
||||
} else if (data.startsWith('\x1b')) {
|
||||
sshStream.write(data);
|
||||
} else {
|
||||
sshStream.write(Buffer.from(data, 'utf8'));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
ws.send(JSON.stringify({type: 'pong'}));
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn('Unknown message type: ' + type);
|
||||
}
|
||||
});
|
||||
|
||||
function handleConnectToHost(data: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
hostConfig: {
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
};
|
||||
}) {
|
||||
const {cols, rows, hostConfig} = data;
|
||||
const {ip, port, username, password, key, keyPassword, keyType, authType} = hostConfig;
|
||||
|
||||
if (!username || typeof username !== 'string' || username.trim() === '') {
|
||||
logger.error('Invalid username provided');
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
|
||||
logger.error('Invalid IP provided');
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!port || typeof port !== 'number' || port <= 0) {
|
||||
logger.error('Invalid port provided');
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn = new Client();
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (sshConn) {
|
||||
logger.error('SSH connection timeout');
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
sshConn.on('ready', () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
const pseudoTtyOpts: PseudoTtyOptions = {
|
||||
term: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
modes: {
|
||||
ECHO: 1,
|
||||
ECHOCTL: 0,
|
||||
ICANON: 1,
|
||||
ISIG: 1,
|
||||
ICRNL: 1,
|
||||
IXON: 1,
|
||||
IXOFF: 0,
|
||||
ISTRIP: 0,
|
||||
OPOST: 1,
|
||||
ONLCR: 1,
|
||||
OCRNL: 0,
|
||||
ONOCR: 0,
|
||||
ONLRET: 0,
|
||||
CS7: 0,
|
||||
CS8: 1,
|
||||
PARENB: 0,
|
||||
PARODD: 0,
|
||||
TTY_OP_ISPEED: 38400,
|
||||
TTY_OP_OSPEED: 38400,
|
||||
}
|
||||
};
|
||||
|
||||
sshConn!.shell(pseudoTtyOpts, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error('Shell error: ' + err.message);
|
||||
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
|
||||
return;
|
||||
}
|
||||
|
||||
sshStream = stream;
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
let data: string;
|
||||
try {
|
||||
data = chunk.toString('utf8');
|
||||
} catch (e) {
|
||||
data = chunk.toString('binary');
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({type: 'data', data}));
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
logger.error('SSH stream error: ' + err.message);
|
||||
|
||||
const isConnectionError = err.message.includes('ECONNRESET') ||
|
||||
err.message.includes('EPIPE') ||
|
||||
err.message.includes('ENOTCONN') ||
|
||||
err.message.includes('ETIMEDOUT');
|
||||
|
||||
if (isConnectionError) {
|
||||
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
|
||||
} else {
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
|
||||
}
|
||||
});
|
||||
|
||||
setupPingInterval();
|
||||
|
||||
ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'}));
|
||||
});
|
||||
});
|
||||
|
||||
sshConn.on('error', (err: Error) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
logger.error('SSH connection error: ' + err.message);
|
||||
|
||||
let errorMessage = 'SSH error: ' + err.message;
|
||||
if (err.message.includes('No matching key exchange algorithm')) {
|
||||
errorMessage = 'SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.';
|
||||
} else if (err.message.includes('No matching cipher')) {
|
||||
errorMessage = 'SSH error: No compatible cipher found. This may be due to an older SSH server or network device.';
|
||||
} else if (err.message.includes('No matching MAC')) {
|
||||
errorMessage = 'SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.';
|
||||
} else if (err.message.includes('ENOTFOUND') || err.message.includes('ENOENT')) {
|
||||
errorMessage = 'SSH error: Could not resolve hostname or connect to server.';
|
||||
} else if (err.message.includes('ECONNREFUSED')) {
|
||||
errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.';
|
||||
} else if (err.message.includes('ETIMEDOUT')) {
|
||||
errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.';
|
||||
} else if (err.message.includes('ECONNRESET') || err.message.includes('EPIPE')) {
|
||||
errorMessage = 'SSH error: Connection was reset. This may be due to network issues or server timeout.';
|
||||
} else if (err.message.includes('authentication failed') || err.message.includes('Permission denied')) {
|
||||
errorMessage = 'SSH error: Authentication failed. Please check your username and password/key.';
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({type: 'error', message: errorMessage}));
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
sshConn.on('close', () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
const connectConfig: any = {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 10000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
env: {
|
||||
TERM: 'xterm-256color',
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
LC_CTYPE: 'en_US.UTF-8',
|
||||
LC_MESSAGES: 'en_US.UTF-8',
|
||||
LC_MONETARY: 'en_US.UTF-8',
|
||||
LC_NUMERIC: 'en_US.UTF-8',
|
||||
LC_TIME: 'en_US.UTF-8',
|
||||
LC_COLLATE: 'en_US.UTF-8',
|
||||
COLORTERM: 'truecolor',
|
||||
},
|
||||
|
||||
algorithms: {
|
||||
kex: [
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group-exchange-sha1',
|
||||
'ecdh-sha2-nistp256',
|
||||
'ecdh-sha2-nistp384',
|
||||
'ecdh-sha2-nistp521'
|
||||
],
|
||||
cipher: [
|
||||
'aes128-ctr',
|
||||
'aes192-ctr',
|
||||
'aes256-ctr',
|
||||
'aes128-gcm@openssh.com',
|
||||
'aes256-gcm@openssh.com',
|
||||
'aes128-cbc',
|
||||
'aes192-cbc',
|
||||
'aes256-cbc',
|
||||
'3des-cbc'
|
||||
],
|
||||
hmac: [
|
||||
'hmac-sha2-256',
|
||||
'hmac-sha2-512',
|
||||
'hmac-sha1',
|
||||
'hmac-md5'
|
||||
],
|
||||
compress: [
|
||||
'none',
|
||||
'zlib@openssh.com',
|
||||
'zlib'
|
||||
]
|
||||
}
|
||||
};
|
||||
if (authType === 'key' && key) {
|
||||
connectConfig.privateKey = key;
|
||||
if (keyPassword) {
|
||||
connectConfig.passphrase = keyPassword;
|
||||
}
|
||||
if (keyType && keyType !== 'auto') {
|
||||
connectConfig.privateKeyType = keyType;
|
||||
}
|
||||
} else if (authType === 'key') {
|
||||
logger.error('SSH key authentication requested but no key provided');
|
||||
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
|
||||
return;
|
||||
} else {
|
||||
connectConfig.password = password;
|
||||
}
|
||||
|
||||
sshConn.connect(connectConfig);
|
||||
}
|
||||
|
||||
function handleResize(data: { cols: number; rows: number }) {
|
||||
if (sshStream && sshStream.setWindow) {
|
||||
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
||||
ws.send(JSON.stringify({type: 'resized', cols: data.cols, rows: data.rows}));
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
if (sshStream) {
|
||||
try {
|
||||
sshStream.end();
|
||||
} catch (e: any) {
|
||||
logger.error('Error closing stream: ' + e.message);
|
||||
}
|
||||
sshStream = null;
|
||||
}
|
||||
|
||||
if (sshConn) {
|
||||
try {
|
||||
sshConn.end();
|
||||
} catch (e: any) {
|
||||
logger.error('Error closing connection: ' + e.message);
|
||||
}
|
||||
sshConn = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupPingInterval() {
|
||||
pingInterval = setInterval(() => {
|
||||
if (sshConn && sshStream) {
|
||||
try {
|
||||
sshStream.write('\x00');
|
||||
} catch (e: any) {
|
||||
logger.error('SSH keepalive failed: ' + e.message);
|
||||
cleanupSSH();
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
});
|
||||
498
src/backend/ssh/terminal.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
|
||||
const wss = new WebSocketServer({ port: 8082 });
|
||||
|
||||
sshLogger.success("SSH Terminal WebSocket server started", {
|
||||
operation: "server_start",
|
||||
port: 8082,
|
||||
});
|
||||
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
ws.on("close", () => {
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
ws.on("message", (msg: RawData) => {
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString());
|
||||
} catch (e) {
|
||||
sshLogger.error("Invalid JSON received", e, {
|
||||
operation: "websocket_message",
|
||||
messageLength: msg.toString().length,
|
||||
});
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, data } = parsed;
|
||||
|
||||
switch (type) {
|
||||
case "connectToHost":
|
||||
handleConnectToHost(data).catch((error) => {
|
||||
sshLogger.error("Failed to connect to host", error, {
|
||||
operation: "ssh_connect",
|
||||
hostId: data.hostConfig?.id,
|
||||
ip: data.hostConfig?.ip,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Failed to connect to host: " +
|
||||
(error instanceof Error ? error.message : "Unknown error"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
break;
|
||||
|
||||
case "resize":
|
||||
handleResize(data);
|
||||
break;
|
||||
|
||||
case "disconnect":
|
||||
cleanupSSH();
|
||||
break;
|
||||
|
||||
case "input":
|
||||
if (sshStream) {
|
||||
if (data === "\t") {
|
||||
sshStream.write(data);
|
||||
} else if (data.startsWith("\x1b")) {
|
||||
sshStream.write(data);
|
||||
} else {
|
||||
sshStream.write(Buffer.from(data, "utf8"));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "ping":
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type received", {
|
||||
operation: "websocket_message",
|
||||
messageType: type,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function handleConnectToHost(data: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
hostConfig: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
};
|
||||
}) {
|
||||
const { cols, rows, hostConfig } = data;
|
||||
const {
|
||||
id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
authType,
|
||||
credentialId,
|
||||
} = hostConfig;
|
||||
|
||||
if (!username || typeof username !== "string" || username.trim() === "") {
|
||||
sshLogger.error("Invalid username provided", undefined, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
ip,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "Invalid username provided" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ip || typeof ip !== "string" || ip.trim() === "") {
|
||||
sshLogger.error("Invalid IP provided", undefined, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "Invalid IP provided" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!port || typeof port !== "number" || port <= 0) {
|
||||
sshLogger.error("Invalid port provided", undefined, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
ip,
|
||||
username,
|
||||
port,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "Invalid port provided" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn = new Client();
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (sshConn) {
|
||||
sshLogger.error("SSH connection timeout", undefined, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "SSH connection timeout" }),
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||
if (credentialId && id && hostConfig.userId) {
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, hostConfig.userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
key: credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
authType: credential.authType,
|
||||
};
|
||||
} else {
|
||||
sshLogger.warn(`No credentials found for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
userId: hostConfig.userId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} else if (credentialId && id) {
|
||||
sshLogger.warn("Missing userId for credential resolution in terminal", {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
hasUserId: !!hostConfig.userId,
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.on("ready", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
sshConn!.shell(
|
||||
{
|
||||
rows: data.rows,
|
||||
cols: data.cols,
|
||||
term: "xterm-256color",
|
||||
} as PseudoTtyOptions,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.error("Shell error", err, {
|
||||
operation: "ssh_shell",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Shell error: " + err.message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sshStream = stream;
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
ws.send(JSON.stringify({ type: "data", data: data.toString() }));
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
stream.on("error", (err: Error) => {
|
||||
sshLogger.error("SSH stream error", err, {
|
||||
operation: "ssh_stream",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "SSH stream error: " + err.message,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
setupPingInterval();
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
sshConn.on("error", (err: Error) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
sshLogger.error("SSH connection error", err, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
authType: resolvedCredentials.authType,
|
||||
});
|
||||
|
||||
let errorMessage = "SSH error: " + err.message;
|
||||
if (err.message.includes("No matching key exchange algorithm")) {
|
||||
errorMessage =
|
||||
"SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.";
|
||||
} else if (err.message.includes("No matching cipher")) {
|
||||
errorMessage =
|
||||
"SSH error: No compatible cipher found. This may be due to an older SSH server or network device.";
|
||||
} else if (err.message.includes("No matching MAC")) {
|
||||
errorMessage =
|
||||
"SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.";
|
||||
} else if (
|
||||
err.message.includes("ENOTFOUND") ||
|
||||
err.message.includes("ENOENT")
|
||||
) {
|
||||
errorMessage =
|
||||
"SSH error: Could not resolve hostname or connect to server.";
|
||||
} else if (err.message.includes("ECONNREFUSED")) {
|
||||
errorMessage =
|
||||
"SSH error: Connection refused. The server may not be running or the port may be incorrect.";
|
||||
} else if (err.message.includes("ETIMEDOUT")) {
|
||||
errorMessage =
|
||||
"SSH error: Connection timed out. Check your network connection and server availability.";
|
||||
} else if (
|
||||
err.message.includes("ECONNRESET") ||
|
||||
err.message.includes("EPIPE")
|
||||
) {
|
||||
errorMessage =
|
||||
"SSH error: Connection was reset. This may be due to network issues or server timeout.";
|
||||
} else if (
|
||||
err.message.includes("authentication failed") ||
|
||||
err.message.includes("Permission denied")
|
||||
) {
|
||||
errorMessage =
|
||||
"SSH error: Authentication failed. Please check your username and password/key.";
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: "error", message: errorMessage }));
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
sshConn.on("close", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
const connectConfig: any = {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
LC_CTYPE: "en_US.UTF-8",
|
||||
LC_MESSAGES: "en_US.UTF-8",
|
||||
LC_MONETARY: "en_US.UTF-8",
|
||||
LC_NUMERIC: "en_US.UTF-8",
|
||||
LC_TIME: "en_US.UTF-8",
|
||||
LC_COLLATE: "en_US.UTF-8",
|
||||
COLORTERM: "truecolor",
|
||||
},
|
||||
|
||||
algorithms: {
|
||||
kex: [
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
],
|
||||
cipher: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
if (resolvedCredentials.authType === "key" && resolvedCredentials.key) {
|
||||
try {
|
||||
if (
|
||||
!resolvedCredentials.key.includes("-----BEGIN") ||
|
||||
!resolvedCredentials.key.includes("-----END")
|
||||
) {
|
||||
throw new Error("Invalid private key format");
|
||||
}
|
||||
|
||||
const cleanKey = resolvedCredentials.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
connectConfig.passphrase = resolvedCredentials.keyPassword;
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedCredentials.keyType &&
|
||||
resolvedCredentials.keyType !== "auto"
|
||||
) {
|
||||
connectConfig.privateKeyType = resolvedCredentials.keyType;
|
||||
}
|
||||
} catch (keyError) {
|
||||
sshLogger.error("SSH key format error: " + keyError.message);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "SSH key format error: Invalid private key format",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (resolvedCredentials.authType === "key") {
|
||||
sshLogger.error("SSH key authentication requested but no key provided");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "SSH key authentication requested but no key provided",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
connectConfig.password = resolvedCredentials.password;
|
||||
}
|
||||
|
||||
sshConn.connect(connectConfig);
|
||||
}
|
||||
|
||||
function handleResize(data: { cols: number; rows: number }) {
|
||||
if (sshStream && sshStream.setWindow) {
|
||||
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
||||
ws.send(
|
||||
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
if (sshStream) {
|
||||
try {
|
||||
sshStream.end();
|
||||
} catch (e: any) {
|
||||
sshLogger.error("Error closing stream: " + e.message);
|
||||
}
|
||||
sshStream = null;
|
||||
}
|
||||
|
||||
if (sshConn) {
|
||||
try {
|
||||
sshConn.end();
|
||||
} catch (e: any) {
|
||||
sshLogger.error("Error closing connection: " + e.message);
|
||||
}
|
||||
sshConn = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupPingInterval() {
|
||||
pingInterval = setInterval(() => {
|
||||
if (sshConn && sshStream) {
|
||||
try {
|
||||
sshStream.write("\x00");
|
||||
} catch (e: any) {
|
||||
sshLogger.error("SSH keepalive failed: " + e.message);
|
||||
cleanupSSH();
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
});
|
||||
1114
src/backend/ssh/tunnel.ts
Normal file
@@ -1,55 +1,65 @@
|
||||
// npx tsc -p tsconfig.node.json
|
||||
// node ./dist/backend/starter.js
|
||||
|
||||
import './database/database.js'
|
||||
import './ssh/ssh.js';
|
||||
import './ssh_tunnel/ssh_tunnel.js';
|
||||
import './config_editor/config_editor.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const fixedIconSymbol = '🚀';
|
||||
|
||||
const getTimeStamp = (): string => {
|
||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
};
|
||||
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${fixedIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
import "./database/database.js";
|
||||
import "./ssh/terminal.js";
|
||||
import "./ssh/tunnel.js";
|
||||
import "./ssh/file-manager.js";
|
||||
import "./ssh/server-stats.js";
|
||||
import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
import "dotenv/config";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
logger.info("Starting all backend servers...");
|
||||
try {
|
||||
const version = process.env.VERSION || "unknown";
|
||||
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
||||
operation: "startup",
|
||||
version: version,
|
||||
});
|
||||
|
||||
logger.success("All servers started successfully");
|
||||
systemLogger.info("Initializing backend services...", {
|
||||
operation: "startup",
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info("Shutting down servers...");
|
||||
process.exit(0);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to start servers:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
systemLogger.success("All backend services initialized successfully", {
|
||||
operation: "startup_complete",
|
||||
services: ["database", "terminal", "tunnel", "file_manager", "stats"],
|
||||
version: version,
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGINT signal, initiating graceful shutdown...",
|
||||
{ operation: "shutdown" },
|
||||
);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGTERM signal, initiating graceful shutdown...",
|
||||
{ operation: "shutdown" },
|
||||
);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
systemLogger.error("Uncaught exception occurred", error, {
|
||||
operation: "error_handling",
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
systemLogger.error("Unhandled promise rejection", reason, {
|
||||
operation: "error_handling",
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to initialize backend services", error, {
|
||||
operation: "startup_failed",
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
174
src/backend/utils/logger.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import chalk from "chalk";
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
||||
|
||||
export interface LogContext {
|
||||
service?: string;
|
||||
operation?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
tunnelName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
duration?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private serviceName: string;
|
||||
private serviceIcon: string;
|
||||
private serviceColor: string;
|
||||
|
||||
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||
this.serviceName = serviceName;
|
||||
this.serviceIcon = serviceIcon;
|
||||
this.serviceColor = serviceColor;
|
||||
}
|
||||
|
||||
private getTimeStamp(): string {
|
||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
}
|
||||
|
||||
private formatMessage(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context?: LogContext,
|
||||
): string {
|
||||
const timestamp = this.getTimeStamp();
|
||||
const levelColor = this.getLevelColor(level);
|
||||
const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`);
|
||||
const levelTag = levelColor(`[${level.toUpperCase()}]`);
|
||||
|
||||
let contextStr = "";
|
||||
if (context) {
|
||||
const contextParts = [];
|
||||
if (context.operation) contextParts.push(`op:${context.operation}`);
|
||||
if (context.userId) contextParts.push(`user:${context.userId}`);
|
||||
if (context.hostId) contextParts.push(`host:${context.hostId}`);
|
||||
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
|
||||
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
|
||||
if (context.requestId) contextParts.push(`req:${context.requestId}`);
|
||||
if (context.duration) contextParts.push(`duration:${context.duration}ms`);
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
private getLevelColor(level: LogLevel): chalk.Chalk {
|
||||
switch (level) {
|
||||
case "debug":
|
||||
return chalk.magenta;
|
||||
case "info":
|
||||
return chalk.cyan;
|
||||
case "warn":
|
||||
return chalk.yellow;
|
||||
case "error":
|
||||
return chalk.redBright;
|
||||
case "success":
|
||||
return chalk.greenBright;
|
||||
default:
|
||||
return chalk.white;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
if (level === "debug" && process.env.NODE_ENV === "production") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("debug")) return;
|
||||
console.debug(this.formatMessage("debug", message, context));
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("info")) return;
|
||||
console.log(this.formatMessage("info", message, context));
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("warn")) return;
|
||||
console.warn(this.formatMessage("warn", message, context));
|
||||
}
|
||||
|
||||
error(message: string, error?: unknown, context?: LogContext): void {
|
||||
if (!this.shouldLog("error")) return;
|
||||
console.error(this.formatMessage("error", message, context));
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
success(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("success")) return;
|
||||
console.log(this.formatMessage("success", message, context));
|
||||
}
|
||||
|
||||
auth(message: string, context?: LogContext): void {
|
||||
this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
|
||||
}
|
||||
|
||||
db(message: string, context?: LogContext): void {
|
||||
this.info(`DB: ${message}`, { ...context, operation: "database" });
|
||||
}
|
||||
|
||||
ssh(message: string, context?: LogContext): void {
|
||||
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
|
||||
}
|
||||
|
||||
tunnel(message: string, context?: LogContext): void {
|
||||
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
|
||||
}
|
||||
|
||||
file(message: string, context?: LogContext): void {
|
||||
this.info(`FILE: ${message}`, { ...context, operation: "file" });
|
||||
}
|
||||
|
||||
api(message: string, context?: LogContext): void {
|
||||
this.info(`API: ${message}`, { ...context, operation: "api" });
|
||||
}
|
||||
|
||||
request(message: string, context?: LogContext): void {
|
||||
this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
|
||||
}
|
||||
|
||||
response(message: string, context?: LogContext): void {
|
||||
this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
|
||||
}
|
||||
|
||||
connection(message: string, context?: LogContext): void {
|
||||
this.info(`CONNECTION: ${message}`, {
|
||||
...context,
|
||||
operation: "connection",
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(message: string, context?: LogContext): void {
|
||||
this.info(`DISCONNECT: ${message}`, {
|
||||
...context,
|
||||
operation: "disconnect",
|
||||
});
|
||||
}
|
||||
|
||||
retry(message: string, context?: LogContext): void {
|
||||
this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
|
||||
}
|
||||
}
|
||||
|
||||
export const databaseLogger = new Logger("DATABASE", "🗄️", "#6366f1");
|
||||
export const sshLogger = new Logger("SSH", "🖥️", "#0ea5e9");
|
||||
export const tunnelLogger = new Logger("TUNNEL", "📡", "#a855f7");
|
||||
export const fileLogger = new Logger("FILE", "📁", "#f59e0b");
|
||||
export const statsLogger = new Logger("STATS", "📊", "#22c55e");
|
||||
export const apiLogger = new Logger("API", "🌐", "#3b82f6");
|
||||
export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
|
||||
export const logger = systemLogger;
|
||||
@@ -1,73 +1,73 @@
|
||||
import {createContext, useContext, useEffect, useState} from "react"
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
@@ -20,7 +20,7 @@ function AccordionItem({
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
@@ -34,7 +34,7 @@ function AccordionTrigger({
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -42,7 +42,7 @@ function AccordionTrigger({
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
@@ -58,7 +58,7 @@ function AccordionContent({
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
@@ -16,8 +16,8 @@ const alertVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
@@ -31,7 +31,7 @@ function Alert({
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 font-medium tracking-tight whitespace-normal break-words",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
@@ -56,11 +56,11 @@ function AlertDescription({
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
@@ -22,8 +22,8 @@ const badgeVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
@@ -32,7 +32,7 @@ function Badge({
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -40,7 +40,7 @@ function Badge({
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
58
src/components/ui/button-group.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Children, ReactElement, cloneElement, isValidElement } from "react";
|
||||
|
||||
import { type ButtonProps } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ButtonGroupProps {
|
||||
className?: string;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
children: ReactElement<ButtonProps>[] | React.ReactNode;
|
||||
}
|
||||
|
||||
export const ButtonGroup = ({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
}: ButtonGroupProps) => {
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
const isVertical = orientation === "vertical";
|
||||
|
||||
// Normalize and filter only valid React elements
|
||||
const childArray = Children.toArray(children).filter(
|
||||
(child): child is ReactElement<ButtonProps> => isValidElement(child),
|
||||
);
|
||||
const totalButtons = childArray.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
{
|
||||
"flex-col": isVertical,
|
||||
"w-fit": isVertical,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{childArray.map((child, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === totalButtons - 1;
|
||||
|
||||
return cloneElement(child, {
|
||||
className: cn(
|
||||
{
|
||||
"rounded-l-none": isHorizontal && !isFirst,
|
||||
"rounded-r-none": isHorizontal && !isLast,
|
||||
"border-l-0": isHorizontal && !isFirst,
|
||||
|
||||
"rounded-t-none": isVertical && !isFirst,
|
||||
"rounded-b-none": isVertical && !isLast,
|
||||
"border-t-0": isVertical && !isFirst,
|
||||
},
|
||||
child.props.className,
|
||||
),
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
@@ -32,8 +32,14 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -41,11 +47,8 @@ function Button({
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -53,7 +56,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants, type ButtonProps };
|
||||
|
||||