v1.6.0 #221
156
.coderabbit.yaml
156
.coderabbit.yaml
@@ -24,20 +24,20 @@ reviews:
|
||||
- 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
|
||||
@@ -48,13 +48,13 @@ reviews:
|
||||
- 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
|
||||
@@ -65,7 +65,7 @@ reviews:
|
||||
- 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
|
||||
@@ -82,27 +82,27 @@ reviews:
|
||||
- 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
|
||||
@@ -123,33 +123,33 @@ reviews:
|
||||
- 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
|
||||
@@ -159,25 +159,25 @@ reviews:
|
||||
- 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
|
||||
@@ -187,91 +187,91 @@ reviews:
|
||||
- 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
|
||||
@@ -283,25 +283,25 @@ reviews:
|
||||
- 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
|
||||
@@ -311,19 +311,19 @@ reviews:
|
||||
- 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
|
||||
@@ -335,61 +335,61 @@ reviews:
|
||||
- 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
|
||||
@@ -403,31 +403,31 @@ reviews:
|
||||
- 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
|
||||
@@ -441,31 +441,31 @@ reviews:
|
||||
- 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
|
||||
@@ -479,91 +479,91 @@ reviews:
|
||||
- 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
|
||||
@@ -575,4 +575,4 @@ reviews:
|
||||
- "TEST"
|
||||
drafts: false
|
||||
chat:
|
||||
auto_reply: true
|
||||
auto_reply: true
|
||||
|
||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
||||
github: [ LukeGus ]
|
||||
github: [LukeGus]
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/bug_report.md
vendored
9
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -3,8 +3,7 @@ name: Bug report
|
||||
about: Create a report to help Termix improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
@@ -12,6 +11,7 @@ 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 '....'
|
||||
@@ -24,8 +24,9 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots or console/Docker logs to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 1.6.0]
|
||||
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 1.6.0]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -3,8 +3,7 @@ name: Feature request
|
||||
about: Suggest an idea for Termix
|
||||
title: "[FEATURE]"
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
|
||||
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -37,4 +37,4 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "weekly"
|
||||
|
||||
4
.github/workflows/docker-image.yml
vendored
4
.github/workflows/docker-image.yml
vendored
@@ -5,8 +5,8 @@ on:
|
||||
branches:
|
||||
- development
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
- "**.md"
|
||||
- ".gitignore"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
|
||||
15
.github/workflows/electron-build.yml
vendored
15
.github/workflows/electron-build.yml
vendored
@@ -5,9 +5,9 @@ on:
|
||||
branches:
|
||||
- development
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
- 'docker/**'
|
||||
- "**.md"
|
||||
- ".gitignore"
|
||||
- "docker/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_type:
|
||||
@@ -34,8 +34,8 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -79,8 +79,8 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -100,4 +100,3 @@ jobs:
|
||||
name: Termix-Linux-Portable
|
||||
path: Termix-Linux-Portable.zip
|
||||
retention-days: 30
|
||||
|
||||
|
||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
1
.prettierrc
Normal file
1
.prettierrc
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -1,4 +1,4 @@
|
||||
_# Contributing
|
||||
\_# Contributing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -9,13 +9,13 @@ _# Contributing
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```sh
|
||||
git clone https://github.com/LukeGus/Termix
|
||||
```
|
||||
```sh
|
||||
git clone https://github.com/LukeGus/Termix
|
||||
```
|
||||
2. Install the dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
## Running the development server
|
||||
|
||||
@@ -34,18 +34,18 @@ This will start the backend and the frontend Vite server. You can access Termix
|
||||
1. **Fork the repository**: Click the "Fork" button at the top right of
|
||||
the [repository page](https://github.com/LukeGus/Termix).
|
||||
2. **Create a new branch**:
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
3. **Make your changes**: Implement your feature, fix, or improvement.
|
||||
4. **Commit your changes**:
|
||||
```sh
|
||||
git commit -m "Feature request my new feature"
|
||||
```
|
||||
```sh
|
||||
git commit -m "Feature request my new feature"
|
||||
```
|
||||
5. **Push to your fork**:
|
||||
```sh
|
||||
git push origin feature/my-feature-request
|
||||
```
|
||||
```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
|
||||
@@ -62,7 +62,7 @@ This will start the backend and the frontend Vite server. You can access Termix
|
||||
### 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) |
|
||||
@@ -74,7 +74,7 @@ This will start the backend and the frontend Vite server. You can access Termix
|
||||
### 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 |
|
||||
@@ -83,7 +83,7 @@ This will start the backend and the frontend Vite server. You can access Termix
|
||||
### 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 |
|
||||
@@ -94,7 +94,7 @@ This will start the backend and the frontend Vite server. You can access Termix
|
||||
### 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 |
|
||||
@@ -104,4 +104,4 @@ This will start the backend and the frontend Vite server. You can access Termix
|
||||
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||
repo.
|
||||
repo.
|
||||
|
||||
218
README-CN.md
218
README-CN.md
@@ -1,109 +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。
|
||||
# 仓库统计
|
||||
|
||||
<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。
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a>
|
||||
</p>
|
||||
|
||||
|
||||

|
||||

|
||||

|
||||
@@ -81,7 +80,7 @@ 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 (
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,4 @@ services:
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
driver: local
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
"!vite.config.ts",
|
||||
"!eslint.config.js"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/node-fetch/**/*"
|
||||
],
|
||||
"asarUnpack": ["node_modules/node-fetch/**/*"],
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.cjs"
|
||||
},
|
||||
@@ -43,4 +41,4 @@
|
||||
"icon": "public/icon.png",
|
||||
"category": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,297 +1,334 @@
|
||||
const {app, BrowserWindow, shell, ipcMain} = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
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 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);
|
||||
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();
|
||||
}
|
||||
});
|
||||
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
|
||||
});
|
||||
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 (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();
|
||||
}
|
||||
});
|
||||
|
||||
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.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
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'};
|
||||
});
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle('get-app-version', () => {
|
||||
return app.getVersion();
|
||||
ipcMain.handle("get-app-version", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-platform', () => {
|
||||
return process.platform;
|
||||
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');
|
||||
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;
|
||||
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');
|
||||
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};
|
||||
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) => {
|
||||
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
try {
|
||||
let fetch;
|
||||
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 = 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;
|
||||
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 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)),
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const normalizedServerUrl = serverUrl.replace(/\/$/, '');
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timeout"));
|
||||
});
|
||||
|
||||
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};
|
||||
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();
|
||||
console.log('Termix started successfully');
|
||||
} else if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
app.on("before-quit", () => {
|
||||
console.log("App is quitting...");
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
} else if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
app.on("will-quit", () => {
|
||||
console.log("App will quit...");
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
console.log('App is quitting...');
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("Uncaught Exception:", error);
|
||||
});
|
||||
|
||||
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);
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
const {contextBridge, ipcRenderer} = require('electron');
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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');
|
||||
console.log("electronAPI exposed to window");
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
@@ -20,4 +20,4 @@ export default tseslint.config([
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
4065
openapi.json
4065
openapi.json
File diff suppressed because it is too large
Load Diff
17
package-lock.json
generated
17
package-lock.json
generated
@@ -102,6 +102,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"prettier": "3.6.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "~5.9.2",
|
||||
@@ -13330,6 +13331,22 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"main": "electron/main.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "npx prettier . --write",
|
||||
"dev": "vite",
|
||||
"build": "vite build && tsc -p tsconfig.node.json",
|
||||
"build:backend": "tsc -p tsconfig.node.json",
|
||||
@@ -114,6 +115,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"prettier": "3.6.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "~5.9.2",
|
||||
|
||||
@@ -1,252 +1,290 @@
|
||||
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';
|
||||
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']
|
||||
}));
|
||||
app.use(
|
||||
cors({
|
||||
origin: "*",
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
}),
|
||||
);
|
||||
|
||||
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) {
|
||||
databaseLogger.error(`Failed to fetch from GitHub API`, error, {operation: 'github_api', endpoint});
|
||||
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) => {
|
||||
let localVersion = process.env.VERSION;
|
||||
|
||||
if (!localVersion) {
|
||||
try {
|
||||
const packagePath = path.resolve(process.cwd(), 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
localVersion = packageJson.version;
|
||||
} catch (error) {
|
||||
databaseLogger.error('Failed to read version from package.json', error, {operation: 'version_check'});
|
||||
}
|
||||
}
|
||||
|
||||
if (!localVersion) {
|
||||
databaseLogger.error('No version information available', undefined, {operation: 'version_check'});
|
||||
return res.status(404).send('Local Version Not Set');
|
||||
}
|
||||
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) {
|
||||
databaseLogger.warn('Remote version not found in GitHub response', {operation: 'version_check', rawTag});
|
||||
return res.status(401).send('Remote Version Not Found');
|
||||
}
|
||||
|
||||
const isUpToDate = localVersion === remoteVersion;
|
||||
|
||||
const response = {
|
||||
status: isUpToDate ? 'up_to_date' : 'requires_update',
|
||||
localVersion: localVersion,
|
||||
version: remoteVersion,
|
||||
latest_release: {
|
||||
tag_name: releaseData.data.tag_name,
|
||||
name: releaseData.data.name,
|
||||
published_at: releaseData.data.published_at,
|
||||
html_url: releaseData.data.html_url
|
||||
},
|
||||
cached: releaseData.cached,
|
||||
cache_age: releaseData.cache_age
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (err) {
|
||||
databaseLogger.error('Version check failed', err, {operation: 'version_check'});
|
||||
res.status(500).send('Fetch Error');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/releases/rss', async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
|
||||
const cacheKey = `releases_rss_${page}_${per_page}`;
|
||||
|
||||
const releasesData = await fetchGitHubAPI(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
|
||||
cacheKey
|
||||
);
|
||||
|
||||
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
|
||||
id: release.id,
|
||||
title: release.name || release.tag_name,
|
||||
description: release.body,
|
||||
link: release.html_url,
|
||||
pubDate: release.published_at,
|
||||
version: release.tag_name,
|
||||
isPrerelease: release.prerelease,
|
||||
isDraft: release.draft,
|
||||
assets: release.assets.map(asset => ({
|
||||
name: asset.name,
|
||||
size: asset.size,
|
||||
download_count: asset.download_count,
|
||||
download_url: asset.browser_download_url
|
||||
}))
|
||||
}));
|
||||
|
||||
const response = {
|
||||
feed: {
|
||||
title: `${REPO_NAME} Releases`,
|
||||
description: `Latest releases from ${REPO_NAME} repository`,
|
||||
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
|
||||
updated: new Date().toISOString()
|
||||
},
|
||||
items: rssItems,
|
||||
total_count: rssItems.length,
|
||||
cached: releasesData.cached,
|
||||
cache_age: releasesData.cache_age
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
const packagePath = path.resolve(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
localVersion = packageJson.version;
|
||||
} catch (error) {
|
||||
databaseLogger.error('Failed to 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'
|
||||
});
|
||||
databaseLogger.error("Failed to read version from package.json", error, {
|
||||
operation: "version_check",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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')
|
||||
if (!localVersion) {
|
||||
databaseLogger.error("No version information available", undefined, {
|
||||
operation: "version_check",
|
||||
});
|
||||
res.status(500).json({error: 'Internal Server Error'});
|
||||
return res.status(404).send("Local Version Not Set");
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = "latest_release";
|
||||
const releaseData = await fetchGitHubAPI(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
|
||||
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
|
||||
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
||||
|
||||
if (!remoteVersion) {
|
||||
databaseLogger.warn("Remote version not found in GitHub response", {
|
||||
operation: "version_check",
|
||||
rawTag,
|
||||
});
|
||||
return res.status(401).send("Remote Version Not Found");
|
||||
}
|
||||
|
||||
const isUpToDate = localVersion === remoteVersion;
|
||||
|
||||
const response = {
|
||||
status: isUpToDate ? "up_to_date" : "requires_update",
|
||||
localVersion: localVersion,
|
||||
version: remoteVersion,
|
||||
latest_release: {
|
||||
tag_name: releaseData.data.tag_name,
|
||||
name: releaseData.data.name,
|
||||
published_at: releaseData.data.published_at,
|
||||
html_url: releaseData.data.html_url,
|
||||
},
|
||||
cached: releaseData.cached,
|
||||
cache_age: releaseData.cache_age,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Version check failed", err, {
|
||||
operation: "version_check",
|
||||
});
|
||||
res.status(500).send("Fetch Error");
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/releases/rss", async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const per_page = Math.min(
|
||||
parseInt(req.query.per_page as string) || 20,
|
||||
100,
|
||||
);
|
||||
const cacheKey = `releases_rss_${page}_${per_page}`;
|
||||
|
||||
const releasesData = await fetchGitHubAPI(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
|
||||
id: release.id,
|
||||
title: release.name || release.tag_name,
|
||||
description: release.body,
|
||||
link: release.html_url,
|
||||
pubDate: release.published_at,
|
||||
version: release.tag_name,
|
||||
isPrerelease: release.prerelease,
|
||||
isDraft: release.draft,
|
||||
assets: release.assets.map((asset) => ({
|
||||
name: asset.name,
|
||||
size: asset.size,
|
||||
download_count: asset.download_count,
|
||||
download_url: asset.browser_download_url,
|
||||
})),
|
||||
}));
|
||||
|
||||
const response = {
|
||||
feed: {
|
||||
title: `${REPO_NAME} Releases`,
|
||||
description: `Latest releases from ${REPO_NAME} repository`,
|
||||
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
|
||||
updated: new Date().toISOString(),
|
||||
},
|
||||
items: rssItems,
|
||||
total_count: rssItems.length,
|
||||
cached: releasesData.cached,
|
||||
cache_age: releasesData.cache_age,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to generate RSS format", error, {
|
||||
operation: "rss_releases",
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to generate RSS format",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.use("/users", userRoutes);
|
||||
app.use("/ssh", sshRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
|
||||
app.use(
|
||||
(
|
||||
err: unknown,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
apiLogger.error("Unhandled error in request", err, {
|
||||
operation: "error_handler",
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
userAgent: req.get("User-Agent"),
|
||||
});
|
||||
res.status(500).json({ error: "Internal Server Error" });
|
||||
},
|
||||
);
|
||||
|
||||
const PORT = 8081;
|
||||
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']
|
||||
});
|
||||
});
|
||||
databaseLogger.success(`Database API server started on port ${PORT}`, {
|
||||
operation: "server_start",
|
||||
port: PORT,
|
||||
routes: [
|
||||
"/users",
|
||||
"/ssh",
|
||||
"/alerts",
|
||||
"/credentials",
|
||||
"/health",
|
||||
"/version",
|
||||
"/releases/rss",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
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';
|
||||
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 dataDir = process.env.DATA_DIR || './db/data';
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const dbDir = path.resolve(dataDir);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
databaseLogger.info(`Creating database directory`, { operation: 'db_init', path: 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');
|
||||
databaseLogger.info(`Initializing SQLite database`, { operation: 'db_init', path: dbPath });
|
||||
const dbPath = path.join(dataDir, "db.sqlite");
|
||||
databaseLogger.info(`Initializing SQLite database`, {
|
||||
operation: "db_init",
|
||||
path: dbPath,
|
||||
});
|
||||
const sqlite = new Database(dbPath);
|
||||
|
||||
sqlite.exec(`
|
||||
@@ -137,90 +143,164 @@ sqlite.exec(`
|
||||
);
|
||||
`);
|
||||
|
||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
||||
const addColumnIfNotExists = (
|
||||
table: string,
|
||||
column: string,
|
||||
definition: string,
|
||||
) => {
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`,
|
||||
)
|
||||
.get();
|
||||
} catch (e) {
|
||||
try {
|
||||
sqlite.prepare(`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`).get();
|
||||
} catch (e) {
|
||||
try {
|
||||
databaseLogger.debug(`Adding column ${column} to ${table}`, { operation: 'schema_migration', table, column });
|
||||
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};`);
|
||||
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 });
|
||||
}
|
||||
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 = () => {
|
||||
databaseLogger.info('Checking for schema updates...', { operation: 'schema_migration' });
|
||||
databaseLogger.info("Checking for schema updates...", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists('users', 'is_oidc', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('users', 'oidc_identifier', 'TEXT');
|
||||
addColumnIfNotExists('users', 'client_id', 'TEXT');
|
||||
addColumnIfNotExists('users', 'client_secret', 'TEXT');
|
||||
addColumnIfNotExists('users', 'issuer_url', 'TEXT');
|
||||
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
|
||||
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
||||
addColumnIfNotExists("users", "client_id", "TEXT");
|
||||
addColumnIfNotExists("users", "client_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
||||
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
||||
addColumnIfNotExists("users", "token_url", "TEXT");
|
||||
|
||||
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'name_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'scopes', 'TEXT');
|
||||
addColumnIfNotExists("users", "identifier_path", "TEXT");
|
||||
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||
|
||||
addColumnIfNotExists('users', 'totp_secret', 'TEXT');
|
||||
addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT');
|
||||
addColumnIfNotExists("users", "totp_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"');
|
||||
addColumnIfNotExists('ssh_data', 'password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'enable_file_manager', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"auth_type",
|
||||
'TEXT NOT NULL DEFAULT "password"',
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_terminal",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_tunnel",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_file_manager",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"created_at",
|
||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"updated_at",
|
||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
|
||||
addColumnIfNotExists('ssh_data', 'credential_id', 'INTEGER REFERENCES ssh_credentials(id)');
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"credential_id",
|
||||
"INTEGER REFERENCES ssh_credentials(id)",
|
||||
);
|
||||
|
||||
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
|
||||
databaseLogger.success('Schema migration completed', { operation: 'schema_migration' });
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
};
|
||||
|
||||
const initializeDatabase = async () => {
|
||||
migrateSchema();
|
||||
migrateSchema();
|
||||
|
||||
try {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (!row) {
|
||||
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) {
|
||||
databaseLogger.warn('Could not initialize default settings', { operation: 'db_init', error: e });
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
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) {
|
||||
databaseLogger.warn("Could not initialize default settings", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
initializeDatabase().catch(error => {
|
||||
databaseLogger.error('Failed to initialize database', error, { operation: 'db_init' });
|
||||
process.exit(1);
|
||||
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});
|
||||
databaseLogger.success("Database connection established", {
|
||||
operation: "db_init",
|
||||
path: dbPath,
|
||||
});
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
@@ -1,117 +1,167 @@
|
||||
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
|
||||
import {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'),
|
||||
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(),
|
||||
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'),
|
||||
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`),
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id),
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
tunnelConnections: text("tunnel_connections"),
|
||||
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
defaultPath: text("default_path"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const fileManagerRecent = sqliteTable('file_manager_recent', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
lastOpened: text("last_opened")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const fileManagerPinned = sqliteTable('file_manager_pinned', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const fileManagerPinned = sqliteTable("file_manager_pinned", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
pinnedAt: text("pinned_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const fileManagerShortcuts = sqliteTable('file_manager_shortcuts', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const dismissedAlerts = sqliteTable('dismissed_alerts', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
alertId: text('alert_id').notNull(),
|
||||
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const dismissedAlerts = sqliteTable("dismissed_alerts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
alertId: text("alert_id").notNull(),
|
||||
dismissedAt: text("dismissed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshCredentials = sqliteTable('ssh_credentials', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'),
|
||||
authType: text('auth_type').notNull(),
|
||||
username: text('username').notNull(),
|
||||
password: text('password'),
|
||||
key: text('key', {length: 16384}),
|
||||
keyPassword: text('key_password'),
|
||||
keyType: text('key_type'),
|
||||
usageCount: integer('usage_count').notNull().default(0),
|
||||
lastUsed: text('last_used'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const 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`),
|
||||
});
|
||||
export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
usedAt: text("used_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
@@ -1,248 +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';
|
||||
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import fetch from "node-fetch";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class AlertCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||
|
||||
set(key: string, data: any): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION
|
||||
});
|
||||
set(key: string, data: any): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION,
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): any | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
get(key: string): any | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
}
|
||||
|
||||
const alertCache = new AlertCache();
|
||||
|
||||
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com';
|
||||
const REPO_OWNER = 'LukeGus';
|
||||
const REPO_NAME = 'Termix-Docs';
|
||||
const ALERTS_FILE = 'main/termix-alerts.json';
|
||||
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
|
||||
const REPO_OWNER = "LukeGus";
|
||||
const REPO_NAME = "Termix-Docs";
|
||||
const ALERTS_FILE = "main/termix-alerts.json";
|
||||
|
||||
interface TermixAlert {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt: string;
|
||||
priority?: 'low' | 'medium' | 'high' | 'critical';
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt: string;
|
||||
priority?: "low" | "medium" | "high" | "critical";
|
||||
type?: "info" | "warning" | "error" | "success";
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
}
|
||||
|
||||
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
const cacheKey = 'termix_alerts';
|
||||
const cachedData = alertCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
const cacheKey = "termix_alerts";
|
||||
const cachedData = alertCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
try {
|
||||
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "TermixAlertChecker/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
authLogger.warn("GitHub API returned error status", {
|
||||
operation: "alerts_fetch",
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
throw new Error(
|
||||
`GitHub raw content error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'TermixAlertChecker/1.0'
|
||||
}
|
||||
});
|
||||
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
|
||||
|
||||
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 now = new Date();
|
||||
|
||||
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
|
||||
const validAlerts = alerts.filter((alert) => {
|
||||
const expiryDate = new Date(alert.expiresAt);
|
||||
const isValid = expiryDate > now;
|
||||
return isValid;
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
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 [];
|
||||
}
|
||||
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'});
|
||||
}
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const alerts = await fetchAlertsFromGitHub();
|
||||
res.json({
|
||||
alerts,
|
||||
cached: alertCache.get("termix_alerts") !== null,
|
||||
total_count: alerts.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get alerts for a specific user (excluding dismissed ones)
|
||||
// GET /alerts/user/:userId
|
||||
router.get('/user/:userId', async (req, res) => {
|
||||
try {
|
||||
const {userId} = req.params;
|
||||
router.get("/user/:userId", async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({error: 'User ID is required'});
|
||||
}
|
||||
|
||||
const allAlerts = await fetchAlertsFromGitHub();
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
.select({alertId: dismissedAlerts.alertId})
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
const dismissedAlertIds = new Set(dismissedAlertRecords.map(record => record.alertId));
|
||||
|
||||
const userAlerts = allAlerts.filter(alert => !dismissedAlertIds.has(alert.id));
|
||||
|
||||
res.json({
|
||||
alerts: userAlerts,
|
||||
total_count: userAlerts.length,
|
||||
dismissed_count: dismissedAlertIds.size
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error('Failed to get user alerts', error);
|
||||
res.status(500).json({error: 'Failed to fetch user alerts'});
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "User ID is required" });
|
||||
}
|
||||
|
||||
const allAlerts = await fetchAlertsFromGitHub();
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
.select({ alertId: dismissedAlerts.alertId })
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
const dismissedAlertIds = new Set(
|
||||
dismissedAlertRecords.map((record) => record.alertId),
|
||||
);
|
||||
|
||||
const userAlerts = allAlerts.filter(
|
||||
(alert) => !dismissedAlertIds.has(alert.id),
|
||||
);
|
||||
|
||||
res.json({
|
||||
alerts: userAlerts,
|
||||
total_count: userAlerts.length,
|
||||
dismissed_count: dismissedAlertIds.size,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get user alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch user alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Dismiss an alert for a user
|
||||
// POST /alerts/dismiss
|
||||
router.post('/dismiss', async (req, res) => {
|
||||
try {
|
||||
const {userId, alertId} = req.body;
|
||||
router.post("/dismiss", async (req, res) => {
|
||||
try {
|
||||
const { userId, alertId } = req.body;
|
||||
|
||||
if (!userId || !alertId) {
|
||||
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'});
|
||||
if (!userId || !alertId) {
|
||||
authLogger.warn("Missing userId or alertId in dismiss request");
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "User ID and Alert ID are required" });
|
||||
}
|
||||
|
||||
const existingDismissal = await db
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (existingDismissal.length > 0) {
|
||||
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
|
||||
return res.status(409).json({ error: "Alert already dismissed" });
|
||||
}
|
||||
|
||||
const result = await db.insert(dismissedAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
});
|
||||
|
||||
res.json({ message: "Alert dismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to dismiss alert", error);
|
||||
res.status(500).json({ error: "Failed to dismiss alert" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get dismissed alerts for a user
|
||||
// GET /alerts/dismissed/:userId
|
||||
router.get('/dismissed/:userId', async (req, res) => {
|
||||
try {
|
||||
const {userId} = req.params;
|
||||
router.get("/dismissed/:userId", async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({error: 'User ID is required'});
|
||||
}
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
.select({
|
||||
alertId: dismissedAlerts.alertId,
|
||||
dismissedAt: dismissedAlerts.dismissedAt
|
||||
})
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
res.json({
|
||||
dismissed_alerts: dismissedAlertRecords,
|
||||
total_count: dismissedAlertRecords.length
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error('Failed to get dismissed alerts', error);
|
||||
res.status(500).json({error: 'Failed to fetch dismissed alerts'});
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "User ID is required" });
|
||||
}
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
.select({
|
||||
alertId: dismissedAlerts.alertId,
|
||||
dismissedAt: dismissedAlerts.dismissedAt,
|
||||
})
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
res.json({
|
||||
dismissed_alerts: dismissedAlertRecords,
|
||||
total_count: dismissedAlertRecords.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get dismissed alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Undismiss an alert for a user (remove from dismissed list)
|
||||
// DELETE /alerts/dismiss
|
||||
router.delete('/dismiss', async (req, res) => {
|
||||
try {
|
||||
const {userId, alertId} = req.body;
|
||||
router.delete("/dismiss", async (req, res) => {
|
||||
try {
|
||||
const { userId, alertId } = req.body;
|
||||
|
||||
if (!userId || !alertId) {
|
||||
return res.status(400).json({error: 'User ID and Alert ID are required'});
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.delete(dismissedAlerts)
|
||||
.where(and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId)
|
||||
));
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({error: 'Dismissed alert not found'});
|
||||
}
|
||||
res.json({message: 'Alert undismissed successfully'});
|
||||
} catch (error) {
|
||||
authLogger.error('Failed to undismiss alert', error);
|
||||
res.status(500).json({error: 'Failed to undismiss alert'});
|
||||
if (!userId || !alertId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "User ID and Alert ID are required" });
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.delete(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Dismissed alert not found" });
|
||||
}
|
||||
res.json({ message: "Alert undismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to undismiss alert", error);
|
||||
res.status(500).json({ error: "Failed to undismiss alert" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,399 +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';
|
||||
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});
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,65 @@
|
||||
// npx tsc -p tsconfig.node.json
|
||||
// node ./dist/backend/starter.js
|
||||
|
||||
import './database/database.js'
|
||||
import './ssh/terminal.js';
|
||||
import './ssh/tunnel.js';
|
||||
import './ssh/file-manager.js';
|
||||
import './ssh/server-stats.js';
|
||||
import { systemLogger, versionLogger } from './utils/logger.js';
|
||||
import 'dotenv/config';
|
||||
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 {
|
||||
const version = process.env.VERSION || 'unknown';
|
||||
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
||||
operation: 'startup',
|
||||
version: version
|
||||
});
|
||||
|
||||
systemLogger.info("Initializing backend services...", { operation: 'startup' });
|
||||
|
||||
systemLogger.success("All backend services initialized successfully", {
|
||||
operation: 'startup_complete',
|
||||
services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats'],
|
||||
version: version
|
||||
});
|
||||
try {
|
||||
const version = process.env.VERSION || "unknown";
|
||||
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
||||
operation: "startup",
|
||||
version: version,
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
systemLogger.info("Received SIGINT signal, initiating graceful shutdown...", { operation: 'shutdown' });
|
||||
process.exit(0);
|
||||
});
|
||||
systemLogger.info("Initializing backend services...", {
|
||||
operation: "startup",
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
systemLogger.info("Received SIGTERM signal, initiating graceful shutdown...", { operation: 'shutdown' });
|
||||
process.exit(0);
|
||||
});
|
||||
systemLogger.success("All backend services initialized successfully", {
|
||||
operation: "startup_complete",
|
||||
services: ["database", "terminal", "tunnel", "file_manager", "stats"],
|
||||
version: version,
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
systemLogger.error("Uncaught exception occurred", error, { operation: 'error_handling' });
|
||||
process.exit(1);
|
||||
});
|
||||
process.on("SIGINT", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGINT signal, initiating graceful shutdown...",
|
||||
{ operation: "shutdown" },
|
||||
);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
systemLogger.error("Unhandled promise rejection", reason, { operation: 'error_handling' });
|
||||
process.exit(1);
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGTERM signal, initiating graceful shutdown...",
|
||||
{ operation: "shutdown" },
|
||||
);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to initialize backend services", error, { operation: 'startup_failed' });
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,158 +1,174 @@
|
||||
import chalk from 'chalk';
|
||||
import chalk from "chalk";
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
||||
|
||||
export interface LogContext {
|
||||
service?: string;
|
||||
operation?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
tunnelName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
duration?: number;
|
||||
[key: string]: any;
|
||||
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;
|
||||
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;
|
||||
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(",")}]`);
|
||||
}
|
||||
}
|
||||
|
||||
private getTimeStamp(): string {
|
||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
}
|
||||
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
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 databaseLogger = new Logger("DATABASE", "🗄️", "#6366f1");
|
||||
export const sshLogger = new Logger("SSH", "🖥️", "#0ea5e9");
|
||||
export const tunnelLogger = new Logger("TUNNEL", "📡", "#a855f7");
|
||||
export const fileLogger = new Logger("FILE", "📁", "#f59e0b");
|
||||
export const statsLogger = new Logger("STATS", "📊", "#22c55e");
|
||||
export const apiLogger = new Logger("API", "🌐", "#3b82f6");
|
||||
export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
|
||||
export const logger = systemLogger;
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
import {createContext, useContext, useEffect, useState} from "react"
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
@@ -20,7 +20,7 @@ function AccordionItem({
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
@@ -34,7 +34,7 @@ function AccordionTrigger({
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -42,7 +42,7 @@ function AccordionTrigger({
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
@@ -58,7 +58,7 @@ function AccordionContent({
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
@@ -16,8 +16,8 @@ const alertVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
@@ -31,7 +31,7 @@ function Alert({
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 font-medium tracking-tight whitespace-normal break-words",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
@@ -56,11 +56,11 @@ function AlertDescription({
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
@@ -22,8 +22,8 @@ const badgeVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
@@ -32,7 +32,7 @@ function Badge({
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -40,7 +40,7 @@ function Badge({
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { Children, ReactElement, cloneElement, isValidElement } from 'react';
|
||||
import { Children, ReactElement, cloneElement, isValidElement } from "react";
|
||||
|
||||
import { type ButtonProps } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type ButtonProps } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ButtonGroupProps {
|
||||
className?: string;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
orientation?: "horizontal" | "vertical";
|
||||
children: ReactElement<ButtonProps>[] | React.ReactNode;
|
||||
}
|
||||
|
||||
export const ButtonGroup = ({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
}: ButtonGroupProps) => {
|
||||
const isHorizontal = orientation === 'horizontal';
|
||||
const isVertical = orientation === 'vertical';
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
const isVertical = orientation === "vertical";
|
||||
|
||||
// Normalize and filter only valid React elements
|
||||
const childArray = Children.toArray(children).filter((child): child is ReactElement<ButtonProps> =>
|
||||
isValidElement(child)
|
||||
const childArray = Children.toArray(children).filter(
|
||||
(child): child is ReactElement<ButtonProps> => isValidElement(child),
|
||||
);
|
||||
const totalButtons = childArray.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
"flex",
|
||||
{
|
||||
'flex-col': isVertical,
|
||||
'w-fit': isVertical,
|
||||
"flex-col": isVertical,
|
||||
"w-fit": isVertical,
|
||||
},
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{childArray.map((child, index) => {
|
||||
@@ -41,18 +41,18 @@ export const ButtonGroup = ({
|
||||
return cloneElement(child, {
|
||||
className: cn(
|
||||
{
|
||||
'rounded-l-none': isHorizontal && !isFirst,
|
||||
'rounded-r-none': isHorizontal && !isLast,
|
||||
'border-l-0': isHorizontal && !isFirst,
|
||||
"rounded-l-none": isHorizontal && !isFirst,
|
||||
"rounded-r-none": isHorizontal && !isLast,
|
||||
"border-l-0": isHorizontal && !isFirst,
|
||||
|
||||
'rounded-t-none': isVertical && !isFirst,
|
||||
'rounded-b-none': isVertical && !isLast,
|
||||
'border-t-0': isVertical && !isFirst,
|
||||
"rounded-t-none": isVertical && !isFirst,
|
||||
"rounded-b-none": isVertical && !isLast,
|
||||
"border-t-0": isVertical && !isFirst,
|
||||
},
|
||||
child.props.className
|
||||
child.props.className,
|
||||
),
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
@@ -32,13 +32,13 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function Button({
|
||||
@@ -48,7 +48,7 @@ function Button({
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -56,7 +56,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants, type ButtonProps }
|
||||
export { Button, buttonVariants, type ButtonProps };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -89,4 +89,4 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
@@ -13,7 +13,7 @@ function Checkbox({
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -24,7 +24,7 @@ function Checkbox({
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@@ -27,16 +27,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
className={cn(
|
||||
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
@@ -46,13 +46,13 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
@@ -64,18 +64,18 @@ const DropdownMenuContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -83,12 +83,12 @@ const DropdownMenuItem = React.forwardRef<
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
@@ -98,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
@@ -122,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -133,13 +133,13 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
@@ -147,12 +147,12 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
@@ -163,8 +163,8 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
@@ -175,9 +175,9 @@ const DropdownMenuShortcut = ({
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
@@ -195,4 +195,4 @@ export {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
@@ -9,23 +9,23 @@ import {
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const Form = FormProvider
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
@@ -37,21 +37,21 @@ const FormField = <
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -60,19 +60,19 @@ const useFormField = () => {
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
@@ -82,14 +82,14 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
@@ -99,11 +99,12 @@ function FormLabel({
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
@@ -117,11 +118,11 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
@@ -130,15 +131,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -150,7 +151,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -162,4 +163,4 @@ export {
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
@@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
@@ -12,11 +12,11 @@ function Label({
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -6,35 +6,36 @@ import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PasswordInputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
export const PasswordInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
PasswordInputProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
ref={ref}
|
||||
type={showPassword ? "text" : "password"}
|
||||
className={cn("h-11 text-base pr-12", className)} // extra padding-right
|
||||
{...props}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
ref={ref}
|
||||
type={showPassword ? "text" : "password"}
|
||||
className={cn("h-11 text-base pr-12", className)} // extra padding-right
|
||||
{...props}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PasswordInput.displayName = "PasswordInput";
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
@@ -29,18 +29,18 @@ function PopoverContent({
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
@@ -13,7 +13,7 @@ function Progress({
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -23,7 +23,7 @@ function Progress({
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
import * as React from "react";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
@@ -13,17 +13,17 @@ function ResizablePanelGroup({
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
@@ -31,14 +31,14 @@ function ResizableHandle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed transition-colors duration-150",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -48,7 +48,7 @@ function ResizableHandle({
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
@@ -23,7 +23,7 @@ function ScrollArea({
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
@@ -41,7 +41,7 @@ function ScrollBar({
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -50,7 +50,7 @@ function ScrollBar({
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
export { ScrollArea, ScrollBar };
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
@@ -28,7 +28,7 @@ function SelectTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -36,7 +36,7 @@ function SelectTrigger({
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -45,7 +45,7 @@ function SelectTrigger({
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
@@ -62,7 +62,7 @@ function SelectContent({
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
@@ -72,7 +72,7 @@ function SelectContent({
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -80,7 +80,7 @@ function SelectContent({
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
@@ -93,7 +93,7 @@ function SelectLabel({
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
@@ -106,7 +106,7 @@ function SelectItem({
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -117,7 +117,7 @@ function SelectItem({
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
@@ -130,7 +130,7 @@ function SelectSeparator({
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
@@ -142,13 +142,13 @@ function SelectScrollUpButton({
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
@@ -160,13 +160,13 @@ function SelectScrollDownButton({
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -180,4 +180,4 @@ export {
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
@@ -18,11 +18,11 @@ function Separator({
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type StatusProps = ComponentProps<typeof Badge> & {
|
||||
status: 'online' | 'offline' | 'maintenance' | 'degraded';
|
||||
status: "online" | "offline" | "maintenance" | "degraded";
|
||||
};
|
||||
|
||||
export const Status = ({ className, status, ...props }: StatusProps) => (
|
||||
<Badge
|
||||
className={cn('flex items-center gap-2', 'group', status, className)}
|
||||
className={cn("flex items-center gap-2", "group", status, className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
/>
|
||||
@@ -24,20 +24,20 @@ export const StatusIndicator = ({
|
||||
<span className="relative flex h-2 w-2" {...props}>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
|
||||
'group-[.online]:bg-emerald-500',
|
||||
'group-[.offline]:bg-red-500',
|
||||
'group-[.maintenance]:bg-blue-500',
|
||||
'group-[.degraded]:bg-amber-500'
|
||||
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
|
||||
"group-[.online]:bg-emerald-500",
|
||||
"group-[.offline]:bg-red-500",
|
||||
"group-[.maintenance]:bg-blue-500",
|
||||
"group-[.degraded]:bg-amber-500",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-2 w-2 rounded-full',
|
||||
'group-[.online]:bg-emerald-500',
|
||||
'group-[.offline]:bg-red-500',
|
||||
'group-[.maintenance]:bg-blue-500',
|
||||
'group-[.degraded]:bg-amber-500'
|
||||
"relative inline-flex h-2 w-2 rounded-full",
|
||||
"group-[.online]:bg-emerald-500",
|
||||
"group-[.offline]:bg-red-500",
|
||||
"group-[.maintenance]:bg-blue-500",
|
||||
"group-[.degraded]:bg-amber-500",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
@@ -52,13 +52,21 @@ export const StatusLabel = ({
|
||||
}: StatusLabelProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className={cn('text-muted-foreground', className)} {...props}>
|
||||
<span className={cn("text-muted-foreground", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="hidden group-[.online]:block">{t('common.online')}</span>
|
||||
<span className="hidden group-[.offline]:block">{t('common.offline')}</span>
|
||||
<span className="hidden group-[.maintenance]:block">{t('common.maintenance')}</span>
|
||||
<span className="hidden group-[.degraded]:block">{t('common.degraded')}</span>
|
||||
<span className="hidden group-[.online]:block">
|
||||
{t("common.online")}
|
||||
</span>
|
||||
<span className="hidden group-[.offline]:block">
|
||||
{t("common.offline")}
|
||||
</span>
|
||||
<span className="hidden group-[.maintenance]:block">
|
||||
{t("common.maintenance")}
|
||||
</span>
|
||||
<span className="hidden group-[.degraded]:block">
|
||||
{t("common.degraded")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
@@ -35,11 +35,11 @@ function SheetOverlay({
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
@@ -48,7 +48,7 @@ function SheetContent({
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@@ -65,7 +65,7 @@ function SheetContent({
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -76,7 +76,7 @@ function SheetContent({
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -86,7 +86,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -96,7 +96,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
@@ -109,7 +109,7 @@ function SheetTitle({
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
@@ -122,7 +122,7 @@ function SheetDescription({
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -134,4 +134,4 @@ export {
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
@@ -60,36 +60,36 @@ function SidebarProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
@@ -98,18 +98,18 @@ function SidebarProvider({
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
@@ -121,8 +121,8 @@ function SidebarProvider({
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
@@ -138,7 +138,7 @@ function SidebarProvider({
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -146,7 +146,7 @@ function SidebarProvider({
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
@@ -157,11 +157,11 @@ function Sidebar({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
@@ -169,13 +169,13 @@ function Sidebar({
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Commented out mobile behavior to keep sidebar always visible
|
||||
@@ -222,7 +222,7 @@ function Sidebar({
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
@@ -236,7 +236,7 @@ function Sidebar({
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -249,7 +249,7 @@ function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
@@ -257,7 +257,7 @@ function SidebarTrigger({
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -267,19 +267,19 @@ function SidebarTrigger({
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -296,11 +296,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
@@ -310,11 +310,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
@@ -328,7 +328,7 @@ function SidebarInput({
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -339,7 +339,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -350,7 +350,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
@@ -364,7 +364,7 @@ function SidebarSeparator({
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -374,11 +374,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -389,7 +389,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
@@ -397,7 +397,7 @@ function SidebarGroupLabel({
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -406,11 +406,11 @@ function SidebarGroupLabel({
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
@@ -418,7 +418,7 @@ function SidebarGroupAction({
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -429,11 +429,11 @@ function SidebarGroupAction({
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
@@ -447,7 +447,7 @@ function SidebarGroupContent({
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
@@ -458,7 +458,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
@@ -469,7 +469,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
@@ -491,8 +491,8 @@ const sidebarMenuButtonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
@@ -503,12 +503,12 @@ function SidebarMenuButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
@@ -519,16 +519,16 @@ function SidebarMenuButton({
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -541,7 +541,7 @@ function SidebarMenuButton({
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
@@ -550,10 +550,10 @@ function SidebarMenuAction({
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -569,11 +569,11 @@ function SidebarMenuAction({
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
@@ -591,11 +591,11 @@ function SidebarMenuBadge({
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
@@ -603,12 +603,12 @@ function SidebarMenuSkeleton({
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -633,7 +633,7 @@ function SidebarMenuSkeleton({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
@@ -644,11 +644,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
@@ -662,7 +662,7 @@ function SidebarMenuSubItem({
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
@@ -672,11 +672,11 @@ function SidebarMenuSubButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -690,11 +690,11 @@ function SidebarMenuSubButton({
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -722,4 +722,4 @@ export {
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
export { Skeleton };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
@@ -17,7 +17,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
export { Toaster };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
@@ -12,18 +12,18 @@ function Switch({
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
export { Switch };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
@@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
@@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
@@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
@@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
@@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
@@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
@@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
@@ -99,7 +99,7 @@ function TableCaption({
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -111,4 +111,4 @@ export {
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
@@ -13,7 +13,7 @@ function Tabs({
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
@@ -25,11 +25,11 @@ function TabsList({
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
@@ -41,11 +41,11 @@ function TabsTrigger({
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
@@ -58,7 +58,7 @@ function TabsContent({
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
@@ -11,14 +11,14 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
@@ -15,7 +15,7 @@ function TooltipProvider({
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
@@ -25,13 +25,13 @@ function Tooltip({
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
@@ -47,14 +47,14 @@ function TooltipContent({
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
@@ -1,65 +1,68 @@
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ConfirmationOptions {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
title: string;
|
||||
description: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
export function useConfirmation() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [options, setOptions] = useState<ConfirmationOptions | null>(null);
|
||||
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [options, setOptions] = useState<ConfirmationOptions | null>(null);
|
||||
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
|
||||
|
||||
const confirm = (opts: ConfirmationOptions, callback: () => void) => {
|
||||
setOptions(opts);
|
||||
setOnConfirm(() => callback);
|
||||
setIsOpen(true);
|
||||
};
|
||||
const confirm = (opts: ConfirmationOptions, callback: () => void) => {
|
||||
setOptions(opts);
|
||||
setOnConfirm(() => callback);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
}
|
||||
setIsOpen(false);
|
||||
setOptions(null);
|
||||
setOnConfirm(null);
|
||||
};
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
}
|
||||
setIsOpen(false);
|
||||
setOptions(null);
|
||||
setOnConfirm(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
setOptions(null);
|
||||
setOnConfirm(null);
|
||||
};
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
setOptions(null);
|
||||
setOnConfirm(null);
|
||||
};
|
||||
|
||||
const confirmWithToast = (message: string, callback: () => void, variant: 'default' | 'destructive' = 'default') => {
|
||||
const actionText = variant === 'destructive' ? 'Delete' : 'Confirm';
|
||||
const cancelText = 'Cancel';
|
||||
const confirmWithToast = (
|
||||
message: string,
|
||||
callback: () => void,
|
||||
variant: "default" | "destructive" = "default",
|
||||
) => {
|
||||
const actionText = variant === "destructive" ? "Delete" : "Confirm";
|
||||
const cancelText = "Cancel";
|
||||
|
||||
toast(message, {
|
||||
action: {
|
||||
label: actionText,
|
||||
onClick: callback
|
||||
},
|
||||
cancel: {
|
||||
label: cancelText,
|
||||
onClick: () => {
|
||||
}
|
||||
},
|
||||
duration: 10000,
|
||||
className: variant === 'destructive' ? 'border-red-500' : ''
|
||||
});
|
||||
};
|
||||
toast(message, {
|
||||
action: {
|
||||
label: actionText,
|
||||
onClick: callback,
|
||||
},
|
||||
cancel: {
|
||||
label: cancelText,
|
||||
onClick: () => {},
|
||||
},
|
||||
duration: 10000,
|
||||
className: variant === "destructive" ? "border-red-500" : "",
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
options,
|
||||
confirm,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
confirmWithToast
|
||||
};
|
||||
return {
|
||||
isOpen,
|
||||
options,
|
||||
confirm,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
confirmWithToast,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile
|
||||
return !!isMobile;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import i18n from 'i18next';
|
||||
import {initReactI18next} from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import enTranslation from '../locales/en/translation.json';
|
||||
import zhTranslation from '../locales/zh/translation.json';
|
||||
import enTranslation from "../locales/en/translation.json";
|
||||
import zhTranslation from "../locales/zh/translation.json";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
supportedLngs: ['en', 'zh'],
|
||||
fallbackLng: 'en',
|
||||
debug: false,
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
supportedLngs: ["en", "zh"],
|
||||
fallbackLng: "en",
|
||||
debug: false,
|
||||
|
||||
detection: {
|
||||
order: ['localStorage', 'cookie'],
|
||||
caches: ['localStorage', 'cookie'],
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
lookupCookie: 'i18nextLng',
|
||||
checkWhitelist: true,
|
||||
},
|
||||
detection: {
|
||||
order: ["localStorage", "cookie"],
|
||||
caches: ["localStorage", "cookie"],
|
||||
lookupLocalStorage: "i18nextLng",
|
||||
lookupCookie: "i18nextLng",
|
||||
checkWhitelist: true,
|
||||
},
|
||||
|
||||
resources: {
|
||||
en: {
|
||||
translation: enTranslation
|
||||
},
|
||||
zh: {
|
||||
translation: zhTranslation
|
||||
}
|
||||
},
|
||||
resources: {
|
||||
en: {
|
||||
translation: enTranslation,
|
||||
},
|
||||
zh: {
|
||||
translation: zhTranslation,
|
||||
},
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
export default i18n;
|
||||
|
||||
313
src/index.css
313
src/index.css
@@ -4,200 +4,201 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #09090b;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #09090b;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--color-dark-bg: #18181b;
|
||||
--color-dark-bg-darker: #0e0e10;
|
||||
--color-dark-bg-darkest: #09090b;
|
||||
--color-dark-bg-input: #222225;
|
||||
--color-dark-bg-button: #23232a;
|
||||
--color-dark-bg-active: #1d1d1f;
|
||||
--color-dark-bg-header: #131316;
|
||||
--color-dark-border: #303032;
|
||||
--color-dark-border-active: #2d2d30;
|
||||
--color-dark-border-hover: #434345;
|
||||
--color-dark-hover: #2d2d30;
|
||||
--color-dark-active: #2a2a2c;
|
||||
--color-dark-pressed: #1a1a1c;
|
||||
--color-dark-hover-alt: #2a2a2d;
|
||||
--color-dark-border-light: #5a5a5d;
|
||||
--color-dark-bg-light: #141416;
|
||||
--color-dark-border-medium: #373739;
|
||||
--color-dark-bg-very-light: #101014;
|
||||
--color-dark-bg-panel: #1b1b1e;
|
||||
--color-dark-border-panel: #222224;
|
||||
--color-dark-bg-panel-hover: #232327;
|
||||
--color-dark-bg: #18181b;
|
||||
--color-dark-bg-darker: #0e0e10;
|
||||
--color-dark-bg-darkest: #09090b;
|
||||
--color-dark-bg-input: #222225;
|
||||
--color-dark-bg-button: #23232a;
|
||||
--color-dark-bg-active: #1d1d1f;
|
||||
--color-dark-bg-header: #131316;
|
||||
--color-dark-border: #303032;
|
||||
--color-dark-border-active: #2d2d30;
|
||||
--color-dark-border-hover: #434345;
|
||||
--color-dark-hover: #2d2d30;
|
||||
--color-dark-active: #2a2a2c;
|
||||
--color-dark-pressed: #1a1a1c;
|
||||
--color-dark-hover-alt: #2a2a2d;
|
||||
--color-dark-border-light: #5a5a5d;
|
||||
--color-dark-bg-light: #141416;
|
||||
--color-dark-border-medium: #373739;
|
||||
--color-dark-bg-very-light: #101014;
|
||||
--color-dark-bg-panel: #1b1b1e;
|
||||
--color-dark-border-panel: #222224;
|
||||
--color-dark-bg-panel-hover: #232327;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.thin-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #303032 transparent;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #303032 transparent;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #303032;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
background-color: #303032;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-track {
|
||||
background: #18181b;
|
||||
background: #18181b;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #434345;
|
||||
border-radius: 3px;
|
||||
background: #434345;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5d;
|
||||
background: #5a5a5d;
|
||||
}
|
||||
|
||||
.thin-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #434345 #18181b;
|
||||
}
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #434345 #18181b;
|
||||
}
|
||||
|
||||
@@ -1,330 +1,388 @@
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
||||
|
||||
export interface LogContext {
|
||||
operation?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
tunnelName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
duration?: number;
|
||||
method?: string;
|
||||
url?: string;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
responseTime?: number;
|
||||
retryCount?: number;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
operation?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
tunnelName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
duration?: number;
|
||||
method?: string;
|
||||
url?: string;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
responseTime?: number;
|
||||
retryCount?: number;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
|
||||
[key: string]: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class FrontendLogger {
|
||||
private serviceName: string;
|
||||
private serviceIcon: string;
|
||||
private serviceColor: string;
|
||||
private isDevelopment: boolean;
|
||||
private serviceName: string;
|
||||
private serviceIcon: string;
|
||||
private serviceColor: string;
|
||||
private isDevelopment: boolean;
|
||||
|
||||
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||
this.serviceName = serviceName;
|
||||
this.serviceIcon = serviceIcon;
|
||||
this.serviceColor = serviceColor;
|
||||
this.isDevelopment = process.env.NODE_ENV === 'development';
|
||||
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||
this.serviceName = serviceName;
|
||||
this.serviceIcon = serviceIcon;
|
||||
this.serviceColor = serviceColor;
|
||||
this.isDevelopment = process.env.NODE_ENV === "development";
|
||||
}
|
||||
|
||||
private getTimeStamp(): string {
|
||||
const now = new Date();
|
||||
return `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, "0")}]`;
|
||||
}
|
||||
|
||||
private formatMessage(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context?: LogContext,
|
||||
): string {
|
||||
const timestamp = this.getTimeStamp();
|
||||
const levelTag = this.getLevelTag(level);
|
||||
const serviceTag = this.getServiceTag();
|
||||
|
||||
let contextStr = "";
|
||||
if (context && this.isDevelopment) {
|
||||
const contextParts = [];
|
||||
if (context.operation) contextParts.push(context.operation);
|
||||
if (context.userId) contextParts.push(`user:${context.userId}`);
|
||||
if (context.hostId) contextParts.push(`host:${context.hostId}`);
|
||||
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
|
||||
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
|
||||
if (context.responseTime) contextParts.push(`${context.responseTime}ms`);
|
||||
if (context.status) contextParts.push(`status:${context.status}`);
|
||||
if (context.errorCode) contextParts.push(`code:${context.errorCode}`);
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
contextStr = ` (${contextParts.join(", ")})`;
|
||||
}
|
||||
}
|
||||
|
||||
private getTimeStamp(): string {
|
||||
const now = new Date();
|
||||
return `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}]`;
|
||||
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
private getLevelTag(level: LogLevel): string {
|
||||
const symbols = {
|
||||
debug: "🔍",
|
||||
info: "ℹ️",
|
||||
warn: "⚠️",
|
||||
error: "❌",
|
||||
success: "✅",
|
||||
};
|
||||
return `${symbols[level]} [${level.toUpperCase()}]`;
|
||||
}
|
||||
|
||||
private getServiceTag(): string {
|
||||
return `${this.serviceIcon} [${this.serviceName}]`;
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
if (level === "debug" && !this.isDevelopment) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private formatMessage(level: LogLevel, message: string, context?: LogContext): string {
|
||||
const timestamp = this.getTimeStamp();
|
||||
const levelTag = this.getLevelTag(level);
|
||||
const serviceTag = this.getServiceTag();
|
||||
private log(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context?: LogContext,
|
||||
error?: unknown,
|
||||
): void {
|
||||
if (!this.shouldLog(level)) return;
|
||||
|
||||
let contextStr = '';
|
||||
if (context && this.isDevelopment) {
|
||||
const contextParts = [];
|
||||
if (context.operation) contextParts.push(context.operation);
|
||||
if (context.userId) contextParts.push(`user:${context.userId}`);
|
||||
if (context.hostId) contextParts.push(`host:${context.hostId}`);
|
||||
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
|
||||
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
|
||||
if (context.responseTime) contextParts.push(`${context.responseTime}ms`);
|
||||
if (context.status) contextParts.push(`status:${context.status}`);
|
||||
if (context.errorCode) contextParts.push(`code:${context.errorCode}`);
|
||||
const formattedMessage = this.formatMessage(level, message, context);
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
contextStr = ` (${contextParts.join(', ')})`;
|
||||
}
|
||||
switch (level) {
|
||||
case "debug":
|
||||
console.debug(formattedMessage);
|
||||
break;
|
||||
case "info":
|
||||
console.log(formattedMessage);
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(formattedMessage);
|
||||
break;
|
||||
case "error":
|
||||
console.error(formattedMessage);
|
||||
if (error) {
|
||||
console.error("Error details:", error);
|
||||
}
|
||||
|
||||
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
||||
break;
|
||||
case "success":
|
||||
console.log(formattedMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private getLevelTag(level: LogLevel): string {
|
||||
const symbols = {
|
||||
debug: '🔍',
|
||||
info: 'ℹ️',
|
||||
warn: '⚠️',
|
||||
error: '❌',
|
||||
success: '✅'
|
||||
};
|
||||
return `${symbols[level]} [${level.toUpperCase()}]`;
|
||||
debug(message: string, context?: LogContext): void {
|
||||
this.log("debug", message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.log("info", message, context);
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
this.log("warn", message, context);
|
||||
}
|
||||
|
||||
error(message: string, error?: unknown, context?: LogContext): void {
|
||||
this.log("error", message, context, error);
|
||||
}
|
||||
|
||||
success(message: string, context?: LogContext): void {
|
||||
this.log("success", message, context);
|
||||
}
|
||||
|
||||
api(message: string, context?: LogContext): void {
|
||||
this.info(`API: ${message}`, { ...context, operation: "api" });
|
||||
}
|
||||
|
||||
request(message: string, context?: LogContext): void {
|
||||
this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
|
||||
}
|
||||
|
||||
response(message: string, context?: LogContext): void {
|
||||
this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
|
||||
}
|
||||
|
||||
auth(message: string, context?: LogContext): void {
|
||||
this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
|
||||
}
|
||||
|
||||
ssh(message: string, context?: LogContext): void {
|
||||
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
|
||||
}
|
||||
|
||||
tunnel(message: string, context?: LogContext): void {
|
||||
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
|
||||
}
|
||||
|
||||
file(message: string, context?: LogContext): void {
|
||||
this.info(`FILE: ${message}`, { ...context, operation: "file" });
|
||||
}
|
||||
|
||||
connection(message: string, context?: LogContext): void {
|
||||
this.info(`CONNECTION: ${message}`, {
|
||||
...context,
|
||||
operation: "connection",
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(message: string, context?: LogContext): void {
|
||||
this.info(`DISCONNECT: ${message}`, {
|
||||
...context,
|
||||
operation: "disconnect",
|
||||
});
|
||||
}
|
||||
|
||||
retry(message: string, context?: LogContext): void {
|
||||
this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
|
||||
}
|
||||
|
||||
performance(message: string, context?: LogContext): void {
|
||||
this.info(`PERFORMANCE: ${message}`, {
|
||||
...context,
|
||||
operation: "performance",
|
||||
});
|
||||
}
|
||||
|
||||
security(message: string, context?: LogContext): void {
|
||||
this.warn(`SECURITY: ${message}`, { ...context, operation: "security" });
|
||||
}
|
||||
|
||||
requestStart(method: string, url: string, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`);
|
||||
this.request(`→ Starting request to ${cleanUrl}`, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl,
|
||||
});
|
||||
}
|
||||
|
||||
requestSuccess(
|
||||
method: string,
|
||||
url: string,
|
||||
status: number,
|
||||
responseTime: number,
|
||||
context?: LogContext,
|
||||
): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
const statusIcon = this.getStatusIcon(status);
|
||||
const performanceIcon = this.getPerformanceIcon(responseTime);
|
||||
|
||||
this.response(
|
||||
`← ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
|
||||
{
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl,
|
||||
status,
|
||||
responseTime,
|
||||
},
|
||||
);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
requestError(
|
||||
method: string,
|
||||
url: string,
|
||||
status: number,
|
||||
errorMessage: string,
|
||||
responseTime?: number,
|
||||
context?: LogContext,
|
||||
): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
const statusIcon = this.getStatusIcon(status);
|
||||
|
||||
this.error(`← ${statusIcon} ${status} ${errorMessage}`, undefined, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl,
|
||||
status,
|
||||
errorMessage,
|
||||
responseTime,
|
||||
});
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
networkError(
|
||||
method: string,
|
||||
url: string,
|
||||
errorMessage: string,
|
||||
context?: LogContext,
|
||||
): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
this.error(`🌐 Network Error: ${errorMessage}`, undefined, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl,
|
||||
errorMessage,
|
||||
errorCode: "NETWORK_ERROR",
|
||||
});
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
authError(method: string, url: string, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
this.security(`🔐 Authentication Required`, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl,
|
||||
errorCode: "AUTH_REQUIRED",
|
||||
});
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
retryAttempt(
|
||||
method: string,
|
||||
url: string,
|
||||
attempt: number,
|
||||
maxAttempts: number,
|
||||
context?: LogContext,
|
||||
): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl,
|
||||
retryCount: attempt,
|
||||
});
|
||||
}
|
||||
|
||||
apiOperation(operation: string, details: string, context?: LogContext): void {
|
||||
this.info(`🔧 ${operation}: ${details}`, {
|
||||
...context,
|
||||
operation: "api_operation",
|
||||
});
|
||||
}
|
||||
|
||||
requestSummary(
|
||||
method: string,
|
||||
url: string,
|
||||
status: number,
|
||||
responseTime: number,
|
||||
context?: LogContext,
|
||||
): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
const statusIcon = this.getStatusIcon(status);
|
||||
const performanceIcon = this.getPerformanceIcon(responseTime);
|
||||
|
||||
console.log(
|
||||
`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
|
||||
"color: #666; font-style: italic; font-size: 0.9em;",
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
private getShortUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const path = urlObj.pathname;
|
||||
const query = urlObj.search;
|
||||
return `${urlObj.hostname}${path}${query}`;
|
||||
} catch {
|
||||
return url.length > 50 ? url.substring(0, 47) + "..." : url;
|
||||
}
|
||||
}
|
||||
|
||||
private getServiceTag(): string {
|
||||
return `${this.serviceIcon} [${this.serviceName}]`;
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
if (level === 'debug' && !this.isDevelopment) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, context?: LogContext, error?: unknown): void {
|
||||
if (!this.shouldLog(level)) return;
|
||||
|
||||
const formattedMessage = this.formatMessage(level, message, context);
|
||||
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
console.debug(formattedMessage);
|
||||
break;
|
||||
case 'info':
|
||||
console.log(formattedMessage);
|
||||
break;
|
||||
case 'warn':
|
||||
console.warn(formattedMessage);
|
||||
break;
|
||||
case 'error':
|
||||
console.error(formattedMessage);
|
||||
if (error) {
|
||||
console.error('Error details:', error);
|
||||
}
|
||||
break;
|
||||
case 'success':
|
||||
console.log(formattedMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
this.log('debug', message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.log('info', message, context);
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
this.log('warn', message, context);
|
||||
}
|
||||
|
||||
error(message: string, error?: unknown, context?: LogContext): void {
|
||||
this.log('error', message, context, error);
|
||||
}
|
||||
|
||||
success(message: string, context?: LogContext): void {
|
||||
this.log('success', message, context);
|
||||
}
|
||||
|
||||
api(message: string, context?: LogContext): void {
|
||||
this.info(`API: ${message}`, {...context, operation: 'api'});
|
||||
}
|
||||
|
||||
request(message: string, context?: LogContext): void {
|
||||
this.info(`REQUEST: ${message}`, {...context, operation: 'request'});
|
||||
}
|
||||
|
||||
response(message: string, context?: LogContext): void {
|
||||
this.info(`RESPONSE: ${message}`, {...context, operation: 'response'});
|
||||
}
|
||||
|
||||
auth(message: string, context?: LogContext): void {
|
||||
this.info(`AUTH: ${message}`, {...context, operation: 'auth'});
|
||||
}
|
||||
|
||||
ssh(message: string, context?: LogContext): void {
|
||||
this.info(`SSH: ${message}`, {...context, operation: 'ssh'});
|
||||
}
|
||||
|
||||
tunnel(message: string, context?: LogContext): void {
|
||||
this.info(`TUNNEL: ${message}`, {...context, operation: 'tunnel'});
|
||||
}
|
||||
|
||||
file(message: string, context?: LogContext): void {
|
||||
this.info(`FILE: ${message}`, {...context, operation: 'file'});
|
||||
}
|
||||
|
||||
connection(message: string, context?: LogContext): void {
|
||||
this.info(`CONNECTION: ${message}`, {...context, operation: 'connection'});
|
||||
}
|
||||
|
||||
disconnect(message: string, context?: LogContext): void {
|
||||
this.info(`DISCONNECT: ${message}`, {...context, operation: 'disconnect'});
|
||||
}
|
||||
|
||||
retry(message: string, context?: LogContext): void {
|
||||
this.warn(`RETRY: ${message}`, {...context, operation: 'retry'});
|
||||
}
|
||||
|
||||
performance(message: string, context?: LogContext): void {
|
||||
this.info(`PERFORMANCE: ${message}`, {...context, operation: 'performance'});
|
||||
}
|
||||
|
||||
security(message: string, context?: LogContext): void {
|
||||
this.warn(`SECURITY: ${message}`, {...context, operation: 'security'});
|
||||
}
|
||||
|
||||
requestStart(method: string, url: string, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`);
|
||||
this.request(`→ Starting request to ${cleanUrl}`, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl
|
||||
});
|
||||
}
|
||||
|
||||
requestSuccess(method: string, url: string, status: number, responseTime: number, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
const statusIcon = this.getStatusIcon(status);
|
||||
const performanceIcon = this.getPerformanceIcon(responseTime);
|
||||
|
||||
this.response(`← ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl,
|
||||
status,
|
||||
responseTime
|
||||
});
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
requestError(method: string, url: string, status: number, errorMessage: string, responseTime?: number, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
const statusIcon = this.getStatusIcon(status);
|
||||
|
||||
this.error(`← ${statusIcon} ${status} ${errorMessage}`, undefined, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl,
|
||||
status,
|
||||
errorMessage,
|
||||
responseTime
|
||||
});
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
networkError(method: string, url: string, errorMessage: string, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
this.error(`🌐 Network Error: ${errorMessage}`, undefined, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl,
|
||||
errorMessage,
|
||||
errorCode: 'NETWORK_ERROR'
|
||||
});
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
authError(method: string, url: string, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
this.security(`🔐 Authentication Required`, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl,
|
||||
errorCode: 'AUTH_REQUIRED'
|
||||
});
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
retryAttempt(method: string, url: string, attempt: number, maxAttempts: number, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, {
|
||||
...context,
|
||||
method: method.toUpperCase(),
|
||||
url: cleanUrl,
|
||||
retryCount: attempt
|
||||
});
|
||||
}
|
||||
|
||||
apiOperation(operation: string, details: string, context?: LogContext): void {
|
||||
this.info(`🔧 ${operation}: ${details}`, {...context, operation: 'api_operation'});
|
||||
}
|
||||
|
||||
requestSummary(method: string, url: string, status: number, responseTime: number, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
const statusIcon = this.getStatusIcon(status);
|
||||
const performanceIcon = this.getPerformanceIcon(responseTime);
|
||||
|
||||
console.log(`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
|
||||
'color: #666; font-style: italic; font-size: 0.9em;',
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
private getShortUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const path = urlObj.pathname;
|
||||
const query = urlObj.search;
|
||||
return `${urlObj.hostname}${path}${query}`;
|
||||
} catch {
|
||||
return url.length > 50 ? url.substring(0, 47) + '...' : url;
|
||||
}
|
||||
}
|
||||
|
||||
private getStatusIcon(status: number): string {
|
||||
if (status >= 200 && status < 300) return '✅';
|
||||
if (status >= 300 && status < 400) return '↩️';
|
||||
if (status >= 400 && status < 500) return '⚠️';
|
||||
if (status >= 500) return '❌';
|
||||
return '❓';
|
||||
}
|
||||
|
||||
private getPerformanceIcon(responseTime: number): string {
|
||||
if (responseTime < 100) return '⚡';
|
||||
if (responseTime < 500) return '🚀';
|
||||
if (responseTime < 1000) return '🏃';
|
||||
if (responseTime < 3000) return '🚶';
|
||||
return '🐌';
|
||||
}
|
||||
|
||||
private sanitizeUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (urlObj.searchParams.has('password') || urlObj.searchParams.has('token')) {
|
||||
urlObj.search = '';
|
||||
}
|
||||
return urlObj.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
private getStatusIcon(status: number): string {
|
||||
if (status >= 200 && status < 300) return "✅";
|
||||
if (status >= 300 && status < 400) return "↩️";
|
||||
if (status >= 400 && status < 500) return "⚠️";
|
||||
if (status >= 500) return "❌";
|
||||
return "❓";
|
||||
}
|
||||
|
||||
private getPerformanceIcon(responseTime: number): string {
|
||||
if (responseTime < 100) return "⚡";
|
||||
if (responseTime < 500) return "🚀";
|
||||
if (responseTime < 1000) return "🏃";
|
||||
if (responseTime < 3000) return "🚶";
|
||||
return "🐌";
|
||||
}
|
||||
|
||||
private sanitizeUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (
|
||||
urlObj.searchParams.has("password") ||
|
||||
urlObj.searchParams.has("token")
|
||||
) {
|
||||
urlObj.search = "";
|
||||
}
|
||||
return urlObj.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiLogger = new FrontendLogger('API', '🌐', '#3b82f6');
|
||||
export const authLogger = new FrontendLogger('AUTH', '🔐', '#dc2626');
|
||||
export const sshLogger = new FrontendLogger('SSH', '🖥️', '#1e3a8a');
|
||||
export const tunnelLogger = new FrontendLogger('TUNNEL', '📡', '#1e3a8a');
|
||||
export const fileLogger = new FrontendLogger('FILE', '📁', '#1e3a8a');
|
||||
export const statsLogger = new FrontendLogger('STATS', '📊', '#22c55e');
|
||||
export const systemLogger = new FrontendLogger('SYSTEM', '🚀', '#1e3a8a');
|
||||
export const apiLogger = new FrontendLogger("API", "🌐", "#3b82f6");
|
||||
export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
|
||||
export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
|
||||
export const tunnelLogger = new FrontendLogger("TUNNEL", "📡", "#1e3a8a");
|
||||
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
|
||||
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
|
||||
export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
|
||||
|
||||
export const logger = systemLogger;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {clsx, type ClassValue} from "clsx"
|
||||
import {twMerge} from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -1031,4 +1031,4 @@
|
||||
"selectHostToStart": "Select a host to start your terminal session",
|
||||
"limitedSupportMessage": "Mobile support is currently limited. A dedicated mobile app is coming soon to enhance your experience."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1018,4 +1018,4 @@
|
||||
"selectHostToStart": "选择一个主机以开始您的终端会话",
|
||||
"limitedSupportMessage": "移动端支持目前有限。我们即将推出专门的移动应用以提升您的体验。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
115
src/main.tsx
115
src/main.tsx
@@ -1,69 +1,72 @@
|
||||
import {StrictMode, useEffect, useState, useRef} from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import './index.css'
|
||||
import DesktopApp from './ui/Desktop/DesktopApp.tsx'
|
||||
import {MobileApp} from './ui/Mobile/MobileApp.tsx'
|
||||
import {ThemeProvider} from "@/components/theme-provider"
|
||||
import './i18n/i18n'
|
||||
import {isElectron} from './ui/main-axios.ts'
|
||||
import { StrictMode, useEffect, useState, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import DesktopApp from "./ui/Desktop/DesktopApp.tsx";
|
||||
import { MobileApp } from "./ui/Mobile/MobileApp.tsx";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./i18n/i18n";
|
||||
import { isElectron } from "./ui/main-axios.ts";
|
||||
|
||||
function useWindowWidth() {
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const lastSwitchTime = useRef(0);
|
||||
const isCurrentlyMobile = useRef(window.innerWidth < 768);
|
||||
const hasSwitchedOnce = useRef(false);
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const lastSwitchTime = useRef(0);
|
||||
const isCurrentlyMobile = useRef(window.innerWidth < 768);
|
||||
const hasSwitchedOnce = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const handleResize = () => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
const newWidth = window.innerWidth;
|
||||
const newIsMobile = newWidth < 768;
|
||||
const now = Date.now();
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const handleResize = () => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
const newWidth = window.innerWidth;
|
||||
const newIsMobile = newWidth < 768;
|
||||
const now = Date.now();
|
||||
|
||||
if (hasSwitchedOnce.current && (now - lastSwitchTime.current) < 10000) {
|
||||
setWidth(newWidth);
|
||||
return;
|
||||
}
|
||||
if (hasSwitchedOnce.current && now - lastSwitchTime.current < 10000) {
|
||||
setWidth(newWidth);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newIsMobile !== isCurrentlyMobile.current && (now - lastSwitchTime.current) > 5000) {
|
||||
lastSwitchTime.current = now;
|
||||
isCurrentlyMobile.current = newIsMobile;
|
||||
hasSwitchedOnce.current = true;
|
||||
setWidth(newWidth);
|
||||
setIsMobile(newIsMobile);
|
||||
} else {
|
||||
setWidth(newWidth);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
if (
|
||||
newIsMobile !== isCurrentlyMobile.current &&
|
||||
now - lastSwitchTime.current > 5000
|
||||
) {
|
||||
lastSwitchTime.current = now;
|
||||
isCurrentlyMobile.current = newIsMobile;
|
||||
hasSwitchedOnce.current = true;
|
||||
setWidth(newWidth);
|
||||
setIsMobile(newIsMobile);
|
||||
} else {
|
||||
setWidth(newWidth);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return width;
|
||||
return width;
|
||||
}
|
||||
|
||||
function RootApp() {
|
||||
const width = useWindowWidth();
|
||||
const isMobile = width < 768;
|
||||
if (isElectron()) {
|
||||
return <DesktopApp/>;
|
||||
}
|
||||
const width = useWindowWidth();
|
||||
const isMobile = width < 768;
|
||||
if (isElectron()) {
|
||||
return <DesktopApp />;
|
||||
}
|
||||
|
||||
return isMobile ? <MobileApp key="mobile"/> : <DesktopApp key="desktop"/>;
|
||||
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<RootApp/>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<RootApp />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -4,56 +4,56 @@
|
||||
// This file contains all shared interfaces and types used across the application
|
||||
// to avoid duplication and ensure consistency.
|
||||
|
||||
import type {Client} from 'ssh2';
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
// ============================================================================
|
||||
// SSH HOST TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: 'password' | 'key' | 'credential';
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: "password" | "key" | "credential";
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SSHHostData {
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder?: string;
|
||||
tags?: string[];
|
||||
pin?: boolean;
|
||||
authType: 'password' | 'key' | 'credential';
|
||||
password?: string;
|
||||
key?: File | null;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number | null;
|
||||
enableTerminal?: boolean;
|
||||
enableTunnel?: boolean;
|
||||
enableFileManager?: boolean;
|
||||
defaultPath?: string;
|
||||
tunnelConnections?: any[];
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder?: string;
|
||||
tags?: string[];
|
||||
pin?: boolean;
|
||||
authType: "password" | "key" | "credential";
|
||||
password?: string;
|
||||
key?: File | null;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number | null;
|
||||
enableTerminal?: boolean;
|
||||
enableTunnel?: boolean;
|
||||
enableFileManager?: boolean;
|
||||
defaultPath?: string;
|
||||
tunnelConnections?: any[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -61,34 +61,34 @@ export interface SSHHostData {
|
||||
// ============================================================================
|
||||
|
||||
export interface Credential {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
lastUsed?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: "password" | "key";
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
lastUsed?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CredentialData {
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: "password" | "key";
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -96,55 +96,55 @@ export interface CredentialData {
|
||||
// ============================================================================
|
||||
|
||||
export interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
endpointHost: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
endpointHost: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
}
|
||||
|
||||
export interface TunnelConfig {
|
||||
name: string;
|
||||
hostName: string;
|
||||
sourceIP: string;
|
||||
sourceSSHPort: number;
|
||||
sourceUsername: string;
|
||||
sourcePassword?: string;
|
||||
sourceAuthMethod: string;
|
||||
sourceSSHKey?: string;
|
||||
sourceKeyPassword?: string;
|
||||
sourceKeyType?: string;
|
||||
sourceCredentialId?: number;
|
||||
sourceUserId?: string;
|
||||
endpointIP: string;
|
||||
endpointSSHPort: number;
|
||||
endpointUsername: string;
|
||||
endpointPassword?: string;
|
||||
endpointAuthMethod: string;
|
||||
endpointSSHKey?: string;
|
||||
endpointKeyPassword?: string;
|
||||
endpointKeyType?: string;
|
||||
endpointCredentialId?: number;
|
||||
endpointUserId?: string;
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
isPinned: boolean;
|
||||
name: string;
|
||||
hostName: string;
|
||||
sourceIP: string;
|
||||
sourceSSHPort: number;
|
||||
sourceUsername: string;
|
||||
sourcePassword?: string;
|
||||
sourceAuthMethod: string;
|
||||
sourceSSHKey?: string;
|
||||
sourceKeyPassword?: string;
|
||||
sourceKeyType?: string;
|
||||
sourceCredentialId?: number;
|
||||
sourceUserId?: string;
|
||||
endpointIP: string;
|
||||
endpointSSHPort: number;
|
||||
endpointUsername: string;
|
||||
endpointPassword?: string;
|
||||
endpointAuthMethod: string;
|
||||
endpointSSHKey?: string;
|
||||
endpointKeyPassword?: string;
|
||||
endpointKeyType?: string;
|
||||
endpointCredentialId?: number;
|
||||
endpointUserId?: string;
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
isPinned: boolean;
|
||||
}
|
||||
|
||||
export interface TunnelStatus {
|
||||
connected: boolean;
|
||||
status: ConnectionState;
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
nextRetryIn?: number;
|
||||
reason?: string;
|
||||
errorType?: ErrorType;
|
||||
manualDisconnect?: boolean;
|
||||
retryExhausted?: boolean;
|
||||
connected: boolean;
|
||||
status: ConnectionState;
|
||||
retryCount?: number;
|
||||
maxRetries?: number;
|
||||
nextRetryIn?: number;
|
||||
reason?: string;
|
||||
errorType?: ErrorType;
|
||||
manualDisconnect?: boolean;
|
||||
retryExhausted?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -152,50 +152,50 @@ export interface TunnelStatus {
|
||||
// ============================================================================
|
||||
|
||||
export interface Tab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
isSSH?: boolean;
|
||||
sshSessionId?: string;
|
||||
filePath?: string;
|
||||
loading?: boolean;
|
||||
dirty?: boolean;
|
||||
id: string | number;
|
||||
title: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
isSSH?: boolean;
|
||||
sshSessionId?: string;
|
||||
filePath?: string;
|
||||
loading?: boolean;
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
export interface FileManagerFile {
|
||||
name: string;
|
||||
path: string;
|
||||
type?: 'file' | 'directory';
|
||||
isSSH?: boolean;
|
||||
sshSessionId?: string;
|
||||
name: string;
|
||||
path: string;
|
||||
type?: "file" | "directory";
|
||||
isSSH?: boolean;
|
||||
sshSessionId?: string;
|
||||
}
|
||||
|
||||
export interface FileManagerShortcut {
|
||||
name: string;
|
||||
path: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isPinned?: boolean;
|
||||
type: 'file' | 'directory';
|
||||
sshSessionId?: string;
|
||||
name: string;
|
||||
path: string;
|
||||
isPinned?: boolean;
|
||||
type: "file" | "directory";
|
||||
sshSessionId?: string;
|
||||
}
|
||||
|
||||
export interface ShortcutItem {
|
||||
name: string;
|
||||
path: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SSHConnection {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
isPinned?: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -203,11 +203,11 @@ export interface SSHConnection {
|
||||
// ============================================================================
|
||||
|
||||
export interface HostInfo {
|
||||
id: number;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
createdAt: string;
|
||||
id: number;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -215,14 +215,14 @@ export interface HostInfo {
|
||||
// ============================================================================
|
||||
|
||||
export interface TermixAlert {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt: string;
|
||||
priority?: 'low' | 'medium' | 'high' | 'critical';
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt: string;
|
||||
priority?: "low" | "medium" | "high" | "critical";
|
||||
type?: "info" | "warning" | "error" | "success";
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -230,11 +230,18 @@ export interface TermixAlert {
|
||||
// ============================================================================
|
||||
|
||||
export interface TabContextTab {
|
||||
id: number;
|
||||
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager' | 'user_profile';
|
||||
title: string;
|
||||
hostConfig?: any;
|
||||
terminalRef?: React.RefObject<any>;
|
||||
id: number;
|
||||
type:
|
||||
| "home"
|
||||
| "terminal"
|
||||
| "ssh_manager"
|
||||
| "server"
|
||||
| "admin"
|
||||
| "file_manager"
|
||||
| "user_profile";
|
||||
title: string;
|
||||
hostConfig?: any;
|
||||
terminalRef?: React.RefObject<any>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -242,38 +249,44 @@ export interface TabContextTab {
|
||||
// ============================================================================
|
||||
|
||||
export const CONNECTION_STATES = {
|
||||
DISCONNECTED: "disconnected",
|
||||
CONNECTING: "connecting",
|
||||
CONNECTED: "connected",
|
||||
VERIFYING: "verifying",
|
||||
FAILED: "failed",
|
||||
UNSTABLE: "unstable",
|
||||
RETRYING: "retrying",
|
||||
WAITING: "waiting",
|
||||
DISCONNECTING: "disconnecting"
|
||||
DISCONNECTED: "disconnected",
|
||||
CONNECTING: "connecting",
|
||||
CONNECTED: "connected",
|
||||
VERIFYING: "verifying",
|
||||
FAILED: "failed",
|
||||
UNSTABLE: "unstable",
|
||||
RETRYING: "retrying",
|
||||
WAITING: "waiting",
|
||||
DISCONNECTING: "disconnecting",
|
||||
} as const;
|
||||
|
||||
export type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES];
|
||||
export type ConnectionState =
|
||||
(typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES];
|
||||
|
||||
export type ErrorType = 'CONNECTION_FAILED' | 'AUTHENTICATION_FAILED' | 'TIMEOUT' | 'NETWORK_ERROR' | 'UNKNOWN';
|
||||
export type ErrorType =
|
||||
| "CONNECTION_FAILED"
|
||||
| "AUTHENTICATION_FAILED"
|
||||
| "TIMEOUT"
|
||||
| "NETWORK_ERROR"
|
||||
| "UNKNOWN";
|
||||
|
||||
// ============================================================================
|
||||
// AUTHENTICATION TYPES
|
||||
// ============================================================================
|
||||
|
||||
export type AuthType = 'password' | 'key' | 'credential';
|
||||
export type AuthType = "password" | "key" | "credential";
|
||||
|
||||
export type KeyType = 'rsa' | 'ecdsa' | 'ed25519';
|
||||
export type KeyType = "rsa" | "ecdsa" | "ed25519";
|
||||
|
||||
// ============================================================================
|
||||
// API RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
status?: number;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -281,107 +294,122 @@ export interface ApiResponse<T = any> {
|
||||
// ============================================================================
|
||||
|
||||
export interface CredentialsManagerProps {
|
||||
onEditCredential?: (credential: Credential) => void;
|
||||
onEditCredential?: (credential: Credential) => void;
|
||||
}
|
||||
|
||||
export interface CredentialEditorProps {
|
||||
editingCredential?: Credential | null;
|
||||
onFormSubmit?: () => void;
|
||||
editingCredential?: Credential | null;
|
||||
onFormSubmit?: () => void;
|
||||
}
|
||||
|
||||
export interface CredentialViewerProps {
|
||||
credential: Credential;
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
credential: Credential;
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export interface CredentialSelectorProps {
|
||||
value?: number | null;
|
||||
onValueChange: (value: number | null) => void;
|
||||
value?: number | null;
|
||||
onValueChange: (value: number | null) => void;
|
||||
}
|
||||
|
||||
export interface HostManagerProps {
|
||||
onSelectView?: (view: string) => void;
|
||||
isTopbarOpen?: boolean;
|
||||
onSelectView?: (view: string) => void;
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface SSHManagerHostEditorProps {
|
||||
editingHost?: SSHHost | null;
|
||||
onFormSubmit?: () => void;
|
||||
editingHost?: SSHHost | null;
|
||||
onFormSubmit?: () => void;
|
||||
}
|
||||
|
||||
export interface SSHManagerHostViewerProps {
|
||||
onEditHost?: (host: SSHHost) => void;
|
||||
onEditHost?: (host: SSHHost) => void;
|
||||
}
|
||||
|
||||
export interface HostProps {
|
||||
host: SSHHost;
|
||||
onHostConnect?: () => void;
|
||||
host: SSHHost;
|
||||
onHostConnect?: () => void;
|
||||
}
|
||||
|
||||
export interface SSHTunnelProps {
|
||||
filterHostKey?: string;
|
||||
filterHostKey?: string;
|
||||
}
|
||||
|
||||
export interface SSHTunnelViewerProps {
|
||||
hosts?: SSHHost[];
|
||||
tunnelStatuses?: Record<string, TunnelStatus>;
|
||||
tunnelActions?: Record<string, (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>>;
|
||||
onTunnelAction?: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||
hosts?: SSHHost[];
|
||||
tunnelStatuses?: Record<string, TunnelStatus>;
|
||||
tunnelActions?: Record<
|
||||
string,
|
||||
(
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => Promise<any>
|
||||
>;
|
||||
onTunnelAction?: (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
export interface FileManagerProps {
|
||||
onSelectView?: (view: string) => void;
|
||||
embedded?: boolean;
|
||||
initialHost?: SSHHost | null;
|
||||
onSelectView?: (view: string) => void;
|
||||
embedded?: boolean;
|
||||
initialHost?: SSHHost | null;
|
||||
}
|
||||
|
||||
export interface FileManagerLeftSidebarProps {
|
||||
onSelectView?: (view: string) => void;
|
||||
onOpenFile: (file: any) => void;
|
||||
tabs: Tab[];
|
||||
host: SSHHost;
|
||||
onOperationComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onSuccess?: (message: string) => void;
|
||||
onPathChange?: (path: string) => void;
|
||||
onDeleteItem?: (item: any) => void;
|
||||
onSelectView?: (view: string) => void;
|
||||
onOpenFile: (file: any) => void;
|
||||
tabs: Tab[];
|
||||
host: SSHHost;
|
||||
onOperationComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onSuccess?: (message: string) => void;
|
||||
onPathChange?: (path: string) => void;
|
||||
onDeleteItem?: (item: any) => void;
|
||||
}
|
||||
|
||||
export interface FileManagerOperationsProps {
|
||||
currentPath: string;
|
||||
sshSessionId: string | null;
|
||||
onOperationComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onSuccess?: (message: string) => void;
|
||||
currentPath: string;
|
||||
sshSessionId: string | null;
|
||||
onOperationComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onSuccess?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface AlertCardProps {
|
||||
alert: TermixAlert;
|
||||
onDismiss: (alertId: string) => void;
|
||||
alert: TermixAlert;
|
||||
onDismiss: (alertId: string) => void;
|
||||
}
|
||||
|
||||
export interface AlertManagerProps {
|
||||
alerts: TermixAlert[];
|
||||
onDismiss: (alertId: string) => void;
|
||||
loggedIn: boolean;
|
||||
alerts: TermixAlert[];
|
||||
onDismiss: (alertId: string) => void;
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
export interface SSHTunnelObjectProps {
|
||||
host: SSHHost;
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||
compact?: boolean;
|
||||
bare?: boolean;
|
||||
host: SSHHost;
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => Promise<any>;
|
||||
compact?: boolean;
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
export interface FolderStats {
|
||||
totalHosts: number;
|
||||
hostsByType: Array<{
|
||||
type: string;
|
||||
count: number;
|
||||
}>;
|
||||
totalHosts: number;
|
||||
hostsByType: Array<{
|
||||
type: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -389,16 +417,16 @@ export interface FolderStats {
|
||||
// ============================================================================
|
||||
|
||||
export interface HostConfig {
|
||||
host: SSHHost;
|
||||
tunnels: TunnelConfig[];
|
||||
host: SSHHost;
|
||||
tunnels: TunnelConfig[];
|
||||
}
|
||||
|
||||
export interface VerificationData {
|
||||
conn: Client;
|
||||
timeout: NodeJS.Timeout;
|
||||
startTime: number;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
conn: Client;
|
||||
timeout: NodeJS.Timeout;
|
||||
startTime: number;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -409,4 +437,4 @@ export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
export type RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
|
||||
export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,202 +1,226 @@
|
||||
import React, {useState, useEffect, useRef} from 'react';
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {FormControl, FormItem, FormLabel} from "@/components/ui/form.tsx";
|
||||
import {getCredentials} from '@/ui/main-axios.ts';
|
||||
import {useTranslation} from "react-i18next";
|
||||
import type {Credential} from '../../../../types';
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
|
||||
import { getCredentials } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Credential } from "../../../../types";
|
||||
|
||||
interface CredentialSelectorProps {
|
||||
value?: number | null;
|
||||
onValueChange: (credentialId: number | null) => void;
|
||||
onCredentialSelect?: (credential: Credential | null) => void;
|
||||
value?: number | null;
|
||||
onValueChange: (credentialId: number | null) => void;
|
||||
onCredentialSelect?: (credential: Credential | null) => void;
|
||||
}
|
||||
|
||||
export function CredentialSelector({value, onValueChange, onCredentialSelect}: CredentialSelectorProps) {
|
||||
const {t} = useTranslation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
export function CredentialSelector({
|
||||
value,
|
||||
onValueChange,
|
||||
onCredentialSelect,
|
||||
}: CredentialSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCredentials = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getCredentials();
|
||||
const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []);
|
||||
setCredentials(credentialsArray);
|
||||
} catch (error) {
|
||||
const {toast} = await import('sonner');
|
||||
toast.error(t('credentials.failedToFetchCredentials'));
|
||||
setCredentials([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCredentials();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const selectedCredential = credentials.find(c => c.id === value);
|
||||
|
||||
const filteredCredentials = credentials.filter(credential => {
|
||||
if (!searchQuery) return true;
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return (
|
||||
credential.name.toLowerCase().includes(searchLower) ||
|
||||
credential.username.toLowerCase().includes(searchLower) ||
|
||||
(credential.folder && credential.folder.toLowerCase().includes(searchLower))
|
||||
);
|
||||
});
|
||||
|
||||
const handleCredentialSelect = (credential: Credential) => {
|
||||
onValueChange(credential.id);
|
||||
if (onCredentialSelect) {
|
||||
onCredentialSelect(credential);
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
setSearchQuery('');
|
||||
useEffect(() => {
|
||||
const fetchCredentials = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getCredentials();
|
||||
const credentialsArray = Array.isArray(data)
|
||||
? data
|
||||
: data.credentials || data.data || [];
|
||||
setCredentials(credentialsArray);
|
||||
} catch (error) {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error(t("credentials.failedToFetchCredentials"));
|
||||
setCredentials([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onValueChange(null);
|
||||
if (onCredentialSelect) {
|
||||
onCredentialSelect(null);
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
fetchCredentials();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const selectedCredential = credentials.find((c) => c.id === value);
|
||||
|
||||
const filteredCredentials = credentials.filter((credential) => {
|
||||
if (!searchQuery) return true;
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t('hosts.selectCredential')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
>
|
||||
{loading ? (
|
||||
t('common.loading')
|
||||
) : value === "existing_credential" ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<span className="font-medium">{t('hosts.existingCredential')}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedCredential ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<span className="font-medium">{selectedCredential.name}</span>
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
({selectedCredential.username} • {selectedCredential.authType})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
t('hosts.selectCredentialPlaceholder')
|
||||
)}
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-80 overflow-hidden backdrop-blur-sm"
|
||||
>
|
||||
<div className="p-2 border-b border-border">
|
||||
<Input
|
||||
placeholder={t('credentials.searchCredentials')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : filteredCredentials.length === 0 ? (
|
||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||
{searchQuery ? t('credentials.noCredentialsMatchFilters') : t('credentials.noCredentialsYet')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2.5">
|
||||
{value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t('common.clear')}
|
||||
</Button>
|
||||
)}
|
||||
{filteredCredentials.map((credential) => (
|
||||
<Button
|
||||
key={credential.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
|
||||
credential.id === value ? 'bg-muted' : ''
|
||||
}`}
|
||||
onClick={() => handleCredentialSelect(credential)}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{credential.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{credential.username} • {credential.authType}
|
||||
{credential.description && ` • ${credential.description}`}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
credential.name.toLowerCase().includes(searchLower) ||
|
||||
credential.username.toLowerCase().includes(searchLower) ||
|
||||
(credential.folder &&
|
||||
credential.folder.toLowerCase().includes(searchLower))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const handleCredentialSelect = (credential: Credential) => {
|
||||
onValueChange(credential.id);
|
||||
if (onCredentialSelect) {
|
||||
onCredentialSelect(credential);
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onValueChange(null);
|
||||
if (onCredentialSelect) {
|
||||
onCredentialSelect(null);
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.selectCredential")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
>
|
||||
{loading ? (
|
||||
t("common.loading")
|
||||
) : value === "existing_credential" ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("hosts.existingCredential")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedCredential ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<span className="font-medium">{selectedCredential.name}</span>
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
({selectedCredential.username} •{" "}
|
||||
{selectedCredential.authType})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
t("hosts.selectCredentialPlaceholder")
|
||||
)}
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-80 overflow-hidden backdrop-blur-sm"
|
||||
>
|
||||
<div className="p-2 border-b border-border">
|
||||
<Input
|
||||
placeholder={t("credentials.searchCredentials")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : filteredCredentials.length === 0 ? (
|
||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||
{searchQuery
|
||||
? t("credentials.noCredentialsMatchFilters")
|
||||
: t("credentials.noCredentialsYet")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2.5">
|
||||
{value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t("common.clear")}
|
||||
</Button>
|
||||
)}
|
||||
{filteredCredentials.map((credential) => (
|
||||
<Button
|
||||
key={credential.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
|
||||
credential.id === value ? "bg-muted" : ""
|
||||
}`}
|
||||
onClick={() => handleCredentialSelect(credential)}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{credential.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{credential.username} • {credential.authType}
|
||||
{credential.description &&
|
||||
` • ${credential.description}`}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,465 +1,533 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||
import {Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Key,
|
||||
User,
|
||||
Calendar,
|
||||
Hash,
|
||||
Folder,
|
||||
Edit3,
|
||||
Copy,
|
||||
Shield,
|
||||
Clock,
|
||||
Server,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import {getCredentialDetails, getCredentialHosts} from '@/ui/main-axios';
|
||||
import {toast} from 'sonner';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type {Credential, HostInfo, CredentialViewerProps} from '../../../types/index.js';
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
Key,
|
||||
User,
|
||||
Calendar,
|
||||
Hash,
|
||||
Folder,
|
||||
Edit3,
|
||||
Copy,
|
||||
Shield,
|
||||
Clock,
|
||||
Server,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
Credential,
|
||||
HostInfo,
|
||||
CredentialViewerProps,
|
||||
} from "../../../types/index.js";
|
||||
|
||||
const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose, onEdit}) => {
|
||||
const {t} = useTranslation();
|
||||
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(null);
|
||||
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>({});
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'security' | 'usage'>('overview');
|
||||
const CredentialViewer: React.FC<CredentialViewerProps> = ({
|
||||
credential,
|
||||
onClose,
|
||||
onEdit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(
|
||||
null,
|
||||
);
|
||||
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "security" | "usage">(
|
||||
"overview",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentialDetails();
|
||||
fetchHostsUsing();
|
||||
}, [credential.id]);
|
||||
useEffect(() => {
|
||||
fetchCredentialDetails();
|
||||
fetchHostsUsing();
|
||||
}, [credential.id]);
|
||||
|
||||
const fetchCredentialDetails = async () => {
|
||||
try {
|
||||
const response = await getCredentialDetails(credential.id);
|
||||
setCredentialDetails(response);
|
||||
} catch (error) {
|
||||
toast.error(t('credentials.failedToFetchCredentialDetails'));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHostsUsing = async () => {
|
||||
try {
|
||||
const response = await getCredentialHosts(credential.id);
|
||||
setHostsUsing(response);
|
||||
} catch (error) {
|
||||
toast.error(t('credentials.failedToFetchHostsUsing'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSensitiveVisibility = (field: string) => {
|
||||
setShowSensitive(prev => ({
|
||||
...prev,
|
||||
[field]: !prev[field]
|
||||
}));
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, fieldName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(t('copiedToClipboard', {field: fieldName}));
|
||||
} catch (error) {
|
||||
toast.error(t('credentials.failedToCopy'));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const getAuthIcon = (authType: string) => {
|
||||
return authType === 'password' ? (
|
||||
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
||||
) : (
|
||||
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400"/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSensitiveField = (
|
||||
value: string | undefined,
|
||||
fieldName: string,
|
||||
label: string,
|
||||
isMultiline = false
|
||||
) => {
|
||||
if (!value) return null;
|
||||
|
||||
const isVisible = showSensitive[fieldName];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleSensitiveVisibility(fieldName)}
|
||||
>
|
||||
{isVisible ? <EyeOff className="h-4 w-4"/> : <Eye className="h-4 w-4"/>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(value, label)}
|
||||
>
|
||||
<Copy className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}>
|
||||
{isVisible ? (
|
||||
<pre
|
||||
className={`text-sm ${isMultiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'} font-mono`}>
|
||||
{value}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{'•'.repeat(isMultiline ? 50 : 20)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading || !credentialDetails) {
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[600px] max-w-[50vw]">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
const fetchCredentialDetails = async () => {
|
||||
try {
|
||||
const response = await getCredentialDetails(credential.id);
|
||||
setCredentialDetails(response);
|
||||
} catch (error) {
|
||||
toast.error(t("credentials.failedToFetchCredentialDetails"));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHostsUsing = async () => {
|
||||
try {
|
||||
const response = await getCredentialHosts(credential.id);
|
||||
setHostsUsing(response);
|
||||
} catch (error) {
|
||||
toast.error(t("credentials.failedToFetchHostsUsing"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSensitiveVisibility = (field: string) => {
|
||||
setShowSensitive((prev) => ({
|
||||
...prev,
|
||||
[field]: !prev[field],
|
||||
}));
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, fieldName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(t("copiedToClipboard", { field: fieldName }));
|
||||
} catch (error) {
|
||||
toast.error(t("credentials.failedToCopy"));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const getAuthIcon = (authType: string) => {
|
||||
return authType === "password" ? (
|
||||
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
) : (
|
||||
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
|
||||
);
|
||||
};
|
||||
|
||||
const renderSensitiveField = (
|
||||
value: string | undefined,
|
||||
fieldName: string,
|
||||
label: string,
|
||||
isMultiline = false,
|
||||
) => {
|
||||
if (!value) return null;
|
||||
|
||||
const isVisible = showSensitive[fieldName];
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
|
||||
<SheetHeader className="space-y-6 pb-8">
|
||||
<SheetTitle className="flex items-center space-x-4">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
{getAuthIcon(credentialDetails.authType)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xl font-semibold">{credentialDetails.name}</div>
|
||||
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
|
||||
{credentialDetails.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline"
|
||||
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
|
||||
{credentialDetails.authType}
|
||||
</Badge>
|
||||
{credentialDetails.keyType && (
|
||||
<Badge variant="secondary"
|
||||
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
|
||||
{credentialDetails.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleSensitiveVisibility(fieldName)}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(value, label)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? "" : "min-h-[2.5rem]"}`}
|
||||
>
|
||||
{isVisible ? (
|
||||
<pre
|
||||
className={`text-sm ${isMultiline ? "whitespace-pre-wrap" : "whitespace-nowrap"} font-mono`}
|
||||
>
|
||||
{value}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{"•".repeat(isMultiline ? 50 : 20)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="space-y-10">
|
||||
{/* Tab Navigation */}
|
||||
<div
|
||||
className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
||||
<Button
|
||||
variant={activeTab === 'overview' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2"/>
|
||||
{t('credentials.overview')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'security' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('security')}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2"/>
|
||||
{t('credentials.security')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'usage' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('usage')}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2"/>
|
||||
{t('credentials.usage')}
|
||||
</Button>
|
||||
if (loading || !credentialDetails) {
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[600px] max-w-[50vw]">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
|
||||
<SheetHeader className="space-y-6 pb-8">
|
||||
<SheetTitle className="flex items-center space-x-4">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
{getAuthIcon(credentialDetails.authType)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xl font-semibold">
|
||||
{credentialDetails.name}
|
||||
</div>
|
||||
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
|
||||
{credentialDetails.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"
|
||||
>
|
||||
{credentialDetails.authType}
|
||||
</Badge>
|
||||
{credentialDetails.keyType && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
{credentialDetails.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-10">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
||||
<Button
|
||||
variant={activeTab === "overview" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab("overview")}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{t("credentials.overview")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === "security" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab("security")}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
{t("credentials.security")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === "usage" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab("usage")}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
{t("credentials.usage")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<Card className="border-zinc-200 dark:border-zinc-700">
|
||||
<CardHeader className="pb-8">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
{t("credentials.basicInformation")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<div className="flex items-center space-x-5">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("common.username")}
|
||||
</div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{credentialDetails.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<Card className="border-zinc-200 dark:border-zinc-700">
|
||||
<CardHeader className="pb-8">
|
||||
<CardTitle
|
||||
className="text-lg font-semibold">{t('credentials.basicInformation')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<div className="flex items-center space-x-5">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.username')}</div>
|
||||
<div
|
||||
className="font-medium text-zinc-800 dark:text-zinc-200">{credentialDetails.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.folder && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.folder')}</div>
|
||||
<div className="font-medium">{credentialDetails.folder}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialDetails.tags.length > 0 && (
|
||||
<div className="flex items-start space-x-4">
|
||||
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1"/>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">{t('hosts.tags')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{credentialDetails.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator/>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.created')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastModified')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.updatedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('credentials.usageStatistics')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
|
||||
{credentialDetails.usageCount}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{t('credentials.timesUsed')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.lastUsed && (
|
||||
<div
|
||||
className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastUsed')}</div>
|
||||
<div
|
||||
className="font-medium">{formatDate(credentialDetails.lastUsed)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div
|
||||
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.connectedHosts')}</div>
|
||||
<div className="font-medium">{hostsUsing.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{credentialDetails.folder && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("common.folder")}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-medium">
|
||||
{credentialDetails.folder}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
||||
<span>{t('credentials.securityDetails')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('credentials.securityDetailsDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div
|
||||
className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400"/>
|
||||
<div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{t('credentials.credentialSecured')}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{t('credentials.credentialSecuredDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{credentialDetails.tags.length > 0 && (
|
||||
<div className="flex items-start space-x-4">
|
||||
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">
|
||||
{t("hosts.tags")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{credentialDetails.tags.map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialDetails.authType === 'password' && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">{t('credentials.passwordAuthentication')}</h3>
|
||||
{renderSensitiveField(credentialDetails.password, 'password', t('common.password'))}
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
|
||||
{credentialDetails.authType === 'key' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-semibold mb-2">{t('credentials.keyAuthentication')}</h3>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("credentials.created")}
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{formatDate(credentialDetails.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div
|
||||
className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
{t('credentials.keyType')}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{credentialDetails.keyType?.toUpperCase() || t('unknown').toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("credentials.lastModified")}
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{formatDate(credentialDetails.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{renderSensitiveField(credentialDetails.key, 'key', t('credentials.privateKey'), true)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{t("credentials.usageStatistics")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
|
||||
{credentialDetails.usageCount}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{t("credentials.timesUsed")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.keyPassword && renderSensitiveField(
|
||||
credentialDetails.keyPassword,
|
||||
'keyPassword',
|
||||
t('credentials.keyPassphrase')
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{credentialDetails.lastUsed && (
|
||||
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("credentials.lastUsed")}
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{formatDate(credentialDetails.lastUsed)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5"/>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
|
||||
{t('credentials.securityReminder')}
|
||||
</div>
|
||||
<div className="text-zinc-700 dark:text-zinc-300">
|
||||
{t('credentials.securityReminderText')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("credentials.connectedHosts")}
|
||||
</div>
|
||||
<div className="font-medium">{hostsUsing.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'usage' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
|
||||
<span>{t('credentials.hostsUsingCredential')}</span>
|
||||
<Badge variant="secondary">{hostsUsing.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hostsUsing.length === 0 ? (
|
||||
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
|
||||
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600"/>
|
||||
<p>{t('credentials.noHostsUsingCredential')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-3">
|
||||
{hostsUsing.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
|
||||
<Server
|
||||
className="h-4 w-4 text-zinc-600 dark:text-zinc-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{host.name || `${host.ip}:${host.port}`}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{host.ip}:{host.port}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-right text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{formatDate(host.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{activeTab === "security" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<span>{t("credentials.securityDetails")}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("credentials.securityDetailsDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{t("credentials.credentialSecured")}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{t("credentials.credentialSecuredDescription")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Edit3 className="h-4 w-4 mr-2"/>
|
||||
{t('credentials.editCredential')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
{credentialDetails.authType === "password" && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">
|
||||
{t("credentials.passwordAuthentication")}
|
||||
</h3>
|
||||
{renderSensitiveField(
|
||||
credentialDetails.password,
|
||||
"password",
|
||||
t("common.password"),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialDetails.authType === "key" && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-semibold mb-2">
|
||||
{t("credentials.keyAuthentication")}
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
{t("credentials.keyType")}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{credentialDetails.keyType?.toUpperCase() ||
|
||||
t("unknown").toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderSensitiveField(
|
||||
credentialDetails.key,
|
||||
"key",
|
||||
t("credentials.privateKey"),
|
||||
true,
|
||||
)}
|
||||
|
||||
{credentialDetails.keyPassword &&
|
||||
renderSensitiveField(
|
||||
credentialDetails.keyPassword,
|
||||
"keyPassword",
|
||||
t("credentials.keyPassphrase"),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
|
||||
{t("credentials.securityReminder")}
|
||||
</div>
|
||||
<div className="text-zinc-700 dark:text-zinc-300">
|
||||
{t("credentials.securityReminderText")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === "usage" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<span>{t("credentials.hostsUsingCredential")}</span>
|
||||
<Badge variant="secondary">{hostsUsing.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hostsUsing.length === 0 ? (
|
||||
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
|
||||
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
|
||||
<p>{t("credentials.noHostsUsingCredential")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-3">
|
||||
{hostsUsing.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
|
||||
<Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{host.name || `${host.ip}:${host.port}`}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{host.ip}:{host.port}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{formatDate(host.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Edit3 className="h-4 w-4 mr-2" />
|
||||
{t("credentials.editCredential")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialViewer;
|
||||
export default CredentialViewer;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,26 @@
|
||||
import React from "react";
|
||||
import {FileManagerTabList} from "./FileManagerTabList.tsx";
|
||||
import { FileManagerTabList } from "./FileManagerTabList.tsx";
|
||||
|
||||
interface FileManagerTopNavbarProps {
|
||||
tabs: { id: string | number, title: string }[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
onHomeClick: () => void;
|
||||
tabs: { id: string | number; title: string }[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
onHomeClick: () => void;
|
||||
}
|
||||
|
||||
export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement {
|
||||
const {tabs, activeTab, setActiveTab, closeTab, onHomeClick} = props;
|
||||
export function FIleManagerTopNavbar(
|
||||
props: FileManagerTopNavbarProps,
|
||||
): React.ReactElement {
|
||||
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
|
||||
|
||||
return (
|
||||
<FileManagerTabList
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
closeTab={closeTab}
|
||||
onHomeClick={onHomeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FileManagerTabList
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
closeTab={closeTab}
|
||||
onHomeClick={onHomeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,335 +1,338 @@
|
||||
import React, {useEffect} from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import {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';
|
||||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||
import { hyperLink } from "@uiw/codemirror-extensions-hyper-link";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
interface FileManagerCodeEditorProps {
|
||||
content: string;
|
||||
fileName: string;
|
||||
onContentChange: (value: string) => void;
|
||||
content: string;
|
||||
fileName: string;
|
||||
onContentChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function FileManagerFileEditor({content, fileName, onContentChange}: FileManagerCodeEditorProps) {
|
||||
function getLanguageName(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return 'text';
|
||||
}
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex === -1) {
|
||||
return 'text';
|
||||
}
|
||||
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case 'ng':
|
||||
return 'angular';
|
||||
case 'apl':
|
||||
return 'apl';
|
||||
case 'asc':
|
||||
return 'asciiArmor';
|
||||
case 'ast':
|
||||
return 'asterisk';
|
||||
case 'bf':
|
||||
return 'brainfuck';
|
||||
case 'c':
|
||||
return 'c';
|
||||
case 'ceylon':
|
||||
return 'ceylon';
|
||||
case 'clj':
|
||||
return 'clojure';
|
||||
case 'cmake':
|
||||
return 'cmake';
|
||||
case 'cob':
|
||||
case 'cbl':
|
||||
return 'cobol';
|
||||
case 'coffee':
|
||||
return 'coffeescript';
|
||||
case 'lisp':
|
||||
return 'commonLisp';
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx':
|
||||
return 'cpp';
|
||||
case 'cr':
|
||||
return 'crystal';
|
||||
case 'cs':
|
||||
return 'csharp';
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'cypher':
|
||||
return 'cypher';
|
||||
case 'd':
|
||||
return 'd';
|
||||
case 'dart':
|
||||
return 'dart';
|
||||
case 'diff':
|
||||
case 'patch':
|
||||
return 'diff';
|
||||
case 'dockerfile':
|
||||
return 'dockerfile';
|
||||
case 'dtd':
|
||||
return 'dtd';
|
||||
case 'dylan':
|
||||
return 'dylan';
|
||||
case 'ebnf':
|
||||
return 'ebnf';
|
||||
case 'ecl':
|
||||
return 'ecl';
|
||||
case 'eiffel':
|
||||
return 'eiffel';
|
||||
case 'elm':
|
||||
return 'elm';
|
||||
case 'erl':
|
||||
return 'erlang';
|
||||
case 'factor':
|
||||
return 'factor';
|
||||
case 'fcl':
|
||||
return 'fcl';
|
||||
case 'fs':
|
||||
return 'forth';
|
||||
case 'f90':
|
||||
case 'for':
|
||||
return 'fortran';
|
||||
case 's':
|
||||
return 'gas';
|
||||
case 'feature':
|
||||
return 'gherkin';
|
||||
case 'go':
|
||||
return 'go';
|
||||
case 'groovy':
|
||||
return 'groovy';
|
||||
case 'hs':
|
||||
return 'haskell';
|
||||
case 'hx':
|
||||
return 'haxe';
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return 'html';
|
||||
case 'http':
|
||||
return 'http';
|
||||
case 'idl':
|
||||
return 'idl';
|
||||
case 'java':
|
||||
return 'java';
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return 'javascript';
|
||||
case 'jinja2':
|
||||
case 'j2':
|
||||
return 'jinja2';
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'jsx':
|
||||
return 'jsx';
|
||||
case 'jl':
|
||||
return 'julia';
|
||||
case 'kt':
|
||||
case 'kts':
|
||||
return 'kotlin';
|
||||
case 'less':
|
||||
return 'less';
|
||||
case 'lezer':
|
||||
return 'lezer';
|
||||
case 'liquid':
|
||||
return 'liquid';
|
||||
case 'litcoffee':
|
||||
return 'livescript';
|
||||
case 'lua':
|
||||
return 'lua';
|
||||
case 'md':
|
||||
return 'markdown';
|
||||
case 'nb':
|
||||
case 'mat':
|
||||
return 'mathematica';
|
||||
case 'mbox':
|
||||
return 'mbox';
|
||||
case 'mmd':
|
||||
return 'mermaid';
|
||||
case 'mrc':
|
||||
return 'mirc';
|
||||
case 'moo':
|
||||
return 'modelica';
|
||||
case 'mscgen':
|
||||
return 'mscgen';
|
||||
case 'm':
|
||||
return 'mumps';
|
||||
case 'sql':
|
||||
return 'mysql';
|
||||
case 'nc':
|
||||
return 'nesC';
|
||||
case 'nginx':
|
||||
return 'nginx';
|
||||
case 'nix':
|
||||
return 'nix';
|
||||
case 'nsi':
|
||||
return 'nsis';
|
||||
case 'nt':
|
||||
return 'ntriples';
|
||||
case 'mm':
|
||||
return 'objectiveCpp';
|
||||
case 'octave':
|
||||
return 'octave';
|
||||
case 'oz':
|
||||
return 'oz';
|
||||
case 'pas':
|
||||
return 'pascal';
|
||||
case 'pl':
|
||||
case 'pm':
|
||||
return 'perl';
|
||||
case 'pgsql':
|
||||
return 'pgsql';
|
||||
case 'php':
|
||||
return 'php';
|
||||
case 'pig':
|
||||
return 'pig';
|
||||
case 'ps1':
|
||||
return 'powershell';
|
||||
case 'properties':
|
||||
return 'properties';
|
||||
case 'proto':
|
||||
return 'protobuf';
|
||||
case 'pp':
|
||||
return 'puppet';
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'q':
|
||||
return 'q';
|
||||
case 'r':
|
||||
return 'r';
|
||||
case 'rb':
|
||||
return 'ruby';
|
||||
case 'rs':
|
||||
return 'rust';
|
||||
case 'sas':
|
||||
return 'sas';
|
||||
case 'sass':
|
||||
case 'scss':
|
||||
return 'sass';
|
||||
case 'scala':
|
||||
return 'scala';
|
||||
case 'scm':
|
||||
return 'scheme';
|
||||
case 'shader':
|
||||
return 'shader';
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
return 'shell';
|
||||
case 'siv':
|
||||
return 'sieve';
|
||||
case 'st':
|
||||
return 'smalltalk';
|
||||
case 'sol':
|
||||
return 'solidity';
|
||||
case 'solr':
|
||||
return 'solr';
|
||||
case 'rq':
|
||||
return 'sparql';
|
||||
case 'xlsx':
|
||||
case 'ods':
|
||||
case 'csv':
|
||||
return 'spreadsheet';
|
||||
case 'nut':
|
||||
return 'squirrel';
|
||||
case 'tex':
|
||||
return 'stex';
|
||||
case 'styl':
|
||||
return 'stylus';
|
||||
case 'svelte':
|
||||
return 'svelte';
|
||||
case 'swift':
|
||||
return 'swift';
|
||||
case 'tcl':
|
||||
return 'tcl';
|
||||
case 'textile':
|
||||
return 'textile';
|
||||
case 'tiddlywiki':
|
||||
return 'tiddlyWiki';
|
||||
case 'tiki':
|
||||
return 'tiki';
|
||||
case 'toml':
|
||||
return 'toml';
|
||||
case 'troff':
|
||||
return 'troff';
|
||||
case 'tsx':
|
||||
return 'tsx';
|
||||
case 'ttcn':
|
||||
return 'ttcn';
|
||||
case 'ttl':
|
||||
case 'turtle':
|
||||
return 'turtle';
|
||||
case 'ts':
|
||||
return 'typescript';
|
||||
case 'vb':
|
||||
return 'vb';
|
||||
case 'vbs':
|
||||
return 'vbscript';
|
||||
case 'vm':
|
||||
return 'velocity';
|
||||
case 'v':
|
||||
return 'verilog';
|
||||
case 'vhd':
|
||||
case 'vhdl':
|
||||
return 'vhdl';
|
||||
case 'vue':
|
||||
return 'vue';
|
||||
case 'wat':
|
||||
return 'wast';
|
||||
case 'webidl':
|
||||
return 'webIDL';
|
||||
case 'xq':
|
||||
case 'xquery':
|
||||
return 'xQuery';
|
||||
case 'xml':
|
||||
return 'xml';
|
||||
case 'yacas':
|
||||
return 'yacas';
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return 'yaml';
|
||||
case 'z80':
|
||||
return 'z80';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
export function FileManagerFileEditor({
|
||||
content,
|
||||
fileName,
|
||||
onContentChange,
|
||||
}: FileManagerCodeEditorProps) {
|
||||
function getLanguageName(filename: string): string {
|
||||
if (!filename || typeof filename !== "string") {
|
||||
return "text";
|
||||
}
|
||||
const lastDotIndex = filename.lastIndexOf(".");
|
||||
if (lastDotIndex === -1) {
|
||||
return "text";
|
||||
}
|
||||
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflowX = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflowX = '';
|
||||
};
|
||||
}, []);
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative overflow-hidden flex flex-col">
|
||||
<div
|
||||
className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper"
|
||||
>
|
||||
<CodeMirror
|
||||
value={content}
|
||||
extensions={[
|
||||
loadLanguage(getLanguageName(fileName || 'untitled.txt') as any) || [],
|
||||
hyperLink,
|
||||
oneDark,
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: 'var(--color-dark-bg-darkest) !important',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--color-dark-bg) !important',
|
||||
},
|
||||
})
|
||||
]}
|
||||
onChange={(value: any) => onContentChange(value)}
|
||||
theme={undefined}
|
||||
height="100%"
|
||||
basicSetup={{lineNumbers: true}}
|
||||
className="min-h-full min-w-full flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
document.body.style.overflowX = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflowX = "";
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative overflow-hidden flex flex-col">
|
||||
<div className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper">
|
||||
<CodeMirror
|
||||
value={content}
|
||||
extensions={[
|
||||
loadLanguage(getLanguageName(fileName || "untitled.txt") as any) ||
|
||||
[],
|
||||
hyperLink,
|
||||
oneDark,
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
backgroundColor: "var(--color-dark-bg-darkest) !important",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--color-dark-bg) !important",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
onChange={(value: any) => onContentChange(value)}
|
||||
theme={undefined}
|
||||
height="100%"
|
||||
basicSetup={{ lineNumbers: true }}
|
||||
className="min-h-full min-w-full flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,201 +1,234 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
|
||||
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type {FileItem, ShortcutItem} from '../../../types/index';
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Trash2, Folder, File, Plus, Pin } from "lucide-react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem, ShortcutItem } from "../../../types/index";
|
||||
|
||||
interface FileManagerHomeViewProps {
|
||||
recent: FileItem[];
|
||||
pinned: FileItem[];
|
||||
shortcuts: ShortcutItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onRemoveRecent: (file: FileItem) => void;
|
||||
onPinFile: (file: FileItem) => void;
|
||||
onUnpinFile: (file: FileItem) => void;
|
||||
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
||||
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
||||
onAddShortcut: (path: string) => void;
|
||||
recent: FileItem[];
|
||||
pinned: FileItem[];
|
||||
shortcuts: ShortcutItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onRemoveRecent: (file: FileItem) => void;
|
||||
onPinFile: (file: FileItem) => void;
|
||||
onUnpinFile: (file: FileItem) => void;
|
||||
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
||||
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
||||
onAddShortcut: (path: string) => void;
|
||||
}
|
||||
|
||||
export function FileManagerHomeView({
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: FileManagerHomeViewProps) {
|
||||
const {t} = useTranslation();
|
||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||
const [newShortcut, setNewShortcut] = useState('');
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut,
|
||||
}: FileManagerHomeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<"recent" | "pinned" | "shortcuts">("recent");
|
||||
const [newShortcut, setNewShortcut] = useState("");
|
||||
|
||||
const renderFileCard = (
|
||||
file: FileItem,
|
||||
onRemove: () => void,
|
||||
onPin?: () => void,
|
||||
isPinned = false,
|
||||
) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenFile(file)}
|
||||
>
|
||||
{file.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onPin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={onPin}
|
||||
>
|
||||
<Pin
|
||||
className={`w-3 h-3 ${isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
|
||||
<div key={file.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenFile(file)}
|
||||
>
|
||||
{file.type === 'directory' ?
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>
|
||||
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
||||
<div
|
||||
key={shortcut.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenShortcut(shortcut)}
|
||||
>
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{shortcut.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={() => onRemoveShortcut(shortcut)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as "recent" | "pinned" | "shortcuts")}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
||||
<TabsTrigger
|
||||
value="recent"
|
||||
className="data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
{t("fileManager.recent")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="pinned"
|
||||
className="data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
{t("fileManager.pinned")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="shortcuts"
|
||||
className="data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
{t("fileManager.folderShortcuts")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("fileManager.noRecentFiles")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
recent.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
() => onRemoveRecent(file),
|
||||
() => (file.isPinned ? onUnpinFile(file) : onPinFile(file)),
|
||||
file.isPinned,
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pinned" className="mt-0">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{pinned.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("fileManager.noPinnedFiles")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
pinned.map((file) =>
|
||||
renderFileCard(file, undefined, () => onUnpinFile(file), true),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shortcuts" className="mt-0">
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-dark-bg border-2 border-dark-border rounded-lg">
|
||||
<Input
|
||||
placeholder={t("fileManager.enterFolderPath")}
|
||||
value={newShortcut}
|
||||
onChange={(e) => setNewShortcut(e.target.value)}
|
||||
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut("");
|
||||
}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onPin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={onPin}
|
||||
>
|
||||
<Pin
|
||||
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
||||
<div key={shortcut.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenShortcut(shortcut)}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 bg-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover rounded-md"
|
||||
onClick={() => {
|
||||
if (newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{shortcut.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={() => onRemoveShortcut(shortcut)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
|
||||
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
||||
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
||||
<TabsTrigger value="recent"
|
||||
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.recent')}</TabsTrigger>
|
||||
<TabsTrigger value="pinned"
|
||||
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.pinned')}</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts"
|
||||
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.folderShortcuts')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">{t('fileManager.noRecentFiles')}</span>
|
||||
</div>
|
||||
) : recent.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
() => onRemoveRecent(file),
|
||||
() => file.isPinned ? onUnpinFile(file) : onPinFile(file),
|
||||
file.isPinned
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pinned" className="mt-0">
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{pinned.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">{t('fileManager.noPinnedFiles')}</span>
|
||||
</div>
|
||||
) : pinned.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
undefined,
|
||||
() => onUnpinFile(file),
|
||||
true
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shortcuts" className="mt-0">
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-dark-bg border-2 border-dark-border rounded-lg">
|
||||
<Input
|
||||
placeholder={t('fileManager.enterFolderPath')}
|
||||
value={newShortcut}
|
||||
onChange={e => setNewShortcut(e.target.value)}
|
||||
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 bg-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover rounded-md"
|
||||
onClick={() => {
|
||||
if (newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1"/>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{shortcuts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">{t('fileManager.noShortcuts')}</span>
|
||||
</div>
|
||||
) : shortcuts.map((shortcut) =>
|
||||
renderShortcutCard(shortcut)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{shortcuts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("fileManager.noShortcuts")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
shortcuts.map((shortcut) => renderShortcutCard(shortcut))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +1,128 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Card} from '@/components/ui/card.tsx';
|
||||
import {Folder, File, Trash2, Pin} from 'lucide-react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card } from "@/components/ui/card.tsx";
|
||||
import { Folder, File, Trash2, Pin } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SSHConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
isPinned?: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'link';
|
||||
path: string;
|
||||
isStarred?: boolean;
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
isStarred?: boolean;
|
||||
}
|
||||
|
||||
interface FileManagerLeftSidebarVileViewerProps {
|
||||
sshConnections: SSHConnection[];
|
||||
onAddSSH: () => void;
|
||||
onConnectSSH: (conn: SSHConnection) => void;
|
||||
onEditSSH: (conn: SSHConnection) => void;
|
||||
onDeleteSSH: (conn: SSHConnection) => void;
|
||||
onPinSSH: (conn: SSHConnection) => void;
|
||||
currentPath: string;
|
||||
files: FileItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onOpenFolder: (folder: FileItem) => void;
|
||||
onStarFile: (file: FileItem) => void;
|
||||
onDeleteFile: (file: FileItem) => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
isSSHMode: boolean;
|
||||
onSwitchToLocal: () => void;
|
||||
onSwitchToSSH: (conn: SSHConnection) => void;
|
||||
currentSSH?: SSHConnection;
|
||||
sshConnections: SSHConnection[];
|
||||
onAddSSH: () => void;
|
||||
onConnectSSH: (conn: SSHConnection) => void;
|
||||
onEditSSH: (conn: SSHConnection) => void;
|
||||
onDeleteSSH: (conn: SSHConnection) => void;
|
||||
onPinSSH: (conn: SSHConnection) => void;
|
||||
currentPath: string;
|
||||
files: FileItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onOpenFolder: (folder: FileItem) => void;
|
||||
onStarFile: (file: FileItem) => void;
|
||||
onDeleteFile: (file: FileItem) => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
isSSHMode: boolean;
|
||||
onSwitchToLocal: () => void;
|
||||
onSwitchToSSH: (conn: SSHConnection) => void;
|
||||
currentSSH?: SSHConnection;
|
||||
}
|
||||
|
||||
export function FileManagerLeftSidebarFileViewer({
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
}: FileManagerLeftSidebarVileViewerProps) {
|
||||
const {t} = useTranslation();
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
}: FileManagerLeftSidebarVileViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? t('common.sshPath') : t('common.localPath')}</span>
|
||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">{t('common.loading')}</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500">{error}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{files.map((item) => (
|
||||
<Card key={item.path}
|
||||
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded">
|
||||
<div className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
|
||||
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground"/>}
|
||||
<span className="text-sm text-white truncate">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
onClick={() => onStarFile(item)}>
|
||||
<Pin
|
||||
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
onClick={() => onDeleteFile(item)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{files.length === 0 &&
|
||||
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{isSSHMode ? t("common.sshPath") : t("common.localPath")}
|
||||
</span>
|
||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500">{error}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{files.map((item) => (
|
||||
<Card
|
||||
key={item.path}
|
||||
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
onClick={() =>
|
||||
item.type === "directory"
|
||||
? onOpenFolder(item)
|
||||
: onOpenFile(item)
|
||||
}
|
||||
>
|
||||
{item.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm text-white truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onStarFile(item)}
|
||||
>
|
||||
<Pin
|
||||
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onDeleteFile(item)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{files.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No files or folders found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,62 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {X, Home} from 'lucide-react';
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { X, Home } from "lucide-react";
|
||||
|
||||
interface FileManagerTab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
id: string | number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface FileManagerTabList {
|
||||
tabs: FileManagerTab[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
onHomeClick: () => void;
|
||||
tabs: FileManagerTab[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
onHomeClick: () => void;
|
||||
}
|
||||
|
||||
export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) {
|
||||
return (
|
||||
<div className="inline-flex items-center h-full gap-2">
|
||||
export function FileManagerTabList({
|
||||
tabs,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
closeTab,
|
||||
onHomeClick,
|
||||
}: FileManagerTabList) {
|
||||
return (
|
||||
<div className="inline-flex items-center h-full gap-2">
|
||||
<Button
|
||||
onClick={onHomeClick}
|
||||
variant="outline"
|
||||
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === "home" ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
</Button>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="inline-flex rounded-md shadow-sm"
|
||||
role="group"
|
||||
>
|
||||
<Button
|
||||
onClick={onHomeClick}
|
||||
variant="outline"
|
||||
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === 'home' ? '!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
variant="outline"
|
||||
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
|
||||
>
|
||||
<Home className="w-4 h-4"/>
|
||||
{tab.title}
|
||||
</Button>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
|
||||
<Button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
variant="outline"
|
||||
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => closeTab(tab.id)}
|
||||
variant="outline"
|
||||
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
|
||||
>
|
||||
<X className="!w-4 !h-4" strokeWidth={2}/>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Button
|
||||
onClick={() => closeTab(tab.id)}
|
||||
variant="outline"
|
||||
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
|
||||
>
|
||||
<X className="!w-4 !h-4" strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,114 +1,142 @@
|
||||
import React, {useState} from "react";
|
||||
import {HostManagerViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {HostManagerEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx";
|
||||
import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
|
||||
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import type {SSHHost, HostManagerProps} from '../../../types/index';
|
||||
import React, { useState } from "react";
|
||||
import { HostManagerViewer } from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { HostManagerEditor } from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx";
|
||||
import { CredentialsManager } from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
|
||||
import { CredentialEditor } from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SSHHost, HostManagerProps } from "../../../types/index";
|
||||
|
||||
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
export function HostManager({
|
||||
onSelectView,
|
||||
isTopbarOpen,
|
||||
}: HostManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
|
||||
const [editingCredential, setEditingCredential] = useState<any | null>(null);
|
||||
const {state: sidebarState} = useSidebar();
|
||||
const [editingCredential, setEditingCredential] = useState<any | null>(null);
|
||||
const { state: sidebarState } = useSidebar();
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
setActiveTab("add_host");
|
||||
};
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
setActiveTab("add_host");
|
||||
};
|
||||
|
||||
const handleFormSubmit = (updatedHost?: SSHHost) => {
|
||||
setEditingHost(null);
|
||||
setActiveTab("host_viewer");
|
||||
};
|
||||
const handleFormSubmit = (updatedHost?: SSHHost) => {
|
||||
setEditingHost(null);
|
||||
setActiveTab("host_viewer");
|
||||
};
|
||||
|
||||
const handleEditCredential = (credential: any) => {
|
||||
setEditingCredential(credential);
|
||||
setActiveTab("add_credential");
|
||||
};
|
||||
const handleEditCredential = (credential: any) => {
|
||||
setEditingCredential(credential);
|
||||
setActiveTab("add_credential");
|
||||
};
|
||||
|
||||
const handleCredentialFormSubmit = () => {
|
||||
setEditingCredential(null);
|
||||
setActiveTab("credentials");
|
||||
};
|
||||
const handleCredentialFormSubmit = () => {
|
||||
setEditingCredential(null);
|
||||
setActiveTab("credentials");
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
if (value !== "add_host") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
if (value !== "add_credential") {
|
||||
setEditingCredential(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
if (value !== "add_host") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
if (value !== "add_credential") {
|
||||
setEditingCredential(null);
|
||||
}
|
||||
};
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
|
||||
}}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col h-full min-h-0">
|
||||
<TabsList className="bg-dark-bg border-2 border-dark-border mt-1.5">
|
||||
<TabsTrigger value="host_viewer">{t('hosts.hostViewer')}</TabsTrigger>
|
||||
<TabsTrigger value="add_host">
|
||||
{editingHost ? t('hosts.editHost') : t('hosts.addHost')}
|
||||
</TabsTrigger>
|
||||
<div className="h-6 w-px bg-dark-border mx-1"></div>
|
||||
<TabsTrigger value="credentials">{t('credentials.credentialsViewer')}</TabsTrigger>
|
||||
<TabsTrigger value="add_credential">
|
||||
{editingCredential ? t('credentials.editCredential') : t('credentials.addCredential')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<HostManagerViewer onEditHost={handleEditHost}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<HostManagerEditor
|
||||
editingHost={editingHost}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="credentials" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<div className="flex flex-col h-full min-h-0 overflow-auto">
|
||||
<CredentialsManager onEditCredential={handleEditCredential}/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="add_credential" className="flex-1 flex flex-col h-full min-h-0">
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1"/>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<CredentialEditor
|
||||
editingCredential={editingCredential}
|
||||
onFormSubmit={handleCredentialFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<TabsList className="bg-dark-bg border-2 border-dark-border mt-1.5">
|
||||
<TabsTrigger value="host_viewer">
|
||||
{t("hosts.hostViewer")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="add_host">
|
||||
{editingHost ? t("hosts.editHost") : t("hosts.addHost")}
|
||||
</TabsTrigger>
|
||||
<div className="h-6 w-px bg-dark-border mx-1"></div>
|
||||
<TabsTrigger value="credentials">
|
||||
{t("credentials.credentialsViewer")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="add_credential">
|
||||
{editingCredential
|
||||
? t("credentials.editCredential")
|
||||
: t("credentials.addCredential")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="host_viewer"
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||
<HostManagerViewer onEditHost={handleEditHost} />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="add_host"
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<HostManagerEditor
|
||||
editingHost={editingHost}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="credentials"
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||
<div className="flex flex-col h-full min-h-0 overflow-auto">
|
||||
<CredentialsManager onEditCredential={handleEditCredential} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="add_credential"
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<CredentialEditor
|
||||
editingCredential={editingCredential}
|
||||
onFormSubmit={handleCredentialFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,418 +1,478 @@
|
||||
import React from "react";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Progress} from "@/components/ui/progress.tsx"
|
||||
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
||||
import {Tunnel} from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
||||
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Progress } from "@/components/ui/progress.tsx";
|
||||
import { Cpu, HardDrive, MemoryStick } from "lucide-react";
|
||||
import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
type ServerMetrics,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: any;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
hostConfig?: any;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function Server({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false
|
||||
}: ServerProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const {state: sidebarState} = useSidebar();
|
||||
const {addTab, tabs} = useTabs() as any;
|
||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: ServerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { addTab, tabs } = useTabs() as any;
|
||||
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
||||
"offline",
|
||||
);
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
React.useEffect(() => {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const {getSSHHosts} = await import('@/ui/main-axios.ts');
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('serverStats.failedToFetchHostConfig'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const {getSSHHosts} = await import('@/ui/main-axios.ts');
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find(h => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('serverStats.failedToFetchHostConfig'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged);
|
||||
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(currentHostConfig?.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
if (error?.response?.status === 503) {
|
||||
setServerStatus('offline');
|
||||
} else if (error?.response?.status === 504) {
|
||||
setServerStatus('offline');
|
||||
} else if (error?.response?.status === 404) {
|
||||
setServerStatus('offline');
|
||||
} else {
|
||||
setServerStatus('offline');
|
||||
}
|
||||
toast.error(t('serverStats.failedToFetchStatus'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
try {
|
||||
setIsLoadingMetrics(true);
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled) {
|
||||
setMetrics(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setMetrics(null);
|
||||
toast.error(t('serverStats.failedToFetchMetrics'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingMetrics(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (currentHostConfig?.id && isVisible) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
intervalId = window.setInterval(() => {
|
||||
if (isVisible) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}
|
||||
}, 30000);
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentHostConfig?.id, isVisible]);
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
||||
if (!currentHostConfig) return false;
|
||||
return tabs.some((tab: any) =>
|
||||
tab.type === 'file_manager' &&
|
||||
tab.hostConfig?.id === currentHostConfig.id
|
||||
);
|
||||
}, [tabs, currentHostConfig]);
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(currentHostConfig?.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
if (error?.response?.status === 503) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 504) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 404) {
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
}
|
||||
toast.error(t("serverStats.failedToFetchStatus"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
const fetchMetrics = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
try {
|
||||
setIsLoadingMetrics(true);
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled) {
|
||||
setMetrics(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setMetrics(null);
|
||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingMetrics(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{/* Top Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||
<StatusIndicator/>
|
||||
</Status>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isRefreshing}
|
||||
onClick={async () => {
|
||||
if (currentHostConfig?.id) {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const res = await getServerStatusById(currentHostConfig.id);
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
setMetrics(data);
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 503) {
|
||||
setServerStatus('offline');
|
||||
} else if (error?.response?.status === 504) {
|
||||
setServerStatus('offline');
|
||||
} else if (error?.response?.status === 404) {
|
||||
setServerStatus('offline');
|
||||
} else {
|
||||
setServerStatus('offline');
|
||||
}
|
||||
setMetrics(null);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={t('serverStats.refreshStatusAndMetrics')}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
{t('serverStats.refreshing')}
|
||||
</div>
|
||||
) : (
|
||||
t('serverStats.refreshStatus')
|
||||
)}
|
||||
</Button>
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
disabled={isFileManagerAlreadyOpen}
|
||||
title={isFileManagerAlreadyOpen ? t('serverStats.fileManagerAlreadyOpen') : t('serverStats.openFileManager')}
|
||||
onClick={() => {
|
||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: 'file_manager',
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('nav.fileManager')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full"/>
|
||||
if (currentHostConfig?.id && isVisible) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
intervalId = window.setInterval(() => {
|
||||
if (isVisible) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
||||
{isLoadingMetrics && !metrics ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">{t('serverStats.loadingMetrics')}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : !metrics && serverStatus === 'offline' ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">{t('serverStats.serverOffline')}</p>
|
||||
<p className="text-sm text-gray-500">{t('serverStats.cannotFetchMetrics')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
||||
{/* CPU Stats */}
|
||||
<div
|
||||
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400"/>
|
||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.cpuUsage')}</h3>
|
||||
</div>
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentHostConfig?.id, isVisible]);
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.cpu?.percent;
|
||||
const cores = metrics?.cpu?.cores;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const coresText = (typeof cores === 'number') ? t('serverStats.cpuCores', {count: cores}) : t('serverStats.naCpus');
|
||||
return `${pctText} ${t('serverStats.of')} ${coresText}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{metrics?.cpu?.load ?
|
||||
`Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}` :
|
||||
'Load: N/A'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Stats */}
|
||||
<div
|
||||
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400"/>
|
||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.memoryUsage')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.memory?.percent;
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = (typeof used === 'number') ? `${used.toFixed(1)} GiB` : 'N/A';
|
||||
const totalText = (typeof total === 'number') ? `${total.toFixed(1)} GiB` : 'N/A';
|
||||
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const free = (typeof used === 'number' && typeof total === 'number') ? (total - used).toFixed(1) : 'N/A';
|
||||
return `Free: ${free} GiB`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Stats */}
|
||||
<div
|
||||
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400"/>
|
||||
<h3 className="font-semibold text-lg text-white">{t('serverStats.rootStorageSpace')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.disk?.percent;
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = used ?? 'N/A';
|
||||
const totalText = total ?? 'N/A';
|
||||
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
return used && total ? `Available: ${total}` : 'Available: N/A';
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SSH Tunnels */}
|
||||
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
|
||||
<div
|
||||
className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
{t('serverStats.feedbackMessage')}{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
||||
if (!currentHostConfig) return false;
|
||||
return tabs.some(
|
||||
(tab: any) =>
|
||||
tab.type === "file_manager" &&
|
||||
tab.hostConfig?.id === currentHostConfig.id,
|
||||
);
|
||||
}, [tabs, currentHostConfig]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Top Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isRefreshing}
|
||||
onClick={async () => {
|
||||
if (currentHostConfig?.id) {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const res = await getServerStatusById(currentHostConfig.id);
|
||||
setServerStatus(
|
||||
res?.status === "online" ? "online" : "offline",
|
||||
);
|
||||
const data = await getServerMetricsById(
|
||||
currentHostConfig.id,
|
||||
);
|
||||
setMetrics(data);
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 503) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 504) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 404) {
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
}
|
||||
setMetrics(null);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={t("serverStats.refreshStatusAndMetrics")}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
{t("serverStats.refreshing")}
|
||||
</div>
|
||||
) : (
|
||||
t("serverStats.refreshStatus")
|
||||
)}
|
||||
</Button>
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
disabled={isFileManagerAlreadyOpen}
|
||||
title={
|
||||
isFileManagerAlreadyOpen
|
||||
? t("serverStats.fileManagerAlreadyOpen")
|
||||
: t("serverStats.openFileManager")
|
||||
}
|
||||
onClick={() => {
|
||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||
const titleBase =
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("nav.fileManager")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
||||
{isLoadingMetrics && !metrics ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">
|
||||
{t("serverStats.loadingMetrics")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : !metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
||||
{/* CPU Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.cpuUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.cpu?.percent;
|
||||
const cores = metrics?.cpu?.cores;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const coresText =
|
||||
typeof cores === "number"
|
||||
? t("serverStats.cpuCores", { count: cores })
|
||||
: t("serverStats.naCpus");
|
||||
return `${pctText} ${t("serverStats.of")} ${coresText}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.cpu?.percent === "number"
|
||||
? metrics!.cpu!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{metrics?.cpu?.load
|
||||
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
|
||||
: "Load: N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.memoryUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.memory?.percent;
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const usedText =
|
||||
typeof used === "number"
|
||||
? `${used.toFixed(1)} GiB`
|
||||
: "N/A";
|
||||
const totalText =
|
||||
typeof total === "number"
|
||||
? `${total.toFixed(1)} GiB`
|
||||
: "N/A";
|
||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.memory?.percent === "number"
|
||||
? metrics!.memory!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const free =
|
||||
typeof used === "number" && typeof total === "number"
|
||||
? (total - used).toFixed(1)
|
||||
: "N/A";
|
||||
return `Free: ${free} GiB`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.rootStorageSpace")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.disk?.percent;
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const usedText = used ?? "N/A";
|
||||
const totalText = total ?? "N/A";
|
||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.disk?.percent === "number"
|
||||
? metrics!.disk!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
return used && total
|
||||
? `Available: ${total}`
|
||||
: "Available: N/A";
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SSH Tunnels */}
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name
|
||||
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
{t("serverStats.feedbackMessage")}{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,163 +1,206 @@
|
||||
import React, {useState, useEffect, useCallback} from "react";
|
||||
import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
|
||||
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
|
||||
import type {SSHHost, TunnelConnection, TunnelStatus, SSHTunnelProps} from '../../../types/index.js';
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { TunnelViewer } from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getTunnelStatuses,
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
cancelTunnel,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type {
|
||||
SSHHost,
|
||||
TunnelConnection,
|
||||
TunnelStatus,
|
||||
SSHTunnelProps,
|
||||
} from "../../../types/index.js";
|
||||
|
||||
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement {
|
||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({});
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({});
|
||||
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<
|
||||
Record<string, TunnelStatus>
|
||||
>({});
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||
|
||||
const haveTunnelConnectionsChanged = (a: TunnelConnection[] = [], b: TunnelConnection[] = []): boolean => {
|
||||
if (a.length !== b.length) return true;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.sourcePort !== y.sourcePort ||
|
||||
x.endpointPort !== y.endpointPort ||
|
||||
x.endpointHost !== y.endpointHost ||
|
||||
x.maxRetries !== y.maxRetries ||
|
||||
x.retryInterval !== y.retryInterval ||
|
||||
x.autoStart !== y.autoStart
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
const haveTunnelConnectionsChanged = (
|
||||
a: TunnelConnection[] = [],
|
||||
b: TunnelConnection[] = [],
|
||||
): boolean => {
|
||||
if (a.length !== b.length) return true;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.sourcePort !== y.sourcePort ||
|
||||
x.endpointPort !== y.endpointPort ||
|
||||
x.endpointHost !== y.endpointHost ||
|
||||
x.maxRetries !== y.maxRetries ||
|
||||
x.retryInterval !== y.retryInterval ||
|
||||
x.autoStart !== y.autoStart
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const fetchHosts = useCallback(async () => {
|
||||
const hostsData = await getSSHHosts();
|
||||
setAllHosts(hostsData);
|
||||
const nextVisible = filterHostKey
|
||||
? hostsData.filter((h) => {
|
||||
const key =
|
||||
h.name && h.name.trim() !== "" ? h.name : `${h.username}@${h.ip}`;
|
||||
return key === filterHostKey;
|
||||
})
|
||||
: hostsData;
|
||||
|
||||
const prev = prevVisibleHostRef.current;
|
||||
const curr = nextVisible[0] ?? null;
|
||||
let changed = false;
|
||||
if (!prev && curr) changed = true;
|
||||
else if (prev && !curr) changed = true;
|
||||
else if (prev && curr) {
|
||||
if (
|
||||
prev.id !== curr.id ||
|
||||
prev.name !== curr.name ||
|
||||
prev.ip !== curr.ip ||
|
||||
prev.port !== curr.port ||
|
||||
prev.username !== curr.username ||
|
||||
haveTunnelConnectionsChanged(
|
||||
prev.tunnelConnections,
|
||||
curr.tunnelConnections,
|
||||
)
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setVisibleHosts(nextVisible);
|
||||
prevVisibleHostRef.current = curr;
|
||||
}
|
||||
}, [filterHostKey]);
|
||||
|
||||
const fetchTunnelStatuses = useCallback(async () => {
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 5000);
|
||||
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
|
||||
const fetchHosts = useCallback(async () => {
|
||||
const hostsData = await getSSHHosts();
|
||||
setAllHosts(hostsData);
|
||||
const nextVisible = filterHostKey
|
||||
? hostsData.filter(h => {
|
||||
const key = (h.name && h.name.trim() !== '') ? h.name : `${h.username}@${h.ip}`;
|
||||
return key === filterHostKey;
|
||||
})
|
||||
: hostsData;
|
||||
|
||||
const prev = prevVisibleHostRef.current;
|
||||
const curr = nextVisible[0] ?? null;
|
||||
let changed = false;
|
||||
if (!prev && curr) changed = true;
|
||||
else if (prev && !curr) changed = true;
|
||||
else if (prev && curr) {
|
||||
if (
|
||||
prev.id !== curr.id ||
|
||||
prev.name !== curr.name ||
|
||||
prev.ip !== curr.ip ||
|
||||
prev.port !== curr.port ||
|
||||
prev.username !== curr.username ||
|
||||
haveTunnelConnectionsChanged(prev.tunnelConnections, curr.tunnelConnections)
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setVisibleHosts(nextVisible);
|
||||
prevVisibleHostRef.current = curr;
|
||||
}
|
||||
}, [filterHostKey]);
|
||||
|
||||
const fetchTunnelStatuses = useCallback(async () => {
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 5000);
|
||||
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
};
|
||||
}, [fetchHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTunnelStatuses();
|
||||
const interval = setInterval(fetchTunnelStatuses, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTunnelStatuses]);
|
||||
|
||||
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
|
||||
setTunnelActions(prev => ({...prev, [tunnelName]: true}));
|
||||
|
||||
try {
|
||||
if (action === 'connect') {
|
||||
const endpointHost = allHosts.find(h =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost
|
||||
);
|
||||
|
||||
if (!endpointHost) {
|
||||
throw new Error('Endpoint host not found');
|
||||
}
|
||||
|
||||
const tunnelConfig = {
|
||||
name: tunnelName,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword: host.authType === 'password' ? host.password : undefined,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.authType === 'key' ? host.key : undefined,
|
||||
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined,
|
||||
sourceKeyType: host.authType === 'key' ? host.keyType : undefined,
|
||||
sourceCredentialId: host.credentialId,
|
||||
sourceUserId: host.userId,
|
||||
endpointIP: endpointHost.ip,
|
||||
endpointSSHPort: endpointHost.port,
|
||||
endpointUsername: endpointHost.username,
|
||||
endpointPassword: endpointHost.authType === 'password' ? endpointHost.password : undefined,
|
||||
endpointAuthMethod: endpointHost.authType,
|
||||
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined,
|
||||
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined,
|
||||
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined,
|
||||
endpointCredentialId: endpointHost.credentialId,
|
||||
endpointUserId: endpointHost.userId,
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
maxRetries: tunnel.maxRetries,
|
||||
retryInterval: tunnel.retryInterval * 1000,
|
||||
autoStart: tunnel.autoStart,
|
||||
isPinned: host.pin
|
||||
};
|
||||
|
||||
await connectTunnel(tunnelConfig);
|
||||
} else if (action === 'disconnect') {
|
||||
await disconnectTunnel(tunnelName);
|
||||
} else if (action === 'cancel') {
|
||||
await cancelTunnel(tunnelName);
|
||||
}
|
||||
|
||||
await fetchTunnelStatuses();
|
||||
} catch (err) {
|
||||
} finally {
|
||||
setTunnelActions(prev => ({...prev, [tunnelName]: false}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TunnelViewer
|
||||
hosts={visibleHosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={handleTunnelAction}
|
||||
/>
|
||||
window.addEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
};
|
||||
}, [fetchHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTunnelStatuses();
|
||||
const interval = setInterval(fetchTunnelStatuses, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTunnelStatuses]);
|
||||
|
||||
const handleTunnelAction = async (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
|
||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
|
||||
|
||||
try {
|
||||
if (action === "connect") {
|
||||
const endpointHost = allHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
|
||||
if (!endpointHost) {
|
||||
throw new Error("Endpoint host not found");
|
||||
}
|
||||
|
||||
const tunnelConfig = {
|
||||
name: tunnelName,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword:
|
||||
host.authType === "password" ? host.password : undefined,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.authType === "key" ? host.key : undefined,
|
||||
sourceKeyPassword:
|
||||
host.authType === "key" ? host.keyPassword : undefined,
|
||||
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
|
||||
sourceCredentialId: host.credentialId,
|
||||
sourceUserId: host.userId,
|
||||
endpointIP: endpointHost.ip,
|
||||
endpointSSHPort: endpointHost.port,
|
||||
endpointUsername: endpointHost.username,
|
||||
endpointPassword:
|
||||
endpointHost.authType === "password"
|
||||
? endpointHost.password
|
||||
: undefined,
|
||||
endpointAuthMethod: endpointHost.authType,
|
||||
endpointSSHKey:
|
||||
endpointHost.authType === "key" ? endpointHost.key : undefined,
|
||||
endpointKeyPassword:
|
||||
endpointHost.authType === "key"
|
||||
? endpointHost.keyPassword
|
||||
: undefined,
|
||||
endpointKeyType:
|
||||
endpointHost.authType === "key" ? endpointHost.keyType : undefined,
|
||||
endpointCredentialId: endpointHost.credentialId,
|
||||
endpointUserId: endpointHost.userId,
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
maxRetries: tunnel.maxRetries,
|
||||
retryInterval: tunnel.retryInterval * 1000,
|
||||
autoStart: tunnel.autoStart,
|
||||
isPinned: host.pin,
|
||||
};
|
||||
|
||||
await connectTunnel(tunnelConfig);
|
||||
} else if (action === "disconnect") {
|
||||
await disconnectTunnel(tunnelName);
|
||||
} else if (action === "cancel") {
|
||||
await cancelTunnel(tunnelName);
|
||||
}
|
||||
|
||||
await fetchTunnelStatuses();
|
||||
} catch (err) {
|
||||
} finally {
|
||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TunnelViewer
|
||||
hosts={visibleHosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={handleTunnelAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,435 +1,533 @@
|
||||
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 {useTranslation} from 'react-i18next';
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card } from "@/components/ui/card.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Loader2,
|
||||
Pin,
|
||||
Network,
|
||||
Tag,
|
||||
Play,
|
||||
Square,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
X
|
||||
Loader2,
|
||||
Pin,
|
||||
Network,
|
||||
Tag,
|
||||
Play,
|
||||
Square,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
import type {TunnelStatus, SSHTunnelObjectProps} from '../../../types/index.js';
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import type {
|
||||
TunnelStatus,
|
||||
SSHTunnelObjectProps,
|
||||
} from "../../../types/index.js";
|
||||
|
||||
export function TunnelObject({
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction,
|
||||
compact = false,
|
||||
bare = false
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction,
|
||||
compact = false,
|
||||
bare = false,
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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 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: t('tunnels.unknown'),
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted/50',
|
||||
borderColor: 'border-border'
|
||||
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
|
||||
if (!status)
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: t("tunnels.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: t("tunnels.connected"),
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-500/10 dark:bg-green-400/10",
|
||||
borderColor: "border-green-500/20 dark:border-green-400/20",
|
||||
};
|
||||
case "CONNECTING":
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
text: t("tunnels.connecting"),
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
|
||||
borderColor: "border-blue-500/20 dark:border-blue-400/20",
|
||||
};
|
||||
case "DISCONNECTING":
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
text: t("tunnels.disconnecting"),
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
bgColor: "bg-orange-500/10 dark:bg-orange-400/10",
|
||||
borderColor: "border-orange-500/20 dark:border-orange-400/20",
|
||||
};
|
||||
case "DISCONNECTED":
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: t("tunnels.disconnected"),
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted/30",
|
||||
borderColor: "border-border",
|
||||
};
|
||||
case "WAITING":
|
||||
return {
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
|
||||
borderColor: "border-blue-500/20 dark:border-blue-400/20",
|
||||
};
|
||||
case "ERROR":
|
||||
case "FAILED":
|
||||
return {
|
||||
icon: <AlertCircle className="h-4 w-4" />,
|
||||
text: status.reason || t("tunnels.error"),
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-500/10 dark:bg-red-400/10",
|
||||
borderColor: "border-red-500/20 dark:border-red-400/20",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: statusValue,
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted/30",
|
||||
borderColor: "border-border",
|
||||
};
|
||||
|
||||
const statusValue = status.status || 'DISCONNECTED';
|
||||
|
||||
switch (statusValue.toUpperCase()) {
|
||||
case 'CONNECTED':
|
||||
return {
|
||||
icon: <Wifi className="h-4 w-4"/>,
|
||||
text: t('tunnels.connected'),
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-500/10 dark:bg-green-400/10',
|
||||
borderColor: 'border-green-500/20 dark:border-green-400/20'
|
||||
};
|
||||
case 'CONNECTING':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
|
||||
text: t('tunnels.connecting'),
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
|
||||
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
|
||||
};
|
||||
case 'DISCONNECTING':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
|
||||
text: t('tunnels.disconnecting'),
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
bgColor: 'bg-orange-500/10 dark:bg-orange-400/10',
|
||||
borderColor: 'border-orange-500/20 dark:border-orange-400/20'
|
||||
};
|
||||
case 'DISCONNECTED':
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4"/>,
|
||||
text: t('tunnels.disconnected'),
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted/30',
|
||||
borderColor: 'border-border'
|
||||
};
|
||||
case 'WAITING':
|
||||
return {
|
||||
icon: <Clock className="h-4 w-4"/>,
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
|
||||
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
|
||||
};
|
||||
case 'ERROR':
|
||||
case 'FAILED':
|
||||
return {
|
||||
icon: <AlertCircle className="h-4 w-4"/>,
|
||||
text: status.reason || t('tunnels.error'),
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bgColor: 'bg-red-500/10 dark:bg-red-400/10',
|
||||
borderColor: 'border-red-500/20 dark:border-red-400/20'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4"/>,
|
||||
text: statusValue,
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted/30',
|
||||
borderColor: 'border-border'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (bare) {
|
||||
return (
|
||||
<div className="w-full min-w-0">
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||
const status = getTunnelStatus(tunnelIndex);
|
||||
const statusDisplay = getTunnelStatusDisplay(status);
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
const isActionLoading = tunnelActions[tunnelName];
|
||||
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
|
||||
const isConnected = statusValue === 'CONNECTED';
|
||||
const isConnecting = statusValue === 'CONNECTING';
|
||||
const isDisconnecting = statusValue === 'DISCONNECTING';
|
||||
const isRetrying = statusValue === 'RETRYING';
|
||||
const isWaiting = statusValue === 'WAITING';
|
||||
|
||||
return (
|
||||
<div key={tunnelIndex}
|
||||
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
||||
{statusDisplay.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
{t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div className={`text-xs ${statusDisplay.color} font-medium`}>
|
||||
{statusDisplay.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
||||
{!isActionLoading ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1"/>
|
||||
{t('tunnels.disconnect')}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1"/>
|
||||
{t('tunnels.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1"/>
|
||||
{t('tunnels.connect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
|
||||
{isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||
<div
|
||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">{t('tunnels.error')}:</div>
|
||||
{status.reason}
|
||||
{status.reason && status.reason.includes('Max retries exhausted') && (
|
||||
<>
|
||||
<div
|
||||
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
{t('tunnels.checkDockerLogs')} <a
|
||||
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
|
||||
create a <a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400">GitHub
|
||||
issue</a> for help.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
||||
<div
|
||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
|
||||
</div>
|
||||
<div>
|
||||
{t('tunnels.attempt', {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span> • {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
|
||||
<p className="text-sm">{t('tunnels.noTunnelConnections')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (bare) {
|
||||
return (
|
||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||
<div className="p-4">
|
||||
{!compact && (
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-card-foreground truncate">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port} • {host.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full min-w-0">
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||
const status = getTunnelStatus(tunnelIndex);
|
||||
const statusDisplay = getTunnelStatusDisplay(status);
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
const isActionLoading = tunnelActions[tunnelName];
|
||||
const statusValue =
|
||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||
const isConnected = statusValue === "CONNECTED";
|
||||
const isConnecting = statusValue === "CONNECTING";
|
||||
const isDisconnecting = statusValue === "DISCONNECTING";
|
||||
const isRetrying = statusValue === "RETRYING";
|
||||
const isWaiting = statusValue === "WAITING";
|
||||
|
||||
{!compact && host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{host.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{host.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 3}
|
||||
</Badge>
|
||||
return (
|
||||
<div
|
||||
key={tunnelIndex}
|
||||
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
|
||||
>
|
||||
{statusDisplay.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
{t("tunnels.port")} {tunnel.sourcePort} →{" "}
|
||||
{tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${statusDisplay.color} font-medium`}
|
||||
>
|
||||
{statusDisplay.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
||||
{!isActionLoading ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction(
|
||||
"disconnect",
|
||||
host,
|
||||
tunnelIndex,
|
||||
)
|
||||
}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.disconnect")}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("cancel", host, tunnelIndex)
|
||||
}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("connect", host, tunnelIndex)
|
||||
}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.connect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{isConnected
|
||||
? t("tunnels.disconnecting")
|
||||
: isRetrying || isWaiting
|
||||
? t("tunnels.canceling")
|
||||
: t("tunnels.connecting")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && <Separator className="mb-3"/>}
|
||||
{(statusValue === "ERROR" || statusValue === "FAILED") &&
|
||||
status?.reason && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{t("tunnels.error")}:
|
||||
</div>
|
||||
{status.reason}
|
||||
{status.reason &&
|
||||
status.reason.includes("Max retries exhausted") && (
|
||||
<>
|
||||
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
{t("tunnels.checkDockerLogs")}{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/jVQGdvHDrf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
Discord
|
||||
</a>{" "}
|
||||
or create a{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
GitHub issue
|
||||
</a>{" "}
|
||||
for help.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{!compact && (
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4"/>
|
||||
{t('tunnels.tunnelConnections')} ({host.tunnelConnections.length})
|
||||
</h4>
|
||||
)}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||
const status = getTunnelStatus(tunnelIndex);
|
||||
const statusDisplay = getTunnelStatusDisplay(status);
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
const isActionLoading = tunnelActions[tunnelName];
|
||||
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
|
||||
const isConnected = statusValue === 'CONNECTED';
|
||||
const isConnecting = statusValue === 'CONNECTING';
|
||||
const isDisconnecting = statusValue === 'DISCONNECTING';
|
||||
const isRetrying = statusValue === 'RETRYING';
|
||||
const isWaiting = statusValue === 'WAITING';
|
||||
|
||||
return (
|
||||
<div key={tunnelIndex}
|
||||
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
|
||||
{statusDisplay.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
{t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div className={`text-xs ${statusDisplay.color} font-medium`}>
|
||||
{statusDisplay.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!isActionLoading && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1"/>
|
||||
{t('tunnels.disconnect')}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1"/>
|
||||
{t('tunnels.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1"/>
|
||||
{t('tunnels.connect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isActionLoading && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
|
||||
{isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||
<div
|
||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">{t('tunnels.error')}:</div>
|
||||
{status.reason}
|
||||
{status.reason && status.reason.includes('Max retries exhausted') && (
|
||||
<>
|
||||
<div
|
||||
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
{t('tunnels.checkDockerLogs')} <a
|
||||
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
|
||||
create a <a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400">GitHub
|
||||
issue</a> for help.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
|
||||
<div
|
||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
|
||||
</div>
|
||||
<div>
|
||||
{t('tunnels.attempt', {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span> • {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{(statusValue === "RETRYING" ||
|
||||
statusValue === "WAITING") &&
|
||||
status?.retryCount &&
|
||||
status?.maxRetries && (
|
||||
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === "WAITING"
|
||||
? t("tunnels.waitingForRetry")
|
||||
: t("tunnels.retryingConnection")}
|
||||
</div>
|
||||
<div>
|
||||
{t("tunnels.attempt", {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries,
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span>
|
||||
{" "}
|
||||
•{" "}
|
||||
{t("tunnels.nextRetryIn", {
|
||||
seconds: status.nextRetryIn,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
|
||||
<p className="text-sm">{t('tunnels.noTunnelConnections')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||
<div className="p-4">
|
||||
{!compact && (
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{host.pin && (
|
||||
<Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-card-foreground truncate">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port} • {host.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{host.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Tag className="h-2 w-2 mr-0.5" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{host.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && <Separator className="mb-3" />}
|
||||
|
||||
<div className="space-y-3">
|
||||
{!compact && (
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4" />
|
||||
{t("tunnels.tunnelConnections")} ({host.tunnelConnections.length})
|
||||
</h4>
|
||||
)}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||
const status = getTunnelStatus(tunnelIndex);
|
||||
const statusDisplay = getTunnelStatusDisplay(status);
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
const isActionLoading = tunnelActions[tunnelName];
|
||||
const statusValue =
|
||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||
const isConnected = statusValue === "CONNECTED";
|
||||
const isConnecting = statusValue === "CONNECTING";
|
||||
const isDisconnecting = statusValue === "DISCONNECTING";
|
||||
const isRetrying = statusValue === "RETRYING";
|
||||
const isWaiting = statusValue === "WAITING";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tunnelIndex}
|
||||
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
|
||||
>
|
||||
{statusDisplay.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
{t("tunnels.port")} {tunnel.sourcePort} →{" "}
|
||||
{tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${statusDisplay.color} font-medium`}
|
||||
>
|
||||
{statusDisplay.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!isActionLoading && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction(
|
||||
"disconnect",
|
||||
host,
|
||||
tunnelIndex,
|
||||
)
|
||||
}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.disconnect")}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("cancel", host, tunnelIndex)
|
||||
}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("connect", host, tunnelIndex)
|
||||
}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.connect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isActionLoading && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{isConnected
|
||||
? t("tunnels.disconnecting")
|
||||
: isRetrying || isWaiting
|
||||
? t("tunnels.canceling")
|
||||
: t("tunnels.connecting")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statusValue === "ERROR" || statusValue === "FAILED") &&
|
||||
status?.reason && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{t("tunnels.error")}:
|
||||
</div>
|
||||
{status.reason}
|
||||
{status.reason &&
|
||||
status.reason.includes("Max retries exhausted") && (
|
||||
<>
|
||||
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
{t("tunnels.checkDockerLogs")}{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/jVQGdvHDrf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
Discord
|
||||
</a>{" "}
|
||||
or create a{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
GitHub issue
|
||||
</a>{" "}
|
||||
for help.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(statusValue === "RETRYING" ||
|
||||
statusValue === "WAITING") &&
|
||||
status?.retryCount &&
|
||||
status?.maxRetries && (
|
||||
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === "WAITING"
|
||||
? t("tunnels.waitingForRetry")
|
||||
: t("tunnels.retryingConnection")}
|
||||
</div>
|
||||
<div>
|
||||
{t("tunnels.attempt", {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries,
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span>
|
||||
{" "}
|
||||
•{" "}
|
||||
{t("tunnels.nextRetryIn", {
|
||||
seconds: status.nextRetryIn,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +1,77 @@
|
||||
import React from "react";
|
||||
import {TunnelObject} from "./TunnelObject.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type {SSHHost, TunnelConnection, TunnelStatus} from '../../../types/index.js';
|
||||
import { TunnelObject } from "./TunnelObject.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
SSHHost,
|
||||
TunnelConnection,
|
||||
TunnelStatus,
|
||||
} from "../../../types/index.js";
|
||||
|
||||
interface SSHTunnelViewerProps {
|
||||
hosts: SSHHost[];
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||
hosts: SSHHost[];
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
export function TunnelViewer({
|
||||
hosts = [],
|
||||
tunnelStatuses = {},
|
||||
tunnelActions = {},
|
||||
onTunnelAction
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||
|
||||
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{t('tunnels.noSshTunnels')}</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{t('tunnels.createFirstTunnelMessage')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
hosts = [],
|
||||
tunnelStatuses = {},
|
||||
tunnelActions = {},
|
||||
onTunnelAction,
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const activeHost: SSHHost | undefined =
|
||||
Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||
|
||||
if (
|
||||
!activeHost ||
|
||||
!activeHost.tunnelConnections ||
|
||||
activeHost.tunnelConnections.length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||
<div className="w-full flex-shrink-0 mb-2">
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('tunnels.title')}</h1>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{activeHost.tunnelConnections.map((t, idx) => (
|
||||
<TunnelObject
|
||||
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
||||
host={{...activeHost, tunnelConnections: [activeHost.tunnelConnections[idx]]}}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={(action, _host, _index) => onTunnelAction(action, activeHost, idx)}
|
||||
compact
|
||||
bare
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("tunnels.noSshTunnels")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{t("tunnels.createFirstTunnelMessage")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||
<div className="w-full flex-shrink-0 mb-2">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
{t("tunnels.title")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{activeHost.tunnelConnections.map((t, idx) => (
|
||||
<TunnelObject
|
||||
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
||||
host={{
|
||||
...activeHost,
|
||||
tunnelConnections: [activeHost.tunnelConnections[idx]],
|
||||
}}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={(action, _host, _index) =>
|
||||
onTunnelAction(action, activeHost, idx)
|
||||
}
|
||||
compact
|
||||
bare
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,88 +1,103 @@
|
||||
import React, {useState, useEffect} from "react"
|
||||
import {LeftSidebar} from "@/ui/Desktop/Navigation/LeftSidebar.tsx"
|
||||
import {Homepage} from "@/ui/Desktop/Homepage/Homepage.tsx"
|
||||
import {AppView} from "@/ui/Desktop/Navigation/AppView.tsx"
|
||||
import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx"
|
||||
import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"
|
||||
import {TopNavbar} from "@/ui/Desktop/Navigation/TopNavbar.tsx";
|
||||
import {AdminSettings} from "@/ui/Desktop/Admin/AdminSettings.tsx";
|
||||
import {UserProfile} from "@/ui/Desktop/User/UserProfile.tsx";
|
||||
import {Toaster} from "@/components/ui/sonner.tsx";
|
||||
import {getUserInfo, getCookie} from "@/ui/main-axios.ts";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { LeftSidebar } from "@/ui/Desktop/Navigation/LeftSidebar.tsx";
|
||||
import { Homepage } from "@/ui/Desktop/Homepage/Homepage.tsx";
|
||||
import { AppView } from "@/ui/Desktop/Navigation/AppView.tsx";
|
||||
import { HostManager } from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx";
|
||||
import {
|
||||
TabProvider,
|
||||
useTabs,
|
||||
} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { TopNavbar } from "@/ui/Desktop/Navigation/TopNavbar.tsx";
|
||||
import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
|
||||
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
import { getUserInfo, getCookie } from "@/ui/main-axios.ts";
|
||||
|
||||
function AppContent() {
|
||||
const [view, setView] = useState<string>("homepage")
|
||||
const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"]))
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [username, setUsername] = useState<string | null>(null)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [authLoading, setAuthLoading] = useState(true)
|
||||
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true)
|
||||
const {currentTab, tabs} = useTabs();
|
||||
const [view, setView] = useState<string>("homepage");
|
||||
const [mountedViews, setMountedViews] = useState<Set<string>>(
|
||||
new Set(["homepage"]),
|
||||
);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
|
||||
const { currentTab, tabs } = useTabs();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt) {
|
||||
setAuthLoading(true);
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
})
|
||||
.finally(() => setAuthLoading(false));
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt) {
|
||||
setAuthLoading(true);
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
document.cookie =
|
||||
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
})
|
||||
.finally(() => setAuthLoading(false));
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth()
|
||||
checkAuth();
|
||||
|
||||
const handleStorageChange = () => checkAuth()
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
const handleStorageChange = () => checkAuth();
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
|
||||
return () => window.removeEventListener('storage', handleStorageChange)
|
||||
}, [])
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, []);
|
||||
|
||||
const handleSelectView = (nextView: string) => {
|
||||
setMountedViews((prev) => {
|
||||
if (prev.has(nextView)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(nextView)
|
||||
return next
|
||||
})
|
||||
setView(nextView)
|
||||
}
|
||||
const handleSelectView = (nextView: string) => {
|
||||
setMountedViews((prev) => {
|
||||
if (prev.has(nextView)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(nextView);
|
||||
return next;
|
||||
});
|
||||
setView(nextView);
|
||||
};
|
||||
|
||||
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => {
|
||||
setIsAuthenticated(true)
|
||||
setIsAdmin(authData.isAdmin)
|
||||
setUsername(authData.username)
|
||||
}
|
||||
const handleAuthSuccess = (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(authData.isAdmin);
|
||||
setUsername(authData.username);
|
||||
};
|
||||
|
||||
const currentTabData = tabs.find(tab => tab.id === currentTab);
|
||||
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
|
||||
const showHome = currentTabData?.type === 'home';
|
||||
const showSshManager = currentTabData?.type === 'ssh_manager';
|
||||
const showAdmin = currentTabData?.type === 'admin';
|
||||
const showProfile = currentTabData?.type === 'user_profile';
|
||||
const currentTabData = tabs.find((tab) => tab.id === currentTab);
|
||||
const showTerminalView =
|
||||
currentTabData?.type === "terminal" ||
|
||||
currentTabData?.type === "server" ||
|
||||
currentTabData?.type === "file_manager";
|
||||
const showHome = currentTabData?.type === "home";
|
||||
const showSshManager = currentTabData?.type === "ssh_manager";
|
||||
const showAdmin = currentTabData?.type === "admin";
|
||||
const showProfile = currentTabData?.type === "user_profile";
|
||||
|
||||
return (
|
||||
return (
|
||||
<div>
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div>
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div>
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `linear-gradient(
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(
|
||||
135deg,
|
||||
transparent 0%,
|
||||
transparent 49%,
|
||||
@@ -91,86 +106,93 @@ function AppContent() {
|
||||
transparent 51%,
|
||||
transparent 100%
|
||||
)`,
|
||||
backgroundSize: '80px 80px'
|
||||
}}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||
<Homepage
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<LeftSidebar
|
||||
onSelectView={handleSelectView}
|
||||
disabled={!isAuthenticated || authLoading}
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
>
|
||||
{showTerminalView && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<AppView isTopbarOpen={isTopbarOpen}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHome && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<Homepage
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSshManager && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdmin && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<AdminSettings isTopbarOpen={isTopbarOpen}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProfile && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-auto">
|
||||
<UserProfile isTopbarOpen={isTopbarOpen}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
||||
</LeftSidebar>
|
||||
)}
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors={false}
|
||||
closeButton
|
||||
duration={5000}
|
||||
offset={20}
|
||||
/>
|
||||
backgroundSize: "80px 80px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||
<Homepage
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<LeftSidebar
|
||||
onSelectView={handleSelectView}
|
||||
disabled={!isAuthenticated || authLoading}
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
>
|
||||
{showTerminalView && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<AppView isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHome && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<Homepage
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSshManager && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<HostManager
|
||||
onSelectView={handleSelectView}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdmin && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProfile && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-auto">
|
||||
<UserProfile isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TopNavbar
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
setIsTopbarOpen={setIsTopbarOpen}
|
||||
/>
|
||||
</LeftSidebar>
|
||||
)}
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors={false}
|
||||
closeButton
|
||||
duration={5000}
|
||||
offset={20}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopApp() {
|
||||
return (
|
||||
<TabProvider>
|
||||
<AppContent/>
|
||||
</TabProvider>
|
||||
);
|
||||
return (
|
||||
<TabProvider>
|
||||
<AppContent />
|
||||
</TabProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default DesktopApp
|
||||
export default DesktopApp;
|
||||
|
||||
@@ -1,216 +1,233 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {Label} from '@/components/ui/label.tsx';
|
||||
import {Alert, AlertTitle, AlertDescription} from '@/components/ui/alert.tsx';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {getServerConfig, saveServerConfig, testServerConnection, type ServerConfig} from '@/ui/main-axios.ts';
|
||||
import {CheckCircle, XCircle, Server, Wifi} from 'lucide-react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
testServerConnection,
|
||||
type ServerConfig,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { CheckCircle, XCircle, Server, Wifi } from "lucide-react";
|
||||
|
||||
interface ServerConfigProps {
|
||||
onServerConfigured: (serverUrl: string) => void;
|
||||
onCancel?: () => void;
|
||||
isFirstTime?: boolean;
|
||||
onServerConfigured: (serverUrl: string) => void;
|
||||
onCancel?: () => void;
|
||||
isFirstTime?: boolean;
|
||||
}
|
||||
|
||||
export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}: ServerConfigProps) {
|
||||
const {t} = useTranslation();
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown');
|
||||
export function ServerConfig({
|
||||
onServerConfigured,
|
||||
onCancel,
|
||||
isFirstTime = false,
|
||||
}: ServerConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
"unknown" | "success" | "error"
|
||||
>("unknown");
|
||||
|
||||
useEffect(() => {
|
||||
loadServerConfig();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadServerConfig();
|
||||
}, []);
|
||||
|
||||
const loadServerConfig = async () => {
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
if (config?.serverUrl) {
|
||||
setServerUrl(config.serverUrl);
|
||||
setConnectionStatus('success');
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
const loadServerConfig = async () => {
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
if (config?.serverUrl) {
|
||||
setServerUrl(config.serverUrl);
|
||||
setConnectionStatus("success");
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t('serverConfig.enterServerUrl'));
|
||||
return;
|
||||
}
|
||||
const handleTestConnection = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t("serverConfig.enterServerUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let normalizedUrl = serverUrl.trim();
|
||||
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
|
||||
normalizedUrl = `http://${normalizedUrl}`;
|
||||
}
|
||||
try {
|
||||
let normalizedUrl = serverUrl.trim();
|
||||
if (
|
||||
!normalizedUrl.startsWith("http://") &&
|
||||
!normalizedUrl.startsWith("https://")
|
||||
) {
|
||||
normalizedUrl = `http://${normalizedUrl}`;
|
||||
}
|
||||
|
||||
const result = await testServerConnection(normalizedUrl);
|
||||
const result = await testServerConnection(normalizedUrl);
|
||||
|
||||
if (result.success) {
|
||||
setConnectionStatus('success');
|
||||
} else {
|
||||
setConnectionStatus('error');
|
||||
setError(result.error || t('serverConfig.connectionFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
setConnectionStatus('error');
|
||||
setError(t('serverConfig.connectionError'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
if (result.success) {
|
||||
setConnectionStatus("success");
|
||||
} else {
|
||||
setConnectionStatus("error");
|
||||
setError(result.error || t("serverConfig.connectionFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
setConnectionStatus("error");
|
||||
setError(t("serverConfig.connectionError"));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t('serverConfig.enterServerUrl'));
|
||||
return;
|
||||
}
|
||||
const handleSaveConfig = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t("serverConfig.enterServerUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionStatus !== 'success') {
|
||||
setError(t('serverConfig.testConnectionFirst'));
|
||||
return;
|
||||
}
|
||||
if (connectionStatus !== "success") {
|
||||
setError(t("serverConfig.testConnectionFirst"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let normalizedUrl = serverUrl.trim();
|
||||
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
|
||||
normalizedUrl = `http://${normalizedUrl}`;
|
||||
}
|
||||
try {
|
||||
let normalizedUrl = serverUrl.trim();
|
||||
if (
|
||||
!normalizedUrl.startsWith("http://") &&
|
||||
!normalizedUrl.startsWith("https://")
|
||||
) {
|
||||
normalizedUrl = `http://${normalizedUrl}`;
|
||||
}
|
||||
|
||||
const config: ServerConfig = {
|
||||
serverUrl: normalizedUrl,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
const config: ServerConfig = {
|
||||
serverUrl: normalizedUrl,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const success = await saveServerConfig(config);
|
||||
const success = await saveServerConfig(config);
|
||||
|
||||
if (success) {
|
||||
onServerConfigured(normalizedUrl);
|
||||
} else {
|
||||
setError(t('serverConfig.saveFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
setError(t('serverConfig.saveError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (success) {
|
||||
onServerConfigured(normalizedUrl);
|
||||
} else {
|
||||
setError(t("serverConfig.saveFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
setError(t("serverConfig.saveError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlChange = (value: string) => {
|
||||
setServerUrl(value);
|
||||
setConnectionStatus('unknown');
|
||||
setError(null);
|
||||
};
|
||||
const handleUrlChange = (value: string) => {
|
||||
setServerUrl(value);
|
||||
setConnectionStatus("unknown");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Server className="w-6 h-6 text-primary"/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{t('serverConfig.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t('serverConfig.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">{t('serverConfig.serverUrl')}</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="http://localhost:8081 or https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
className="flex-1 h-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !serverUrl.trim() || loading}
|
||||
className="w-10 h-10 p-0 flex items-center justify-center"
|
||||
>
|
||||
{testing ? (
|
||||
<div
|
||||
className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"/>
|
||||
) : (
|
||||
<Wifi className="w-4 h-4"/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connectionStatus !== 'unknown' && (
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
{connectionStatus === 'success' ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500"/>
|
||||
<span className="text-green-600">{t('serverConfig.connected')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-4 h-4 text-red-500"/>
|
||||
<span className="text-red-600">{t('serverConfig.disconnected')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{onCancel && !isFirstTime && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
|
||||
onClick={handleSaveConfig}
|
||||
disabled={loading || testing || connectionStatus !== 'success'}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"/>
|
||||
<span>{t('serverConfig.saving')}</span>
|
||||
</div>
|
||||
) : (
|
||||
t('serverConfig.saveConfig')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t('serverConfig.helpText')}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Server className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
);
|
||||
<h2 className="text-xl font-semibold">{t("serverConfig.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t("serverConfig.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="http://localhost:8081 or https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
className="flex-1 h-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !serverUrl.trim() || loading}
|
||||
className="w-10 h-10 p-0 flex items-center justify-center"
|
||||
>
|
||||
{testing ? (
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Wifi className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connectionStatus !== "unknown" && (
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
{connectionStatus === "success" ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-green-600">
|
||||
{t("serverConfig.connected")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-red-600">
|
||||
{t("serverConfig.disconnected")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{onCancel && !isFirstTime && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
|
||||
onClick={handleSaveConfig}
|
||||
disabled={loading || testing || connectionStatus !== "success"}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>{t("serverConfig.saving")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("serverConfig.saveConfig")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t("serverConfig.helpText")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,141 +1,155 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {HomepageAuth} from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
|
||||
import {HomepageUpdateLog} from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {getUserInfo, getDatabaseHealth, getCookie} from "@/ui/main-axios.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { HomepageAuth } from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
|
||||
import { HomepageUpdateLog } from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { getUserInfo, getDatabaseHealth, getCookie } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HomepageProps {
|
||||
onSelectView: (view: string) => void;
|
||||
isAuthenticated: boolean;
|
||||
authLoading: boolean;
|
||||
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
|
||||
isTopbarOpen: boolean;
|
||||
onSelectView: (view: string) => void;
|
||||
isAuthenticated: boolean;
|
||||
authLoading: boolean;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => void;
|
||||
isTopbarOpen: boolean;
|
||||
}
|
||||
|
||||
export function Homepage({
|
||||
isAuthenticated,
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
isTopbarOpen
|
||||
}: HomepageProps): React.ReactElement {
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
isAuthenticated,
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
isTopbarOpen,
|
||||
}: HomepageProps): React.ReactElement {
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = 26;
|
||||
const bottomMarginPx = 8;
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = 26;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
useEffect(() => {
|
||||
setLoggedIn(isAuthenticated);
|
||||
}, [isAuthenticated]);
|
||||
useEffect(() => {
|
||||
setLoggedIn(isAuthenticated);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt) {
|
||||
Promise.all([
|
||||
getUserInfo(),
|
||||
getDatabaseHealth()
|
||||
])
|
||||
.then(([meRes]) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.id || null);
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt) {
|
||||
Promise.all([getUserInfo(), getDatabaseHealth()])
|
||||
.then(([meRes]) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.id || null);
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError(
|
||||
"Could not connect to the database. Please try again later.",
|
||||
);
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loggedIn ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<HomepageAuth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
dbError={dbError}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center gap-8 relative z-10">
|
||||
<div className="flex flex-col items-center gap-6 w-[400px]">
|
||||
<HomepageUpdateLog loggedIn={loggedIn} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loggedIn ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<HomepageAuth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
dbError={dbError}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
}}
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/LukeGus/Termix", "_blank")
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center gap-8 relative z-10">
|
||||
<div className="flex flex-col items-center gap-6 w-[400px]">
|
||||
<HomepageUpdateLog
|
||||
loggedIn={loggedIn}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-dark-border"></div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-dark-border"></div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
|
||||
>
|
||||
Discord
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-dark-border"></div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
|
||||
>
|
||||
Donate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
GitHub
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-dark-border"></div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://github.com/LukeGus/Termix/issues/new",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-dark-border"></div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://discord.com/invite/jVQGdvHDrf",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
Discord
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-dark-border"></div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/sponsors/LukeGus", "_blank")
|
||||
}
|
||||
>
|
||||
Donate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,143 +1,157 @@
|
||||
import React from "react";
|
||||
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import type {TermixAlert} from '../../../types/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import {
|
||||
X,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TermixAlert } from "../../../types/index.js";
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: TermixAlert;
|
||||
onDismiss: (alertId: string) => void;
|
||||
onClose: () => void;
|
||||
alert: TermixAlert;
|
||||
onDismiss: (alertId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getAlertIcon = (type?: string) => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500"/>;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-5 w-5 text-red-500"/>;
|
||||
case 'success':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500"/>;
|
||||
case 'info':
|
||||
default:
|
||||
return <Info className="h-5 w-5 text-blue-500"/>;
|
||||
}
|
||||
switch (type) {
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||
case "error":
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
case "success":
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case "info":
|
||||
default:
|
||||
return <Info className="h-5 w-5 text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityBadgeVariant = (priority?: string) => {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
return 'destructive';
|
||||
case 'high':
|
||||
return 'destructive';
|
||||
case 'medium':
|
||||
return 'secondary';
|
||||
case 'low':
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
switch (priority) {
|
||||
case "critical":
|
||||
return "destructive";
|
||||
case "high":
|
||||
return "destructive";
|
||||
case "medium":
|
||||
return "secondary";
|
||||
case "low":
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeBadgeVariant = (type?: string) => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return 'secondary';
|
||||
case 'error':
|
||||
return 'destructive';
|
||||
case 'success':
|
||||
return 'default';
|
||||
case 'info':
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
switch (type) {
|
||||
case "warning":
|
||||
return "secondary";
|
||||
case "error":
|
||||
return "destructive";
|
||||
case "success":
|
||||
return "default";
|
||||
case "info":
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
export function HomepageAlertCard({
|
||||
alert,
|
||||
onDismiss,
|
||||
onClose,
|
||||
}: AlertCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!alert) {
|
||||
return null;
|
||||
}
|
||||
if (!alert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
onDismiss(alert.id);
|
||||
onClose();
|
||||
};
|
||||
const handleDismiss = () => {
|
||||
onDismiss(alert.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const formatExpiryDate = (expiryString: string) => {
|
||||
const expiryDate = new Date(expiryString);
|
||||
const now = new Date();
|
||||
const diffTime = expiryDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
const formatExpiryDate = (expiryString: string) => {
|
||||
const expiryDate = new Date(expiryString);
|
||||
const now = new Date();
|
||||
const diffTime = expiryDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return t('common.expired');
|
||||
if (diffDays === 0) return t('common.expiresToday');
|
||||
if (diffDays === 1) return t('common.expiresTomorrow');
|
||||
return t('common.expiresInDays', {days: diffDays});
|
||||
};
|
||||
if (diffDays < 0) return t("common.expired");
|
||||
if (diffDays === 0) return t("common.expiresToday");
|
||||
if (diffDays === 1) return t("common.expiresTomorrow");
|
||||
return t("common.expiresInDays", { days: diffDays });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getAlertIcon(alert.type)}
|
||||
<CardTitle className="text-xl font-bold">
|
||||
{alert.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{alert.priority && (
|
||||
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
|
||||
{alert.priority.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{alert.type && (
|
||||
<Badge variant={getTypeBadgeVariant(alert.type)}>
|
||||
{alert.type}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatExpiryDate(alert.expiresAt)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{alert.message}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between pt-0">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
{alert.actionUrl && alert.actionText && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => window.open(alert.actionUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="gap-2"
|
||||
>
|
||||
{alert.actionText}
|
||||
<ExternalLink className="h-4 w-4"/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getAlertIcon(alert.type)}
|
||||
<CardTitle className="text-xl font-bold">{alert.title}</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{alert.priority && (
|
||||
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
|
||||
{alert.priority.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{alert.type && (
|
||||
<Badge variant={getTypeBadgeVariant(alert.type)}>
|
||||
{alert.type}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatExpiryDate(alert.expiresAt)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{alert.message}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between pt-0">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
{alert.actionUrl && alert.actionText && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
window.open(alert.actionUrl, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
{alert.actionText}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,171 +1,179 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {getUserAlerts, dismissAlert} from "@/ui/main-axios.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import type {TermixAlert} from '../../../types/index.js';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { HomepageAlertCard } from "./HomepageAlertCard.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TermixAlert } from "../../../types/index.js";
|
||||
|
||||
interface AlertManagerProps {
|
||||
userId: string | null;
|
||||
loggedIn: boolean;
|
||||
userId: string | null;
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
||||
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export function HomepageAlertManager({
|
||||
userId,
|
||||
loggedIn,
|
||||
}: AlertManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
||||
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn && userId) {
|
||||
fetchUserAlerts();
|
||||
}
|
||||
}, [loggedIn, userId]);
|
||||
|
||||
const fetchUserAlerts = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getUserAlerts(userId);
|
||||
|
||||
const userAlerts = response.alerts || [];
|
||||
|
||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||
const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
|
||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
|
||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority;
|
||||
}
|
||||
|
||||
return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
|
||||
});
|
||||
|
||||
setAlerts(sortedAlerts);
|
||||
setCurrentAlertIndex(0);
|
||||
} catch (err) {
|
||||
const {toast} = await import('sonner');
|
||||
toast.error(t('homepage.failedToLoadAlerts'));
|
||||
setError(t('homepage.failedToLoadAlerts'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissAlert = async (alertId: string) => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
await dismissAlert(userId, alertId);
|
||||
|
||||
setAlerts(prev => {
|
||||
const newAlerts = prev.filter(alert => alert.id !== alertId);
|
||||
return newAlerts;
|
||||
});
|
||||
|
||||
setCurrentAlertIndex(prevIndex => {
|
||||
const newAlertsLength = alerts.length - 1;
|
||||
if (newAlertsLength === 0) return 0;
|
||||
if (prevIndex >= newAlertsLength) return Math.max(0, newAlertsLength - 1);
|
||||
return prevIndex;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(t('homepage.failedToDismissAlert'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCurrentAlert = () => {
|
||||
if (alerts.length === 0) return;
|
||||
|
||||
if (currentAlertIndex < alerts.length - 1) {
|
||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||
} else {
|
||||
setAlerts([]);
|
||||
setCurrentAlertIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousAlert = () => {
|
||||
if (currentAlertIndex > 0) {
|
||||
setCurrentAlertIndex(currentAlertIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextAlert = () => {
|
||||
if (currentAlertIndex < alerts.length - 1) {
|
||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loggedIn || !userId) {
|
||||
return null;
|
||||
useEffect(() => {
|
||||
if (loggedIn && userId) {
|
||||
fetchUserAlerts();
|
||||
}
|
||||
}, [loggedIn, userId]);
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return null;
|
||||
const fetchUserAlerts = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getUserAlerts(userId);
|
||||
|
||||
const userAlerts = response.alerts || [];
|
||||
|
||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||
const aPriority =
|
||||
priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
|
||||
const bPriority =
|
||||
priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority;
|
||||
}
|
||||
|
||||
return (
|
||||
new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
setAlerts(sortedAlerts);
|
||||
setCurrentAlertIndex(0);
|
||||
} catch (err) {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error(t("homepage.failedToLoadAlerts"));
|
||||
setError(t("homepage.failedToLoadAlerts"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentAlert = alerts[currentAlertIndex];
|
||||
const handleDismissAlert = async (alertId: string) => {
|
||||
if (!userId) return;
|
||||
|
||||
if (!currentAlert) {
|
||||
return null;
|
||||
try {
|
||||
await dismissAlert(userId, alertId);
|
||||
|
||||
setAlerts((prev) => {
|
||||
const newAlerts = prev.filter((alert) => alert.id !== alertId);
|
||||
return newAlerts;
|
||||
});
|
||||
|
||||
setCurrentAlertIndex((prevIndex) => {
|
||||
const newAlertsLength = alerts.length - 1;
|
||||
if (newAlertsLength === 0) return 0;
|
||||
if (prevIndex >= newAlertsLength)
|
||||
return Math.max(0, newAlertsLength - 1);
|
||||
return prevIndex;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(t("homepage.failedToDismissAlert"));
|
||||
}
|
||||
};
|
||||
|
||||
const priorityCounts = {critical: 0, high: 0, medium: 0, low: 0};
|
||||
alerts.forEach(alert => {
|
||||
const priority = alert.priority || 'low';
|
||||
priorityCounts[priority as keyof typeof priorityCounts]++;
|
||||
});
|
||||
const hasMultipleAlerts = alerts.length > 1;
|
||||
const handleCloseCurrentAlert = () => {
|
||||
if (alerts.length === 0) return;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
|
||||
<div className="relative w-full max-w-2xl mx-4">
|
||||
<HomepageAlertCard
|
||||
alert={currentAlert}
|
||||
onDismiss={handleDismissAlert}
|
||||
onClose={handleCloseCurrentAlert}
|
||||
/>
|
||||
if (currentAlertIndex < alerts.length - 1) {
|
||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||
} else {
|
||||
setAlerts([]);
|
||||
setCurrentAlertIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
{hasMultipleAlerts && (
|
||||
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousAlert}
|
||||
disabled={currentAlertIndex === 0}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{currentAlertIndex + 1} of {alerts.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextAlert}
|
||||
disabled={currentAlertIndex === alerts.length - 1}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
const handlePreviousAlert = () => {
|
||||
if (currentAlertIndex > 0) {
|
||||
setCurrentAlertIndex(currentAlertIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
{error && (
|
||||
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
|
||||
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
const handleNextAlert = () => {
|
||||
if (currentAlertIndex < alerts.length - 1) {
|
||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loggedIn || !userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentAlert = alerts[currentAlertIndex];
|
||||
|
||||
if (!currentAlert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
alerts.forEach((alert) => {
|
||||
const priority = alert.priority || "low";
|
||||
priorityCounts[priority as keyof typeof priorityCounts]++;
|
||||
});
|
||||
const hasMultipleAlerts = alerts.length > 1;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
|
||||
<div className="relative w-full max-w-2xl mx-4">
|
||||
<HomepageAlertCard
|
||||
alert={currentAlert}
|
||||
onDismiss={handleDismissAlert}
|
||||
onClose={handleCloseCurrentAlert}
|
||||
/>
|
||||
|
||||
{hasMultipleAlerts && (
|
||||
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousAlert}
|
||||
disabled={currentAlertIndex === 0}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{currentAlertIndex + 1} of {alerts.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextAlert}
|
||||
disabled={currentAlertIndex === alerts.length - 1}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
|
||||
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user