Compare commits

...

60 Commits

Author SHA1 Message Date
github-actions[bot]
2cad71b878 chore: add VERSION v0.3.0 2025-10-10 13:10:38 +00:00
Michel Roegl-Brunner
9649f63474 Fix terminal input handling (#106)
* Fix terminal input handling

- Fix stale executionId issue in terminal input handler
- Add proper terminal focus management
- Fix timing issues with input handler registration
- Remove invalid terminal.off() cleanup method
- Add isTerminalReady state to ensure proper initialization order
- Clean up debugging console logs

Fixes issue where terminal input would stop working after script restarts
due to stale executionId being captured in the input handler closure.

* Fix TypeScript build errors

- Fix unsafe argument type for removeEventListener
- Remove unused eslint-disable directive
- Build now passes successfully
2025-10-10 15:02:36 +02:00
Michel Roegl-Brunner
e63958e5eb feat: Add confirmation modal for script installation and server sorting (#105)
- Add confirmation modal when only one server is saved
- Show script name and server details in confirmation view
- Auto-select single server but require user confirmation
- Preserve existing behavior for multiple servers (no pre-selection)
- Sort servers alphabetically by name in all components
- Fix ESLint issues with nullish coalescing operators

Fixes: PROX2 now appears before PROX3 in server lists
2025-10-10 14:40:52 +02:00
Michel Roegl-Brunner
ba5730287f Change release drafter to use minor version 2025-10-10 14:36:35 +02:00
Michel Roegl-Brunner
4faa74b4c5 feat: Server Color Coding System (#103)
* feat: implement server color coding feature

- Add color column to servers table with migration
- Add SERVER_COLOR_CODING_ENABLED environment variable
- Create API route for color coding toggle settings
- Add color field to Server and CreateServerData types
- Update database CRUD operations to handle color field
- Update server API routes to handle color field
- Create colorUtils.ts with contrast calculation function
- Add color coding toggle to GeneralSettingsModal
- Add color picker to ServerForm component (only shown when enabled)
- Apply colors to InstalledScriptsTab (borders and server column)
- Apply colors to ScriptInstallationCard component
- Apply colors to ServerList component
- Fix 'Local' display issue in installed scripts table

* fix: resolve TypeScript errors in color coding implementation

- Fix unsafe argument type errors in GeneralSettingsModal and ServerForm
- Remove unused import in ServerList component

* feat: add color-coded dropdown for server selection

- Create ColorCodedDropdown component with server color indicators
- Replace HTML select with custom dropdown in ExecutionModeModal
- Add color dots next to server names in dropdown options
- Maintain all existing functionality with improved visual design

* fix: generate new execution ID for each script run

- Change executionId from useState to allow updates
- Generate new execution ID in startScript function for each run
- Fixes issue where scripts couldn't be run multiple times without page reload
- Resolves 'Script execution already running' error on subsequent runs

* fix: improve whiptail handling and execution ID generation

- Remove premature terminal clearing for whiptail sessions
- Let whiptail handle its own display without interference
- Generate new execution ID for both initial and manual script runs
- Fix whiptail session state management
- Should resolve blank screen and script restart issues

* fix: revert problematic whiptail changes that broke terminal display

- Remove complex whiptail session handling that caused blank screen
- Simplify output handling to just write data directly to terminal
- Keep execution ID generation fix for multiple script runs
- Remove unused inWhiptailSession state variable
- Terminal should now display output normally again

* fix: remove remaining inWhiptailSession reference

- Remove inWhiptailSession from useEffect dependency array
- Fixes ReferenceError: inWhiptailSession is not defined
- Terminal should now work without JavaScript errors

* debug: add console logging to terminal message handling

- Add debug logs to see what messages are being received
- Help diagnose why terminal shows blank screen
- Will remove debug logs once issue is identified

* fix: prevent WebSocket reconnection loop

- Remove executionId from useEffect dependency arrays
- Fixes terminal constantly reconnecting and showing blank screen
- WebSocket now maintains stable connection during script execution
- Removes debug console logs

* fix: prevent WebSocket reconnection on second script run

- Remove handleMessage from useEffect dependency array
- Fixes loop of START messages and connection blinking on subsequent runs
- WebSocket connection now stable for multiple script executions
- handleMessage recreation no longer triggers WebSocket reconnection

* debug: add logging to identify WebSocket reconnection cause

- Add console logs to useEffect and startScript
- Track what dependencies are changing
- Identify why WebSocket reconnects on second run

* fix: remove isRunning from WebSocket useEffect dependencies

- isRunning state change was causing WebSocket reconnection loop
- Each script start changed isRunning from false to true
- This triggered useEffect to reconnect WebSocket
- Removing isRunning from dependencies breaks the loop
- WebSocket connection now stable during script execution

* feat: preselect SSH mode in execution modal and clean up debug logs

- Preselect SSH execution mode by default since it's the only available option
- Remove debug console logs from Terminal component
- Clean up code for production readiness

* fix: resolve build errors and warnings

- Add missing SettingsModal import to ExecutionModeModal
- Remove unused selectedMode and handleModeChange variables
- Add ESLint disable comments for intentional useEffect dependency exclusions
- Build now passes successfully with no errors or warnings
2025-10-10 14:30:28 +02:00
Michel Roegl-Brunner
aa9e155b0c feat: auto-select single server and remove local execution (#102)
* feat: auto-select single server and remove local execution

- Remove local execution option entirely, all scripts now execute via SSH
- Auto-select and skip modal when exactly one server is configured
- Add server settings button when no servers are configured
- Auto-refresh server list when settings modal closes
- Update modal title from 'Execution Mode' to 'Select Server'

* fix: remove debug messages from WebSocket output

- Remove console.log for WebSocket messages in Terminal component
- Remove debug output from SSH command execution in installedScripts router
- Clean up command output chunk logging and config data logging
- Remove container check result debug messages
- This eliminates unwanted debug messages appearing in terminal output
2025-10-10 13:26:24 +02:00
Michel Roegl-Brunner
d819cd79fe feat: Add card/list view toggle with enhanced list view (#101)
* feat: Add card/list view toggle with enhanced list view

- Add ViewToggle component with grid/list icons and active state styling
- Create ScriptCardList component with horizontal layout design
- Add view-mode API endpoint for GET/POST operations to persist view preference
- Update ScriptsGrid and DownloadedScriptsTab with view mode state and conditional rendering
- Enhance list view with additional information:
  - Categories with tag icon
  - Creation date with calendar icon
  - OS and version with computer icon
  - Default port with terminal icon
  - Script ID with info icon
- View preference persists across page reloads
- Same view mode applies to both Available and Downloaded scripts pages
- List view shows same information as card view but in compact horizontal layout

* fix: Resolve TypeScript/ESLint build errors

- Fix unsafe argument type errors in view mode loading
- Use proper type guards for viewMode validation
- Replace logical OR with nullish coalescing operator
- Add explicit type casting for API response validation
2025-10-10 13:04:57 +02:00
Michel Roegl-Brunner
c618fef2ef feat: Add script count badges to tab navigation (#100)
* feat: add script count badges to tab navigation

- Add script counts to Available Scripts, Downloaded Scripts, and Installed Scripts tabs
- Counts are calculated from API data and displayed as small badges
- Available scripts count shows total GitHub scripts
- Downloaded scripts count shows scripts that have local versions
- Installed scripts count shows total installed script records
- Badges use muted styling to blend with the UI

* fix: use nullish coalescing operator for safer null handling

- Replace logical OR (||) with nullish coalescing (??) for better null/undefined safety
- Fixes ESLint error: @typescript-eslint/prefer-nullish-coalescing
- Ensures build passes successfully
2025-10-10 12:52:53 +02:00
Michel Roegl-Brunner
6265ffeab5 feat: Implement comprehensive authentication system (#99)
* feat: implement JWT-based authentication system

- Add bcrypt password hashing and JWT token generation
- Create blocking auth modals for login and setup
- Add authentication management to General Settings
- Implement API routes for login, verify, setup, and credential management
- Add AuthProvider and AuthGuard components
- Support first-time setup and persistent authentication
- Store credentials securely in .env file

* feat: add option to skip enabling auth during setup

- Add toggle in SetupModal to choose whether to enable authentication immediately
- Users can set up credentials but keep authentication disabled initially
- Authentication can be enabled/disabled later through General Settings
- Maintains flexibility for users who want to configure auth gradually

* fix: allow proceeding without password when auth is disabled

- Make password fields optional when authentication is disabled in setup
- Update button validation to only require password when auth is enabled
- Modify API to handle optional password parameter
- Update hasCredentials logic to work with username-only setup
- Users can now complete setup with just username when auth is disabled
- Password can be added later when enabling authentication

* feat: don't store credentials when authentication is disabled

- When auth is disabled, no username or password is stored
- Setup modal only requires credentials when authentication is enabled
- Disabling authentication clears all stored credentials
- Users can skip authentication entirely without storing any data
- Clean separation between enabled/disabled authentication states

* feat: add setup completed flag to prevent modal on every load

- Add AUTH_SETUP_COMPLETED flag to track when user has completed setup
- Setup modal only appears when setupCompleted is false
- Both enabled and disabled auth setups mark setup as completed
- Clean .env file when authentication is disabled (no empty credential lines)
- Prevents setup modal from appearing on every page load after user decision

* fix: add missing Authentication tab button in settings modal

- Authentication tab button was missing from the tabs navigation
- Users couldn't access authentication settings
- Added Authentication tab button with proper styling and click handler
- Authentication settings are now accessible through the settings modal

* fix: properly load and display authentication settings

- Add setupCompleted state variable to track setup status
- Update loadAuthCredentials to include setupCompleted field
- Fix authentication status display logic to show correct state
- Show proper status when auth is disabled but setup is completed
- Enable toggle only when setup is completed (not just when credentials exist)
- Settings now correctly reflect the actual authentication state

* fix: handle empty FILTERS environment variable

- Add check for empty or invalid FILTERS JSON before parsing
- Prevents 'Unexpected end of JSON input' error when FILTERS is empty
- Return null filters instead of throwing parse error
- Clean up empty FILTERS line from .env file
- Fixes console error when loading settings modal

* fix: load authentication credentials when settings modal opens

- Add loadAuthCredentials() call to useEffect when modal opens
- Authentication settings were not loading because the function wasn't being called
- Now properly loads auth configuration when settings modal is opened
- Settings will display the correct authentication status and state

* fix: prevent multiple JWT secret generation with caching

- Add JWT secret caching to prevent race conditions
- Multiple API calls were generating duplicate JWT secrets
- Now caches secret after first generation/read
- Clean up duplicate JWT_SECRET lines from .env file
- Prevents .env file from being cluttered with multiple secrets

* feat: auto-login user after setup with authentication enabled

- When user sets up authentication with credentials, automatically log them in
- Prevents need to manually log in after setup completion
- Setup modal now calls login API after successful setup when auth is enabled
- AuthGuard no longer reloads page after setup, just refreshes config
- Seamless user experience from setup to authenticated state

* fix: resolve console errors and improve auth flow

- Fix 401 Unauthorized error by checking setup status before auth verification
- AuthProvider now checks if setup is completed before attempting to verify auth
- Prevents unnecessary auth verification calls when no credentials exist
- Add webpack polling configuration to fix WebSocket HMR issues
- Improves development experience when accessing from different IPs
- Eliminates console errors during initial setup flow

* fix: resolve build errors and linting issues

- Fix TypeScript ESLint error: use optional chain expression in auth.ts
- Fix React Hook warning: add missing 'isRunning' dependency to useEffect in Terminal.tsx
- Build now compiles successfully without any errors or warnings
- All linting rules are now satisfied
2025-10-10 12:45:45 +02:00
Michel Roegl-Brunner
608a7ac78c fix: resolve Next.js warnings and SVG path errors (#98)
* fix: move viewport metadata to separate export

- Remove viewport from metadata export in layout.tsx
- Add separate viewport export following Next.js 14+ conventions
- Fixes unsupported metadata viewport warning

* fix: resolve Next.js warnings and SVG path errors

- Move viewport metadata to separate export in layout.tsx (Next.js 14+ compliance)
- Fix malformed SVG arc command in CategorySidebar.tsx key icon
- Add allowedDevOrigins configuration for cross-origin requests from local networks
- Support all private network ranges without hardcoding specific IPs
2025-10-10 11:54:29 +02:00
Michel Roegl-Brunner
ff1ab35b46 feat: Add SSH key authentication and custom port support (#97)
* feat: Add SSH key authentication and custom port support

- Add SSH key authentication support with three modes: password, key, or both
- Add custom SSH port support (defaults to 22)
- Create SSHKeyInput component with file upload and paste modes
- Update database schema with auth_type, ssh_key, ssh_key_passphrase, and ssh_port columns
- Update TypeScript interfaces to support new authentication fields
- Update SSH services to handle key authentication and custom ports
- Update ServerForm with authentication type selection and SSH port field
- Update API routes with validation for new fields
- Add proper cleanup for temporary SSH key files
- Support for encrypted SSH keys with passphrase protection
- Maintain backward compatibility with existing password-only servers

* fix: Resolve TypeScript build errors and improve type safety

- Replace || operators with ?? (nullish coalescing) for better type safety
- Add proper null checks for password fields in SSH services
- Fix JSDoc type annotations for better TypeScript inference
- Update error object types to use Record<keyof CreateServerData, string>
- Ensure all SSH authentication methods handle optional fields correctly
2025-10-10 11:54:15 +02:00
dependabot[bot]
e8be9e7214 build(deps-dev): Bump @types/node from 24.7.0 to 24.7.1 (#96) 2025-10-09 22:04:20 +02:00
github-actions[bot]
cfcd09611e chore: add VERSION v0.2.5 (#90)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-09 07:54:19 +00:00
Michel Roegl-Brunner
4ed3e42148 feat: Add sorting functionality to installed scripts table (#88)
* feat: Add sorting functionality to installed scripts table

- Add sortable columns for Script Name, Container ID, Server, Status, and Installation Date
- Implement clickable table headers with visual sort indicators
- Add ascending/descending toggle functionality
- Maintain existing search and filter functionality
- Default sort by Script Name (ascending)
- Handle null values gracefully in sorting logic

* fix: Replace logical OR with nullish coalescing operator

- Fix TypeScript ESLint errors in InstalledScriptsTab.tsx
- Replace || with ?? for safer null/undefined handling
- Build now passes successfully
2025-10-09 09:51:33 +02:00
Michel Roegl-Brunner
a09f331d5f add restart command for service 2025-10-09 09:34:36 +02:00
github-actions[bot]
36beb427c0 chore: add VERSION v0.2.4 (#87)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-09 07:20:38 +00:00
Michel Roegl-Brunner
ca2cbd5a7f Fix terminal colors and functionality issues (#86)
* Fix terminal colors and stop button functionality

- Updated terminal theme to GitHub Dark with proper ANSI color support
- Fixed terminal background and foreground colors for better readability
- Removed aggressive CSS overrides that were breaking ANSI color handling
- Fixed stop button restarting script execution issue
- Added isStopped state to prevent automatic script restart after stop
- Improved WebSocket connection stability to prevent duplicate executions
- Fixed cursor rendering issues in whiptail sessions
- Enhanced terminal styling with proper color palette configuration

* Fix downloaded scripts terminal functionality

- Add install functionality to DownloadedScriptsTab component
- Pass onInstallScript prop from main page to DownloadedScriptsTab
- Enable terminal display when installing from downloaded scripts tab
- Maintain consistency with available scripts tab functionality
- Fix missing terminal integration for downloaded script installations

* Improve mobile terminal focus behavior

- Add terminal ref to main page for precise scrolling
- Update scroll behavior to focus terminal instead of page top
- Add mobile-specific offset for better terminal visibility
- Remove generic page scroll from ScriptDetailModal
- Ensure terminal is properly focused when starting installations on mobile
2025-10-09 09:19:55 +02:00
github-actions[bot]
d6803b99a6 chore: add VERSION v0.2.3 (#82)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-08 14:26:12 +00:00
Michel Roegl-Brunner
8b630c9201 feat: Add LXC auto-detection and cleanup of orphaned LXC (#80)
* feat: Add auto-detect LXC containers feature with improved UX

- Add auto-detection for LXC containers with 'community-script' tag
- SSH to Proxmox servers and scan /etc/pve/lxc/ config files
- Extract container ID and hostname from config files
- Automatically create installed script records for detected containers
- Replace alert popups with modern status messages
- Add visual feedback with success/error states
- Auto-close form on successful detection
- Add clear UI indicators for community-script tag requirement
- Improve error handling and logging for better debugging
- Support both local and SSH execution modes

* feat: Add automatic cleanup and duplicate prevention for LXC auto-detection

- Add automatic cleanup of orphaned LXC container scripts on tab load
- Implement duplicate checking to prevent re-adding existing scripts
- Replace flashy blue messages with subtle slate color scheme
- Add comprehensive status messages for cleanup and auto-detection
- Fix all ESLint errors and warnings
- Improve user experience with non-intrusive feedback
- Add detailed logging for debugging cleanup process
- Support both success and error states with appropriate styling
2025-10-08 16:20:36 +02:00
Michel Roegl-Brunner
5eaafbde48 feat: Add filter persistence with settings integration (#78)
* feat: Add settings modal with GitHub PAT and filter toggle

- Add GeneralSettingsModal with General and GitHub tabs
- Create GitHub PAT input field that saves to .env as GITHUB_TOKEN
- Add animated toggle component for SAVE_FILTER setting
- Create API endpoints for settings management
- Add Input and Toggle UI components
- Implement smooth animations for toggle interactions
- Add proper error handling and user feedback

* feat: Add filter persistence with settings integration

- Add filter persistence system that saves user filter preferences to .env
- Create FILTERS variable in .env to store complete filter state as JSON
- Add SAVE_FILTER toggle in settings to enable/disable persistence
- Implement auto-save functionality with 500ms debounce
- Add loading states and visual feedback for filter restoration
- Create API endpoints for managing saved filters
- Add filter management UI in settings modal
- Support for search query, type filters, sort order, and updatable status
- Seamless integration across all script tabs (Available, Downloaded, Installed)
- Auto-clear saved filters when persistence is disabled
2025-10-08 15:37:36 +02:00
github-actions[bot]
92f78c7008 chore: add VERSION v0.2.2 (#77)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-08 11:57:22 +00:00
Michel Roegl-Brunner
d932f5a499 feat: comprehensive mobile responsiveness improvements (#76)
* feat: comprehensive mobile responsiveness improvements

- Made main layout responsive with proper mobile padding and spacing
- Updated Terminal component with mobile-friendly controls and sizing
- Enhanced VersionDisplay with responsive layout and condensed mobile text
- Improved ScriptsGrid and DownloadedScriptsTab with mobile-first design
- Made CategorySidebar responsive with horizontal scroll on mobile
- Fixed FilterBar styling consistency and added Lucide icons
- Enhanced all modals (Settings, ScriptDetail, ExecutionMode, etc.) for mobile
- Updated ServerForm and ServerList with mobile-optimized layouts
- Added global CSS improvements for mobile touch targets and typography
- Fixed close button placement in ScriptDetailModal to follow UI conventions
- Implemented responsive breakpoints throughout the application
- Added proper viewport meta tag for mobile rendering

All components now provide excellent user experience across all device sizes.

* fix: improve mobile terminal input handling for SSH processes

- Updated mobile input controls to use up/down arrows instead of numbered buttons
- Fixed WebSocket input handling to support both regular processes and pty processes (SSH)
- Added comprehensive debugging logs for input handling
- Added visual feedback showing when inputs are sent
- Improved error handling and user feedback for input failures

The mobile terminal input should now work properly with SSH-executed scripts.

* debug: add comprehensive debugging for mobile terminal input

- Added byte-level debugging to see exact input being sent
- Added test button to verify basic input works
- Enhanced server-side logging to track input processing
- Improved escape sequence handling for arrow keys

This will help identify why mobile inputs aren't working while keyboard works.

* debug: add comprehensive server-side debugging for mobile input

- Added detailed logging for mobile input processing
- Added confirmation messages sent back to client
- Enhanced debugging to track input flow from client to server
- Added JSON string representation of inputs for better debugging

This will help identify why mobile inputs aren't working while keyboard works.

* debug: add WebSocket message routing debugging

- Added comprehensive logging for all WebSocket messages
- Added specific debugging for input action handling
- Added full message object logging to identify routing issues
- Enhanced input action validation logging

This will help identify if input messages are reaching the server at all.

* debug: add comprehensive client-side debugging for mobile input

- Added detailed logging for button click events
- Added WebSocket connection state debugging
- Added message sending confirmation logging
- Enhanced sendInput function with complete debugging

This will help identify if mobile buttons are being clicked and if WebSocket messages are being sent.

* debug: add WebSocket connection tracking and message debugging

- Added connection ID tracking for each WebSocket connection
- Added detailed logging for all incoming WebSocket messages
- Added connection close and error event logging
- Enhanced message parsing debugging

This will help identify if mobile input messages are reaching the server and which connection they're using.

* fix: correct WebSocket message format for keyboard input

- Fixed keyboard input to use 'data' field instead of 'input' field
- Added debugging for keyboard input to compare with mobile input
- Both mobile and keyboard inputs now use consistent message format
- This should fix the issue where mobile inputs weren't working

The server expects 'data' field but keyboard was sending 'input' field.

* debug: add WebSocket connection details for mobile vs keyboard input

- Added WebSocket URL and protocol logging for both mobile and keyboard input
- Added WebSocket object logging to compare connections
- Enhanced debugging to identify if mobile and keyboard use different WebSocket connections

This will help identify if there's a connection mismatch between mobile and keyboard input.

* fix: correct WebSocket message format to use 'input' field

- Reverted both mobile and keyboard input to use 'input' field instead of 'data'
- Updated server to expect 'input' field in WebSocket messages
- Fixed server-side logging to use correct field names
- This should restore both keyboard and mobile input functionality

The server was actually expecting 'input' field, not 'data' field.

* feat: add left/right arrow buttons to mobile terminal input

- Added ChevronLeft and ChevronRight icons to imports
- Added left/right navigation buttons alongside up/down buttons
- Left button sends \x1b[D (ANSI escape sequence for left arrow)
- Right button sends \x1b[C (ANSI escape sequence for right arrow)
- Updated visual feedback to show 'Left' and 'Right' for arrow inputs
- Mobile users now have full directional navigation: up, down, left, right

This completes the mobile terminal navigation controls for touch devices.

* feat: add spacebar button and clean up mobile terminal controls

- Added spacebar button to mobile input controls
- Removed 'Yes (y)' and 'Test (1)' buttons to simplify interface
- Changed action buttons from 3-column to 2-column grid (Enter, Space)
- Updated visual feedback to show 'Space' for spacebar input
- Mobile controls now focus on essential navigation and input

This streamlines the mobile terminal interface with only the most useful controls.

* feat: add backspace button to mobile terminal controls

- Added backspace button (⌫ Backspace) to action buttons
- Sends \b character for backspace functionality
- Changed action buttons from 2-column to 3-column grid
- Updated visual feedback to show 'Backspace' for backspace input
- Mobile users now have complete text editing capabilities

This completes the essential mobile terminal input controls with navigation, text input, and editing functions.

* feat: improve mobile terminal scaling and responsiveness

- Reduced font size from 14px to 10px on mobile devices (< 768px width)
- Set mobile-specific terminal dimensions (20 rows, 60 cols) for better fit
- Reduced mobile terminal height from 20rem to 16rem (256px min-height)
- Added responsive resize listener to adjust terminal size on orientation changes
- Improved mobile terminal display to prevent cramped text and odd appearance
- Better balance between terminal content and mobile input controls

This makes the terminal much more readable and usable on mobile devices.

* fix: improve ANSI escape sequence handling for whiptail dialogs

- Added better ANSI handling configuration to terminal
- Added detection and logging for screen clearing sequences (\x1b[2J, \x1b[H\x1b[2J)
- Added detection and logging for cursor positioning sequences
- Enabled allowProposedApi for better terminal compatibility
- Added debugging to identify when clear screen operations occur

This should fix the issue where whiptail dialogs duplicate content and don't properly clear the screen on mobile input.

* debug: add whiptail/dialog detection and logging

- Added detection for whiptail and dialog output in terminal messages
- Added logging to track when whiptail content is being processed
- This will help identify if the issue is with ANSI sequence processing
- Console logs will show when clear screen, cursor positioning, and whiptail content is detected

This debugging will help identify the root cause of the terminal rerendering issue.

* fix: force screen clear on cursor positioning to prevent whiptail duplication

- Modified output handling to force screen clear (\x1b[2J\x1b[H) when cursor positioning is detected
- Removed whiptail-specific detection in favor of broader cursor positioning approach
- This should prevent content duplication when whiptail redraws its interface
- Cursor positioning sequences often indicate a full screen redraw is intended

This aggressive approach should finally fix the terminal rerendering issue.

* debug: add comprehensive output debugging and try terminal.clear()

- Added detailed logging for all output messages including length, preview, and ANSI detection
- Changed cursor positioning handling to use xtermRef.current.clear() for more aggressive clearing
- This will help identify exactly what data is being received and how it's being processed
- The terminal.clear() method should completely reset the terminal buffer

This debugging will help us understand why the duplication is still occurring.

* debug: add whiptail session detection and enhanced debugging

- Added inWhiptailSession state to track when we're in a whiptail dialog
- Added detection for whiptail/dialog content to set session flag
- Enhanced output debugging with comprehensive logging
- Added aggressive terminal clearing specifically for whiptail sessions
- Reset whiptail session when script ends
- Set explicit terminal dimensions (cols/rows) for better behavior

This should provide much more detailed debugging information and better handling of whiptail sessions.

* feat: improve whiptail centering on mobile devices

- Reduced mobile terminal dimensions to 50 cols x 18 rows for better whiptail centering
- Reduced mobile terminal height from 16rem to 14rem (224px min-height)
- Added isMobile state to component level for consistent mobile detection
- Added horizontal padding on mobile to help center terminal content
- Smaller terminal dimensions should help whiptail dialogs appear more centered

This should improve the visual positioning of whiptail dialogs on mobile devices.

* feat: improve whiptail horizontal centering on mobile

- Reduced mobile terminal dimensions to 40 cols x 16 rows for better centering
- Added mobile-terminal CSS class with flex centering
- Added CSS rules to center xterm content horizontally on mobile
- Added transform translateX for fine-tuning horizontal position
- Increased horizontal padding to 2rem on mobile

This should better center the whiptail dialog horizontally on mobile devices.

* fix: resolve text wrapping and overflow issues in mobile terminal

- Increased mobile terminal dimensions to 60 cols x 20 rows to prevent text cutoff
- Increased mobile terminal height back to 16rem (256px) for better content display
- Added overflow: hidden to mobile terminal CSS to prevent content overflow
- Added width and max-width constraints to xterm elements
- Reduced horizontal padding and transform to better fit content
- Cleaned up duplicate terminal configuration options

This should fix the weird text wrapping and cutoff issues on mobile devices.

* fix: try auto-fit approach to resolve mobile terminal text wrapping

- Removed fixed terminal dimensions on mobile to let it auto-fit
- Added double fit calls for mobile to ensure proper sizing
- Removed restrictive CSS overflow and transform rules
- Simplified terminal container styling for better auto-fitting
- Let xterm.js handle the sizing automatically on mobile

This approach should allow the terminal to properly fit the content without text cutoff.

* fix: improve mobile terminal centering with specific dimensions

- Set mobile terminal to 45 cols x 18 rows for better whiptail dialog fit
- Added padding and transform to better center content on mobile
- Used flex centering in terminal container for mobile
- Added overflow hidden to prevent text cutoff
- More aggressive centering approach for mobile devices

This should better center the whiptail dialog and prevent text cutoff on mobile.

* feat: implement virtual terminal overflow approach for mobile whiptail

- Increased mobile virtual terminal to 80 cols x 30 rows (larger than display)
- Let virtual terminal overflow and center the whiptail dialog in viewport
- Added overflow: hidden to container to hide overflow content
- Centered viewport to show middle portion of virtual terminal
- This approach lets whiptail render at full size while showing only the center

This should properly center the whiptail dialog without text wrapping or cutoff issues.

* revert: simplify mobile terminal approach and reduce font size

- Reverted to simpler 50 cols x 20 rows terminal dimensions
- Reduced mobile font size from 10px to 8px for better fit
- Simplified CSS to basic flex centering without complex overflow handling
- Added multiple fit calls for mobile to ensure proper terminal sizing
- Removed complex virtual terminal overflow approach that wasn't working

This should provide a more stable and predictable mobile terminal display.

* feat: reduce mobile terminal font size and dimensions for better fit

- Reduced mobile font size from 8px to 6px for even smaller text
- Reduced mobile terminal dimensions to 45 cols x 18 rows
- This should provide more room for whiptail dialog content on mobile
- Smaller font and dimensions should prevent text cutoff and wrapping

This should finally get the whiptail dialog to fit properly on mobile devices.

* feat: increase mobile font size and fix whiptail duplication

- Increased mobile font size from 6px to 7px for slightly larger text
- Fixed whiptail duplication by adding delay after terminal clear
- Added setTimeout to ensure clear is processed before writing new content
- This should prevent the weird lines/duplication while keeping good fit

This should give a good balance between readability and fit while preventing duplication.

* fix: implement more aggressive terminal clearing for whiptail

- Added multiple clear operations: clear(), \x1b[2J\x1b[H, \x1b[3J, \x1b[2J
- Added scrollback buffer clearing with \x1b[3J
- Increased delay from 10ms to 50ms for better processing
- Added double clear() calls to force terminal buffer clearing
- This should finally eliminate the duplication lines in whiptail dialogs

This aggressive approach should completely clear the terminal before redrawing whiptail content.

* fix: implement terminal reset approach for whiptail duplication

- Added immediate clearing when whiptail session is detected
- Implemented terminal.reset() method for complete terminal state reset
- Added longer 100ms delay after reset to ensure proper processing
- Clear terminal immediately when whiptail content is first detected
- This nuclear approach should completely eliminate duplication issues

This should finally solve the persistent duplication problem by completely resetting the terminal state.

* cleanup: remove all debug logging from terminal component

- Removed all console.log statements from output handling
- Removed debug logging from mobile input functions
- Removed debug logging from keyboard input handler
- Removed debug logging from all mobile button click handlers
- Simplified button onClick handlers to direct function calls
- Kept all functionality while removing debugging noise

The terminal now has clean, production-ready code without debug output.

* feat: make InstalledScriptsTab mobile-friendly with responsive layout

- Created ScriptInstallationCard component for mobile view
- Added responsive layout: cards on mobile (< md), table on desktop (>= md)
- Made filters section mobile-friendly with stacked layout
- Improved add script form with responsive button layout
- Cards show all script details in a clean, touch-friendly format
- Maintained all existing functionality (edit, update, delete)
- Used proper Tailwind breakpoints for seamless responsive behavior

The installed scripts tab now provides an optimal experience on both mobile and desktop devices.

* fix: resolve React hooks dependency warnings in Terminal component

- Added missing 'inWhiptailSession' dependency to useCallback
- Fixed ref cleanup issue by storing terminalRef.current in variable
- Added missing 'isMobile' dependency to useEffect
- Improved nullish coalescing in ScriptInstallationCard (|| to ??)
- All React hooks warnings resolved, build passes cleanly

The Terminal component now follows React best practices for hooks dependencies.
2025-10-08 13:56:20 +02:00
Michel Roegl-Brunner
39a572a393 Add default reviewer to CODEOWNERS file 2025-10-08 13:54:34 +02:00
Michel Roegl-Brunner
81fbd440ce feat: Add prominent LXC creation completion message (#73)
* feat: Add prominent LXC creation completion message

- Enhanced Terminal component to detect LXC creation scripts
- Display prominent block message when LXC creation finishes successfully
- Detection based on script path (ct/), container ID, and script name patterns
- Maintains backward compatibility for non-LXC scripts
- Shows 'LXC CREATION FINISHED' block instead of standard completion message

* Delete scripts/ct/debian.sh

* Delete scripts/install/debian-install.sh

* Fix linting errors in Terminal.tsx

- Add handleMessage to useEffect dependency array
- Move handleMessage function before useEffect and wrap with useCallback
- Replace logical OR with proper null check for containerId
- Fix React hooks exhaustive-deps warning
- Fix TypeScript prefer-nullish-coalescing error

* Remove duplicate handleMessage function

- Clean up merge conflict resolution that left duplicate function
- Keep only the properly memoized useCallback version
- Fix code duplication issue
2025-10-08 10:59:05 +02:00
Michel Roegl-Brunner
6a84da5e85 feat: implement real-time update progress with proper theming (#72)
* fix(update): properly detach update script to survive service shutdown

- Use setsid and nohup to completely detach update process from parent Node.js
- Add 3-second grace period to allow parent process to respond to client
- Fix issue where update script would stop when killing Node.js process
- Improve systemd service detection using systemctl status with exit code check

* fix(update): prevent infinite loop in script relocation

- Check for --relocated flag at the start of main() before any other logic
- Set PVE_UPDATE_RELOCATED environment variable immediately when --relocated is detected
- Prevents relocated script from triggering relocation logic again

* fix(update): use systemd-run and double-fork for complete process isolation

- Primary: Use systemd-run --user --scope with KillMode=none for complete isolation
- Fallback: Implement double-fork daemonization technique
- Ensures update script survives systemd service shutdown
- Script is fully orphaned and reparented to init/systemd

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh
2025-10-08 10:43:52 +02:00
dependabot[bot]
0d40ced2f8 build(deps): Bump zod from 3.25.76 to 4.1.12 (#70)
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.1.12.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.1.12)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.1.12
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 21:28:01 +02:00
dependabot[bot]
37d7aea258 build(deps-dev): Bump jsdom from 26.1.0 to 27.0.0 (#71)
Bumps [jsdom](https://github.com/jsdom/jsdom) from 26.1.0 to 27.0.0.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/main/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/26.1.0...27.0.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 27.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 21:27:51 +02:00
github-actions[bot]
e3f10b8b6e chore: add VERSION v0.2.1 (#69)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-07 14:28:34 +00:00
Michel Roegl-Brunner
6c2868f8b9 chore: replace emojis with Lucide icons (#68)
- Replace all emojis with Lucide React icons for better accessibility and consistency
- Update page header: rocket emoji → Rocket icon
- Update tab navigation: package, hard drive, folder open icons
- Update terminal controls: Play, Square, Trash2, X icons
- Update filter bar: Package, Monitor, Wrench, Server, FileText, Calendar icons
- Update version display: Check icon for up-to-date status
- Replace emoji prefixes in terminal and error messages with text labels
- Remove unused icon imports
2025-10-07 16:27:28 +02:00
Michel Roegl-Brunner
c2705430a3 Enhance README with an illustrative image
Added an image to the README for better visualization.
2025-10-07 16:18:39 +02:00
Michel Roegl-Brunner
fc4c6efa8c Change release drafter to use patch versioning
Updated release drafter configuration to use patch versioning.
2025-10-07 16:17:54 +02:00
github-actions[bot]
8039d5aa96 chore: add VERSION v0.2.0 (#67)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-07 14:14:55 +00:00
Michel Roegl-Brunner
b670c4e3c8 Update publish_release.yml 2025-10-07 16:13:11 +02:00
Michel Roegl-Brunner
3e90369682 Change release drafter versioning to minor version 2025-10-07 16:12:15 +02:00
Michel Roegl-Brunner
24430ee77d Add web-based update system with detached process management (#65)
* feat: Add version checking and update functionality

- Add version display component with GitHub release comparison
- Implement update.sh script execution via API
- Add hover tooltip with update instructions
- Create shadcn/ui style Badge component
- Add version router with getCurrentVersion, getLatestRelease, and executeUpdate endpoints
- Update homepage header to show version and update status
- Add Update Now button with loading states and result feedback
- Support automatic page refresh after successful update

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Workflow

* Workflow

* Workflow

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh
2025-10-07 16:10:51 +02:00
Michel Roegl-Brunner
0b1ce29b64 Exclude automated PRs from release notes
Added configuration to exclude PRs with the 'automated' label from release notes.
2025-10-07 12:55:14 +02:00
Michel Roegl-Brunner
c7af2eb1a8 chore: bump dependencies to latest versions (#62)
* build(deps): Bump better-sqlite3 from 9.6.0 to 12.4.1 (#49)

---
updated-dependencies:
- dependency-name: better-sqlite3
  dependency-version: 12.4.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): Bump tailwindcss from 4.1.13 to 4.1.14 (#47)

Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.1.13 to 4.1.14.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.14/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.1.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): Bump @testing-library/jest-dom from 6.8.0 to 6.9.1 (#45)

Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.8.0 to 6.9.1.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.8.0...v6.9.1)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 6.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): Bump eslint-config-next from 15.5.3 to 15.5.4 (#46)

Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 15.5.3 to 15.5.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.4/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 15.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): Bump @trpc/client from 11.5.1 to 11.6.0 (#48)

Bumps [@trpc/client](https://github.com/trpc/trpc/tree/HEAD/packages/client) from 11.5.1 to 11.6.0.
- [Release notes](https://github.com/trpc/trpc/releases)
- [Commits](https://github.com/trpc/trpc/commits/v11.6.0/packages/client)

---
updated-dependencies:
- dependency-name: "@trpc/client"
  dependency-version: 11.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore: bump dependencies to latest versions

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 12:51:29 +02:00
Michel Roegl-Brunner
7ff4d56753 correct VERSION file 2025-10-07 12:50:56 +02:00
github-actions[bot]
b2ae96dcd0 chore: add VERSION v0.1.2 (#61)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-07 10:42:02 +00:00
Michel Roegl-Brunner
3530d78c78 feat: Add Downloaded Scripts tab (#51)
* feat: Add Downloaded Scripts tab

- Create new DownloadedScriptsTab component that shows only downloaded scripts
- Add tab navigation between Available Scripts and Installed Scripts
- Include filtering, searching, and categorization features
- Display statistics for downloaded scripts
- Remove unused script files (2fauth.sh, debian.sh and their install scripts)
- Update main page to include the new tab in navigation

* fix: Resolve ESLint errors in DownloadedScriptsTab

- Fix unescaped apostrophe in empty state message
- Fix empty arrow function by adding proper comment structure
2025-10-07 12:38:54 +02:00
Michel Roegl-Brunner
a3f062a77f Workflow 2025-10-07 12:38:03 +02:00
Michel Roegl-Brunner
bcdae46867 Workflow 2025-10-07 12:36:39 +02:00
Michel Roegl-Brunner
f055be1f4a Workflow 2025-10-07 12:36:04 +02:00
Michel Roegl-Brunner
7e91c598ae Workflow 2025-10-07 12:35:17 +02:00
Michel Roegl-Brunner
123977d0a3 Workflow 2025-10-07 12:34:07 +02:00
Michel Roegl-Brunner
35cc000a2a Workflow 2025-10-07 11:31:35 +02:00
Michel Roegl-Brunner
d71e8dd96a Workflow 2025-10-07 11:30:28 +02:00
Michel Roegl-Brunner
67b63019ab Update authentication token in publish_release.yml 2025-10-07 11:20:16 +02:00
Michel Roegl-Brunner
ff076a5a40 Authenticate gh with PAT before merging PR
Updated GitHub Actions workflow to authenticate with a Personal Access Token (PAT) before merging pull requests.
2025-10-07 11:19:43 +02:00
Michel Roegl-Brunner
a7479091dc Replace auto-merge action with gh command 2025-10-07 11:10:57 +02:00
Michel Roegl-Brunner
ff9a875561 Fix PR creation command in publish_release.yml 2025-10-07 11:06:15 +02:00
Michel Roegl-Brunner
6bcf139493 Refactor publish_release workflow for PR creation
Updated the workflow to create a pull request using GitHub CLI and modified the handling of the VERSION file.
2025-10-07 11:04:37 +02:00
Michel Roegl-Brunner
eb8801bfc8 Disable pull request creation in publish_release.yml
Comment out the pull request creation step in the workflow.
2025-10-07 10:59:21 +02:00
Michel Roegl-Brunner
91cdc557a1 Enhance VERSION file creation in workflow
Updated the VERSION file creation process to include a timestamp and handle no changes gracefully.
2025-10-07 10:57:06 +02:00
Michel Roegl-Brunner
6a7a1f94f9 Refactor branch handling in publish_release.yml
Updated the workflow to handle branch deletion and creation more gracefully.
2025-10-07 10:51:41 +02:00
Michel Roegl-Brunner
b96b5493f3 Update GitHub Actions workflow for draft release 2025-10-07 10:45:31 +02:00
Michel Roegl-Brunner
6baef6bb84 Enhance publish_release workflow with PR automation
Updated the workflow to create a branch for the VERSION file and automate PR creation and merging.
2025-10-07 10:13:21 +02:00
Michel Roegl-Brunner
05d88eb8c8 Force push changes in publish_release workflow
Updated git push command to use --force option.
2025-10-07 10:10:02 +02:00
Michel Roegl-Brunner
03d871eca8 Add GitHub Actions workflow for publishing draft releases 2025-10-07 10:06:51 +02:00
Michel Roegl-Brunner
b366a33f07 Remove execution_mode dependencies from InstalledScriptsTab (#50)
- Remove ExecutionModeBadge import and usage
- Update filtering logic to use server_name presence instead of execution_mode
- Simplify update script logic by removing mode property
- Update Terminal component call to determine mode based on server presence
- Replace ExecutionModeBadge in table with simple text display
- Maintain backend API compatibility by keeping execution_mode in mutations
- Use nullish coalescing operator (??) for better null handling
2025-10-07 09:59:10 +02:00
80 changed files with 10020 additions and 2417 deletions

View File

@@ -16,3 +16,13 @@ ALLOWED_SCRIPT_PATHS="scripts/"
# WebSocket Configuration # WebSocket Configuration
WEBSOCKET_PORT="3001" WEBSOCKET_PORT="3001"
# User settings
GITHUB_TOKEN=
SAVE_FILTER=false
FILTERS=
AUTH_USERNAME=
AUTH_PASSWORD_HASH=
AUTH_ENABLED=false
AUTH_SETUP_COMPLETED=false
JWT_SECRET=

1
.github/CODEOWNERS vendored
View File

@@ -11,5 +11,6 @@
# Set default reviewers # Set default reviewers
* @michelroegl-brunner
* @community-scripts/Contributor * @community-scripts/Contributor

View File

@@ -1,6 +1,11 @@
# Template for release drafts # Template for release drafts
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION name-template: 'v$NEXT_MINOR_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
tag-template: 'v$NEXT_PATCH_VERSION' tag-template: 'v$NEXT_MINOR_VERSION'
# Exclude PRs with this label from release notes
exclude-labels:
- automated
categories: categories:
- title: "🚀 Features" - title: "🚀 Features"
labels: labels:
@@ -17,6 +22,7 @@ categories:
labels: labels:
- dependencies - dependencies
- javascript - javascript
change-template: '- $TITLE (#$NUMBER) by @$AUTHOR' change-template: '- $TITLE (#$NUMBER) by @$AUTHOR'
change-title-template: '### $TITLE' change-title-template: '### $TITLE'
template: | template: |

119
.github/workflows/publish_release.yml vendored Normal file
View File

@@ -0,0 +1,119 @@
name: Publish draft release
on:
workflow_dispatch: # Manual trigger; can be automated later
permissions:
contents: write
pull-requests: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Get latest draft release
id: draft
run: |
draft_info=$(gh release list --limit 5 --json tagName,isDraft --jq '.[] | select(.isDraft==true) | .tagName' | head -n1)
echo "tag_name=${draft_info}" >> $GITHUB_OUTPUT
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Validate draft version
run: |
if [ -z "${{ steps.draft.outputs.tag_name }}" ]; then
echo "No draft release found!" >&2
exit 1
fi
echo "Found draft version: ${{ steps.draft.outputs.tag_name }}"
- name: Create branch and commit VERSION
run: |
branch="update-version-${{ steps.draft.outputs.tag_name }}"
# Delete remote branch if exists
git push origin --delete "$branch" || echo "No remote branch to delete"
git fetch origin main
git checkout -b "$branch" origin/main
# Write VERSION file and timestamp to ensure a diff
version="${{ steps.draft.outputs.tag_name }}"
echo "$version" | sed 's/^v//' > VERSION
git add VERSION
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -m "chore: add VERSION $version" --allow-empty
git push --set-upstream origin "$branch"
- name: Create PR with GitHub CLI
id: pr
run: |
pr_url=$(gh pr create \
--base main \
--head update-version-${{ steps.draft.outputs.tag_name }} \
--title "chore: add VERSION ${{ steps.draft.outputs.tag_name }}" \
--body "Adds VERSION file for release ${{ steps.draft.outputs.tag_name }}" \
--label automated)
pr_number=$(echo "$pr_url" | awk -F/ '{print $NF}')
echo $pr_number
echo "pr_number=$pr_number" >> $GITHUB_OUTPUT
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# - name: Approve pull request
# env:
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# run: |
# PR_NUMBER="${{ steps.pr.outputs.pr_number }}"
# if [ -n "$PR_NUMBER" ]; then
# gh pr review $PR_NUMBER --approve
# fi
- name: Merge PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global user.name "github-actions-automege[bot]"
git config --global user.email "github-actions-automege[bot]@users.noreply.github.com"
PR_NUMBER="${{ steps.pr.outputs.pr_number }}"
if [ -n "$PR_NUMBER" ]; then
gh pr merge "$PR_NUMBER" --squash --admin
fi
- name: Wait for PR merge
uses: actions/github-script@v7
with:
script: |
const prNum = parseInt("${{ steps.pr.outputs.pr_number }}")
let merged = false
const maxRetries = 20
let tries = 0
while(!merged && tries < maxRetries){
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNum
})
merged = pr.data.merged
if(!merged){
tries++
console.log("Waiting for PR to merge...")
await new Promise(r => setTimeout(r, 5000))
}
}
if(!merged) throw new Error("PR not merged in time")
- name: Create tag
run: |
git tag "${{ steps.draft.outputs.tag_name }}"
git push origin "${{ steps.draft.outputs.tag_name }}"
- name: Publish draft release
run: gh release edit "${{ steps.draft.outputs.tag_name }}" --draft=false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -2,6 +2,11 @@
A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming. No more need for curl -> bash calls, it all happens in your enviroment. A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming. No more need for curl -> bash calls, it all happens in your enviroment.
<img width="1725" height="1088" alt="image" src="https://github.com/user-attachments/assets/75323765-7375-4346-a41e-08d219275248" />
## 🎯 Deployment Options ## 🎯 Deployment Options
This application can be deployed in multiple ways to suit different environments: This application can be deployed in multiple ways to suit different environments:

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.3.0

View File

@@ -18,6 +18,40 @@ const config = {
}, },
], ],
}, },
// Allow cross-origin requests from local network ranges
allowedDevOrigins: [
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://[::1]:3000',
'http://10.*',
'http://172.16.*',
'http://172.17.*',
'http://172.18.*',
'http://172.19.*',
'http://172.20.*',
'http://172.21.*',
'http://172.22.*',
'http://172.23.*',
'http://172.24.*',
'http://172.25.*',
'http://172.26.*',
'http://172.27.*',
'http://172.28.*',
'http://172.29.*',
'http://172.30.*',
'http://172.31.*',
'http://192.168.*',
],
webpack: (config, { dev, isServer }) => {
if (dev && !isServer) {
config.watchOptions = {
poll: 1000,
aggregateTimeout: 300,
};
}
return config;
},
}; };
export default config; export default config;

1895
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,17 +22,23 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
"@tanstack/react-query": "^5.87.4", "@tanstack/react-query": "^5.87.4",
"@trpc/client": "^11.0.0", "@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.0.0", "@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.0.0", "@trpc/server": "^11.6.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"better-sqlite3": "^9.6.0", "bcryptjs": "^3.0.2",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.545.0",
"next": "^15.5.3", "next": "^15.5.3",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -42,29 +48,32 @@
"server-only": "^0.0.1", "server-only": "^0.0.1",
"strip-ansi": "^7.1.2", "strip-ansi": "^7.1.2",
"superjson": "^2.2.1", "superjson": "^2.2.1",
"tailwind-merge": "^3.3.1",
"ws": "^8.18.3", "ws": "^8.18.3",
"zod": "^3.24.2" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15", "@tailwindcss/postcss": "^4.0.15",
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.8",
"@types/node": "^24.3.1", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.7.1",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4", "@vitest/ui": "^3.2.4",
"eslint": "^9.23.0", "eslint": "^9.23.0",
"eslint-config-next": "^15.2.3", "eslint-config-next": "^15.5.4",
"jsdom": "^26.1.0", "jsdom": "^27.0.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.15", "tailwindcss": "^4.1.14",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.27.0", "typescript-eslint": "^8.27.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,4 +1,84 @@
[ [
{
"name": "sassanix/Warracker",
"version": "0.10.1.14",
"date": "2025-10-06T23:35:16Z"
},
{
"name": "outline/outline",
"version": "v1.0.0-1",
"date": "2025-10-06T23:16:32Z"
},
{
"name": "Ombi-app/Ombi",
"version": "v4.47.1",
"date": "2025-01-05T21:14:23Z"
},
{
"name": "Kometa-Team/Kometa",
"version": "v2.2.2",
"date": "2025-10-06T21:31:07Z"
},
{
"name": "booklore-app/booklore",
"version": "v1.5.0",
"date": "2025-10-06T20:56:57Z"
},
{
"name": "grokability/snipe-it",
"version": "v8.3.3",
"date": "2025-10-06T19:57:17Z"
},
{
"name": "meilisearch/meilisearch",
"version": "prototype-shorten-snapshot-creation-2",
"date": "2025-10-06T19:36:54Z"
},
{
"name": "TwiN/gatus",
"version": "v5.26.0",
"date": "2025-10-06T17:57:27Z"
},
{
"name": "seerr-team/seerr",
"version": "preview-seerr",
"date": "2025-10-06T16:50:29Z"
},
{
"name": "zwave-js/zwave-js-ui",
"version": "v11.4.0",
"date": "2025-10-06T16:08:51Z"
},
{
"name": "fuma-nama/fumadocs",
"version": "fumadocs-ui@15.8.4",
"date": "2025-10-06T15:41:49Z"
},
{
"name": "bunkerity/bunkerweb",
"version": "v1.6.5",
"date": "2025-10-06T15:25:17Z"
},
{
"name": "bastienwirtz/homer",
"version": "v25.10.1",
"date": "2025-10-06T14:23:20Z"
},
{
"name": "chrisvel/tududi",
"version": "v0.83",
"date": "2025-10-06T13:49:52Z"
},
{
"name": "dgtlmoon/changedetection.io",
"version": "0.50.16",
"date": "2025-10-06T13:40:13Z"
},
{
"name": "n8n-io/n8n",
"version": "n8n@1.114.3",
"date": "2025-10-06T12:22:22Z"
},
{ {
"name": "Graylog2/graylog2-server", "name": "Graylog2/graylog2-server",
"version": "7.0.0-beta.3", "version": "7.0.0-beta.3",
@@ -29,11 +109,6 @@
"version": "v0.24.82", "version": "v0.24.82",
"date": "2025-10-06T07:56:13Z" "date": "2025-10-06T07:56:13Z"
}, },
{
"name": "dgtlmoon/changedetection.io",
"version": "0.50.15",
"date": "2025-10-06T07:15:01Z"
},
{ {
"name": "firefly-iii/firefly-iii", "name": "firefly-iii/firefly-iii",
"version": "v6.4.0", "version": "v6.4.0",
@@ -74,11 +149,6 @@
"version": "4.5.3", "version": "4.5.3",
"date": "2025-08-25T13:59:56Z" "date": "2025-08-25T13:59:56Z"
}, },
{
"name": "outline/outline",
"version": "v1.0.0-0",
"date": "2025-10-05T20:30:31Z"
},
{ {
"name": "plankanban/planka", "name": "plankanban/planka",
"version": "planka-1.0.5", "version": "planka-1.0.5",
@@ -101,19 +171,14 @@
}, },
{ {
"name": "runtipi/runtipi", "name": "runtipi/runtipi",
"version": "v4.4.0", "version": "nightly",
"date": "2025-09-02T19:26:18Z" "date": "2025-10-05T14:13:25Z"
}, },
{ {
"name": "Prowlarr/Prowlarr", "name": "Prowlarr/Prowlarr",
"version": "v2.0.5.5160", "version": "v2.0.5.5160",
"date": "2025-08-23T21:23:11Z" "date": "2025-08-23T21:23:11Z"
}, },
{
"name": "chrisvel/tududi",
"version": "v0.82-rc5",
"date": "2025-09-23T07:31:12Z"
},
{ {
"name": "TandoorRecipes/recipes", "name": "TandoorRecipes/recipes",
"version": "2.3.0", "version": "2.3.0",
@@ -159,11 +224,6 @@
"version": "2.520", "version": "2.520",
"date": "2025-10-05T00:51:34Z" "date": "2025-10-05T00:51:34Z"
}, },
{
"name": "Ombi-app/Ombi",
"version": "v4.47.1",
"date": "2025-01-05T21:14:23Z"
},
{ {
"name": "ollama/ollama", "name": "ollama/ollama",
"version": "v0.12.4-rc5", "version": "v0.12.4-rc5",
@@ -224,16 +284,6 @@
"version": "2025.10.1", "version": "2025.10.1",
"date": "2025-10-03T18:10:59Z" "date": "2025-10-03T18:10:59Z"
}, },
{
"name": "fuma-nama/fumadocs",
"version": "@fumadocs/mdx-remote@1.4.2",
"date": "2025-10-03T17:01:32Z"
},
{
"name": "bunkerity/bunkerweb",
"version": "v1.6.5",
"date": "2025-10-03T16:43:34Z"
},
{ {
"name": "immich-app/immich", "name": "immich-app/immich",
"version": "v2.0.1", "version": "v2.0.1",
@@ -259,11 +309,6 @@
"version": "v0.30.1", "version": "v0.30.1",
"date": "2025-10-03T06:55:25Z" "date": "2025-10-03T06:55:25Z"
}, },
{
"name": "booklore-app/booklore",
"version": "v1.4.1",
"date": "2025-10-03T06:52:35Z"
},
{ {
"name": "redis/redis", "name": "redis/redis",
"version": "8.2.2", "version": "8.2.2",
@@ -279,16 +324,6 @@
"version": "v0.9.95", "version": "v0.9.95",
"date": "2025-10-02T16:07:18Z" "date": "2025-10-02T16:07:18Z"
}, },
{
"name": "meilisearch/meilisearch",
"version": "prototype-shorten-snapshot-creation-0",
"date": "2025-10-02T15:16:05Z"
},
{
"name": "n8n-io/n8n",
"version": "n8n@1.112.6",
"date": "2025-09-26T10:56:27Z"
},
{ {
"name": "theonedev/onedev", "name": "theonedev/onedev",
"version": "v13.0.7", "version": "v13.0.7",
@@ -389,11 +424,6 @@
"version": "v4.4.2", "version": "v4.4.2",
"date": "2025-09-30T20:16:13Z" "date": "2025-09-30T20:16:13Z"
}, },
{
"name": "TwiN/gatus",
"version": "v5.25.2",
"date": "2025-09-30T18:32:35Z"
},
{ {
"name": "WordPress/WordPress", "name": "WordPress/WordPress",
"version": "4.7.31", "version": "4.7.31",
@@ -414,11 +444,6 @@
"version": "4.4.46", "version": "4.4.46",
"date": "2025-09-30T13:21:24Z" "date": "2025-09-30T13:21:24Z"
}, },
{
"name": "fallenbagel/jellyseerr",
"version": "preview-rename-tags",
"date": "2025-09-30T12:50:15Z"
},
{ {
"name": "emqx/emqx", "name": "emqx/emqx",
"version": "e6.0.0", "version": "e6.0.0",
@@ -459,11 +484,6 @@
"version": "v2.7.12", "version": "v2.7.12",
"date": "2025-05-29T17:08:26Z" "date": "2025-05-29T17:08:26Z"
}, },
{
"name": "sassanix/Warracker",
"version": "0.10.1.13",
"date": "2025-09-29T17:11:25Z"
},
{ {
"name": "AdguardTeam/AdGuardHome", "name": "AdguardTeam/AdGuardHome",
"version": "v0.107.67", "version": "v0.107.67",
@@ -536,8 +556,8 @@
}, },
{ {
"name": "javedh-dev/tracktor", "name": "javedh-dev/tracktor",
"version": "0.3.17", "version": "0.3.18",
"date": "2025-09-27T07:00:36Z" "date": "2025-09-27T10:32:09Z"
}, },
{ {
"name": "Dolibarr/dolibarr", "name": "Dolibarr/dolibarr",
@@ -554,11 +574,6 @@
"version": "v4.104.2", "version": "v4.104.2",
"date": "2025-09-26T22:34:32Z" "date": "2025-09-26T22:34:32Z"
}, },
{
"name": "bastienwirtz/homer",
"version": "v25.09.1",
"date": "2025-09-26T19:22:16Z"
},
{ {
"name": "traefik/traefik", "name": "traefik/traefik",
"version": "v3.5.3", "version": "v3.5.3",
@@ -624,11 +639,6 @@
"version": "v1.9.10", "version": "v1.9.10",
"date": "2025-09-24T13:49:53Z" "date": "2025-09-24T13:49:53Z"
}, },
{
"name": "zwave-js/zwave-js-ui",
"version": "v11.3.1",
"date": "2025-09-24T11:58:00Z"
},
{ {
"name": "syncthing/syncthing", "name": "syncthing/syncthing",
"version": "v2.0.10", "version": "v2.0.10",
@@ -719,11 +729,6 @@
"version": "v0.23.2", "version": "v0.23.2",
"date": "2025-09-18T17:18:59Z" "date": "2025-09-18T17:18:59Z"
}, },
{
"name": "grokability/snipe-it",
"version": "v8.3.2",
"date": "2025-09-18T13:55:58Z"
},
{ {
"name": "NLnetLabs/unbound", "name": "NLnetLabs/unbound",
"version": "release-1.24.0", "version": "release-1.24.0",
@@ -1039,11 +1044,6 @@
"version": "latest", "version": "latest",
"date": "2025-08-15T15:33:51Z" "date": "2025-08-15T15:33:51Z"
}, },
{
"name": "Kometa-Team/Kometa",
"version": "v2.2.1",
"date": "2025-08-13T19:49:01Z"
},
{ {
"name": "swapplications/uhf-server-dist", "name": "swapplications/uhf-server-dist",
"version": "1.5.1", "version": "1.5.1",

View File

@@ -12,7 +12,7 @@
"documentation": "https://www.zigbee2mqtt.io/guide/getting-started/", "documentation": "https://www.zigbee2mqtt.io/guide/getting-started/",
"website": "https://www.zigbee2mqtt.io/", "website": "https://www.zigbee2mqtt.io/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/zigbee2mqtt.webp", "logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/zigbee2mqtt.webp",
"config_path": "/opt/zigbee2mqtt/data/configuration.yaml", "config_path": "debian: /opt/zigbee2mqtt/data/configuration.yaml | alpine: /var/lib/zigbee2mqtt/configuration.yaml",
"description": "Zigbee2MQTT is an open-source software project that allows you to use Zigbee-based smart home devices (such as those sold under the Philips Hue and Ikea Tradfri brands) with MQTT-based home automation systems, like Home Assistant, Node-RED, and others. The software acts as a bridge between your Zigbee devices and MQTT, allowing you to control and monitor these devices from your home automation system.", "description": "Zigbee2MQTT is an open-source software project that allows you to use Zigbee-based smart home devices (such as those sold under the Philips Hue and Ikea Tradfri brands) with MQTT-based home automation systems, like Home Assistant, Node-RED, and others. The software acts as a bridge between your Zigbee devices and MQTT, allowing you to control and monitor these devices from your home automation system.",
"install_methods": [ "install_methods": [
{ {

View File

@@ -1,29 +0,0 @@
> pve-scripts-local@0.1.0 dev
> node server.js
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
code: 'EADDRINUSE',
errno: -98,
syscall: 'listen',
address: '0.0.0.0',
port: 3000
}
uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
code: 'EADDRINUSE',
errno: -98,
syscall: 'listen',
address: '0.0.0.0',
port: 3000
}
uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
code: 'EADDRINUSE',
errno: -98,
syscall: 'listen',
address: '0.0.0.0',
port: 3000
}
Terminated

View File

@@ -0,0 +1,73 @@
'use client';
import { useState, useEffect, type ReactNode } from 'react';
import { useAuth } from './AuthProvider';
import { AuthModal } from './AuthModal';
import { SetupModal } from './SetupModal';
interface AuthGuardProps {
children: ReactNode;
}
interface AuthConfig {
username: string | null;
enabled: boolean;
hasCredentials: boolean;
setupCompleted: boolean;
}
export function AuthGuard({ children }: AuthGuardProps) {
const { isAuthenticated, isLoading } = useAuth();
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
const [configLoading, setConfigLoading] = useState(true);
const [setupCompleted, setSetupCompleted] = useState(false);
const handleSetupComplete = async () => {
setSetupCompleted(true);
// Refresh auth config without reloading the page
await fetchAuthConfig();
};
const fetchAuthConfig = async () => {
try {
const response = await fetch('/api/settings/auth-credentials');
if (response.ok) {
const config = await response.json() as AuthConfig;
setAuthConfig(config);
}
} catch (error) {
console.error('Error fetching auth config:', error);
} finally {
setConfigLoading(false);
}
};
useEffect(() => {
void fetchAuthConfig();
}, []);
// Show loading while checking auth status
if (isLoading || configLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-4"></div>
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}
// Show setup modal if setup has not been completed yet
if (authConfig && !authConfig.setupCompleted && !setupCompleted) {
return <SetupModal isOpen={true} onComplete={handleSetupComplete} />;
}
// Show auth modal if auth is enabled but user is not authenticated
if (authConfig && authConfig.enabled && !isAuthenticated) {
return <AuthModal isOpen={true} />;
}
// Render children if authenticated or auth is disabled
return <>{children}</>;
}

View File

@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { useAuth } from './AuthProvider';
import { Lock, User, AlertCircle } from 'lucide-react';
interface AuthModalProps {
isOpen: boolean;
}
export function AuthModal({ isOpen }: AuthModalProps) {
const { login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
const success = await login(username, password);
if (!success) {
setError('Invalid username or password');
}
setIsLoading(false);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="flex items-center gap-3">
<Lock className="h-8 w-8 text-blue-600" />
<h2 className="text-2xl font-bold text-card-foreground">Authentication Required</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-muted-foreground text-center mb-6">
Please enter your credentials to access the application.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
Username
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="username"
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
className="pl-10"
required
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
required
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-800 border border-red-200 rounded-md">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
)}
<Button
type="submit"
disabled={isLoading || !username.trim() || !password.trim()}
className="w-full"
>
{isLoading ? 'Signing In...' : 'Sign In'}
</Button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
interface AuthContextType {
isAuthenticated: boolean;
username: string | null;
isLoading: boolean;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
checkAuth: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const checkAuth = async () => {
try {
// First check if setup is completed
const setupResponse = await fetch('/api/settings/auth-credentials');
if (setupResponse.ok) {
const setupData = await setupResponse.json() as { setupCompleted: boolean; enabled: boolean };
// If setup is not completed or auth is disabled, don't verify
if (!setupData.setupCompleted || !setupData.enabled) {
setIsAuthenticated(false);
setUsername(null);
setIsLoading(false);
return;
}
}
// Only verify authentication if setup is completed and auth is enabled
const response = await fetch('/api/auth/verify');
if (response.ok) {
const data = await response.json() as { username: string };
setIsAuthenticated(true);
setUsername(data.username);
} else {
setIsAuthenticated(false);
setUsername(null);
}
} catch (error) {
console.error('Error checking auth:', error);
setIsAuthenticated(false);
setUsername(null);
} finally {
setIsLoading(false);
}
};
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const data = await response.json() as { username: string };
setIsAuthenticated(true);
setUsername(data.username);
return true;
} else {
const errorData = await response.json();
console.error('Login failed:', errorData.error);
return false;
}
} catch (error) {
console.error('Login error:', error);
return false;
}
};
const logout = () => {
// Clear the auth cookie by setting it to expire
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
setIsAuthenticated(false);
setUsername(null);
};
useEffect(() => {
void checkAuth();
}, []);
return (
<AuthContext.Provider
value={{
isAuthenticated,
username,
isLoading,
login,
logout,
checkAuth,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -16,15 +16,15 @@ export function Badge({ variant, type, noteType, status, executionMode, children
const getTypeStyles = (scriptType: string) => { const getTypeStyles = (scriptType: string) => {
switch (scriptType.toLowerCase()) { switch (scriptType.toLowerCase()) {
case 'ct': case 'ct':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-700'; return 'bg-primary/10 text-primary border-primary/20';
case 'addon': case 'addon':
return 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border-purple-200 dark:border-purple-700'; return 'bg-purple-500/10 text-purple-400 border-purple-500/20';
case 'vm': case 'vm':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-700'; return 'bg-green-500/10 text-green-400 border-green-500/20';
case 'pve': case 'pve':
return 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200 border-orange-200 dark:border-orange-700'; return 'bg-orange-500/10 text-orange-400 border-orange-500/20';
default: default:
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border-gray-200 dark:border-gray-600'; return 'bg-muted text-muted-foreground border-border';
} }
}; };
@@ -34,45 +34,45 @@ export function Badge({ variant, type, noteType, status, executionMode, children
return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles('unknown')}`; return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles('unknown')}`;
case 'updateable': case 'updateable':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-700'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20';
case 'privileged': case 'privileged':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-700'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
case 'status': case 'status':
switch (status) { switch (status) {
case 'success': case 'success':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-700'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20';
case 'failed': case 'failed':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-700'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
case 'in_progress': case 'in_progress':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-700'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20';
default: default:
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
} }
case 'execution-mode': case 'execution-mode':
switch (executionMode) { switch (executionMode) {
case 'local': case 'local':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-700'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
case 'ssh': case 'ssh':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-700'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20';
default: default:
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
} }
case 'note': case 'note':
switch (noteType) { switch (noteType) {
case 'warning': case 'warning':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-700'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20';
case 'error': case 'error':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-700'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
default: default:
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-700'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
} }
default: default:
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600'; return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
} }
}; };

View File

@@ -40,7 +40,7 @@ const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; c
), ),
key: ( key: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1721 9z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z" />
</svg> </svg>
), ),
archive: ( archive: (
@@ -195,24 +195,24 @@ export function CategorySidebar({
}); });
return ( return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 transition-all duration-300 ${ <div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
isCollapsed ? 'w-16' : 'w-80' isCollapsed ? 'w-16' : 'w-full lg:w-80'
}`}> }`}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between p-4 border-b border-border">
{!isCollapsed && ( {!isCollapsed && (
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Categories</h3> <h3 className="text-lg font-semibold text-foreground">Categories</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{totalScripts} Total scripts</p> <p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
</div> </div>
)} )}
<button <button
onClick={() => setIsCollapsed(!isCollapsed)} onClick={() => setIsCollapsed(!isCollapsed)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
title={isCollapsed ? 'Expand categories' : 'Collapse categories'} title={isCollapsed ? 'Expand categories' : 'Collapse categories'}
> >
<svg <svg
className={`w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform ${ className={`w-5 h-5 text-muted-foreground transition-transform ${
isCollapsed ? 'rotate-180' : '' isCollapsed ? 'rotate-180' : ''
}`} }`}
fill="none" fill="none"
@@ -233,21 +233,21 @@ export function CategorySidebar({
onClick={() => onCategorySelect(null)} onClick={() => onCategorySelect(null)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${ className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
selectedCategory === null selectedCategory === null
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800' ? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : 'hover:bg-accent text-muted-foreground'
}`} }`}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<CategoryIcon <CategoryIcon
iconName="template" iconName="template"
className={`w-5 h-5 ${selectedCategory === null ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400'}`} className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground'}`}
/> />
<span className="font-medium">All Categories</span> <span className="font-medium">All Categories</span>
</div> </div>
<span className={`text-sm px-2 py-1 rounded-full ${ <span className={`text-sm px-2 py-1 rounded-full ${
selectedCategory === null selectedCategory === null
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300' ? 'bg-primary/20 text-primary'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400' : 'bg-muted text-muted-foreground'
}`}> }`}>
{totalScripts} {totalScripts}
</span> </span>
@@ -263,14 +263,14 @@ export function CategorySidebar({
onClick={() => onCategorySelect(category)} onClick={() => onCategorySelect(category)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${ className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
isSelected isSelected
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800' ? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : 'hover:bg-accent text-muted-foreground'
}`} }`}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<CategoryIcon <CategoryIcon
iconName={categoryIconMapping[category] ?? 'box'} iconName={categoryIconMapping[category] ?? 'box'}
className={`w-5 h-5 ${isSelected ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400'}`} className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`}
/> />
<span className="font-medium capitalize"> <span className="font-medium capitalize">
{category.replace(/[_-]/g, ' ')} {category.replace(/[_-]/g, ' ')}
@@ -278,8 +278,8 @@ export function CategorySidebar({
</div> </div>
<span className={`text-sm px-2 py-1 rounded-full ${ <span className={`text-sm px-2 py-1 rounded-full ${
isSelected isSelected
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300' ? 'bg-primary/20 text-primary'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400' : 'bg-muted text-muted-foreground'
}`}> }`}>
{count} {count}
</span> </span>
@@ -292,32 +292,32 @@ export function CategorySidebar({
{/* Collapsed state - show only icons with counters and tooltips */} {/* Collapsed state - show only icons with counters and tooltips */}
{isCollapsed && ( {isCollapsed && (
<div className="p-2 flex flex-col space-y-2"> <div className="p-2 flex flex-row lg:flex-col space-x-2 lg:space-x-0 lg:space-y-2 overflow-x-auto lg:overflow-x-visible">
{/* "All Categories" option */} {/* "All Categories" option */}
<div className="group relative"> <div className="group relative">
<button <button
onClick={() => onCategorySelect(null)} onClick={() => onCategorySelect(null)}
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${ className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
selectedCategory === null selectedCategory === null
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800' ? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : 'hover:bg-accent text-muted-foreground'
}`} }`}
> >
<CategoryIcon <CategoryIcon
iconName="template" iconName="template"
className={`w-5 h-5 ${selectedCategory === null ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200'}`} className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/> />
<span className={`text-xs mt-1 px-1 rounded ${ <span className={`text-xs mt-1 px-1 rounded ${
selectedCategory === null selectedCategory === null
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300' ? 'bg-primary/20 text-primary'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400' : 'bg-muted text-muted-foreground'
}`}> }`}>
{totalScripts} {totalScripts}
</span> </span>
</button> </button>
{/* Tooltip */} {/* Tooltip */}
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 dark:bg-gray-700 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50"> <div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
All Categories ({totalScripts}) All Categories ({totalScripts})
</div> </div>
</div> </div>
@@ -332,25 +332,25 @@ export function CategorySidebar({
onClick={() => onCategorySelect(category)} onClick={() => onCategorySelect(category)}
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${ className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
isSelected isSelected
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800' ? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : 'hover:bg-accent text-muted-foreground'
}`} }`}
> >
<CategoryIcon <CategoryIcon
iconName={categoryIconMapping[category] ?? 'box'} iconName={categoryIconMapping[category] ?? 'box'}
className={`w-5 h-5 ${isSelected ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200'}`} className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/> />
<span className={`text-xs mt-1 px-1 rounded ${ <span className={`text-xs mt-1 px-1 rounded ${
isSelected isSelected
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300' ? 'bg-primary/20 text-primary'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400' : 'bg-muted text-muted-foreground'
}`}> }`}>
{count} {count}
</span> </span>
</button> </button>
{/* Tooltip */} {/* Tooltip */}
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 dark:bg-gray-700 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50"> <div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
{category} ({count}) {category} ({count})
</div> </div>
</div> </div>

View File

@@ -0,0 +1,125 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import type { Server } from '../../types/server';
interface ColorCodedDropdownProps {
servers: Server[];
selectedServer: Server | null;
onServerSelect: (server: Server | null) => void;
placeholder?: string;
disabled?: boolean;
}
export function ColorCodedDropdown({
servers,
selectedServer,
onServerSelect,
placeholder = "Select a server...",
disabled = false
}: ColorCodedDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleServerClick = (server: Server) => {
onServerSelect(server);
setIsOpen(false);
};
const handleClearSelection = () => {
onServerSelect(null);
setIsOpen(false);
};
return (
<div className="relative" ref={dropdownRef}>
{/* Dropdown Button */}
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className={`w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary bg-background text-foreground text-left flex items-center justify-between ${
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-accent'
}`}
>
<span className="truncate">
{selectedServer ? (
<span className="flex items-center gap-2">
{selectedServer.color && (
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: selectedServer.color }}
/>
)}
{selectedServer.name} ({selectedServer.ip}) - {selectedServer.user}
</span>
) : (
placeholder
)}
</span>
<svg
className={`w-4 h-4 flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
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>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-card border border-border rounded-md shadow-lg max-h-60 overflow-auto">
{/* Clear Selection Option */}
<button
type="button"
onClick={handleClearSelection}
className="w-full px-3 py-2 text-left text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
{placeholder}
</button>
{/* Server Options */}
{servers
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
.map((server) => (
<button
key={server.id}
type="button"
onClick={() => handleServerClick(server)}
className={`w-full px-3 py-2 text-left text-sm transition-colors flex items-center gap-2 ${
selectedServer?.id === server.id
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent hover:text-foreground'
}`}
>
{server.color && (
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: server.color }}
/>
)}
<span className="truncate">
{server.name} ({server.ip}) - {server.user}
</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -1,86 +0,0 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface DarkModeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
isDark: boolean;
}
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
export function DarkModeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system');
const [isDark, setIsDark] = useState(false);
const [mounted, setMounted] = useState(false);
// Initialize theme from localStorage after mount
useEffect(() => {
const stored = localStorage.getItem('theme') as Theme;
if (stored && ['light', 'dark', 'system'].includes(stored)) {
setThemeState(stored);
}
// Set initial isDark state based on current DOM state
const currentlyDark = document.documentElement.classList.contains('dark');
setIsDark(currentlyDark);
setMounted(true);
}, []);
// Update dark mode state and DOM when theme changes
useEffect(() => {
if (!mounted) return;
const updateDarkMode = () => {
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = theme === 'dark' || (theme === 'system' && systemDark);
// Only update if there's actually a change
if (shouldBeDark !== isDark) {
setIsDark(shouldBeDark);
// Apply to document
if (shouldBeDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
};
updateDarkMode();
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
updateDarkMode();
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme, mounted, isDark]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
};
return (
<DarkModeContext.Provider value={{ theme, setTheme, isDark }}>
{children}
</DarkModeContext.Provider>
);
}
export function useDarkMode() {
const context = useContext(DarkModeContext);
if (context === undefined) {
throw new Error('useDarkMode must be used within a DarkModeProvider');
}
return context;
}

View File

@@ -1,66 +0,0 @@
'use client';
import { useDarkMode } from './DarkModeProvider';
export function DarkModeToggle() {
const { theme, setTheme, isDark } = useDarkMode();
const toggleTheme = () => {
if (theme === 'light') {
setTheme('dark');
} else if (theme === 'dark') {
setTheme('system');
} else {
setTheme('light');
}
};
const getIcon = () => {
if (theme === 'light') {
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
</svg>
);
} else if (theme === 'dark') {
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
);
} else {
// System theme icon
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7l.159-1.591A3.001 3.001 0 0112 8a3 3 0 01-3.229 2.409L8.771 12z" clipRule="evenodd" />
</svg>
);
}
};
const getLabel = () => {
if (theme === 'light') return 'Light mode';
if (theme === 'dark') return 'Dark mode';
return 'System theme';
};
return (
<button
onClick={toggleTheme}
className={`
flex items-center justify-center
w-10 h-10 rounded-lg
transition-all duration-200
hover:scale-105 active:scale-95
${isDark
? 'bg-gray-800 text-yellow-400 hover:bg-gray-700 border border-gray-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-300'
}
`}
title={getLabel()}
aria-label={getLabel()}
>
{getIcon()}
</button>
);
}

View File

@@ -45,17 +45,17 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
key={index} key={index}
className={`flex font-mono text-sm ${ className={`flex font-mono text-sm ${
isAdded isAdded
? 'bg-green-50 text-green-800 border-l-4 border-green-400' ? 'bg-green-500/10 text-green-400 border-l-4 border-green-500'
: isRemoved : isRemoved
? 'bg-red-50 text-red-800 border-l-4 border-red-400' ? 'bg-destructive/10 text-destructive border-l-4 border-destructive'
: 'bg-gray-50 text-gray-700' : 'bg-muted text-muted-foreground'
}`} }`}
> >
<div className="w-16 text-right pr-2 text-gray-500 select-none"> <div className="w-16 text-right pr-2 text-muted-foreground select-none">
{lineNumber} {lineNumber}
</div> </div>
<div className="flex-1 pl-2"> <div className="flex-1 pl-2">
<span className={isAdded ? 'text-green-600' : isRemoved ? 'text-red-600' : ''}> <span className={isAdded ? 'text-green-400' : isRemoved ? 'text-destructive' : ''}>
{isAdded ? '+' : isRemoved ? '-' : ' '} {isAdded ? '+' : isRemoved ? '-' : ' '}
</span> </span>
<span className="whitespace-pre-wrap">{content}</span> <span className="whitespace-pre-wrap">{content}</span>
@@ -66,27 +66,27 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
return ( return (
<div <div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className="bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden"> <div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden border border-border mx-4 sm:mx-0">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200"> <div className="flex items-center justify-between p-4 border-b border-border">
<div> <div>
<h2 className="text-xl font-bold text-gray-900">Script Diff</h2> <h2 className="text-xl font-bold text-foreground">Script Diff</h2>
<p className="text-sm text-gray-600">{filePath}</p> <p className="text-sm text-muted-foreground">{filePath}</p>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<button <button
onClick={handleRefresh} onClick={handleRefresh}
disabled={isLoading} disabled={isLoading}
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors disabled:opacity-50" className="px-3 py-1 text-sm bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors disabled:opacity-50"
> >
{isLoading ? 'Refreshing...' : 'Refresh'} {isLoading ? 'Refreshing...' : 'Refresh'}
</button> </button>
<button <button
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -96,19 +96,19 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
</div> </div>
{/* Legend */} {/* Legend */}
<div className="px-4 py-2 bg-gray-50 border-b border-gray-200"> <div className="px-4 py-2 bg-muted border-b border-border">
<div className="flex items-center space-x-4 text-sm"> <div className="flex items-center space-x-4 text-sm">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div className="w-3 h-3 bg-green-100 border border-green-300"></div> <div className="w-3 h-3 bg-green-500/20 border border-green-500/40"></div>
<span className="text-green-700">Added (Remote)</span> <span className="text-green-400">Added (Remote)</span>
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div className="w-3 h-3 bg-red-100 border border-red-300"></div> <div className="w-3 h-3 bg-destructive/20 border border-destructive/40"></div>
<span className="text-red-700">Removed (Local)</span> <span className="text-destructive">Removed (Local)</span>
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div className="w-3 h-3 bg-gray-100 border border-gray-300"></div> <div className="w-3 h-3 bg-muted border border-border"></div>
<span className="text-gray-700">Unchanged</span> <span className="text-muted-foreground">Unchanged</span>
</div> </div>
</div> </div>
</div> </div>
@@ -117,14 +117,14 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
<div className="overflow-y-auto max-h-[calc(90vh-120px)]"> <div className="overflow-y-auto max-h-[calc(90vh-120px)]">
{diffData?.success ? ( {diffData?.success ? (
diffData.diff ? ( diffData.diff ? (
<div className="divide-y divide-gray-200"> <div className="divide-y divide-border">
{diffData.diff.split('\n').map((line, index) => {diffData.diff.split('\n').map((line, index) =>
line.trim() ? renderDiffLine(line, index) : null line.trim() ? renderDiffLine(line, index) : null
)} )}
</div> </div>
) : ( ) : (
<div className="p-8 text-center text-gray-500"> <div className="p-8 text-center text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-12 h-12 mx-auto mb-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<p>No differences found</p> <p>No differences found</p>
@@ -132,16 +132,16 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
</div> </div>
) )
) : diffData?.error ? ( ) : diffData?.error ? (
<div className="p-8 text-center text-red-500"> <div className="p-8 text-center text-destructive">
<svg className="w-12 h-12 mx-auto mb-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-12 h-12 mx-auto mb-4 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<p>Error loading diff</p> <p>Error loading diff</p>
<p className="text-sm">{diffData.error}</p> <p className="text-sm">{diffData.error}</p>
</div> </div>
) : ( ) : (
<div className="p-8 text-center text-gray-500"> <div className="p-8 text-center text-muted-foreground">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p>Loading diff...</p> <p>Loading diff...</p>
</div> </div>
)} )}

View File

@@ -0,0 +1,527 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { api } from '~/trpc/react';
import { ScriptCard } from './ScriptCard';
import { ScriptCardList } from './ScriptCardList';
import { ScriptDetailModal } from './ScriptDetailModal';
import { CategorySidebar } from './CategorySidebar';
import { FilterBar, type FilterState } from './FilterBar';
import { ViewToggle } from './ViewToggle';
import { Button } from './ui/button';
import type { ScriptCard as ScriptCardType } from '~/types/script';
interface DownloadedScriptsTabProps {
onInstallScript?: (
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: any,
) => void;
}
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [filters, setFilters] = useState<FilterState>({
searchQuery: '',
showUpdatable: null,
selectedTypes: [],
sortBy: 'name',
sortOrder: 'asc',
});
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getCtScripts.useQuery();
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
{ slug: selectedSlug ?? '' },
{ enabled: !!selectedSlug }
);
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
useEffect(() => {
const loadSettings = async () => {
try {
// Load SAVE_FILTER setting
const saveFilterResponse = await fetch('/api/settings/save-filter');
let saveFilterEnabled = false;
if (saveFilterResponse.ok) {
const saveFilterData = await saveFilterResponse.json();
saveFilterEnabled = saveFilterData.enabled ?? false;
setSaveFiltersEnabled(saveFilterEnabled);
}
// Load saved filters if SAVE_FILTER is enabled
if (saveFilterEnabled) {
const filtersResponse = await fetch('/api/settings/filters');
if (filtersResponse.ok) {
const filtersData = await filtersResponse.json();
if (filtersData.filters) {
setFilters(filtersData.filters as FilterState);
}
}
}
// Load view mode
const viewModeResponse = await fetch('/api/settings/view-mode');
if (viewModeResponse.ok) {
const viewModeData = await viewModeResponse.json();
const viewMode = viewModeData.viewMode;
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
setViewMode(viewMode);
}
}
} catch (error) {
console.error('Error loading settings:', error);
} finally {
setIsLoadingFilters(false);
}
};
void loadSettings();
}, []);
// Save filters when they change (if SAVE_FILTER is enabled)
useEffect(() => {
if (!saveFiltersEnabled || isLoadingFilters) return;
const saveFilters = async () => {
try {
await fetch('/api/settings/filters', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filters }),
});
} catch (error) {
console.error('Error saving filters:', error);
}
};
// Debounce the save operation
const timeoutId = setTimeout(() => void saveFilters(), 500);
return () => clearTimeout(timeoutId);
}, [filters, saveFiltersEnabled, isLoadingFilters]);
// Save view mode when it changes
useEffect(() => {
if (isLoadingFilters) return;
const saveViewMode = async () => {
try {
await fetch('/api/settings/view-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ viewMode }),
});
} catch (error) {
console.error('Error saving view mode:', error);
}
};
// Debounce the save operation
const timeoutId = setTimeout(() => void saveViewMode(), 300);
return () => clearTimeout(timeoutId);
}, [viewMode, isLoadingFilters]);
// Extract categories from metadata
const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
return (scriptCardsData.metadata.categories as any[])
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
.sort((a, b) => a.sort_order - b.sort_order)
.map((cat) => cat.name as string)
.filter((name): name is string => typeof name === 'string');
}, [scriptCardsData]);
// Get GitHub scripts with download status (deduplicated)
const combinedScripts = React.useMemo((): ScriptCardType[] => {
if (!scriptCardsData?.success) return [];
// Use Map to deduplicate by slug/name
const scriptMap = new Map<string, ScriptCardType>();
scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, {
...script,
source: 'github' as const,
isDownloaded: false, // Will be updated by status check
isUpToDate: false, // Will be updated by status check
});
}
}
});
return Array.from(scriptMap.values());
}, [scriptCardsData]);
// Update scripts with download status and filter to only downloaded scripts
const downloadedScripts = React.useMemo((): ScriptCardType[] => {
return combinedScripts
.map(script => {
if (!script?.name) {
return script; // Return as-is if invalid
}
// Check if there's a corresponding local script
const hasLocalVersion = localScriptsData?.scripts?.some(local => {
if (!local?.name) return false;
const localName = local.name.replace(/\.sh$/, '');
return localName.toLowerCase() === script.name.toLowerCase() ||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
}) ?? false;
return {
...script,
isDownloaded: hasLocalVersion,
};
})
.filter(script => script.isDownloaded); // Only show downloaded scripts
}, [combinedScripts, localScriptsData]);
// Count scripts per category (using downloaded scripts only)
const categoryCounts = React.useMemo((): Record<string, number> => {
if (!scriptCardsData?.success) return {};
const counts: Record<string, number> = {};
// Initialize all categories with 0
categories.forEach((categoryName: string) => {
counts[categoryName] = 0;
});
// Count each unique downloaded script only once per category
downloadedScripts.forEach(script => {
if (script.categoryNames && script.slug) {
const countedCategories = new Set<string>();
script.categoryNames.forEach((categoryName: unknown) => {
if (typeof categoryName === 'string' && counts[categoryName] !== undefined && !countedCategories.has(categoryName)) {
countedCategories.add(categoryName);
counts[categoryName]++;
}
});
}
});
return counts;
}, [categories, downloadedScripts, scriptCardsData?.success]);
// Filter scripts based on all filters and category
const filteredScripts = React.useMemo((): ScriptCardType[] => {
let scripts = downloadedScripts;
// Filter by search query
if (filters.searchQuery?.trim()) {
const query = filters.searchQuery.toLowerCase().trim();
if (query.length >= 1) {
scripts = scripts.filter(script => {
if (!script || typeof script !== 'object') {
return false;
}
const name = (script.name ?? '').toLowerCase();
const slug = (script.slug ?? '').toLowerCase();
return name.includes(query) ?? slug.includes(query);
});
}
}
// Filter by category using real category data from downloaded scripts
if (selectedCategory) {
scripts = scripts.filter(script => {
if (!script) return false;
// Check if the downloaded script has categoryNames that include the selected category
return script.categoryNames?.includes(selectedCategory) ?? false;
});
}
// Filter by updateable status
if (filters.showUpdatable !== null) {
scripts = scripts.filter(script => {
if (!script) return false;
const isUpdatable = script.updateable ?? false;
return filters.showUpdatable ? isUpdatable : !isUpdatable;
});
}
// Filter by script types
if (filters.selectedTypes.length > 0) {
scripts = scripts.filter(script => {
if (!script) return false;
const scriptType = (script.type ?? '').toLowerCase();
return filters.selectedTypes.some(type => type.toLowerCase() === scriptType);
});
}
// Apply sorting
scripts.sort((a, b) => {
if (!a || !b) return 0;
let compareValue = 0;
switch (filters.sortBy) {
case 'name':
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
break;
case 'created':
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
const aCreated = a?.date_created ?? '';
const bCreated = b?.date_created ?? '';
// If both have dates, compare them directly
if (aCreated && bCreated) {
// For dates: asc = oldest first (2020 before 2024), desc = newest first (2024 before 2020)
compareValue = aCreated.localeCompare(bCreated);
} else if (aCreated && !bCreated) {
// Scripts with dates come before scripts without dates
compareValue = -1;
} else if (!aCreated && bCreated) {
// Scripts without dates come after scripts with dates
compareValue = 1;
} else {
// Both have no dates, fallback to name comparison
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
}
break;
default:
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
}
// Apply sort order
return filters.sortOrder === 'asc' ? compareValue : -compareValue;
});
return scripts;
}, [downloadedScripts, filters, selectedCategory]);
// Calculate filter counts for FilterBar
const filterCounts = React.useMemo(() => {
const updatableCount = downloadedScripts.filter(script => script?.updateable).length;
return { installedCount: downloadedScripts.length, updatableCount };
}, [downloadedScripts]);
// Handle filter changes
const handleFiltersChange = (newFilters: FilterState) => {
setFilters(newFilters);
};
// Handle category selection with auto-scroll
const handleCategorySelect = (category: string | null) => {
setSelectedCategory(category);
};
// Auto-scroll effect when category changes
useEffect(() => {
if (selectedCategory && gridRef.current) {
const timeoutId = setTimeout(() => {
gridRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
}, 100);
return () => clearTimeout(timeoutId);
}
}, [selectedCategory]);
const handleCardClick = (scriptCard: { slug: string }) => {
// All scripts are GitHub scripts, open modal
setSelectedSlug(scriptCard.slug);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedSlug(null);
};
if (githubLoading || localLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span>
</div>
);
}
if (githubError || localError) {
return (
<div className="text-center py-12">
<div className="text-red-600 mb-4">
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p className="text-lg font-medium">Failed to load downloaded scripts</p>
<p className="text-sm text-muted-foreground mt-1">
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
</p>
</div>
<Button
onClick={() => refetch()}
variant="default"
size="default"
className="mt-4"
>
Try Again
</Button>
</div>
);
}
if (!downloadedScripts || downloadedScripts.length === 0) {
return (
<div className="text-center py-12">
<div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-lg font-medium">No downloaded scripts found</p>
<p className="text-sm text-muted-foreground mt-1">
You haven&apos;t downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.
</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
{/* Category Sidebar */}
<div className="flex-shrink-0 order-2 lg:order-1">
<CategorySidebar
categories={categories}
categoryCounts={categoryCounts}
totalScripts={downloadedScripts.length}
selectedCategory={selectedCategory}
onCategorySelect={handleCategorySelect}
/>
</div>
{/* Main Content */}
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
{/* Enhanced Filter Bar */}
<FilterBar
filters={filters}
onFiltersChange={handleFiltersChange}
totalScripts={downloadedScripts.length}
filteredCount={filteredScripts.length}
updatableCount={filterCounts.updatableCount}
saveFiltersEnabled={saveFiltersEnabled}
isLoadingFilters={isLoadingFilters}
/>
{/* View Toggle */}
<ViewToggle
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* Scripts Grid */}
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
<div className="text-center py-12">
<div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p className="text-lg font-medium">No matching downloaded scripts found</p>
<p className="text-sm text-muted-foreground mt-1">
Try different filter settings or clear all filters.
</p>
<div className="flex justify-center gap-2 mt-4">
{filters.searchQuery && (
<Button
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
variant="default"
size="default"
>
Clear Search
</Button>
)}
{selectedCategory && (
<Button
onClick={() => handleCategorySelect(null)}
variant="secondary"
size="default"
>
Clear Category
</Button>
)}
</div>
</div>
</div>
) : (
viewMode === 'card' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties
if (!script || typeof script !== 'object') {
return null;
}
// Create a unique key by combining slug, name, and index to handle duplicates
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<ScriptCard
key={uniqueKey}
script={script}
onClick={handleCardClick}
/>
);
})}
</div>
) : (
<div className="space-y-3">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties
if (!script || typeof script !== 'object') {
return null;
}
// Create a unique key by combining slug, name, and index to handle duplicates
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<ScriptCardList
key={uniqueKey}
script={script}
onClick={handleCardClick}
/>
);
})}
</div>
)
)}
<ScriptDetailModal
script={scriptData?.success ? scriptData.script : null}
isOpen={isModalOpen}
onClose={handleCloseModal}
onInstallScript={onInstallScript}
/>
</div>
</div>
</div>
);
}

View File

@@ -2,6 +2,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { Server } from '../../types/server'; import type { Server } from '../../types/server';
import { Button } from './ui/button';
import { ColorCodedDropdown } from './ColorCodedDropdown';
import { SettingsModal } from './SettingsModal';
interface ExecutionModeModalProps { interface ExecutionModeModalProps {
isOpen: boolean; isOpen: boolean;
@@ -14,8 +18,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
const [servers, setServers] = useState<Server[]>([]); const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedMode, setSelectedMode] = useState<'local' | 'ssh'>('local');
const [selectedServer, setSelectedServer] = useState<Server | null>(null); const [selectedServer, setSelectedServer] = useState<Server | null>(null);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -23,6 +27,20 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
} }
}, [isOpen]); }, [isOpen]);
// Auto-select server when exactly one server is available
useEffect(() => {
if (isOpen && !loading && servers.length === 1) {
setSelectedServer(servers[0] ?? null);
}
}, [isOpen, loading, servers]);
// Refresh servers when settings modal closes
const handleSettingsModalClose = () => {
setSettingsModalOpen(false);
// Refetch servers to reflect any changes made in settings
void fetchServers();
};
const fetchServers = async () => { const fetchServers = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -32,7 +50,11 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
throw new Error('Failed to fetch servers'); throw new Error('Failed to fetch servers');
} }
const data = await response.json(); const data = await response.json();
setServers(data as Server[]); // Sort servers by name alphabetically
const sortedServers = (data as Server[]).sort((a, b) =>
(a.name ?? '').localeCompare(b.name ?? '')
);
setServers(sortedServers);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred'); setError(err instanceof Error ? err.message : 'An error occurred');
} finally { } finally {
@@ -41,166 +63,175 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
}; };
const handleExecute = () => { const handleExecute = () => {
if (selectedMode === 'ssh' && !selectedServer) { if (!selectedServer) {
setError('Please select a server for SSH execution'); setError('Please select a server for SSH execution');
return; return;
} }
onExecute(selectedMode, selectedServer ?? undefined); onExecute('ssh', selectedServer);
onClose(); onClose();
}; };
const handleModeChange = (mode: 'local' | 'ssh') => {
setSelectedMode(mode); const handleServerSelect = (server: Server | null) => {
if (mode === 'local') { setSelectedServer(server);
setSelectedServer(null);
}
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200"> <div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-xl font-bold text-gray-900">Execution Mode</h2> <h2 className="text-xl font-bold text-foreground">Select Server</h2>
<button <Button
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors" variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </Button>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-gray-900 mb-2">
Where would you like to execute &quot;{scriptName}&quot;?
</h3>
</div>
{error && ( {error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md"> <div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> <svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" /> <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg> </svg>
</div> </div>
<div className="ml-3"> <div className="ml-3">
<p className="text-sm text-red-700">{error}</p> <p className="text-sm text-destructive">{error}</p>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* Execution Mode Selection */}
<div className="space-y-4 mb-6">
{/* SSH Execution */}
<div
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
selectedMode === 'ssh'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => handleModeChange('ssh')}
>
<div className="flex items-center">
<input
type="radio"
id="ssh"
name="executionMode"
value="ssh"
checked={selectedMode === 'ssh'}
onChange={() => handleModeChange('ssh')}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<label htmlFor="ssh" className="ml-3 flex-1 cursor-pointer">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-gray-900">SSH Execution</h4>
<p className="text-sm text-gray-500">Run the script on a remote server</p>
</div>
</div>
</label>
</div>
</div>
</div>
{/* Server Selection (only for SSH mode) */}
{selectedMode === 'ssh' && (
<div className="mb-6">
<label htmlFor="server" className="block text-sm font-medium text-gray-700 mb-2">
Select Server
</label>
{loading ? ( {loading ? (
<div className="text-center py-4"> <div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div> <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="mt-2 text-sm text-gray-600">Loading servers...</p> <p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
</div> </div>
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<div className="text-center py-4 text-gray-500"> <div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No servers configured</p> <p className="text-sm">No servers configured</p>
<p className="text-xs mt-1">Add servers in Settings to use SSH execution</p> <p className="text-xs mt-1">Add servers in Settings to execute scripts</p>
</div> <Button
) : ( onClick={() => setSettingsModalOpen(true)}
<select variant="outline"
id="server" size="sm"
value={selectedServer?.id ?? ''} className="mt-3"
onChange={(e) => {
const serverId = parseInt(e.target.value);
const server = servers.find(s => s.id === serverId);
setSelectedServer(server ?? null);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
> >
<option value="">Select a server...</option> Open Server Settings
{servers.map((server) => ( </Button>
<option key={server.id} value={server.id}> </div>
{server.name} ({server.ip}) - {server.user} ) : servers.length === 1 ? (
</option> /* Single Server Confirmation View */
))} <div className="space-y-6">
</select> <div className="text-center">
)} <h3 className="text-lg font-medium text-foreground mb-2">
Install Script Confirmation
</h3>
<p className="text-sm text-muted-foreground">
Do you want to install &quot;{scriptName}&quot; on the following server?
</p>
</div>
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{selectedServer?.name ?? 'Unnamed Server'}
</p>
<p className="text-sm text-muted-foreground">
{selectedServer?.ip}
</p>
</div>
</div>
</div> </div>
)}
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
<button <Button
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" variant="outline"
size="default"
> >
Cancel Cancel
</button> </Button>
<button <Button
onClick={handleExecute} onClick={handleExecute}
disabled={selectedMode === 'ssh' && !selectedServer} variant="default"
className={`px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${ size="default"
selectedMode === 'ssh' && !selectedServer
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
> >
{selectedMode === 'local' ? 'Run Locally' : 'Run on Server'} Install
</button> </Button>
</div> </div>
</div>
) : (
/* Multiple Servers Selection View */
<div className="space-y-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-foreground mb-2">
Select server to execute &quot;{scriptName}&quot;
</h3>
</div>
{/* Server Selection */}
<div className="mb-6">
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
Select Server
</label>
<ColorCodedDropdown
servers={servers}
selectedServer={selectedServer}
onServerSelect={handleServerSelect}
placeholder="Select a server..."
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<Button
onClick={onClose}
variant="outline"
size="default"
>
Cancel
</Button>
<Button
onClick={handleExecute}
disabled={!selectedServer}
variant="default"
size="default"
className={!selectedServer ? 'bg-gray-400 cursor-not-allowed' : ''}
>
Run on Server
</Button>
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
{/* Server Settings Modal */}
<SettingsModal
isOpen={settingsModalOpen}
onClose={handleSettingsModalClose}
/>
</>
); );
} }

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { Button } from "./ui/button";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
export interface FilterState { export interface FilterState {
searchQuery: string; searchQuery: string;
@@ -16,13 +18,15 @@ interface FilterBarProps {
totalScripts: number; totalScripts: number;
filteredCount: number; filteredCount: number;
updatableCount?: number; updatableCount?: number;
saveFiltersEnabled?: boolean;
isLoadingFilters?: boolean;
} }
const SCRIPT_TYPES = [ const SCRIPT_TYPES = [
{ value: "ct", label: "LXC Container", icon: "📦" }, { value: "ct", label: "LXC Container", Icon: Package },
{ value: "vm", label: "Virtual Machine", icon: "💻" }, { value: "vm", label: "Virtual Machine", Icon: Monitor },
{ value: "addon", label: "Add-on", icon: "🔧" }, { value: "addon", label: "Add-on", Icon: Wrench },
{ value: "pve", label: "PVE Host", icon: "🖥️" }, { value: "pve", label: "PVE Host", Icon: Server },
]; ];
export function FilterBar({ export function FilterBar({
@@ -31,8 +35,11 @@ export function FilterBar({
totalScripts, totalScripts,
filteredCount, filteredCount,
updatableCount = 0, updatableCount = 0,
saveFiltersEnabled = false,
isLoadingFilters = false,
}: FilterBarProps) { }: FilterBarProps) {
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false); const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
const updateFilters = (updates: Partial<FilterState>) => { const updateFilters = (updates: Partial<FilterState>) => {
onFiltersChange({ ...filters, ...updates }); onFiltersChange({ ...filters, ...updates });
@@ -74,13 +81,35 @@ export function FilterBar({
}; };
return ( return (
<div className="mb-6 rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800"> <div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
{/* Loading State */}
{isLoadingFilters && (
<div className="mb-4 flex items-center justify-center py-2">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span>Loading saved filters...</span>
</div>
</div>
)}
{/* Filter Persistence Status */}
{!isLoadingFilters && saveFiltersEnabled && (
<div className="mb-4 flex items-center justify-center py-1">
<div className="flex items-center space-x-2 text-xs text-green-600">
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>Filters are being saved automatically</span>
</div>
</div>
)}
{/* Search Bar */} {/* Search Bar */}
<div className="mb-4"> <div className="mb-4">
<div className="relative max-w-md"> <div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg <svg
className="h-5 w-5 text-gray-400 dark:text-gray-500" className="h-5 w-5 text-muted-foreground"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -98,12 +127,14 @@ export function FilterBar({
placeholder="Search scripts..." placeholder="Search scripts..."
value={filters.searchQuery} value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })} onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-gray-300 bg-white py-3 pr-10 pl-10 text-sm leading-5 text-gray-900 placeholder-gray-500 focus:border-blue-500 focus:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:border-blue-400 dark:focus:placeholder-gray-300 dark:focus:ring-blue-400" className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
/> />
{filters.searchQuery && ( {filters.searchQuery && (
<button <Button
onClick={() => updateFilters({ searchQuery: "" })} onClick={() => updateFilters({ searchQuery: "" })}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300" variant="ghost"
size="icon"
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
> >
<svg <svg
className="h-5 w-5" className="h-5 w-5"
@@ -118,15 +149,15 @@ export function FilterBar({
d="M6 18L18 6M6 6l12 12" d="M6 18L18 6M6 6l12 12"
/> />
</svg> </svg>
</button> </Button>
)} )}
</div> </div>
</div> </div>
{/* Filter Buttons */} {/* Filter Buttons */}
<div className="mb-4 flex flex-wrap gap-3"> <div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Updateable Filter */} {/* Updateable Filter */}
<button <Button
onClick={() => { onClick={() => {
const next = const next =
filters.showUpdatable === null filters.showUpdatable === null
@@ -136,27 +167,33 @@ export function FilterBar({
: null; : null;
updateFilters({ showUpdatable: next }); updateFilters({ showUpdatable: next });
}} }}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${ variant="outline"
size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
filters.showUpdatable === null filters.showUpdatable === null
? "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600" ? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: filters.showUpdatable === true : filters.showUpdatable === true
? "border border-green-300 bg-green-100 text-green-800 dark:border-green-700 dark:bg-green-900/50 dark:text-green-200" ? "border border-green-500/20 bg-green-500/10 text-green-400"
: "border border-red-300 bg-red-100 text-red-800 dark:border-red-700 dark:bg-red-900/50 dark:text-red-200" : "border border-destructive/20 bg-destructive/10 text-destructive"
}`} }`}
> >
{getUpdatableButtonText()} <RefreshCw className="h-4 w-4" />
</button> <span>{getUpdatableButtonText()}</span>
</Button>
{/* Type Dropdown */} {/* Type Dropdown */}
<div className="relative"> <div className="relative w-full sm:w-auto">
<button <Button
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)} onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${ variant="outline"
size="default"
className={`w-full flex items-center justify-center space-x-2 ${
filters.selectedTypes.length === 0 filters.selectedTypes.length === 0
? "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600" ? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: "border border-cyan-300 bg-cyan-100 text-cyan-800 dark:border-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-200" : "border border-primary/20 bg-primary/10 text-primary"
}`} }`}
> >
<Filter className="h-4 w-4" />
<span>{getTypeButtonText()}</span> <span>{getTypeButtonText()}</span>
<svg <svg
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`} className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
@@ -171,15 +208,17 @@ export function FilterBar({
d="M19 9l-7 7-7-7" d="M19 9l-7 7-7-7"
/> />
</svg> </svg>
</button> </Button>
{isTypeDropdownOpen && ( {isTypeDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"> <div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2"> <div className="p-2">
{SCRIPT_TYPES.map((type) => ( {SCRIPT_TYPES.map((type) => {
const IconComponent = type.Icon;
return (
<label <label
key={type.value} key={type.value}
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700" className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
> >
<input <input
type="checkbox" type="checkbox"
@@ -200,52 +239,104 @@ export function FilterBar({
}); });
} }
}} }}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600" className="rounded border-input text-primary focus:ring-primary"
/> />
<span className="text-lg">{type.icon}</span> <IconComponent className="h-4 w-4" />
<span className="text-sm text-gray-700 dark:text-gray-300"> <span className="text-sm text-muted-foreground">
{type.label} {type.label}
</span> </span>
</label> </label>
))} );
})}
</div> </div>
<div className="border-t border-gray-200 p-2 dark:border-gray-700"> <div className="border-t border-border p-2">
<button <Button
onClick={() => { onClick={() => {
updateFilters({ selectedTypes: [] }); updateFilters({ selectedTypes: [] });
setIsTypeDropdownOpen(false); setIsTypeDropdownOpen(false);
}} }}
className="w-full rounded-md px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100" variant="ghost"
size="sm"
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
> >
Clear all Clear all
</Button>
</div>
</div>
)}
</div>
{/* Sort By Dropdown */}
<div className="relative w-full sm:w-auto">
<Button
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{filters.sortBy === "name" ? (
<FileText className="h-4 w-4" />
) : (
<Calendar className="h-4 w-4" />
)}
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
<svg
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
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>
{isSortDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2">
<button
onClick={() => {
updateFilters({ sortBy: "name" });
setIsSortDropdownOpen(false);
}}
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
}`}
>
<FileText className="h-4 w-4" />
<span className="text-sm">By Name</span>
</button>
<button
onClick={() => {
updateFilters({ sortBy: "created" });
setIsSortDropdownOpen(false);
}}
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
}`}
>
<Calendar className="h-4 w-4" />
<span className="text-sm">By Created Date</span>
</button> </button>
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Sort Options */}
<div className="flex items-center space-x-2">
{/* Sort By Dropdown */}
<select
value={filters.sortBy}
onChange={(e) =>
updateFilters({ sortBy: e.target.value as "name" | "created" })
}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:focus:ring-blue-400"
>
<option value="name">📝 By Name</option>
<option value="created">📅 By Created Date</option>
</select>
{/* Sort Order Button */} {/* Sort Order Button */}
<button <Button
onClick={() => onClick={() =>
updateFilters({ updateFilters({
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc", sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
}) })
} }
className="flex items-center space-x-1 rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600" variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
> >
{filters.sortOrder === "asc" ? ( {filters.sortOrder === "asc" ? (
<> <>
@@ -286,20 +377,19 @@ export function FilterBar({
</span> </span>
</> </>
)} )}
</button> </Button>
</div>
</div> </div>
{/* Filter Summary and Clear All */} {/* Filter Summary and Clear All */}
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div className="text-sm text-gray-600 dark:text-gray-400"> <div className="text-sm text-muted-foreground">
{filteredCount === totalScripts ? ( {filteredCount === totalScripts ? (
<span>Showing all {totalScripts} scripts</span> <span>Showing all {totalScripts} scripts</span>
) : ( ) : (
<span> <span>
{filteredCount} of {totalScripts} scripts{" "} {filteredCount} of {totalScripts} scripts{" "}
{hasActiveFilters && ( {hasActiveFilters && (
<span className="font-medium text-blue-600 dark:text-blue-400"> <span className="font-medium text-blue-600">
(filtered) (filtered)
</span> </span>
)} )}
@@ -308,9 +398,11 @@ export function FilterBar({
</div> </div>
{hasActiveFilters && ( {hasActiveFilters && (
<button <Button
onClick={clearAllFilters} onClick={clearAllFilters}
className="flex items-center space-x-1 rounded-md px-3 py-1 text-sm text-red-600 transition-colors hover:bg-red-50 hover:text-red-800 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:text-red-300" variant="ghost"
size="sm"
className="flex items-center space-x-1 text-red-600 hover:bg-red-50 hover:text-red-800 w-full sm:w-auto justify-center sm:justify-start"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -326,15 +418,18 @@ export function FilterBar({
/> />
</svg> </svg>
<span>Clear all filters</span> <span>Clear all filters</span>
</button> </Button>
)} )}
</div> </div>
{/* Click outside to close dropdown */} {/* Click outside to close dropdowns */}
{isTypeDropdownOpen && ( {(isTypeDropdownOpen || isSortDropdownOpen) && (
<div <div
className="fixed inset-0 z-0" className="fixed inset-0 z-0"
onClick={() => setIsTypeDropdownOpen(false)} onClick={() => {
setIsTypeDropdownOpen(false);
setIsSortDropdownOpen(false);
}}
/> />
)} )}
</div> </div>

View File

@@ -0,0 +1,593 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Toggle } from './ui/toggle';
interface GeneralSettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth'>('general');
const [githubToken, setGithubToken] = useState('');
const [saveFilter, setSaveFilter] = useState(false);
const [savedFilters, setSavedFilters] = useState<any>(null);
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Auth state
const [authUsername, setAuthUsername] = useState('');
const [authPassword, setAuthPassword] = useState('');
const [authConfirmPassword, setAuthConfirmPassword] = useState('');
const [authEnabled, setAuthEnabled] = useState(false);
const [authHasCredentials, setAuthHasCredentials] = useState(false);
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
// Load existing settings when modal opens
useEffect(() => {
if (isOpen) {
void loadGithubToken();
void loadSaveFilter();
void loadSavedFilters();
void loadAuthCredentials();
void loadColorCodingSetting();
}
}, [isOpen]);
const loadGithubToken = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/settings/github-token');
if (response.ok) {
const data = await response.json();
setGithubToken((data.token as string) ?? '');
}
} catch (error) {
console.error('Error loading GitHub token:', error);
} finally {
setIsLoading(false);
}
};
const loadSaveFilter = async () => {
try {
const response = await fetch('/api/settings/save-filter');
if (response.ok) {
const data = await response.json();
setSaveFilter((data.enabled as boolean) ?? false);
}
} catch (error) {
console.error('Error loading save filter setting:', error);
}
};
const saveSaveFilter = async (enabled: boolean) => {
try {
const response = await fetch('/api/settings/save-filter', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setSaveFilter(enabled);
setMessage({ type: 'success', text: 'Save filter setting updated!' });
// If disabling save filters, clear saved filters
if (!enabled) {
await clearSavedFilters();
}
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save setting' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save setting' });
}
};
const loadSavedFilters = async () => {
try {
const response = await fetch('/api/settings/filters');
if (response.ok) {
const data = await response.json();
setSavedFilters(data.filters);
}
} catch (error) {
console.error('Error loading saved filters:', error);
}
};
const clearSavedFilters = async () => {
try {
const response = await fetch('/api/settings/filters', {
method: 'DELETE',
});
if (response.ok) {
setSavedFilters(null);
setMessage({ type: 'success', text: 'Saved filters cleared!' });
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to clear filters' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to clear filters' });
}
};
const saveGithubToken = async () => {
setIsSaving(true);
setMessage(null);
try {
const response = await fetch('/api/settings/github-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: githubToken }),
});
if (response.ok) {
setMessage({ type: 'success', text: 'GitHub token saved successfully!' });
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save token' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save token' });
} finally {
setIsSaving(false);
}
};
const loadColorCodingSetting = async () => {
try {
const response = await fetch('/api/settings/color-coding');
if (response.ok) {
const data = await response.json();
setColorCodingEnabled(Boolean(data.enabled));
}
} catch (error) {
console.error('Error loading color coding setting:', error);
}
};
const saveColorCodingSetting = async (enabled: boolean) => {
try {
const response = await fetch('/api/settings/color-coding', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setColorCodingEnabled(enabled);
setMessage({ type: 'success', text: 'Color coding setting saved successfully' });
setTimeout(() => setMessage(null), 3000);
} else {
setMessage({ type: 'error', text: 'Failed to save color coding setting' });
setTimeout(() => setMessage(null), 3000);
}
} catch (error) {
console.error('Error saving color coding setting:', error);
setMessage({ type: 'error', text: 'Failed to save color coding setting' });
setTimeout(() => setMessage(null), 3000);
}
};
const loadAuthCredentials = async () => {
setAuthLoading(true);
try {
const response = await fetch('/api/settings/auth-credentials');
if (response.ok) {
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean };
setAuthUsername(data.username ?? '');
setAuthEnabled(data.enabled ?? false);
setAuthHasCredentials(data.hasCredentials ?? false);
setAuthSetupCompleted(data.setupCompleted ?? false);
}
} catch (error) {
console.error('Error loading auth credentials:', error);
} finally {
setAuthLoading(false);
}
};
const saveAuthCredentials = async () => {
if (authPassword !== authConfirmPassword) {
setMessage({ type: 'error', text: 'Passwords do not match' });
return;
}
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch('/api/settings/auth-credentials', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: authUsername,
password: authPassword,
enabled: authEnabled
}),
});
if (response.ok) {
setMessage({ type: 'success', text: 'Authentication credentials updated successfully!' });
setAuthPassword('');
setAuthConfirmPassword('');
void loadAuthCredentials();
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save credentials' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save credentials' });
} finally {
setAuthLoading(false);
}
};
const toggleAuthEnabled = async (enabled: boolean) => {
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch('/api/settings/auth-credentials', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setAuthEnabled(enabled);
setMessage({
type: 'success',
text: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully!`
});
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to update auth status' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to update auth status' });
} finally {
setAuthLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-8 px-4 sm:px-6">
<Button
onClick={() => setActiveTab('general')}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'general'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
General
</Button>
<Button
onClick={() => setActiveTab('github')}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'github'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
GitHub
</Button>
<Button
onClick={() => setActiveTab('auth')}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'auth'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Authentication
</Button>
</nav>
</div>
{/* Content */}
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
{activeTab === 'general' && (
<div className="space-y-4 sm:space-y-6">
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">General Settings</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure general application preferences and behavior.
</p>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Save Filters</h4>
<p className="text-sm text-muted-foreground mb-4">Save your configured script filters.</p>
<Toggle
checked={saveFilter}
onCheckedChange={saveSaveFilter}
label="Enable filter saving"
/>
{saveFilter && (
<div className="mt-4 p-3 bg-muted rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Saved Filters</p>
<p className="text-xs text-muted-foreground">
{savedFilters ? 'Filters are currently saved' : 'No filters saved yet'}
</p>
{savedFilters && (
<div className="mt-2 text-xs text-muted-foreground">
<div>Search: {savedFilters.searchQuery ?? 'None'}</div>
<div>Types: {savedFilters.selectedTypes?.length ?? 0} selected</div>
<div>Sort: {savedFilters.sortBy} ({savedFilters.sortOrder})</div>
</div>
)}
</div>
{savedFilters && (
<Button
onClick={clearSavedFilters}
variant="outline"
size="sm"
className="text-red-600 hover:text-red-800"
>
Clear
</Button>
)}
</div>
</div>
)}
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Server Color Coding</h4>
<p className="text-sm text-muted-foreground mb-4">Enable color coding for servers to visually distinguish them throughout the application.</p>
<Toggle
checked={colorCodingEnabled}
onCheckedChange={saveColorCodingSetting}
label="Enable server color coding"
/>
</div>
</div>
</div>
)}
{activeTab === 'github' && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">GitHub Integration</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure GitHub integration for script management and updates.
</p>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">GitHub Personal Access Token</h4>
<p className="text-sm text-muted-foreground mb-4">Save a GitHub Personal Access Token to circumvent GitHub API rate limits.</p>
<div className="space-y-3">
<div>
<label htmlFor="github-token" className="block text-sm font-medium text-foreground mb-1">
Token
</label>
<Input
id="github-token"
type="password"
placeholder="Enter your GitHub Personal Access Token"
value={githubToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGithubToken(e.target.value)}
disabled={isLoading || isSaving}
className="w-full"
/>
</div>
{message && (
<div className={`p-3 rounded-md text-sm ${
message.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}>
{message.text}
</div>
)}
<div className="flex gap-2">
<Button
onClick={saveGithubToken}
disabled={isSaving || isLoading || !githubToken.trim()}
className="flex-1"
>
{isSaving ? 'Saving...' : 'Save Token'}
</Button>
<Button
onClick={loadGithubToken}
disabled={isLoading || isSaving}
variant="outline"
>
{isLoading ? 'Loading...' : 'Refresh'}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'auth' && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Authentication Settings</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure authentication to secure access to your application.
</p>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Authentication Status</h4>
<p className="text-sm text-muted-foreground mb-4">
{authSetupCompleted
? (authHasCredentials
? `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. Current username: ${authUsername}`
: `Authentication is ${authEnabled ? 'enabled' : 'disabled'}. No credentials configured.`)
: 'Authentication setup has not been completed yet.'
}
</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Enable Authentication</p>
<p className="text-xs text-muted-foreground">
{authEnabled
? 'Authentication is required on every page load'
: 'Authentication is optional'
}
</p>
</div>
<Toggle
checked={authEnabled}
onCheckedChange={toggleAuthEnabled}
disabled={authLoading || !authSetupCompleted}
label="Enable authentication"
/>
</div>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
<p className="text-sm text-muted-foreground mb-4">
Change your username and password for authentication.
</p>
<div className="space-y-3">
<div>
<label htmlFor="auth-username" className="block text-sm font-medium text-foreground mb-1">
Username
</label>
<Input
id="auth-username"
type="text"
placeholder="Enter username"
value={authUsername}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthUsername(e.target.value)}
disabled={authLoading}
className="w-full"
minLength={3}
/>
</div>
<div>
<label htmlFor="auth-password" className="block text-sm font-medium text-foreground mb-1">
New Password
</label>
<Input
id="auth-password"
type="password"
placeholder="Enter new password"
value={authPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthPassword(e.target.value)}
disabled={authLoading}
className="w-full"
minLength={6}
/>
</div>
<div>
<label htmlFor="auth-confirm-password" className="block text-sm font-medium text-foreground mb-1">
Confirm Password
</label>
<Input
id="auth-confirm-password"
type="password"
placeholder="Confirm new password"
value={authConfirmPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthConfirmPassword(e.target.value)}
disabled={authLoading}
className="w-full"
minLength={6}
/>
</div>
{message && (
<div className={`p-3 rounded-md text-sm ${
message.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}>
{message.text}
</div>
)}
<div className="flex gap-2">
<Button
onClick={saveAuthCredentials}
disabled={authLoading || !authUsername.trim() || !authPassword.trim() || !authConfirmPassword.trim()}
className="flex-1"
>
{authLoading ? 'Saving...' : 'Update Credentials'}
</Button>
<Button
onClick={loadAuthCredentials}
disabled={authLoading}
variant="outline"
>
{authLoading ? 'Loading...' : 'Refresh'}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,12 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect, useRef } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Terminal } from './Terminal'; import { Terminal } from './Terminal';
import { StatusBadge, ExecutionModeBadge } from './Badge'; import { StatusBadge } from './Badge';
import { Button } from './ui/button';
import { ScriptInstallationCard } from './ScriptInstallationCard';
import { getContrastColor } from '../../lib/colorUtils';
interface InstalledScript { interface InstalledScript {
id: number; id: number;
@@ -15,7 +18,7 @@ interface InstalledScript {
server_ip: string | null; server_ip: string | null;
server_user: string | null; server_user: string | null;
server_password: string | null; server_password: string | null;
execution_mode: 'local' | 'ssh'; server_color: string | null;
installation_date: string; installation_date: string;
status: 'in_progress' | 'success' | 'failed'; status: 'in_progress' | 'success' | 'failed';
output_log: string | null; output_log: string | null;
@@ -25,11 +28,18 @@ export function InstalledScriptsTab() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
const [serverFilter, setServerFilter] = useState<string>('all'); const [serverFilter, setServerFilter] = useState<string>('all');
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any; mode: 'local' | 'ssh' } | null>(null); const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('script_name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
const [editingScriptId, setEditingScriptId] = useState<number | null>(null); const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' }); const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' }); const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' });
const [showAutoDetectForm, setShowAutoDetectForm] = useState(false);
const [autoDetectServerId, setAutoDetectServerId] = useState<string>('');
const [autoDetectStatus, setAutoDetectStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
const [cleanupStatus, setCleanupStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
const cleanupRunRef = useRef(false);
// Fetch installed scripts // Fetch installed scripts
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery(); const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
@@ -67,12 +77,90 @@ export function InstalledScriptsTab() {
} }
}); });
// Auto-detect LXC containers mutation
const autoDetectMutation = api.installedScripts.autoDetectLXCContainers.useMutation({
onSuccess: (data) => {
console.log('Auto-detect success:', data);
void refetchScripts();
setShowAutoDetectForm(false);
setAutoDetectServerId('');
// Show detailed message about what was added/skipped
let statusMessage = data.message ?? 'Auto-detection completed successfully!';
if (data.skippedContainers && data.skippedContainers.length > 0) {
const skippedNames = data.skippedContainers.map((c: any) => String(c.hostname)).join(', ');
statusMessage += ` Skipped duplicates: ${skippedNames}`;
}
setAutoDetectStatus({
type: 'success',
message: statusMessage
});
// Clear status after 8 seconds (longer for detailed info)
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 8000);
},
onError: (error) => {
console.error('Auto-detect mutation error:', error);
console.error('Error details:', {
message: error.message,
data: error.data
});
setAutoDetectStatus({
type: 'error',
message: error.message ?? 'Auto-detection failed. Please try again.'
});
// Clear status after 5 seconds
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
}
});
// Cleanup orphaned scripts mutation
const cleanupMutation = api.installedScripts.cleanupOrphanedScripts.useMutation({
onSuccess: (data) => {
console.log('Cleanup success:', data);
void refetchScripts();
if (data.deletedCount > 0) {
setCleanupStatus({
type: 'success',
message: `Cleanup completed! Removed ${data.deletedCount} orphaned script(s): ${data.deletedScripts.join(', ')}`
});
} else {
setCleanupStatus({
type: 'success',
message: 'Cleanup completed! No orphaned scripts found.'
});
}
// Clear status after 8 seconds (longer for cleanup info)
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 8000);
},
onError: (error) => {
console.error('Cleanup mutation error:', error);
setCleanupStatus({
type: 'error',
message: error.message ?? 'Cleanup failed. Please try again.'
});
// Clear status after 5 seconds
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 5000);
}
});
const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? []; const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
const stats = statsData?.stats; const stats = statsData?.stats;
// Filter scripts based on search and filters // Run cleanup when component mounts and scripts are loaded (only once)
const filteredScripts = scripts.filter((script: InstalledScript) => { useEffect(() => {
if (scripts.length > 0 && serversData?.servers && !cleanupMutation.isPending && !cleanupRunRef.current) {
console.log('Running automatic cleanup check...');
cleanupRunRef.current = true;
void cleanupMutation.mutate();
}
}, [scripts.length, serversData?.servers, cleanupMutation]);
// Filter and sort scripts
const filteredScripts = scripts
.filter((script: InstalledScript) => {
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) || const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(script.container_id?.includes(searchTerm) ?? false) || (script.container_id?.includes(searchTerm) ?? false) ||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false); (script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
@@ -80,10 +168,47 @@ export function InstalledScriptsTab() {
const matchesStatus = statusFilter === 'all' || script.status === statusFilter; const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
const matchesServer = serverFilter === 'all' || const matchesServer = serverFilter === 'all' ||
(serverFilter === 'local' && script.execution_mode === 'local') || (serverFilter === 'local' && !script.server_name) ||
(script.server_name === serverFilter); (script.server_name === serverFilter);
return matchesSearch && matchesStatus && matchesServer; return matchesSearch && matchesStatus && matchesServer;
})
.sort((a: InstalledScript, b: InstalledScript) => {
let aValue: any;
let bValue: any;
switch (sortField) {
case 'script_name':
aValue = a.script_name.toLowerCase();
bValue = b.script_name.toLowerCase();
break;
case 'container_id':
aValue = a.container_id ?? '';
bValue = b.container_id ?? '';
break;
case 'server_name':
aValue = a.server_name ?? 'Local';
bValue = b.server_name ?? 'Local';
break;
case 'status':
aValue = a.status;
bValue = b.status;
break;
case 'installation_date':
aValue = new Date(a.installation_date).getTime();
bValue = new Date(b.installation_date).getTime();
break;
default:
return 0;
}
if (aValue < bValue) {
return sortDirection === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return sortDirection === 'asc' ? 1 : -1;
}
return 0;
}); });
// Get unique servers for filter // Get unique servers for filter
@@ -111,7 +236,7 @@ export function InstalledScriptsTab() {
if (confirm(`Are you sure you want to update ${script.script_name}?`)) { if (confirm(`Are you sure you want to update ${script.script_name}?`)) {
// Get server info if it's SSH mode // Get server info if it's SSH mode
let server = null; let server = null;
if (script.execution_mode === 'ssh' && script.server_id && script.server_user && script.server_password) { if (script.server_id && script.server_user && script.server_password) {
server = { server = {
id: script.server_id, id: script.server_id,
name: script.server_name, name: script.server_name,
@@ -124,8 +249,7 @@ export function InstalledScriptsTab() {
setUpdatingScript({ setUpdatingScript({
id: script.id, id: script.id,
containerId: script.container_id, containerId: script.container_id,
server: server, server: server
mode: script.execution_mode
}); });
} }
}; };
@@ -197,6 +321,34 @@ export function InstalledScriptsTab() {
setAddFormData({ script_name: '', container_id: '', server_id: 'local' }); setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
}; };
const handleAutoDetect = () => {
if (!autoDetectServerId) {
return;
}
if (autoDetectMutation.isPending) {
return;
}
setAutoDetectStatus({ type: null, message: '' });
console.log('Starting auto-detect for server ID:', autoDetectServerId);
autoDetectMutation.mutate({ serverId: Number(autoDetectServerId) });
};
const handleCancelAutoDetect = () => {
setShowAutoDetectForm(false);
setAutoDetectServerId('');
};
const handleSort = (field: 'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date') => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString(); return new Date(dateString).toLocaleString();
@@ -205,7 +357,7 @@ export function InstalledScriptsTab() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400">Loading installed scripts...</div> <div className="text-muted-foreground">Loading installed scripts...</div>
</div> </div>
); );
} }
@@ -218,7 +370,7 @@ export function InstalledScriptsTab() {
<Terminal <Terminal
scriptPath={`update-${updatingScript.containerId}`} scriptPath={`update-${updatingScript.containerId}`}
onClose={handleCloseUpdateTerminal} onClose={handleCloseUpdateTerminal}
mode={updatingScript.mode} mode={updatingScript.server ? 'ssh' : 'local'}
server={updatingScript.server} server={updatingScript.server}
isUpdate={true} isUpdate={true}
containerId={updatingScript.containerId} containerId={updatingScript.containerId}
@@ -227,79 +379,87 @@ export function InstalledScriptsTab() {
)} )}
{/* Header with Stats */} {/* Header with Stats */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div className="bg-card rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">Installed Scripts</h2> <h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2>
{stats && ( {stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-blue-50 p-4 rounded-lg"> <div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{stats.total}</div> <div className="text-2xl font-bold text-blue-400">{stats.total}</div>
<div className="text-sm text-blue-800">Total Installations</div> <div className="text-sm text-blue-300">Total Installations</div>
</div> </div>
<div className="bg-green-50 p-4 rounded-lg"> <div className="bg-green-500/10 border border-green-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-600">{stats.byStatus.success}</div> <div className="text-2xl font-bold text-green-400">{stats.byStatus.success}</div>
<div className="text-sm text-green-800">Successful</div> <div className="text-sm text-green-300">Successful</div>
</div> </div>
<div className="bg-red-50 p-4 rounded-lg"> <div className="bg-red-500/10 border border-red-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-red-600">{stats.byStatus.failed}</div> <div className="text-2xl font-bold text-red-400">{stats.byStatus.failed}</div>
<div className="text-sm text-red-800">Failed</div> <div className="text-sm text-red-300">Failed</div>
</div> </div>
<div className="bg-yellow-50 p-4 rounded-lg"> <div className="bg-yellow-500/10 border border-yellow-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{stats.byStatus.in_progress}</div> <div className="text-2xl font-bold text-yellow-400">{stats.byStatus.in_progress}</div>
<div className="text-sm text-yellow-800">In Progress</div> <div className="text-sm text-yellow-300">In Progress</div>
</div> </div>
</div> </div>
)} )}
{/* Add Script Button */} {/* Add Script and Auto-Detect Buttons */}
<div className="mb-4"> <div className="mb-4 flex flex-col sm:flex-row gap-3">
<button <Button
onClick={() => setShowAddForm(!showAddForm)} onClick={() => setShowAddForm(!showAddForm)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors" variant={showAddForm ? "outline" : "default"}
size="default"
> >
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'} {showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
</button> </Button>
<Button
onClick={() => setShowAutoDetectForm(!showAutoDetectForm)}
variant={showAutoDetectForm ? "outline" : "secondary"}
size="default"
>
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
</Button>
</div> </div>
{/* Add Script Form */} {/* Add Script Form */}
{showAddForm && ( {showAddForm && (
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600"> <div className="mb-6 p-4 sm:p-6 bg-card rounded-lg border border-border shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Add Manual Script Entry</h3> <h3 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Add Manual Script Entry</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="space-y-4 sm:space-y-6">
<div> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-foreground">
Script Name * Script Name *
</label> </label>
<input <input
type="text" type="text"
value={addFormData.script_name} value={addFormData.script_name}
onChange={(e) => handleAddFormChange('script_name', e.target.value)} onChange={(e) => handleAddFormChange('script_name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Enter script name" placeholder="Enter script name"
/> />
</div> </div>
<div> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-foreground">
Container ID Container ID
</label> </label>
<input <input
type="text" type="text"
value={addFormData.container_id} value={addFormData.container_id}
onChange={(e) => handleAddFormChange('container_id', e.target.value)} onChange={(e) => handleAddFormChange('container_id', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Enter container ID" placeholder="Enter container ID"
/> />
</div> </div>
<div> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-foreground">
Server Server
</label> </label>
<select <select
value={addFormData.server_id} value={addFormData.server_id}
onChange={(e) => handleAddFormChange('server_id', e.target.value)} onChange={(e) => handleAddFormChange('server_id', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
> >
<option value="local">Select Server (Local if none)</option> <option value="local">Select Server</option>
{serversData?.servers?.map((server: any) => ( {serversData?.servers?.map((server: any) => (
<option key={server.id} value={server.id}> <option key={server.id} value={server.id}>
{server.name} {server.name}
@@ -308,40 +468,186 @@ export function InstalledScriptsTab() {
</select> </select>
</div> </div>
</div> </div>
<div className="flex justify-end space-x-3 mt-4"> <div className="flex flex-col sm:flex-row justify-end gap-3 mt-4 sm:mt-6">
<button <Button
onClick={handleCancelAdd} onClick={handleCancelAdd}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-md hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" variant="outline"
size="default"
className="w-full sm:w-auto"
> >
Cancel Cancel
</button> </Button>
<button <Button
onClick={handleAddScript} onClick={handleAddScript}
disabled={createScriptMutation.isPending} disabled={createScriptMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed" variant="default"
size="default"
className="w-full sm:w-auto"
> >
{createScriptMutation.isPending ? 'Adding...' : 'Add Script'} {createScriptMutation.isPending ? 'Adding...' : 'Add Script'}
</button> </Button>
</div>
</div>
)}
{/* Status Messages */}
{(autoDetectStatus.type ?? cleanupStatus.type) && (
<div className="mb-4 space-y-2">
{/* Auto-Detect Status Message */}
{autoDetectStatus.type && (
<div className={`p-4 rounded-lg border ${
autoDetectStatus.type === 'success'
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'
}`}>
<div className="flex items-center">
<div className="flex-shrink-0">
{autoDetectStatus.type === 'success' ? (
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="ml-3">
<p className={`text-sm font-medium ${
autoDetectStatus.type === 'success'
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}>
{autoDetectStatus.message}
</p>
</div>
</div>
</div>
)}
{/* Cleanup Status Message */}
{cleanupStatus.type && (
<div className={`p-4 rounded-lg border ${
cleanupStatus.type === 'success'
? 'bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700'
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'
}`}>
<div className="flex items-center">
<div className="flex-shrink-0">
{cleanupStatus.type === 'success' ? (
<svg className="h-5 w-5 text-slate-500 dark:text-slate-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="ml-3">
<p className={`text-sm font-medium ${
cleanupStatus.type === 'success'
? 'text-slate-700 dark:text-slate-300'
: 'text-red-800 dark:text-red-200'
}`}>
{cleanupStatus.message}
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Auto-Detect LXC Containers Form */}
{showAutoDetectForm && (
<div className="mb-6 p-4 sm:p-6 bg-card rounded-lg border border-border shadow-sm">
<h3 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Auto-Detect LXC Containers (Must contain a tag with &quot;community-script&quot;)</h3>
<div className="space-y-4 sm:space-y-6">
<div className="bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-slate-500 dark:text-slate-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300">
How it works
</h4>
<div className="mt-2 text-sm text-slate-600 dark:text-slate-400">
<p>This feature will:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Connect to the selected server via SSH</li>
<li>Scan all LXC config files in /etc/pve/lxc/</li>
<li>Find containers with &quot;community-script&quot; in their tags</li>
<li>Extract the container ID and hostname</li>
<li>Add them as installed script entries</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Select Server *
</label>
<select
value={autoDetectServerId}
onChange={(e) => setAutoDetectServerId(e.target.value)}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
>
<option value="">Choose a server...</option>
{serversData?.servers?.map((server: any) => (
<option key={server.id} value={server.id}>
{server.name} ({server.ip})
</option>
))}
</select>
</div>
</div>
<div className="flex flex-col sm:flex-row justify-end gap-3 mt-4 sm:mt-6">
<Button
onClick={handleCancelAutoDetect}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
onClick={handleAutoDetect}
disabled={autoDetectMutation.isPending || !autoDetectServerId}
variant="default"
size="default"
className="w-full sm:w-auto"
>
{autoDetectMutation.isPending ? '🔍 Scanning...' : '🔍 Start Auto-Detection'}
</Button>
</div> </div>
</div> </div>
)} )}
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap gap-4"> <div className="space-y-4">
<div className="flex-1 min-w-64"> {/* Search Input - Full Width on Mobile */}
<div className="w-full">
<input <input
type="text" type="text"
placeholder="Search scripts, container IDs, or servers..." placeholder="Search scripts, container IDs, or servers..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/> />
</div> </div>
{/* Filter Dropdowns - Responsive Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')} onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
> >
<option value="all">All Status</option> <option value="all">All Status</option>
<option value="success">Success</option> <option value="success">Success</option>
@@ -352,7 +658,7 @@ export function InstalledScriptsTab() {
<select <select
value={serverFilter} value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)} onChange={(e) => setServerFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
> >
<option value="all">All Servers</option> <option value="all">All Servers</option>
<option value="local">Local</option> <option value="local">Local</option>
@@ -362,44 +668,118 @@ export function InstalledScriptsTab() {
</select> </select>
</div> </div>
</div> </div>
</div>
{/* Scripts Table */} {/* Scripts Display - Mobile Cards / Desktop Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden"> <div className="bg-card rounded-lg shadow overflow-hidden">
{filteredScripts.length === 0 ? ( {filteredScripts.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <div className="text-center py-8 text-muted-foreground">
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'} {scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> {/* Mobile Card Layout */}
<thead className="bg-gray-50 dark:bg-gray-700"> <div className="block md:hidden p-4 space-y-4">
{filteredScripts.map((script) => (
<ScriptInstallationCard
key={script.id}
script={script}
isEditing={editingScriptId === script.id}
editFormData={editFormData}
onInputChange={handleInputChange}
onEdit={() => handleEditScript(script)}
onSave={handleSaveEdit}
onCancel={handleCancelEdit}
onUpdate={() => handleUpdateScript(script)}
onDelete={() => handleDeleteScript(Number(script.id))}
isUpdating={updateScriptMutation.isPending}
isDeleting={deleteScriptMutation.isPending}
/>
))}
</div>
{/* Desktop Table Layout */}
<div className="hidden md:block overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-muted">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> <th
Script Name className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('script_name')}
>
<div className="flex items-center space-x-1">
<span>Script Name</span>
{sortField === 'script_name' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> <th
Container ID className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('container_id')}
>
<div className="flex items-center space-x-1">
<span>Container ID</span>
{sortField === 'container_id' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> <th
Server className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('server_name')}
>
<div className="flex items-center space-x-1">
<span>Server</span>
{sortField === 'server_name' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> <th
Mode className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('status')}
>
<div className="flex items-center space-x-1">
<span>Status</span>
{sortField === 'status' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> <th
Status className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('installation_date')}
>
<div className="flex items-center space-x-1">
<span>Installation Date</span>
{sortField === 'installation_date' && (
<span className="text-primary">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Installation Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="bg-card divide-y divide-gray-200">
{filteredScripts.map((script) => ( {filteredScripts.map((script) => (
<tr key={script.id} className="hover:bg-gray-50 dark:hover:bg-gray-700"> <tr
key={script.id}
className="hover:bg-accent"
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? ( {editingScriptId === script.id ? (
<div className="space-y-2"> <div className="space-y-2">
@@ -407,15 +787,15 @@ export function InstalledScriptsTab() {
type="text" type="text"
value={editFormData.script_name} value={editFormData.script_name}
onChange={(e) => handleInputChange('script_name', e.target.value)} onChange={(e) => handleInputChange('script_name', e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Script name" placeholder="Script name"
/> />
<div className="text-xs text-gray-500 dark:text-gray-400">{script.script_path}</div> <div className="text-xs text-muted-foreground">{script.script_path}</div>
</div> </div>
) : ( ) : (
<div> <div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{script.script_name}</div> <div className="text-sm font-medium text-foreground">{script.script_name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{script.script_path}</div> <div className="text-sm text-muted-foreground">{script.script_path}</div>
</div> </div>
)} )}
</td> </td>
@@ -425,81 +805,82 @@ export function InstalledScriptsTab() {
type="text" type="text"
value={editFormData.container_id} value={editFormData.container_id}
onChange={(e) => handleInputChange('container_id', e.target.value)} onChange={(e) => handleInputChange('container_id', e.target.value)}
className="w-full px-2 py-1 text-sm font-mono border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Container ID" placeholder="Container ID"
/> />
) : ( ) : (
script.container_id ? ( script.container_id ? (
<span className="text-sm font-mono text-gray-900 dark:text-gray-100">{String(script.container_id)}</span> <span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
) : ( ) : (
<span className="text-sm text-gray-400 dark:text-gray-500">-</span> <span className="text-sm text-muted-foreground">-</span>
) )
)} )}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
{script.execution_mode === 'local' ? ( <span
<span className="text-sm text-gray-900 dark:text-gray-100">Local</span> className="text-sm px-3 py-1 rounded"
) : ( style={{
<div> backgroundColor: script.server_color ?? 'transparent',
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{script.server_name}</div> color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
<div className="text-sm text-gray-500 dark:text-gray-400">{script.server_ip}</div> }}
</div> >
)} {script.server_name ?? '-'}
</td> </span>
<td className="px-6 py-4 whitespace-nowrap">
<ExecutionModeBadge mode={script.execution_mode}>
{script.execution_mode.toUpperCase()}
</ExecutionModeBadge>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={script.status}> <StatusBadge status={script.status}>
{script.status.replace('_', ' ').toUpperCase()} {script.status.replace('_', ' ').toUpperCase()}
</StatusBadge> </StatusBadge>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"> <td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{formatDate(String(script.installation_date))} {formatDate(String(script.installation_date))}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2"> <div className="flex space-x-2">
{editingScriptId === script.id ? ( {editingScriptId === script.id ? (
<> <>
<button <Button
onClick={handleSaveEdit} onClick={handleSaveEdit}
disabled={updateScriptMutation.isPending} disabled={updateScriptMutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-sm font-medium disabled:opacity-50" variant="default"
size="sm"
> >
{updateScriptMutation.isPending ? 'Saving...' : 'Save'} {updateScriptMutation.isPending ? 'Saving...' : 'Save'}
</button> </Button>
<button <Button
onClick={handleCancelEdit} onClick={handleCancelEdit}
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm font-medium" variant="outline"
size="sm"
> >
Cancel Cancel
</button> </Button>
</> </>
) : ( ) : (
<> <>
<button <Button
onClick={() => handleEditScript(script)} onClick={() => handleEditScript(script)}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-sm font-medium" variant="default"
size="sm"
> >
Edit Edit
</button> </Button>
{script.container_id && ( {script.container_id && (
<button <Button
onClick={() => handleUpdateScript(script)} onClick={() => handleUpdateScript(script)}
className="text-blue-600 hover:text-blue-900" variant="link"
size="sm"
> >
Update Update
</button> </Button>
)} )}
<button <Button
onClick={() => handleDeleteScript(Number(script.id))} onClick={() => handleDeleteScript(Number(script.id))}
className="text-red-600 hover:text-red-900" variant="destructive"
size="sm"
disabled={deleteScriptMutation.isPending} disabled={deleteScriptMutation.isPending}
> >
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'} {deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</button> </Button>
</> </>
)} )}
</div> </div>
@@ -509,6 +890,7 @@ export function InstalledScriptsTab() {
</tbody> </tbody>
</table> </table>
</div> </div>
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,85 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { api } from '~/trpc/react';
interface ProxmoxCheckProps {
children: React.ReactNode;
}
export function ProxmoxCheck({ children }: ProxmoxCheckProps) {
const [isChecking, setIsChecking] = useState(true);
const [isProxmoxVE, setIsProxmoxVE] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
const { data: proxmoxData, isLoading } = api.scripts.checkProxmoxVE.useQuery();
useEffect(() => {
if (proxmoxData && typeof proxmoxData === 'object' && 'success' in proxmoxData) {
setIsChecking(false);
if (proxmoxData.success) {
const isProxmox = 'isProxmoxVE' in proxmoxData ? proxmoxData.isProxmoxVE as boolean : false;
setIsProxmoxVE(isProxmox);
if (!isProxmox) {
setError('This application can only run on a Proxmox VE Host');
}
} else {
const errorMsg = 'error' in proxmoxData ? proxmoxData.error as string : 'Failed to check Proxmox VE status';
setError(errorMsg);
setIsProxmoxVE(false);
}
}
}, [proxmoxData]);
// Show loading state
if (isChecking || isLoading) {
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Checking system requirements...</p>
</div>
</div>
);
}
// Show error if not running on Proxmox VE
if (!isProxmoxVE || error) {
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="max-w-md mx-auto text-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-8">
<div className="flex items-center justify-center w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 19.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-red-800 mb-2">
System Requirements Not Met
</h1>
<p className="text-red-700 mb-4">
{error ?? 'This application can only run on a Proxmox VE Host'}
</p>
<div className="text-sm text-red-600 bg-red-100 rounded-lg p-4">
<p className="font-medium mb-2">To use this application, you need:</p>
<ul className="text-left space-y-1">
<li> A Proxmox VE host system</li>
<li> The <code className="bg-red-200 px-1 rounded">pveversion</code> command must be available</li>
<li> Proper permissions to execute system commands</li>
</ul>
</div>
<button
onClick={() => window.location.reload()}
className="mt-6 px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Retry Check
</button>
</div>
</div>
</div>
);
}
// If running on Proxmox VE, render the children
return <>{children}</>;
}

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Button } from './ui/button';
export function ResyncButton() { export function ResyncButton() {
const [isResyncing, setIsResyncing] = useState(false); const [isResyncing, setIsResyncing] = useState(false);
@@ -39,36 +40,34 @@ export function ResyncButton() {
return ( return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3"> <div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-gray-600 dark:text-gray-300 font-medium"> <div className="text-sm text-muted-foreground font-medium">
Sync scripts with ProxmoxVE repo Sync scripts with ProxmoxVE repo
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3"> <div className="flex flex-col sm:flex-row sm:items-center gap-3">
<button <Button
onClick={handleResync} onClick={handleResync}
disabled={isResyncing} disabled={isResyncing}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${ variant="outline"
isResyncing size="default"
? 'bg-gray-400 dark:bg-gray-600 text-white cursor-not-allowed' className="inline-flex items-center"
: 'bg-blue-600 dark:bg-blue-700 text-white hover:bg-blue-700 dark:hover:bg-blue-600'
}`}
> >
{isResyncing ? ( {isResyncing ? (
<> <>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
<span>Syncing...</span> <span>Syncing...</span>
</> </>
) : ( ) : (
<> <>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg> </svg>
<span>Sync Json Files</span> <span>Sync Json Files</span>
</> </>
)} )}
</button> </Button>
{lastSync && ( {lastSync && (
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-muted-foreground">
Last sync: {lastSync.toLocaleTimeString()} Last sync: {lastSync.toLocaleTimeString()}
</div> </div>
)} )}
@@ -77,8 +76,8 @@ export function ResyncButton() {
{syncMessage && ( {syncMessage && (
<div className={`text-sm px-3 py-1 rounded-lg ${ <div className={`text-sm px-3 py-1 rounded-lg ${
syncMessage.includes('Error') || syncMessage.includes('Failed') syncMessage.includes('Error') || syncMessage.includes('Failed')
? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300' ? 'bg-red-100 text-destructive'
: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300' : 'bg-green-100 text-green-700'
}`}> }`}>
{syncMessage} {syncMessage}
</div> </div>

View File

@@ -0,0 +1,191 @@
'use client';
import { useState, useRef } from 'react';
import { Button } from './ui/button';
interface SSHKeyInputProps {
value: string;
onChange: (value: string) => void;
onError?: (error: string) => void;
disabled?: boolean;
}
export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHKeyInputProps) {
const [inputMode, setInputMode] = useState<'upload' | 'paste'>('upload');
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const validateSSHKey = (keyContent: string): boolean => {
const trimmed = keyContent.trim();
return (
trimmed.includes('BEGIN') &&
trimmed.includes('PRIVATE KEY') &&
trimmed.includes('END') &&
trimmed.includes('PRIVATE KEY')
);
};
const handleFileUpload = (file: File) => {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
if (validateSSHKey(content)) {
onChange(content);
onError?.('');
} else {
onError?.('Invalid SSH key format. Please ensure the file contains a valid private key.');
}
};
reader.onerror = () => {
onError?.('Failed to read the file. Please try again.');
};
reader.readAsText(file);
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFileUpload(file);
}
};
const handleDragOver = (event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(false);
};
const handleDrop = (event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(false);
const file = event.dataTransfer.files[0];
if (file) {
handleFileUpload(file);
}
};
const handlePasteChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const content = event.target.value;
onChange(content);
if (content.trim() && !validateSSHKey(content)) {
onError?.('Invalid SSH key format. Please ensure the content is a valid private key.');
} else {
onError?.('');
}
};
const getKeyFingerprint = (keyContent: string): string => {
// This is a simplified fingerprint - in a real implementation,
// you might want to use a library to generate proper SSH key fingerprints
if (!keyContent.trim()) return '';
const lines = keyContent.trim().split('\n');
const keyLine = lines.find(line =>
line.includes('BEGIN') && line.includes('PRIVATE KEY')
);
if (keyLine) {
const keyType = keyLine.includes('RSA') ? 'RSA' :
keyLine.includes('ED25519') ? 'ED25519' :
keyLine.includes('ECDSA') ? 'ECDSA' : 'Unknown';
return `${keyType} key (${keyContent.length} characters)`;
}
return 'Unknown key type';
};
return (
<div className="space-y-4">
{/* Mode Toggle */}
<div className="flex space-x-2">
<Button
type="button"
variant={inputMode === 'upload' ? 'default' : 'outline'}
size="sm"
onClick={() => setInputMode('upload')}
disabled={disabled}
>
Upload File
</Button>
<Button
type="button"
variant={inputMode === 'paste' ? 'default' : 'outline'}
size="sm"
onClick={() => setInputMode('paste')}
disabled={disabled}
>
Paste Key
</Button>
</div>
{/* File Upload Mode */}
{inputMode === 'upload' && (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isDragOver
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !disabled && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa"
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
/>
<div className="space-y-2">
<div className="text-lg">📁</div>
<p className="text-sm text-muted-foreground">
Drag and drop your SSH private key here, or click to browse
</p>
<p className="text-xs text-muted-foreground">
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, etc.)
</p>
</div>
</div>
)}
{/* Paste Mode */}
{inputMode === 'paste' && (
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">
Paste your SSH private key:
</label>
<textarea
value={value}
onChange={handlePasteChange}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABFwAAAAdzc2gtcn...&#10;-----END OPENSSH PRIVATE KEY-----"
className="w-full h-32 px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring font-mono text-xs"
disabled={disabled}
/>
</div>
)}
{/* Key Information */}
{value && (
<div className="p-3 bg-muted rounded-md">
<div className="text-sm">
<span className="font-medium">Key detected:</span> {getKeyFingerprint(value)}
</div>
<div className="text-xs text-muted-foreground mt-1">
Keep your private keys secure. This key will be stored in the database.
</div>
</div>
)}
</div>
);
}

View File

@@ -19,7 +19,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
return ( return (
<div <div
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg dark:hover:shadow-xl transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 h-full flex flex-col" className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col"
onClick={() => onClick(script)} onClick={() => onClick(script)}
> >
<div className="p-6 flex-1 flex flex-col"> <div className="p-6 flex-1 flex flex-col">
@@ -36,15 +36,15 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400 text-lg font-semibold"> <span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'} {script.name?.charAt(0)?.toUpperCase() || '?'}
</span> </span>
</div> </div>
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate"> <h3 className="text-lg font-semibold text-foreground truncate">
{script.name || 'Unnamed Script'} {script.name || 'Unnamed Script'}
</h3> </h3>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
@@ -60,7 +60,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
script.isDownloaded ? 'bg-green-500' : 'bg-red-500' script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
}`}></div> }`}></div>
<span className={`text-xs font-medium ${ <span className={`text-xs font-medium ${
script.isDownloaded ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300' script.isDownloaded ? 'text-green-700' : 'text-destructive'
}`}> }`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'} {script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
</span> </span>
@@ -70,7 +70,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
</div> </div>
{/* Description */} {/* Description */}
<p className="text-gray-600 dark:text-gray-300 text-sm line-clamp-3 mb-4 flex-1"> <p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
{script.description || 'No description available'} {script.description || 'No description available'}
</p> </p>
@@ -81,7 +81,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm font-medium flex items-center space-x-1" className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<span>Website</span> <span>Website</span>

View File

@@ -0,0 +1,164 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import type { ScriptCard } from '~/types/script';
import { TypeBadge, UpdateableBadge } from './Badge';
interface ScriptCardListProps {
script: ScriptCard;
onClick: (script: ScriptCard) => void;
}
export function ScriptCardList({ script, onClick }: ScriptCardListProps) {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
setImageError(true);
};
const formatDate = (dateString?: string) => {
if (!dateString) return 'Unknown';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return 'Unknown';
}
};
const getCategoryNames = () => {
if (!script.categoryNames || script.categoryNames.length === 0) return 'Uncategorized';
return script.categoryNames.join(', ');
};
return (
<div
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary"
onClick={() => onClick(script)}
>
<div className="p-6">
<div className="flex items-start space-x-4">
{/* Logo */}
<div className="flex-shrink-0">
{script.logo && !imageError ? (
<Image
src={script.logo}
alt={`${script.name} logo`}
width={56}
height={56}
className="w-14 h-14 rounded-lg object-contain"
onError={handleImageError}
/>
) : (
<div className="w-14 h-14 bg-muted rounded-lg flex items-center justify-center">
<span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'}
</span>
</div>
)}
</div>
{/* Main Content */}
<div className="flex-1 min-w-0">
{/* Header Row */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-foreground truncate mb-2">
{script.name || 'Unnamed Script'}
</h3>
<div className="flex items-center space-x-3">
<TypeBadge type={script.type ?? 'unknown'} />
{script.updateable && <UpdateableBadge />}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
}`}></div>
<span className={`text-sm font-medium ${
script.isDownloaded ? 'text-green-700' : 'text-destructive'
}`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
</span>
</div>
</div>
</div>
{/* Right side - Website link */}
{script.website && (
<a
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1 ml-4"
onClick={(e) => e.stopPropagation()}
>
<span>Website</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
{/* Description */}
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
{script.description || 'No description available'}
</p>
{/* Metadata Row */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span>Categories: {getCategoryNames()}</span>
</div>
<div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>Created: {formatDate(script.date_created)}</span>
</div>
{(script.os ?? script.version) && (
<div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<span>
{script.os && script.version
? `${script.os.charAt(0).toUpperCase() + script.os.slice(1)} ${script.version}`
: script.os
? script.os.charAt(0).toUpperCase() + script.os.slice(1)
: script.version
? `Version ${script.version}`
: ''
}
</span>
</div>
)}
{script.interface_port && (
<div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>Port: {script.interface_port}</span>
</div>
)}
</div>
<div className="flex items-center space-x-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>ID: {script.slug || 'unknown'}</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { DiffViewer } from "./DiffViewer";
import { TextViewer } from "./TextViewer"; import { TextViewer } from "./TextViewer";
import { ExecutionModeModal } from "./ExecutionModeModal"; import { ExecutionModeModal } from "./ExecutionModeModal";
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge"; import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
import { Button } from "./ui/button";
interface ScriptDetailModalProps { interface ScriptDetailModalProps {
script: Script | null; script: Script | null;
@@ -62,20 +63,20 @@ export function ScriptDetailModal({
if (data.success) { if (data.success) {
const message = const message =
"message" in data ? data.message : "Script loaded successfully"; "message" in data ? data.message : "Script loaded successfully";
setLoadMessage(` ${message}`); setLoadMessage(`[SUCCESS] ${message}`);
// Refetch script files status and comparison data to update the UI // Refetch script files status and comparison data to update the UI
void refetchScriptFiles(); void refetchScriptFiles();
void refetchComparison(); void refetchComparison();
} else { } else {
const error = "error" in data ? data.error : "Failed to load script"; const error = "error" in data ? data.error : "Failed to load script";
setLoadMessage(` ${error}`); setLoadMessage(`[ERROR] ${error}`);
} }
// Clear message after 5 seconds // Clear message after 5 seconds
setTimeout(() => setLoadMessage(null), 5000); setTimeout(() => setLoadMessage(null), 5000);
}, },
onError: (error) => { onError: (error) => {
setIsLoading(false); setIsLoading(false);
setLoadMessage(`❌ Error: ${error.message}`); setLoadMessage(`[ERROR] ${error.message}`);
setTimeout(() => setLoadMessage(null), 5000); setTimeout(() => setLoadMessage(null), 5000);
}, },
}); });
@@ -119,9 +120,6 @@ export function ScriptDetailModal({
// Pass execution mode and server info to the parent // Pass execution mode and server info to the parent
onInstallScript(scriptPath, scriptName, mode, server); onInstallScript(scriptPath, scriptName, mode, server);
// Scroll to top of the page to see the terminal
window.scrollTo({ top: 0, behavior: "smooth" });
onClose(); // Close the modal when starting installation onClose(); // Close the modal when starting installation
} }
}; };
@@ -132,48 +130,75 @@ export function ScriptDetailModal({
return ( return (
<div <div
className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black p-4" className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto"> <div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700"> <div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
{script.logo && !imageError ? ( {script.logo && !imageError ? (
<Image <Image
src={script.logo} src={script.logo}
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={64} width={64}
height={64} height={64}
className="h-16 w-16 rounded-lg object-contain" className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-gray-200 dark:bg-gray-700"> <div className="flex h-12 w-12 sm:h-16 sm:w-16 items-center justify-center rounded-lg bg-muted flex-shrink-0">
<span className="text-2xl font-semibold text-gray-500 dark:text-gray-400"> <span className="text-lg sm:text-2xl font-semibold text-muted-foreground">
{script.name.charAt(0).toUpperCase()} {script.name.charAt(0).toUpperCase()}
</span> </span>
</div> </div>
)} )}
<div> <div className="min-w-0 flex-1">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100"> <h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
{script.name} {script.name}
</h2> </h2>
<div className="mt-1 flex items-center space-x-2"> <div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
<TypeBadge type={script.type} /> <TypeBadge type={script.type} />
{script.updateable && <UpdateableBadge />} {script.updateable && <UpdateableBadge />}
{script.privileged && <PrivilegedBadge />} {script.privileged && <PrivilegedBadge />}
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4">
{/* Close Button */}
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
>
<svg
className="h-5 w-5 sm:h-6 sm:w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border">
{/* Install Button - only show if script files exist */} {/* Install Button - only show if script files exist */}
{scriptFilesData?.success && {scriptFilesData?.success &&
scriptFilesData.ctExists && scriptFilesData.ctExists &&
onInstallScript && ( onInstallScript && (
<button <Button
onClick={handleInstallScript} onClick={handleInstallScript}
className="flex items-center space-x-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700" variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -189,15 +214,17 @@ export function ScriptDetailModal({
/> />
</svg> </svg>
<span>Install</span> <span>Install</span>
</button> </Button>
)} )}
{/* View Button - only show if script files exist */} {/* View Button - only show if script files exist */}
{scriptFilesData?.success && {scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists) && ( (scriptFilesData.ctExists || scriptFilesData.installExists) && (
<button <Button
onClick={handleViewScript} onClick={handleViewScript}
className="flex items-center space-x-2 rounded-lg bg-purple-600 px-4 py-2 font-medium text-white transition-colors hover:bg-purple-700" variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -219,7 +246,7 @@ export function ScriptDetailModal({
/> />
</svg> </svg>
<span>View</span> <span>View</span>
</button> </Button>
)} )}
{/* Load/Update Script Button */} {/* Load/Update Script Button */}
@@ -239,7 +266,7 @@ export function ScriptDetailModal({
disabled={isLoading} disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${ className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading isLoading
? "cursor-not-allowed bg-gray-400 text-white" ? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-green-600 text-white hover:bg-green-700" : "bg-green-600 text-white hover:bg-green-700"
}`} }`}
> >
@@ -273,7 +300,7 @@ export function ScriptDetailModal({
return ( return (
<button <button
disabled disabled
className="flex cursor-not-allowed items-center space-x-2 rounded-lg bg-gray-400 px-4 py-2 font-medium text-white transition-colors" className="flex cursor-not-allowed items-center space-x-2 rounded-lg bg-muted px-4 py-2 font-medium text-muted-foreground transition-colors"
> >
<svg <svg
className="h-4 w-4" className="h-4 w-4"
@@ -299,7 +326,7 @@ export function ScriptDetailModal({
disabled={isLoading} disabled={isLoading}
className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${ className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading isLoading
? "cursor-not-allowed bg-gray-400 text-white" ? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-orange-600 text-white hover:bg-orange-700" : "bg-orange-600 text-white hover:bg-orange-700"
}`} }`}
> >
@@ -330,37 +357,18 @@ export function ScriptDetailModal({
); );
} }
})()} })()}
<button
onClick={onClose}
className="text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div> </div>
{/* Load Message */} {/* Load Message */}
{loadMessage && ( {loadMessage && (
<div className="mx-6 mb-4 rounded-lg bg-blue-50 p-3 text-sm text-blue-800"> <div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
{loadMessage} {loadMessage}
</div> </div>
)} )}
{/* Script Files Status */} {/* Script Files Status */}
{(scriptFilesLoading || comparisonLoading) && ( {(scriptFilesLoading || comparisonLoading) && (
<div className="mx-6 mb-4 rounded-lg bg-blue-50 p-3 text-sm"> <div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
<span>Loading script status...</span> <span>Loading script status...</span>
@@ -385,11 +393,11 @@ export function ScriptDetailModal({
} }
return ( return (
<div className="mx-6 mb-4 rounded-lg bg-gray-50 p-3 text-sm text-gray-700 dark:bg-gray-700 dark:text-gray-300"> <div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<div className="flex items-center space-x-4"> <div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-gray-300"}`} className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-muted"}`}
></div> ></div>
<span> <span>
{scriptType}:{" "} {scriptType}:{" "}
@@ -398,7 +406,7 @@ export function ScriptDetailModal({
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-green-500" : "bg-gray-300"}`} className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-green-500" : "bg-muted"}`}
></div> ></div>
<span> <span>
Install Script:{" "} Install Script:{" "}
@@ -426,7 +434,7 @@ export function ScriptDetailModal({
)} )}
</div> </div>
{scriptFilesData.files.length > 0 && ( {scriptFilesData.files.length > 0 && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400"> <div className="mt-2 text-xs text-muted-foreground break-words">
Files: {scriptFilesData.files.join(", ")} Files: {scriptFilesData.files.join(", ")}
</div> </div>
)} )}
@@ -435,64 +443,64 @@ export function ScriptDetailModal({
})()} })()}
{/* Content */} {/* Content */}
<div className="space-y-6 p-6"> <div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* Description */} {/* Description */}
<div> <div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100"> <h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
Description Description
</h3> </h3>
<p className="text-gray-600 dark:text-gray-300"> <p className="text-sm sm:text-base text-muted-foreground">
{script.description} {script.description}
</p> </p>
</div> </div>
{/* Basic Information */} {/* Basic Information */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
<div> <div>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Basic Information Basic Information
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
<div> <div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt className="text-sm font-medium text-muted-foreground">
Slug Slug
</dt> </dt>
<dd className="font-mono text-sm text-gray-900 dark:text-gray-100"> <dd className="font-mono text-sm text-foreground">
{script.slug} {script.slug}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt className="text-sm font-medium text-muted-foreground">
Date Created Date Created
</dt> </dt>
<dd className="text-sm text-gray-900 dark:text-gray-100"> <dd className="text-sm text-foreground">
{script.date_created} {script.date_created}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt className="text-sm font-medium text-muted-foreground">
Categories Categories
</dt> </dt>
<dd className="text-sm text-gray-900 dark:text-gray-100"> <dd className="text-sm text-foreground">
{script.categories.join(", ")} {script.categories.join(", ")}
</dd> </dd>
</div> </div>
{script.interface_port && ( {script.interface_port && (
<div> <div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt className="text-sm font-medium text-muted-foreground">
Interface Port Interface Port
</dt> </dt>
<dd className="text-sm text-gray-900 dark:text-gray-100"> <dd className="text-sm text-foreground">
{script.interface_port} {script.interface_port}
</dd> </dd>
</div> </div>
)} )}
{script.config_path && ( {script.config_path && (
<div> <div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt className="text-sm font-medium text-muted-foreground">
Config Path Config Path
</dt> </dt>
<dd className="font-mono text-sm text-gray-900 dark:text-gray-100"> <dd className="font-mono text-sm text-foreground">
{script.config_path} {script.config_path}
</dd> </dd>
</div> </div>
@@ -501,13 +509,13 @@ export function ScriptDetailModal({
</div> </div>
<div> <div>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Links Links
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
{script.website && ( {script.website && (
<div> <div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt className="text-sm font-medium text-muted-foreground">
Website Website
</dt> </dt>
<dd className="text-sm"> <dd className="text-sm">
@@ -515,7 +523,7 @@ export function ScriptDetailModal({
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="break-all text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" className="break-all text-primary hover:text-primary/80"
> >
{script.website} {script.website}
</a> </a>
@@ -524,7 +532,7 @@ export function ScriptDetailModal({
)} )}
{script.documentation && ( {script.documentation && (
<div> <div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400"> <dt className="text-sm font-medium text-muted-foreground">
Documentation Documentation
</dt> </dt>
<dd className="text-sm"> <dd className="text-sm">
@@ -532,7 +540,7 @@ export function ScriptDetailModal({
href={script.documentation} href={script.documentation}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="break-all text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" className="break-all text-primary hover:text-primary/80"
> >
{script.documentation} {script.documentation}
</a> </a>
@@ -548,53 +556,53 @@ export function ScriptDetailModal({
script.type !== "pve" && script.type !== "pve" &&
script.type !== "addon" && ( script.type !== "addon" && (
<div> <div>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Install Methods Install Methods
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{script.install_methods.map((method, index) => ( {script.install_methods.map((method, index) => (
<div <div
key={index} key={index}
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-600 dark:bg-gray-700" className="rounded-lg border border-border bg-card p-3 sm:p-4"
> >
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0">
<h4 className="font-medium text-gray-900 capitalize dark:text-gray-100"> <h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
{method.type} {method.type}
</h4> </h4>
<span className="font-mono text-sm text-gray-500 dark:text-gray-400"> <span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
{method.script} {method.script}
</span> </span>
</div> </div>
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4"> <div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
<div> <div>
<dt className="font-medium text-gray-500 dark:text-gray-400"> <dt className="font-medium text-muted-foreground">
CPU CPU
</dt> </dt>
<dd className="text-gray-900 dark:text-gray-100"> <dd className="text-foreground">
{method.resources.cpu} cores {method.resources.cpu} cores
</dd> </dd>
</div> </div>
<div> <div>
<dt className="font-medium text-gray-500 dark:text-gray-400"> <dt className="font-medium text-muted-foreground">
RAM RAM
</dt> </dt>
<dd className="text-gray-900 dark:text-gray-100"> <dd className="text-foreground">
{method.resources.ram} MB {method.resources.ram} MB
</dd> </dd>
</div> </div>
<div> <div>
<dt className="font-medium text-gray-500 dark:text-gray-400"> <dt className="font-medium text-muted-foreground">
HDD HDD
</dt> </dt>
<dd className="text-gray-900 dark:text-gray-100"> <dd className="text-foreground">
{method.resources.hdd} GB {method.resources.hdd} GB
</dd> </dd>
</div> </div>
<div> <div>
<dt className="font-medium text-gray-500 dark:text-gray-400"> <dt className="font-medium text-muted-foreground">
OS OS
</dt> </dt>
<dd className="text-gray-900 dark:text-gray-100"> <dd className="text-foreground">
{method.resources.os} {method.resources.version} {method.resources.os} {method.resources.version}
</dd> </dd>
</div> </div>
@@ -609,26 +617,26 @@ export function ScriptDetailModal({
{(script.default_credentials.username ?? {(script.default_credentials.username ??
script.default_credentials.password) && ( script.default_credentials.password) && (
<div> <div>
<h3 className="mb-3 text-lg font-semibold text-gray-900"> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Default Credentials Default Credentials
</h3> </h3>
<dl className="space-y-2"> <dl className="space-y-2">
{script.default_credentials.username && ( {script.default_credentials.username && (
<div> <div>
<dt className="text-sm font-medium text-gray-500"> <dt className="text-sm font-medium text-muted-foreground">
Username Username
</dt> </dt>
<dd className="font-mono text-sm text-gray-900"> <dd className="font-mono text-sm text-foreground">
{script.default_credentials.username} {script.default_credentials.username}
</dd> </dd>
</div> </div>
)} )}
{script.default_credentials.password && ( {script.default_credentials.password && (
<div> <div>
<dt className="text-sm font-medium text-gray-500"> <dt className="text-sm font-medium text-muted-foreground">
Password Password
</dt> </dt>
<dd className="font-mono text-sm text-gray-900"> <dd className="font-mono text-sm text-foreground">
{script.default_credentials.password} {script.default_credentials.password}
</dd> </dd>
</div> </div>
@@ -640,7 +648,7 @@ export function ScriptDetailModal({
{/* Notes */} {/* Notes */}
{script.notes.length > 0 && ( {script.notes.length > 0 && (
<div> <div>
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-gray-100"> <h3 className="mb-3 text-lg font-semibold text-foreground">
Notes Notes
</h3> </h3>
<ul className="space-y-2"> <ul className="space-y-2">
@@ -655,10 +663,10 @@ export function ScriptDetailModal({
key={index} key={index}
className={`rounded-lg p-3 text-sm ${ className={`rounded-lg p-3 text-sm ${
noteType === "warning" noteType === "warning"
? "border-l-4 border-yellow-400 bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200" ? "border-l-4 border-yellow-400 bg-yellow-500/10 text-yellow-400"
: noteType === "error" : noteType === "error"
? "border-l-4 border-red-400 bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-200" ? "border-l-4 border-destructive bg-destructive/10 text-destructive"
: "bg-gray-50 text-gray-600 dark:bg-gray-700 dark:text-gray-300" : "bg-muted text-muted-foreground"
}`} }`}
> >
<div className="flex items-start"> <div className="flex items-start">

View File

@@ -0,0 +1,186 @@
'use client';
import { Button } from './ui/button';
import { StatusBadge } from './Badge';
import { getContrastColor } from '../../lib/colorUtils';
interface InstalledScript {
id: number;
script_name: string;
script_path: string;
container_id: string | null;
server_id: number | null;
server_name: string | null;
server_ip: string | null;
server_user: string | null;
server_password: string | null;
server_color: string | null;
installation_date: string;
status: 'in_progress' | 'success' | 'failed';
output_log: string | null;
}
interface ScriptInstallationCardProps {
script: InstalledScript;
isEditing: boolean;
editFormData: { script_name: string; container_id: string };
onInputChange: (field: 'script_name' | 'container_id', value: string) => void;
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
onUpdate: () => void;
onDelete: () => void;
isUpdating: boolean;
isDeleting: boolean;
}
export function ScriptInstallationCard({
script,
isEditing,
editFormData,
onInputChange,
onEdit,
onSave,
onCancel,
onUpdate,
onDelete,
isUpdating,
isDeleting
}: ScriptInstallationCardProps) {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
return (
<div
className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
>
{/* Header with Script Name and Status */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="space-y-2">
<input
type="text"
value={editFormData.script_name}
onChange={(e) => onInputChange('script_name', e.target.value)}
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Script name"
/>
<div className="text-xs text-muted-foreground">{script.script_path}</div>
</div>
) : (
<div>
<div className="text-sm font-medium text-foreground truncate">{script.script_name}</div>
<div className="text-xs text-muted-foreground truncate">{script.script_path}</div>
</div>
)}
</div>
<div className="ml-2 flex-shrink-0">
<StatusBadge status={script.status}>
{script.status.replace('_', ' ').toUpperCase()}
</StatusBadge>
</div>
</div>
{/* Details Grid */}
<div className="grid grid-cols-1 gap-3 mb-4">
{/* Container ID */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Container ID</div>
{isEditing ? (
<input
type="text"
value={editFormData.container_id}
onChange={(e) => onInputChange('container_id', e.target.value)}
className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Container ID"
/>
) : (
<div className="text-sm font-mono text-foreground break-all">
{script.container_id ?? '-'}
</div>
)}
</div>
{/* Server */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
<span
className="text-sm px-3 py-1 rounded inline-block"
style={{
backgroundColor: script.server_color ?? 'transparent',
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
}}
>
{script.server_name ?? '-'}
</span>
</div>
{/* Installation Date */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Installation Date</div>
<div className="text-sm text-muted-foreground">
{formatDate(String(script.installation_date))}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
{isEditing ? (
<>
<Button
onClick={onSave}
disabled={isUpdating}
variant="default"
size="sm"
className="flex-1 min-w-0"
>
{isUpdating ? 'Saving...' : 'Save'}
</Button>
<Button
onClick={onCancel}
variant="outline"
size="sm"
className="flex-1 min-w-0"
>
Cancel
</Button>
</>
) : (
<>
<Button
onClick={onEdit}
variant="default"
size="sm"
className="flex-1 min-w-0"
>
Edit
</Button>
{script.container_id && (
<Button
onClick={onUpdate}
variant="link"
size="sm"
className="flex-1 min-w-0"
>
Update
</Button>
)}
<Button
onClick={onDelete}
variant="destructive"
size="sm"
disabled={isDeleting}
className="flex-1 min-w-0"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</>
)}
</div>
</div>
);
}

View File

@@ -3,9 +3,12 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { ScriptCard } from './ScriptCard'; import { ScriptCard } from './ScriptCard';
import { ScriptCardList } from './ScriptCardList';
import { ScriptDetailModal } from './ScriptDetailModal'; import { ScriptDetailModal } from './ScriptDetailModal';
import { CategorySidebar } from './CategorySidebar'; import { CategorySidebar } from './CategorySidebar';
import { FilterBar, type FilterState } from './FilterBar'; import { FilterBar, type FilterState } from './FilterBar';
import { ViewToggle } from './ViewToggle';
import { Button } from './ui/button';
import type { ScriptCard as ScriptCardType } from '~/types/script'; import type { ScriptCard as ScriptCardType } from '~/types/script';
@@ -18,6 +21,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [filters, setFilters] = useState<FilterState>({ const [filters, setFilters] = useState<FilterState>({
searchQuery: '', searchQuery: '',
showUpdatable: null, showUpdatable: null,
@@ -25,6 +29,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
sortBy: 'name', sortBy: 'name',
sortOrder: 'asc', sortOrder: 'asc',
}); });
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery(); const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
@@ -34,6 +40,95 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{ enabled: !!selectedSlug } { enabled: !!selectedSlug }
); );
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
useEffect(() => {
const loadSettings = async () => {
try {
// Load SAVE_FILTER setting
const saveFilterResponse = await fetch('/api/settings/save-filter');
let saveFilterEnabled = false;
if (saveFilterResponse.ok) {
const saveFilterData = await saveFilterResponse.json();
saveFilterEnabled = saveFilterData.enabled ?? false;
setSaveFiltersEnabled(saveFilterEnabled);
}
// Load saved filters if SAVE_FILTER is enabled
if (saveFilterEnabled) {
const filtersResponse = await fetch('/api/settings/filters');
if (filtersResponse.ok) {
const filtersData = await filtersResponse.json();
if (filtersData.filters) {
setFilters(filtersData.filters as FilterState);
}
}
}
// Load view mode
const viewModeResponse = await fetch('/api/settings/view-mode');
if (viewModeResponse.ok) {
const viewModeData = await viewModeResponse.json();
const viewMode = viewModeData.viewMode;
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
setViewMode(viewMode);
}
}
} catch (error) {
console.error('Error loading settings:', error);
} finally {
setIsLoadingFilters(false);
}
};
void loadSettings();
}, []);
// Save filters when they change (if SAVE_FILTER is enabled)
useEffect(() => {
if (!saveFiltersEnabled || isLoadingFilters) return;
const saveFilters = async () => {
try {
await fetch('/api/settings/filters', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filters }),
});
} catch (error) {
console.error('Error saving filters:', error);
}
};
// Debounce the save operation
const timeoutId = setTimeout(() => void saveFilters(), 500);
return () => clearTimeout(timeoutId);
}, [filters, saveFiltersEnabled, isLoadingFilters]);
// Save view mode when it changes
useEffect(() => {
if (isLoadingFilters) return;
const saveViewMode = async () => {
try {
await fetch('/api/settings/view-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ viewMode }),
});
} catch (error) {
console.error('Error saving view mode:', error);
}
};
// Debounce the save operation
const timeoutId = setTimeout(() => void saveViewMode(), 300);
return () => clearTimeout(timeoutId);
}, [viewMode, isLoadingFilters]);
// Extract categories from metadata // Extract categories from metadata
const categories = React.useMemo((): string[] => { const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return []; if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
@@ -269,7 +364,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">Loading scripts...</span> <span className="ml-2 text-muted-foreground">Loading scripts...</span>
</div> </div>
); );
} }
@@ -282,16 +377,18 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg> </svg>
<p className="text-lg font-medium">Failed to load scripts</p> <p className="text-lg font-medium">Failed to load scripts</p>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-muted-foreground mt-1">
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'} {githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
</p> </p>
</div> </div>
<button <Button
onClick={() => refetch()} onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" variant="default"
size="default"
className="mt-4"
> >
Try Again Try Again
</button> </Button>
</div> </div>
); );
} }
@@ -299,12 +396,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
if (!scriptsWithStatus || scriptsWithStatus.length === 0) { if (!scriptsWithStatus || scriptsWithStatus.length === 0) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="text-gray-500"> <div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg> </svg>
<p className="text-lg font-medium">No scripts found</p> <p className="text-lg font-medium">No scripts found</p>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-muted-foreground mt-1">
No script files were found in the repository or local directory. No script files were found in the repository or local directory.
</p> </p>
</div> </div>
@@ -313,9 +410,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
} }
return ( return (
<div className="flex gap-6"> <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
{/* Category Sidebar */} {/* Category Sidebar */}
<div className="flex-shrink-0"> <div className="flex-shrink-0 order-2 lg:order-1">
<CategorySidebar <CategorySidebar
categories={categories} categories={categories}
categoryCounts={categoryCounts} categoryCounts={categoryCounts}
@@ -326,7 +423,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
</div> </div>
{/* Main Content */} {/* Main Content */}
<div className="flex-1 min-w-0" ref={gridRef}> <div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
{/* Enhanced Filter Bar */} {/* Enhanced Filter Bar */}
<FilterBar <FilterBar
filters={filters} filters={filters}
@@ -334,13 +431,21 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
totalScripts={scriptsWithStatus.length} totalScripts={scriptsWithStatus.length}
filteredCount={filteredScripts.length} filteredCount={filteredScripts.length}
updatableCount={filterCounts.updatableCount} updatableCount={filterCounts.updatableCount}
saveFiltersEnabled={saveFiltersEnabled}
isLoadingFilters={isLoadingFilters}
/>
{/* View Toggle */}
<ViewToggle
viewMode={viewMode}
onViewModeChange={setViewMode}
/> />
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */} {/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
<div className="hidden mb-8"> <div className="hidden mb-8">
<div className="relative max-w-md mx-auto"> <div className="relative max-w-md mx-auto">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
</div> </div>
@@ -349,12 +454,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
placeholder="Search scripts by name..." placeholder="Search scripts by name..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-800 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-gray-100 focus:outline-none focus:placeholder-gray-400 dark:focus:placeholder-gray-300 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 text-sm" className="block w-full pl-10 pr-3 py-3 border border-border rounded-lg leading-5 bg-card placeholder-muted-foreground text-foreground focus:outline-none focus:placeholder-muted-foreground focus:ring-2 focus:ring-ring focus:border-ring text-sm"
/> />
{searchQuery && ( {searchQuery && (
<button <button
onClick={() => setSearchQuery('')} onClick={() => setSearchQuery('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300" className="absolute inset-y-0 right-0 pr-3 flex items-center text-muted-foreground hover:text-foreground"
> >
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -363,7 +468,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
)} )}
</div> </div>
{(searchQuery || selectedCategory) && ( {(searchQuery || selectedCategory) && (
<div className="text-center mt-2 text-sm text-gray-600"> <div className="text-center mt-2 text-sm text-muted-foreground">
{filteredScripts.length === 0 ? ( {filteredScripts.length === 0 ? (
<span>No scripts found{searchQuery ? ` matching "${searchQuery}"` : ''}{selectedCategory ? ` in category "${selectedCategory}"` : ''}</span> <span>No scripts found{searchQuery ? ` matching "${searchQuery}"` : ''}{selectedCategory ? ` in category "${selectedCategory}"` : ''}</span>
) : ( ) : (
@@ -380,35 +485,38 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{/* Scripts Grid */} {/* Scripts Grid */}
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? ( {filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="text-gray-500"> <div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
<p className="text-lg font-medium">No matching scripts found</p> <p className="text-lg font-medium">No matching scripts found</p>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-muted-foreground mt-1">
Try different filter settings or clear all filters. Try different filter settings or clear all filters.
</p> </p>
<div className="flex justify-center gap-2 mt-4"> <div className="flex justify-center gap-2 mt-4">
{filters.searchQuery && ( {filters.searchQuery && (
<button <Button
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })} onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" variant="default"
size="default"
> >
Clear Search Clear Search
</button> </Button>
)} )}
{selectedCategory && ( {selectedCategory && (
<button <Button
onClick={() => handleCategorySelect(null)} onClick={() => handleCategorySelect(null)}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors" variant="secondary"
size="default"
> >
Clear Category Clear Category
</button> </Button>
)} )}
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
viewMode === 'card' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredScripts.map((script, index) => { {filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties // Add validation to ensure script has required properties
@@ -428,6 +536,27 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
); );
})} })}
</div> </div>
) : (
<div className="space-y-3">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties
if (!script || typeof script !== 'object') {
return null;
}
// Create a unique key by combining slug, name, and index to handle duplicates
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<ScriptCardList
key={uniqueKey}
script={script}
onClick={handleCardClick}
/>
);
})}
</div>
)
)} )}
<ScriptDetailModal <ScriptDetailModal

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import type { CreateServerData } from '../../types/server'; import type { CreateServerData } from '../../types/server';
import { Button } from './ui/button';
import { SSHKeyInput } from './SSHKeyInput';
interface ServerFormProps { interface ServerFormProps {
onSubmit: (data: CreateServerData) => void; onSubmit: (data: CreateServerData) => void;
@@ -17,13 +19,35 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
ip: '', ip: '',
user: '', user: '',
password: '', password: '',
auth_type: 'password',
ssh_key: '',
ssh_key_passphrase: '',
ssh_port: 22,
color: '#3b82f6',
} }
); );
const [errors, setErrors] = useState<Partial<CreateServerData>>({}); const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
const [sshKeyError, setSshKeyError] = useState<string>('');
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
useEffect(() => {
const loadColorCodingSetting = async () => {
try {
const response = await fetch('/api/settings/color-coding');
if (response.ok) {
const data = await response.json();
setColorCodingEnabled(Boolean(data.enabled));
}
} catch (error) {
console.error('Error loading color coding setting:', error);
}
};
void loadColorCodingSetting();
}, []);
const validateForm = (): boolean => { const validateForm = (): boolean => {
const newErrors: Partial<CreateServerData> = {}; const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
if (!formData.name.trim()) { if (!formData.name.trim()) {
newErrors.name = 'Server name is required'; newErrors.name = 'Server name is required';
@@ -43,12 +67,36 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
newErrors.user = 'Username is required'; newErrors.user = 'Username is required';
} }
if (!formData.password.trim()) { // Validate SSH port
newErrors.password = 'Password is required'; if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
newErrors.ssh_port = 'SSH port must be between 1 and 65535';
}
// Validate authentication based on auth_type
const authType = formData.auth_type ?? 'password';
if (authType === 'password' || authType === 'both') {
if (!formData.password?.trim()) {
newErrors.password = 'Password is required for password authentication';
}
}
if (authType === 'key' || authType === 'both') {
if (!formData.ssh_key?.trim()) {
newErrors.ssh_key = 'SSH key is required for key authentication';
}
}
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!formData.password?.trim() && !formData.ssh_key?.trim()) {
newErrors.password = 'At least one authentication method (password or SSH key) is required';
newErrors.ssh_key = 'At least one authentication method (password or SSH key) is required';
}
} }
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0 && !sshKeyError;
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -56,13 +104,23 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
if (validateForm()) { if (validateForm()) {
onSubmit(formData); onSubmit(formData);
if (!isEditing) { if (!isEditing) {
setFormData({ name: '', ip: '', user: '', password: '' }); setFormData({
name: '',
ip: '',
user: '',
password: '',
auth_type: 'password',
ssh_key: '',
ssh_key_passphrase: '',
ssh_port: 22,
color: '#3b82f6'
});
} }
} }
}; };
const handleChange = (field: keyof CreateServerData) => ( const handleChange = (field: keyof CreateServerData) => (
e: React.ChangeEvent<HTMLInputElement> e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => { ) => {
setFormData(prev => ({ ...prev, [field]: e.target.value })); setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user starts typing // Clear error when user starts typing
@@ -71,11 +129,18 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
} }
}; };
const handleSSHKeyChange = (value: string) => {
setFormData(prev => ({ ...prev, ssh_key: value }));
if (errors.ssh_key) {
setErrors(prev => ({ ...prev, ssh_key: undefined }));
}
};
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
Server Name * Server Name *
</label> </label>
<input <input
@@ -83,16 +148,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="name" id="name"
value={formData.name} value={formData.name}
onChange={handleChange('name')} onChange={handleChange('name')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.name ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600' errors.name ? 'border-destructive' : 'border-border'
}`} }`}
placeholder="e.g., Production Server" placeholder="e.g., Production Server"
/> />
{errors.name && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.name}</p>} {errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
</div> </div>
<div> <div>
<label htmlFor="ip" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
IP Address * IP Address *
</label> </label>
<input <input
@@ -100,16 +165,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="ip" id="ip"
value={formData.ip} value={formData.ip}
onChange={handleChange('ip')} onChange={handleChange('ip')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ip ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600' errors.ip ? 'border-destructive' : 'border-border'
}`} }`}
placeholder="e.g., 192.168.1.100" placeholder="e.g., 192.168.1.100"
/> />
{errors.ip && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.ip}</p>} {errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
</div> </div>
<div> <div>
<label htmlFor="user" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label htmlFor="user" className="block text-sm font-medium text-muted-foreground mb-1">
Username * Username *
</label> </label>
<input <input
@@ -117,48 +182,145 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="user" id="user"
value={formData.user} value={formData.user}
onChange={handleChange('user')} onChange={handleChange('user')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.user ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600' errors.user ? 'border-destructive' : 'border-border'
}`} }`}
placeholder="e.g., root" placeholder="e.g., root"
/> />
{errors.user && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.user}</p>} {errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
</div> </div>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
Password * SSH Port
</label>
<input
type="number"
id="ssh_port"
value={formData.ssh_port ?? 22}
onChange={handleChange('ssh_port')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ssh_port ? 'border-destructive' : 'border-border'
}`}
placeholder="22"
min="1"
max="65535"
/>
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
</div>
<div>
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
Authentication Type *
</label>
<select
id="auth_type"
value={formData.auth_type ?? 'password'}
onChange={handleChange('auth_type')}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
>
<option value="password">Password Only</option>
<option value="key">SSH Key Only</option>
<option value="both">Both Password & SSH Key</option>
</select>
</div>
{colorCodingEnabled && (
<div>
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
Server Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id="color"
value={formData.color ?? '#3b82f6'}
onChange={handleChange('color')}
className="w-20 h-10 rounded cursor-pointer border border-border"
/>
<span className="text-sm text-muted-foreground">
Choose a color to identify this server
</span>
</div>
</div>
)}
</div>
{/* Password Authentication */}
{(formData.auth_type === 'password' || formData.auth_type === 'both') && (
<div>
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
</label> </label>
<input <input
type="password" type="password"
id="password" id="password"
value={formData.password} value={formData.password ?? ''}
onChange={handleChange('password')} onChange={handleChange('password')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.password ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600' errors.password ? 'border-destructive' : 'border-border'
}`} }`}
placeholder="Enter password" placeholder="Enter password"
/> />
{errors.password && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.password}</p>} {errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
</div> </div>
)}
{/* SSH Key Authentication */}
{(formData.auth_type === 'key' || formData.auth_type === 'both') && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
SSH Private Key {formData.auth_type === 'both' ? '(Optional)' : '*'}
</label>
<SSHKeyInput
value={formData.ssh_key ?? ''}
onChange={handleSSHKeyChange}
onError={setSshKeyError}
/>
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
</div> </div>
<div className="flex justify-end space-x-3 pt-4"> <div>
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
SSH Key Passphrase (Optional)
</label>
<input
type="password"
id="ssh_key_passphrase"
value={formData.ssh_key_passphrase ?? ''}
onChange={handleChange('ssh_key_passphrase')}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
placeholder="Enter passphrase for encrypted key"
/>
<p className="mt-1 text-xs text-muted-foreground">
Only required if your SSH key is encrypted with a passphrase
</p>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
{isEditing && onCancel && ( {isEditing && onCancel && (
<button <Button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400" variant="outline"
size="default"
className="w-full sm:w-auto order-2 sm:order-1"
> >
Cancel Cancel
</button> </Button>
)} )}
<button <Button
type="submit" type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 dark:bg-blue-700 border border-transparent rounded-md hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400" variant="default"
size="default"
className="w-full sm:w-auto order-1 sm:order-2"
> >
{isEditing ? 'Update Server' : 'Add Server'} {isEditing ? 'Update Server' : 'Add Server'}
</button> </Button>
</div> </div>
</form> </form>
); );

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Server, CreateServerData } from '../../types/server'; import type { Server, CreateServerData } from '../../types/server';
import { ServerForm } from './ServerForm'; import { ServerForm } from './ServerForm';
import { Button } from './ui/button';
interface ServerListProps { interface ServerListProps {
servers: Server[]; servers: Server[];
@@ -71,12 +72,12 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
if (servers.length === 0) { if (servers.length === 0) {
return ( return (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-muted-foreground">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="mx-auto h-12 w-12 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg> </svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No servers configured</h3> <h3 className="mt-2 text-sm font-medium text-foreground">No servers configured</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding a new server configuration above.</p> <p className="mt-1 text-sm text-muted-foreground">Get started by adding a new server configuration above.</p>
</div> </div>
); );
} }
@@ -84,16 +85,25 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{servers.map((server) => ( {servers.map((server) => (
<div key={server.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm"> <div
key={server.id}
className="bg-card border border-border rounded-lg p-4 shadow-sm"
style={{ borderLeft: `4px solid ${server.color ?? 'transparent'}` }}
>
{editingId === server.id ? ( {editingId === server.id ? (
<div> <div>
<h4 className="text-lg font-medium text-gray-900 mb-4">Edit Server</h4> <h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4>
<ServerForm <ServerForm
initialData={{ initialData={{
name: server.name, name: server.name,
ip: server.ip, ip: server.ip,
user: server.user, user: server.user,
password: server.password, password: server.password,
auth_type: server.auth_type,
ssh_key: server.ssh_key,
ssh_key_passphrase: server.ssh_key_passphrase,
ssh_port: server.ssh_port,
color: server.color,
}} }}
onSubmit={handleUpdate} onSubmit={handleUpdate}
isEditing={true} isEditing={true}
@@ -101,33 +111,33 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
/> />
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between space-y-4 sm:space-y-0">
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="flex items-center space-x-3"> <div className="flex items-start sm:items-center space-x-3">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 sm:w-6 sm:h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg> </svg>
</div> </div>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-gray-900 truncate">{server.name}</h3> <h3 className="text-base sm:text-lg font-medium text-foreground truncate">{server.name}</h3>
<div className="mt-1 flex items-center space-x-4 text-sm text-gray-500"> <div className="mt-1 flex flex-col sm:flex-row sm:items-center space-y-1 sm:space-y-0 sm:space-x-4 text-sm text-muted-foreground">
<span className="flex items-center"> <span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
</svg> </svg>
{server.ip} <span className="truncate">{server.ip}</span>
</span> </span>
<span className="flex items-center"> <span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg> </svg>
{server.user} <span className="truncate">{server.user}</span>
</span> </span>
</div> </div>
<div className="mt-1 text-xs text-gray-400"> <div className="mt-1 text-xs text-muted-foreground">
Created: {new Date(server.created_at).toLocaleDateString()} Created: {new Date(server.created_at).toLocaleDateString()}
{server.updated_at !== server.created_at && ( {server.updated_at !== server.created_at && (
<span> Updated: {new Date(server.updated_at).toLocaleDateString()}</span> <span> Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
@@ -161,46 +171,58 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
<button <Button
onClick={() => handleTestConnection(server)} onClick={() => handleTestConnection(server)}
disabled={testingConnections.has(server.id)} disabled={testingConnections.has(server.id)}
className="inline-flex items-center px-3 py-1.5 border border-green-300 text-xs font-medium rounded text-green-700 bg-white hover:bg-green-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed" variant="outline"
size="sm"
className="w-full sm:w-auto border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
> >
{testingConnections.has(server.id) ? ( {testingConnections.has(server.id) ? (
<> <>
<svg className="w-4 h-4 mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg> </svg>
Testing... <span className="hidden sm:inline">Testing...</span>
<span className="sm:hidden">Test...</span>
</> </>
) : ( ) : (
<> <>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
Test Connection <span className="hidden sm:inline">Test Connection</span>
<span className="sm:hidden">Test</span>
</> </>
)} )}
</button> </Button>
<button <div className="flex space-x-2">
<Button
onClick={() => handleEdit(server)} onClick={() => handleEdit(server)}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" variant="outline"
size="sm"
className="flex-1 sm:flex-none"
> >
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg> </svg>
Edit <span className="hidden sm:inline">Edit</span>
</button> <span className="sm:hidden"></span>
<button </Button>
<Button
onClick={() => handleDelete(server.id)} onClick={() => handleDelete(server.id)}
className="inline-flex items-center px-3 py-1.5 border border-red-300 text-xs font-medium rounded text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" variant="outline"
size="sm"
className="flex-1 sm:flex-none border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
> >
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg> </svg>
Delete <span className="hidden sm:inline">Delete</span>
</button> <span className="sm:hidden">🗑</span>
</Button>
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -0,0 +1,50 @@
'use client';
import { useState } from 'react';
import { SettingsModal } from './SettingsModal';
import { Button } from './ui/button';
export function ServerSettingsButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
Add and manage PVE Servers:
</div>
<Button
onClick={() => setIsOpen(true)}
variant="outline"
size="default"
className="inline-flex items-center"
title="Add PVE Server"
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Manage PVE Servers
</Button>
</div>
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { SettingsModal } from './SettingsModal'; import { GeneralSettingsModal } from './GeneralSettingsModal';
import { Button } from './ui/button';
import { Settings } from 'lucide-react';
export function SettingsButton() { export function SettingsButton() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -9,40 +11,22 @@ export function SettingsButton() {
return ( return (
<> <>
<div className="flex flex-col sm:flex-row sm:items-center gap-3"> <div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-gray-600 dark:text-gray-300 font-medium"> <div className="text-sm text-muted-foreground font-medium">
Add and manage PVE Servers Application Settings:
</div> </div>
<button <Button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors duration-200" variant="outline"
title="Add PVE Server" size="default"
className="inline-flex items-center"
title="Open Settings"
> >
<svg <Settings className="w-5 h-5 mr-2" />
className="w-5 h-5 mr-2" Settings
fill="none" </Button>
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Manage PVE Servers
</button>
</div> </div>
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} /> <GeneralSettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
</> </>
); );
} }

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import type { Server, CreateServerData } from '../../types/server'; import type { Server, CreateServerData } from '../../types/server';
import { ServerForm } from './ServerForm'; import { ServerForm } from './ServerForm';
import { ServerList } from './ServerList'; import { ServerList } from './ServerList';
import { Button } from './ui/button';
interface SettingsModalProps { interface SettingsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -14,7 +15,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const [servers, setServers] = useState<Server[]>([]); const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'servers' | 'general'>('servers');
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -31,7 +31,11 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
throw new Error('Failed to fetch servers'); throw new Error('Failed to fetch servers');
} }
const data = await response.json(); const data = await response.json();
setServers(data as Server[]); // Sort servers by name alphabetically
const sortedServers = (data as Server[]).sort((a, b) =>
(a.name ?? '').localeCompare(b.name ?? '')
);
setServers(sortedServers);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred'); setError(err instanceof Error ? err.message : 'An error occurred');
} finally { } finally {
@@ -98,76 +102,52 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 dark:bg-black dark:bg-opacity-70 flex items-center justify-center z-50"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden"> <div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Settings</h2> <h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
<button <Button
onClick={onClose} onClick={onClose}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </Button>
</div> </div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex space-x-8 px-6">
<button
onClick={() => setActiveTab('servers')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'servers'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
Server Settings
</button>
<button
onClick={() => setActiveTab('general')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'general'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
General
</button>
</nav>
</div>
{/* Content */} {/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]"> <div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
{error && ( {error && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md"> <div className="mb-4 p-3 sm:p-4 bg-destructive/10 border border-destructive rounded-md">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400 dark:text-red-300" viewBox="0 0 20 20" fill="currentColor"> <svg className="h-4 w-4 sm:h-5 sm:w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" /> <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg> </svg>
</div> </div>
<div className="ml-3"> <div className="ml-2 sm:ml-3 min-w-0 flex-1">
<h3 className="text-sm font-medium text-red-800">Error</h3> <h3 className="text-xs sm:text-sm font-medium text-red-800">Error</h3>
<div className="mt-2 text-sm text-red-700">{error}</div> <div className="mt-1 sm:mt-2 text-xs sm:text-sm text-red-700 break-words">{error}</div>
</div> </div>
</div> </div>
</div> </div>
)} )}
{activeTab === 'servers' && ( <div className="space-y-4 sm:space-y-6">
<div className="space-y-6">
<div> <div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Server Configurations</h3> <h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
<ServerForm onSubmit={handleCreateServer} /> <ServerForm onSubmit={handleCreateServer} />
</div> </div>
<div> <div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Saved Servers</h3> <h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Saved Servers</h3>
{loading ? ( {loading ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <div className="text-center py-8 text-muted-foreground">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600">Loading servers...</p> <p className="mt-2 text-gray-600">Loading servers...</p>
</div> </div>
@@ -180,14 +160,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
)} )}
</div> </div>
</div> </div>
)}
{activeTab === 'general' && (
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">General Settings</h3>
<p className="text-gray-600 dark:text-gray-300">General settings will be available in a future update.</p>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,204 @@
'use client';
import { useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Toggle } from './ui/toggle';
import { Lock, User, Shield, AlertCircle } from 'lucide-react';
interface SetupModalProps {
isOpen: boolean;
onComplete: () => void;
}
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [enableAuth, setEnableAuth] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
// Only validate passwords if authentication is enabled
if (enableAuth && password !== confirmPassword) {
setError('Passwords do not match');
setIsLoading(false);
return;
}
try {
const response = await fetch('/api/auth/setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: enableAuth ? username : undefined,
password: enableAuth ? password : undefined,
enabled: enableAuth
}),
});
if (response.ok) {
// If authentication is enabled, automatically log in the user
if (enableAuth) {
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (loginResponse.ok) {
// Login successful, complete setup
onComplete();
} else {
// Setup succeeded but login failed, still complete setup
console.warn('Setup completed but auto-login failed');
onComplete();
}
} else {
// Authentication disabled, just complete setup
onComplete();
}
} else {
const errorData = await response.json() as { error: string };
setError(errorData.error ?? 'Failed to setup authentication');
}
} catch (error) {
console.error('Setup error:', error);
setError('Failed to setup authentication');
}
setIsLoading(false);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="flex items-center gap-3">
<Shield className="h-8 w-8 text-green-600" />
<h2 className="text-2xl font-bold text-card-foreground">Setup Authentication</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-muted-foreground text-center mb-6">
Set up authentication to secure your application. This will be required for future access.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="setup-username" className="block text-sm font-medium text-foreground mb-2">
Username
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="setup-username"
type="text"
placeholder="Choose a username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={3}
/>
</div>
</div>
<div>
<label htmlFor="setup-password" className="block text-sm font-medium text-foreground mb-2">
Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="setup-password"
type="password"
placeholder="Choose a password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={6}
/>
</div>
</div>
<div>
<label htmlFor="confirm-password" className="block text-sm font-medium text-foreground mb-2">
Confirm Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={6}
/>
</div>
</div>
<div className="p-4 border border-border rounded-lg bg-muted/30">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-foreground mb-1">Enable Authentication</h4>
<p className="text-sm text-muted-foreground">
{enableAuth
? 'Authentication will be required on every page load'
: 'Authentication will be optional (can be enabled later in settings)'
}
</p>
</div>
<Toggle
checked={enableAuth}
onCheckedChange={setEnableAuth}
disabled={isLoading}
label="Enable authentication"
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-800 border border-red-200 rounded-md">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
)}
<Button
type="submit"
disabled={
isLoading ||
(enableAuth && (!username.trim() || !password.trim() || !confirmPassword.trim()))
}
className="w-full"
>
{isLoading ? 'Setting Up...' : 'Complete Setup'}
</Button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
import { Button } from './ui/button';
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
interface TerminalProps { interface TerminalProps {
scriptPath: string; scriptPath: string;
@@ -22,42 +24,129 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
const [mobileInput, setMobileInput] = useState('');
const [showMobileInput, setShowMobileInput] = useState(false);
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
const [isMobile, setIsMobile] = useState(false);
const [isStopped, setIsStopped] = useState(false);
const [isTerminalReady, setIsTerminalReady] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<any>(null); const xtermRef = useRef<any>(null);
const fitAddonRef = useRef<any>(null); const fitAddonRef = useRef<any>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const [executionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); const inputHandlerRef = useRef<((data: string) => void) | null>(null);
const [executionId, setExecutionId] = useState(() => `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
const isConnectingRef = useRef<boolean>(false); const isConnectingRef = useRef<boolean>(false);
const hasConnectedRef = useRef<boolean>(false); const hasConnectedRef = useRef<boolean>(false);
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script'; const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
const handleMessage = useCallback((message: TerminalMessage) => {
if (!xtermRef.current) return;
const timestamp = new Date(message.timestamp).toLocaleTimeString();
const prefix = `[${timestamp}] `;
switch (message.type) {
case 'start':
xtermRef.current.writeln(`${prefix}[START] ${message.data}`);
setIsRunning(true);
break;
case 'output':
// Write directly to terminal - xterm.js handles ANSI codes natively
xtermRef.current.write(message.data);
break;
case 'error':
// Check if this looks like ANSI terminal output (contains escape codes)
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
// This is likely terminal output sent to stderr, treat it as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('TERM environment variable not set')) {
// This is a common warning, treat as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
// This is a script error, show it with error prefix
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
} else {
// This is a real error, show it with error prefix
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
}
break;
case 'end':
setIsRunning(false);
// Check if this is an LXC creation script
const isLxcCreation = scriptPath.includes('ct/') ||
scriptPath.includes('create_lxc') ||
(containerId != null) ||
scriptName.includes('lxc') ||
scriptName.includes('container');
if (isLxcCreation && message.data.includes('SSH script execution finished with code: 0')) {
// Display prominent LXC creation completion message
xtermRef.current.writeln('');
xtermRef.current.writeln('#########################################');
xtermRef.current.writeln('########## LXC CREATION FINISHED ########');
xtermRef.current.writeln('#########################################');
xtermRef.current.writeln('');
} else {
xtermRef.current.writeln(`${prefix}${message.data}`);
}
break;
}
}, [scriptPath, containerId, scriptName]);
// Ensure we're on the client side // Ensure we're on the client side
useEffect(() => { useEffect(() => {
setIsClient(true); setIsClient(true);
// Detect mobile on mount
setIsMobile(window.innerWidth < 768);
}, []); }, []);
useEffect(() => { useEffect(() => {
// Only initialize on client side // Only initialize on client side
if (!isClient || !terminalRef.current || xtermRef.current) return; if (!isClient || !terminalRef.current || xtermRef.current) return;
// Store ref value to avoid stale closure
const terminalElement = terminalRef.current;
// Use setTimeout to ensure DOM is fully ready // Use setTimeout to ensure DOM is fully ready
const initTerminal = async () => { const initTerminal = async () => {
if (!terminalRef.current || xtermRef.current) return; if (!terminalElement || xtermRef.current) return;
// Dynamically import xterm modules to avoid SSR issues // Dynamically import xterm modules to avoid SSR issues
const { Terminal: XTerm } = await import('@xterm/xterm'); const { Terminal: XTerm } = await import('@xterm/xterm');
const { FitAddon } = await import('@xterm/addon-fit'); const { FitAddon } = await import('@xterm/addon-fit');
const { WebLinksAddon } = await import('@xterm/addon-web-links'); const { WebLinksAddon } = await import('@xterm/addon-web-links');
// Use the mobile state
const terminal = new XTerm({ const terminal = new XTerm({
theme: { theme: {
background: '#000000', background: '#0d1117',
foreground: '#00ff00', foreground: '#e6edf3',
cursor: '#00ff00', cursor: '#58a6ff',
cursorAccent: '#0d1117',
// Let ANSI colors work naturally - only define basic colors
black: '#484f58',
red: '#f85149',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39d353',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ff7b72',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d364',
brightWhite: '#f0f6fc',
}, },
fontSize: 14, fontSize: isMobile ? 7 : 14,
fontFamily: 'Courier New, monospace', fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
cursorBlink: true, cursorBlink: true,
cursorStyle: 'block', cursorStyle: 'block',
scrollback: 1000, scrollback: 1000,
@@ -68,6 +157,12 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
macOptionIsMeta: false, macOptionIsMeta: false,
rightClickSelectsWord: false, rightClickSelectsWord: false,
wordSeparator: ' ()[]{}\'"`<>|', wordSeparator: ' ()[]{}\'"`<>|',
// Better ANSI handling
allowProposedApi: true,
// Force proper terminal behavior for interactive applications
// Use smaller dimensions on mobile but ensure proper fit
cols: isMobile ? 45 : 80,
rows: isMobile ? 18 : 24,
}); });
// Add addons // Add addons
@@ -76,40 +171,70 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
terminal.loadAddon(fitAddon); terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon); terminal.loadAddon(webLinksAddon);
// Enable better ANSI handling
terminal.options.allowProposedApi = true;
// Open terminal // Open terminal
terminal.open(terminalRef.current); terminal.open(terminalElement);
// Ensure proper terminal rendering
setTimeout(() => {
terminal.refresh(0, terminal.rows - 1);
// Ensure cursor is properly positioned
terminal.focus();
// Force focus on the terminal element
terminalElement.focus();
terminalElement.click();
// Add click handler to ensure terminal stays focused
const focusHandler = () => {
terminal.focus();
terminalElement.focus();
};
terminalElement.addEventListener('click', focusHandler);
// Store the handler for cleanup
(terminalElement as any).focusHandler = focusHandler;
}, 100);
// Fit after a small delay to ensure proper sizing // Fit after a small delay to ensure proper sizing
setTimeout(() => { setTimeout(() => {
fitAddon.fit(); fitAddon.fit();
// Force fit multiple times for mobile to ensure proper sizing
if (isMobile) {
setTimeout(() => {
fitAddon.fit();
setTimeout(() => {
fitAddon.fit();
}, 200);
}, 300);
}
}, 100); }, 100);
// Store references // Add resize listener for mobile responsiveness
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Handle terminal input
terminal.onData((data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
action: 'input',
executionId,
input: data
}));
}
});
// Handle terminal resize
const handleResize = () => { const handleResize = () => {
if (fitAddonRef.current) { if (fitAddonRef.current) {
setTimeout(() => {
fitAddonRef.current.fit(); fitAddonRef.current.fit();
}, 50);
} }
}; };
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
// Store the handler for cleanup
(terminalElement as any).resizeHandler = handleResize;
// Store references
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Mark terminal as ready
setIsTerminalReady(true);
return () => { return () => {
window.removeEventListener('resize', handleResize);
terminal.dispose(); terminal.dispose();
}; };
}; };
@@ -121,13 +246,49 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
return () => { return () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (terminalElement && (terminalElement as any).resizeHandler) {
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
}
if (terminalElement && (terminalElement as any).focusHandler) {
terminalElement.removeEventListener('click', (terminalElement as any).focusHandler as (this: HTMLDivElement, ev: PointerEvent) => any);
}
if (xtermRef.current) { if (xtermRef.current) {
xtermRef.current.dispose(); xtermRef.current.dispose();
xtermRef.current = null; xtermRef.current = null;
fitAddonRef.current = null; fitAddonRef.current = null;
setIsTerminalReady(false);
} }
}; };
}, [executionId, isClient]); }, [isClient, isMobile]);
// Handle terminal input with current executionId
useEffect(() => {
if (!isTerminalReady || !xtermRef.current) {
return;
}
const terminal = xtermRef.current;
const handleData = (data: string) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
action: 'input',
executionId,
input: data
};
wsRef.current.send(JSON.stringify(message));
}
};
// Store the handler reference
inputHandlerRef.current = handleData;
terminal.onData(handleData);
return () => {
// Clear the handler reference
inputHandlerRef.current = null;
};
}, [executionId, isTerminalReady]); // Depend on terminal ready state
useEffect(() => { useEffect(() => {
// Prevent multiple connections in React Strict Mode // Prevent multiple connections in React Strict Mode
@@ -142,6 +303,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
} }
isConnectingRef.current = true; isConnectingRef.current = true;
const isInitialConnection = !hasConnectedRef.current;
hasConnectedRef.current = true; hasConnectedRef.current = true;
// Small delay to prevent rapid reconnection // Small delay to prevent rapid reconnection
@@ -157,17 +319,23 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
setIsConnected(true); setIsConnected(true);
isConnectingRef.current = false; isConnectingRef.current = false;
// Send start message immediately after connection // Only auto-start on initial connection, not on reconnections
if (isInitialConnection && !isRunning) {
// Generate a new execution ID for the initial run
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
setExecutionId(newExecutionId);
const message = { const message = {
action: 'start', action: 'start',
scriptPath, scriptPath,
executionId, executionId: newExecutionId,
mode, mode,
server, server,
isUpdate, isUpdate,
containerId containerId
}; };
ws.send(JSON.stringify(message)); ws.send(JSON.stringify(message));
}
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -204,52 +372,19 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
wsRef.current.close(); wsRef.current.close();
} }
}; };
}, [scriptPath, executionId, mode, server, isUpdate, containerId]); }, [scriptPath, mode, server, isUpdate, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
const handleMessage = (message: TerminalMessage) => {
if (!xtermRef.current) return;
const timestamp = new Date(message.timestamp).toLocaleTimeString();
const prefix = `[${timestamp}] `;
switch (message.type) {
case 'start':
xtermRef.current.writeln(`${prefix}🚀 ${message.data}`);
setIsRunning(true);
break;
case 'output':
// Write directly to terminal - xterm.js handles ANSI codes natively
xtermRef.current.write(message.data);
break;
case 'error':
// Check if this looks like ANSI terminal output (contains escape codes)
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
// This is likely terminal output sent to stderr, treat it as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('TERM environment variable not set')) {
// This is a common warning, treat as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
// This is a script error, show it with error prefix
xtermRef.current.writeln(`${prefix}${message.data}`);
} else {
// This is a real error, show it with error prefix
xtermRef.current.writeln(`${prefix}${message.data}`);
}
break;
case 'end':
xtermRef.current.writeln(`${prefix}${message.data}`);
setIsRunning(false);
break;
}
};
const startScript = () => { const startScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
// Generate a new execution ID for each script run
const newExecutionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
setExecutionId(newExecutionId);
setIsStopped(false);
wsRef.current.send(JSON.stringify({ wsRef.current.send(JSON.stringify({
action: 'start', action: 'start',
scriptPath, scriptPath,
executionId, executionId: newExecutionId,
mode, mode,
server, server,
isUpdate, isUpdate,
@@ -260,6 +395,8 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const stopScript = () => { const stopScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
setIsStopped(true);
setIsRunning(false);
wsRef.current.send(JSON.stringify({ wsRef.current.send(JSON.stringify({
action: 'stop', action: 'stop',
executionId executionId
@@ -273,47 +410,71 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
} }
}; };
const sendInput = (input: string) => {
setLastInputSent(input);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
action: 'input',
executionId,
input: input
};
wsRef.current.send(JSON.stringify(message));
// Clear the feedback after 2 seconds
setTimeout(() => setLastInputSent(null), 2000);
}
};
const handleMobileInput = (input: string) => {
sendInput(input);
setMobileInput('');
};
const handleEnterKey = () => {
sendInput('\r');
};
// Don't render on server side // Don't render on server side
if (!isClient) { if (!isClient) {
return ( return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden"> <div className="bg-card rounded-lg border border-border overflow-hidden">
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700"> <div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="flex space-x-1"> <div className="flex space-x-1">
<div className="w-3 h-3 bg-red-500 rounded-full"></div> <div className="w-3 h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div> <div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div> <div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div> </div>
<span className="text-gray-300 font-mono text-sm ml-2"> <span className="text-foreground font-mono text-sm ml-2">
{scriptName} {scriptName}
</span> </span>
</div> </div>
</div> </div>
<div className="h-96 w-full flex items-center justify-center"> <div className="h-96 w-full flex items-center justify-center">
<div className="text-gray-400">Loading terminal...</div> <div className="text-muted-foreground">Loading terminal...</div>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden"> <div className="bg-card rounded-lg border border-border overflow-hidden">
{/* Terminal Header */} {/* Terminal Header */}
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700"> <div className="bg-muted px-2 sm:px-4 py-2 flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2 min-w-0 flex-1">
<div className="flex space-x-1"> <div className="flex space-x-1 flex-shrink-0">
<div className="w-3 h-3 bg-red-500 rounded-full"></div> <div className="w-2 h-2 sm:w-3 sm:h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div> <div className="w-2 h-2 sm:w-3 sm:h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div> <div className="w-2 h-2 sm:w-3 sm:h-3 bg-green-500 rounded-full"></div>
</div> </div>
<span className="text-gray-300 font-mono text-sm ml-2"> <span className="text-foreground font-mono text-xs sm:text-sm ml-1 sm:ml-2 truncate">
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`} {scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
</span> </span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-1 sm:space-x-2 flex-shrink-0">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div> <div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="text-gray-400 text-xs"> <span className="text-muted-foreground text-xs hidden sm:inline">
{isConnected ? 'Connected' : 'Disconnected'} {isConnected ? 'Connected' : 'Disconnected'}
</span> </span>
</div> </div>
@@ -322,51 +483,199 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
{/* Terminal Output */} {/* Terminal Output */}
<div <div
ref={terminalRef} ref={terminalRef}
className="h-96 w-full" className={`h-[16rem] sm:h-[24rem] lg:h-[32rem] w-full max-w-4xl mx-auto ${isMobile ? 'mobile-terminal' : ''}`}
style={{ minHeight: '384px' }} style={{
minHeight: '256px'
}}
/> />
{/* Terminal Controls */} {/* Mobile Input Controls - Only show on mobile */}
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-t border-gray-700"> <div className="block sm:hidden bg-muted/50 px-2 py-3 border-t border-border">
<div className="flex space-x-2"> <div className="flex items-center justify-between mb-3">
<button <div className="flex items-center gap-2">
onClick={startScript} <span className="text-sm font-medium text-foreground">Mobile Input</span>
disabled={!isConnected || isRunning} {lastInputSent && (
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${ <span className="text-xs text-green-500 bg-green-500/10 px-2 py-1 rounded">
isConnected && !isRunning Sent: {lastInputSent === '\r' ? 'Enter' :
? 'bg-green-600 text-white hover:bg-green-700' lastInputSent === ' ' ? 'Space' :
: 'bg-gray-600 text-gray-400 cursor-not-allowed' lastInputSent === '\b' ? 'Backspace' :
}`} lastInputSent === '\x1b[A' ? 'Up' :
lastInputSent === '\x1b[B' ? 'Down' :
lastInputSent === '\x1b[C' ? 'Right' :
lastInputSent === '\x1b[D' ? 'Left' :
lastInputSent}
</span>
)}
</div>
<Button
onClick={() => setShowMobileInput(!showMobileInput)}
variant="ghost"
size="sm"
className="text-xs"
> >
Start <Keyboard className="h-4 w-4 mr-1" />
</button> {showMobileInput ? 'Hide' : 'Show'} Input
</Button>
<button
onClick={stopScript}
disabled={!isRunning}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
isRunning
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
}`}
>
Stop
</button>
<button
onClick={clearOutput}
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
🗑 Clear
</button>
</div> </div>
<button {showMobileInput && (
onClick={onClose} <div className="space-y-3">
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors" {/* Navigation Buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => sendInput('\x1b[A')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
> >
Close <ChevronUp className="h-4 w-4" />
</button> Up
</Button>
<Button
onClick={() => sendInput('\x1b[B')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
>
<ChevronDown className="h-4 w-4" />
Down
</Button>
</div>
{/* Left/Right Navigation Buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => sendInput('\x1b[D')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
>
<ChevronLeft className="h-4 w-4" />
Left
</Button>
<Button
onClick={() => sendInput('\x1b[C')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
>
<ChevronRight className="h-4 w-4" />
Right
</Button>
</div>
{/* Action Buttons */}
<div className="grid grid-cols-3 gap-2">
<Button
onClick={handleEnterKey}
variant="outline"
size="sm"
className="text-sm"
disabled={!isConnected}
>
Enter
</Button>
<Button
onClick={() => sendInput(' ')}
variant="outline"
size="sm"
className="text-sm"
disabled={!isConnected}
>
Space
</Button>
<Button
onClick={() => sendInput('\b')}
variant="outline"
size="sm"
className="text-sm"
disabled={!isConnected}
>
Backspace
</Button>
</div>
{/* Custom Input */}
<div className="flex gap-2">
<input
type="text"
value={mobileInput}
onChange={(e) => setMobileInput(e.target.value)}
placeholder="Type command..."
className="flex-1 px-3 py-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleMobileInput(mobileInput);
}
}}
disabled={!isConnected}
/>
<Button
onClick={() => handleMobileInput(mobileInput)}
variant="default"
size="sm"
disabled={!isConnected || !mobileInput.trim()}
className="px-3"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
{/* Terminal Controls */}
<div className="bg-muted px-2 sm:px-4 py-2 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 border-t border-border">
<div className="flex flex-wrap gap-1 sm:gap-2">
<Button
onClick={startScript}
disabled={!isConnected || (isRunning && !isStopped)}
variant="default"
size="sm"
className={`text-xs sm:text-sm ${isConnected && (!isRunning || isStopped) ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
>
<Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
<span className="hidden sm:inline">Start</span>
<span className="sm:hidden"></span>
</Button>
<Button
onClick={stopScript}
disabled={!isRunning}
variant="default"
size="sm"
className={`text-xs sm:text-sm ${isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
>
<Square className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
<span className="hidden sm:inline">Stop</span>
<span className="sm:hidden"></span>
</Button>
<Button
onClick={clearOutput}
variant="secondary"
size="sm"
className="text-xs sm:text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
<span className="hidden sm:inline">Clear</span>
<span className="sm:hidden">🗑</span>
</Button>
</div>
<Button
onClick={onClose}
variant="secondary"
size="sm"
className="text-xs sm:text-sm bg-gray-600 text-white hover:bg-gray-700 w-full sm:w-auto"
>
<X className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
Close
</Button>
</div> </div>
</div> </div>
); );

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Button } from './ui/button';
interface TextViewerProps { interface TextViewerProps {
scriptName: string; scriptName: string;
@@ -99,44 +100,38 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
return ( return (
<div <div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className="bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col"> <div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200"> <div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<h2 className="text-2xl font-bold text-gray-800"> <h2 className="text-2xl font-bold text-foreground">
Script Viewer: {scriptName} Script Viewer: {scriptName}
</h2> </h2>
{scriptContent.ctScript && scriptContent.installScript && ( {scriptContent.ctScript && scriptContent.installScript && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <Button
variant={activeTab === 'ct' ? 'outline' : 'ghost'}
onClick={() => setActiveTab('ct')} onClick={() => setActiveTab('ct')}
className={`px-3 py-1 text-sm rounded transition-colors ${ className="px-3 py-1 text-sm"
activeTab === 'ct'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
> >
CT Script CT Script
</button> </Button>
<button <Button
variant={activeTab === 'install' ? 'outline' : 'ghost'}
onClick={() => setActiveTab('install')} onClick={() => setActiveTab('install')}
className={`px-3 py-1 text-sm rounded transition-colors ${ className="px-3 py-1 text-sm"
activeTab === 'install'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
> >
Install Script Install Script
</button> </Button>
</div> </div>
)} )}
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -148,11 +143,11 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
<div className="flex-1 overflow-hidden flex flex-col"> <div className="flex-1 overflow-hidden flex flex-col">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-lg text-gray-600">Loading script content...</div> <div className="text-lg text-muted-foreground">Loading script content...</div>
</div> </div>
) : error ? ( ) : error ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-lg text-red-600">Error: {error}</div> <div className="text-lg text-destructive">Error: {error}</div>
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">

View File

@@ -0,0 +1,312 @@
'use client';
import { api } from "~/trpc/react";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
import { useState, useEffect, useRef } from "react";
// Loading overlay component with log streaming
function LoadingOverlay({
isNetworkError = false,
logs = []
}: {
isNetworkError?: boolean;
logs?: string[];
}) {
const logsEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-card rounded-lg p-8 shadow-2xl border border-border max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
<div className="flex flex-col items-center space-y-4">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-card-foreground mb-2">
{isNetworkError ? 'Server Restarting' : 'Updating Application'}
</h3>
<p className="text-sm text-muted-foreground">
{isNetworkError
? 'The server is restarting after the update...'
: 'Please stand by while we update your application...'
}
</p>
<p className="text-xs text-muted-foreground mt-2">
{isNetworkError
? 'This may take a few moments. The page will reload automatically.'
: 'The server will restart automatically when complete.'
}
</p>
</div>
{/* Log output */}
{logs.length > 0 && (
<div className="w-full mt-4 bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-60 overflow-y-auto terminal-output">
{logs.map((log, index) => (
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
{log}
</div>
))}
<div ref={logsEndRef} />
</div>
)}
<div className="flex space-x-1">
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
</div>
);
}
export function VersionDisplay() {
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
const [isUpdating, setIsUpdating] = useState(false);
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
const [isNetworkError, setIsNetworkError] = useState(false);
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
const [shouldSubscribe, setShouldSubscribe] = useState(false);
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
const lastLogTimeRef = useRef<number>(Date.now());
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
const executeUpdate = api.version.executeUpdate.useMutation({
onSuccess: (result) => {
setUpdateResult({ success: result.success, message: result.message });
if (result.success) {
// Start subscribing to update logs
setShouldSubscribe(true);
setUpdateLogs(['Update started...']);
} else {
setIsUpdating(false);
}
},
onError: (error) => {
setUpdateResult({ success: false, message: error.message });
setIsUpdating(false);
}
});
// Poll for update logs
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
enabled: shouldSubscribe,
refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true,
});
// Update logs when data changes
useEffect(() => {
if (updateLogsData?.success && updateLogsData.logs) {
lastLogTimeRef.current = Date.now();
setUpdateLogs(updateLogsData.logs);
if (updateLogsData.isComplete) {
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
setIsNetworkError(true);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
}
}
}, [updateLogsData]);
// Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => {
if (!shouldSubscribe) return;
// Only use this as a fallback - the main trigger should be completion detection
const checkInterval = setInterval(() => {
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
// Only start reconnection if we've been updating for at least 3 minutes
// and no logs for 60 seconds (very conservative fallback)
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
console.log('Fallback: Assuming server restart due to long silence');
setIsNetworkError(true);
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
// Start trying to reconnect
startReconnectAttempts();
}
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
// Attempt to reconnect and reload page when server is back
const startReconnectAttempts = () => {
if (reconnectIntervalRef.current) return;
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
reconnectIntervalRef.current = setInterval(() => {
void (async () => {
try {
// Try to fetch the root path to check if server is back
const response = await fetch('/', { method: 'HEAD' });
if (response.ok || response.status === 200) {
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
// Clear interval and reload
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
}
setTimeout(() => {
window.location.reload();
}, 1000);
}
} catch {
// Server still down, keep trying
}
})();
}, 2000);
};
// Cleanup reconnect interval on unmount
useEffect(() => {
return () => {
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
}
};
}, []);
const handleUpdate = () => {
setIsUpdating(true);
setUpdateResult(null);
setIsNetworkError(false);
setUpdateLogs([]);
setShouldSubscribe(false);
setUpdateStartTime(Date.now());
lastLogTimeRef.current = Date.now();
executeUpdate.mutate();
};
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="animate-pulse">
Loading...
</Badge>
</div>
);
}
if (error || !versionStatus?.success) {
return (
<div className="flex items-center gap-2">
<Badge variant="destructive">
v{versionStatus?.currentVersion ?? 'Unknown'}
</Badge>
<span className="text-xs text-muted-foreground">
(Unable to check for updates)
</span>
</div>
);
}
const { currentVersion, isUpToDate, updateAvailable, releaseInfo } = versionStatus;
return (
<>
{/* Loading overlay */}
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
<Badge variant={isUpToDate ? "default" : "secondary"} className="text-xs">
v{currentVersion}
</Badge>
{updateAvailable && releaseInfo && (
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-3">
<div className="relative group">
<Badge variant="destructive" className="animate-pulse cursor-help text-xs">
Update Available
</Badge>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 mt-2 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10 hidden sm:block">
<div className="text-center">
<div className="font-semibold mb-1">How to update:</div>
<div>Click the button to update, when installed via the helper script</div>
<div>or update manually:</div>
<div>cd $PVESCRIPTLOCAL_DIR</div>
<div>git pull</div>
<div>npm install</div>
<div>npm run build</div>
<div>npm start</div>
</div>
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"></div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleUpdate}
disabled={isUpdating}
size="sm"
variant="destructive"
className="text-xs h-6 px-2"
>
{isUpdating ? (
<>
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
<span className="hidden sm:inline">Updating...</span>
<span className="sm:hidden">...</span>
</>
) : (
<>
<Download className="h-3 w-3 mr-1" />
<span className="hidden sm:inline">Update Now</span>
<span className="sm:hidden">Update</span>
</>
)}
</Button>
<a
href={releaseInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="View latest release"
>
<ExternalLink className="h-3 w-3" />
</a>
</div>
{updateResult && (
<div className={`text-xs px-2 py-1 rounded text-center ${
updateResult.success
? 'bg-chart-2/20 text-chart-2 border border-chart-2/30'
: 'bg-destructive/20 text-destructive border border-destructive/30'
}`}>
{updateResult.message}
</div>
)}
</div>
)}
{isUpToDate && (
<span className="text-xs text-chart-2">
Up to date
</span>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { Button } from './ui/button';
import { Grid3X3, List } from 'lucide-react';
interface ViewToggleProps {
viewMode: 'card' | 'list';
onViewModeChange: (mode: 'card' | 'list') => void;
}
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
return (
<div className="flex justify-center mb-6">
<div className="flex items-center space-x-1 bg-muted rounded-lg p-1">
<Button
onClick={() => onViewModeChange('card')}
variant={viewMode === 'card' ? 'default' : 'ghost'}
size="sm"
className={`flex items-center space-x-2 ${
viewMode === 'card'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Grid3X3 className="h-4 w-4" />
<span className="text-sm">Card View</span>
</Button>
<Button
onClick={() => onViewModeChange('list')}
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className={`flex items-center space-x-2 ${
viewMode === 'list'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<List className="h-4 w-4" />
<span className="text-sm">List View</span>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import { cn } from "~/lib/utils"
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: "default" | "secondary" | "destructive" | "outline"
}
function Badge({ className, variant = "default", ...props }: BadgeProps) {
const variantClasses = {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
}
return (
<div
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
variantClasses[variant],
className
)}
{...props}
/>
)
}
export { Badge }

View File

@@ -0,0 +1,109 @@
import type { VariantProps } from "class-variance-authority";
import { Slot, Slottable } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
expandIcon:
"group relative text-primary-foreground bg-primary hover:bg-primary/90",
ringHover:
"bg-primary text-primary-foreground transition-all duration-300 hover:bg-primary/90 hover:ring-2 hover:ring-primary/90 hover:ring-offset-2",
shine:
"text-primary-foreground animate-shine bg-gradient-to-r from-primary via-primary/75 to-primary bg-[length:400%_100%] ",
gooeyRight:
"text-primary-foreground relative bg-primary z-0 overflow-hidden transition-all duration-500 before:absolute before:inset-0 before:-z-10 before:translate-x-[150%] before:translate-y-[150%] before:scale-[2.5] before:rounded-[100%] before:bg-gradient-to-r from-zinc-400 before:transition-transform before:duration-1000 hover:before:translate-x-[0%] hover:before:translate-y-[0%] ",
gooeyLeft:
"text-primary-foreground relative bg-primary z-0 overflow-hidden transition-all duration-500 after:absolute after:inset-0 after:-z-10 after:translate-x-[-150%] after:translate-y-[150%] after:scale-[2.5] after:rounded-[100%] after:bg-gradient-to-l from-zinc-400 after:transition-transform after:duration-1000 hover:after:translate-x-[0%] hover:after:translate-y-[0%] ",
linkHover1:
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-left after:scale-x-100 hover:after:origin-bottom-right hover:after:scale-x-0 after:transition-transform after:ease-in-out after:duration-300",
linkHover2:
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-right after:scale-x-0 hover:after:origin-bottom-left hover:after:scale-x-100 after:transition-transform after:ease-in-out after:duration-300",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-9 w-9 ",
null: "py-1 px-3 rouded-xs",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type IconProps = {
Icon: React.ElementType;
iconPlacement: "left" | "right";
};
type IconRefProps = {
Icon?: never;
iconPlacement?: undefined;
};
export type ButtonProps = {
asChild?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
export type ButtonIconProps = IconProps | IconRefProps;
const Button = React.forwardRef<
HTMLButtonElement,
ButtonProps & ButtonIconProps
>(
(
{
className,
variant,
size,
asChild = false,
Icon,
iconPlacement,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
>
{Icon && iconPlacement === "left" && (
<div className="group-hover:translate-x-100 w-0 translate-x-[0%] pr-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:pr-2 group-hover:opacity-100">
<Icon />
</div>
)}
<Slottable>{props.children}</Slottable>
{Icon && iconPlacement === "right" && (
<div className="w-0 translate-x-[100%] pl-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:translate-x-0 group-hover:pl-2 group-hover:opacity-100">
<Icon />
</div>
)}
</Comp>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "../../../lib/utils"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,41 @@
import * as React from "react"
import { cn } from "../../../lib/utils"
export interface ToggleProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
label?: string;
}
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
({ className, checked, onCheckedChange, label, ...props }, ref) => {
return (
<div className="flex items-center space-x-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={(e) => onCheckedChange?.(e.target.checked)}
ref={ref}
{...props}
/>
<div className={cn(
"w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out peer-checked:bg-blue-600 transition-colors duration-300 ease-in-out",
checked && "bg-blue-600 after:translate-x-full",
className
)} />
</label>
{label && (
<span className="text-sm font-medium text-foreground">
{label}
</span>
)}
</div>
)
}
)
Toggle.displayName = "Toggle"
export { Toggle }

View File

@@ -0,0 +1,66 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { comparePassword, generateToken, getAuthConfig } from '~/lib/auth';
export async function POST(request: NextRequest) {
try {
const { username, password } = await request.json() as { username: string; password: string };
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 }
);
}
const authConfig = getAuthConfig();
if (!authConfig.hasCredentials) {
return NextResponse.json(
{ error: 'Authentication not configured' },
{ status: 400 }
);
}
if (username !== authConfig.username) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
const isValidPassword = await comparePassword(password, authConfig.passwordHash!);
if (!isValidPassword) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
const token = generateToken(username);
const response = NextResponse.json({
success: true,
message: 'Login successful',
username
});
// Set httpOnly cookie
response.cookies.set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60, // 7 days
path: '/',
});
return response;
} catch (error) {
console.error('Error during login:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,94 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { updateAuthCredentials, getAuthConfig, setSetupCompleted } from '~/lib/auth';
import fs from 'fs';
import path from 'path';
export async function POST(request: NextRequest) {
try {
const { username, password, enabled } = await request.json() as { username?: string; password?: string; enabled?: boolean };
// If authentication is disabled, we don't need any credentials
if (enabled === false) {
// Just set AUTH_ENABLED to false without storing credentials
const envPath = path.join(process.cwd(), '.env');
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Update or add AUTH_ENABLED
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
}
// Set setup completed flag
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=.*$/m;
if (setupCompletedRegex.test(envContent)) {
envContent = envContent.replace(setupCompletedRegex, 'AUTH_SETUP_COMPLETED=true');
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_SETUP_COMPLETED=true\n';
}
// Clean up any empty AUTH_USERNAME or AUTH_PASSWORD_HASH lines
envContent = envContent.replace(/^AUTH_USERNAME=\s*$/m, '');
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=\s*$/m, '');
envContent = envContent.replace(/\n\n+/g, '\n');
fs.writeFileSync(envPath, envContent);
return NextResponse.json({
success: true,
message: 'Authentication disabled successfully'
});
}
// If authentication is enabled, require username and password
if (!username) {
return NextResponse.json(
{ error: 'Username is required when authentication is enabled' },
{ status: 400 }
);
}
if (username.length < 3) {
return NextResponse.json(
{ error: 'Username must be at least 3 characters long' },
{ status: 400 }
);
}
if (!password || password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters long' },
{ status: 400 }
);
}
// Check if credentials already exist
const authConfig = getAuthConfig();
if (authConfig.hasCredentials) {
return NextResponse.json(
{ error: 'Authentication is already configured' },
{ status: 400 }
);
}
await updateAuthCredentials(username, password, enabled ?? true);
setSetupCompleted();
return NextResponse.json({
success: true,
message: 'Authentication setup completed successfully'
});
} catch (error) {
console.error('Error during setup:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,37 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { verifyToken } from '~/lib/auth';
export async function GET(request: NextRequest) {
try {
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'No token provided' },
{ status: 401 }
);
}
const decoded = verifyToken(token);
if (!decoded) {
return NextResponse.json(
{ error: 'Invalid token' },
{ status: 401 }
);
}
return NextResponse.json({
success: true,
username: decoded.username,
authenticated: true
});
} catch (error) {
console.error('Error verifying token:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -52,16 +52,55 @@ export async function PUT(
} }
const body = await request.json(); const body = await request.json();
const { name, ip, user, password }: CreateServerData = body; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
// Validate required fields // Validate required fields
if (!name || !ip || !user || !password) { if (!name || !ip || !user) {
return NextResponse.json( return NextResponse.json(
{ error: 'Missing required fields' }, { error: 'Missing required fields: name, ip, and user are required' },
{ status: 400 } { status: 400 }
); );
} }
// Validate SSH port
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
return NextResponse.json(
{ error: 'SSH port must be between 1 and 65535' },
{ status: 400 }
);
}
// Validate authentication based on auth_type
const authType = auth_type ?? 'password';
if (authType === 'password' || authType === 'both') {
if (!password?.trim()) {
return NextResponse.json(
{ error: 'Password is required for password authentication' },
{ status: 400 }
);
}
}
if (authType === 'key' || authType === 'both') {
if (!ssh_key?.trim()) {
return NextResponse.json(
{ error: 'SSH key is required for key authentication' },
{ status: 400 }
);
}
}
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!password?.trim() && !ssh_key?.trim()) {
return NextResponse.json(
{ error: 'At least one authentication method (password or SSH key) is required' },
{ status: 400 }
);
}
}
const db = getDatabase(); const db = getDatabase();
// Check if server exists // Check if server exists
@@ -73,7 +112,17 @@ export async function PUT(
); );
} }
const result = db.updateServer(id, { name, ip, user, password }); const result = db.updateServer(id, {
name,
ip,
user,
password,
auth_type: authType,
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
color
});
return NextResponse.json( return NextResponse.json(
{ {

View File

@@ -20,18 +20,67 @@ export async function GET() {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { name, ip, user, password }: CreateServerData = body; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
// Validate required fields // Validate required fields
if (!name || !ip || !user || !password) { if (!name || !ip || !user) {
return NextResponse.json( return NextResponse.json(
{ error: 'Missing required fields' }, { error: 'Missing required fields: name, ip, and user are required' },
{ status: 400 } { status: 400 }
); );
} }
// Validate SSH port
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
return NextResponse.json(
{ error: 'SSH port must be between 1 and 65535' },
{ status: 400 }
);
}
// Validate authentication based on auth_type
const authType = auth_type ?? 'password';
if (authType === 'password' || authType === 'both') {
if (!password?.trim()) {
return NextResponse.json(
{ error: 'Password is required for password authentication' },
{ status: 400 }
);
}
}
if (authType === 'key' || authType === 'both') {
if (!ssh_key?.trim()) {
return NextResponse.json(
{ error: 'SSH key is required for key authentication' },
{ status: 400 }
);
}
}
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!password?.trim() && !ssh_key?.trim()) {
return NextResponse.json(
{ error: 'At least one authentication method (password or SSH key) is required' },
{ status: 400 }
);
}
}
const db = getDatabase(); const db = getDatabase();
const result = db.createServer({ name, ip, user, password }); const result = db.createServer({
name,
ip,
user,
password,
auth_type: authType,
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
color
});
return NextResponse.json( return NextResponse.json(
{ {

View File

@@ -0,0 +1,117 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth';
import fs from 'fs';
import path from 'path';
export async function GET() {
try {
const authConfig = getAuthConfig();
return NextResponse.json({
username: authConfig.username,
enabled: authConfig.enabled,
hasCredentials: authConfig.hasCredentials,
setupCompleted: authConfig.setupCompleted,
});
} catch (error) {
console.error('Error reading auth credentials:', error);
return NextResponse.json(
{ error: 'Failed to read auth configuration' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const { username, password, enabled } = await request.json() as { username: string; password: string; enabled?: boolean };
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 }
);
}
if (username.length < 3) {
return NextResponse.json(
{ error: 'Username must be at least 3 characters long' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters long' },
{ status: 400 }
);
}
await updateAuthCredentials(username, password, enabled ?? false);
return NextResponse.json({
success: true,
message: 'Authentication credentials updated successfully'
});
} catch (error) {
console.error('Error updating auth credentials:', error);
return NextResponse.json(
{ error: 'Failed to update auth credentials' },
{ status: 500 }
);
}
}
export async function PATCH(request: NextRequest) {
try {
const { enabled } = await request.json() as { enabled: boolean };
if (typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: 'Enabled flag must be a boolean' },
{ status: 400 }
);
}
if (enabled) {
// When enabling, just update the flag
updateAuthEnabled(enabled);
} else {
// When disabling, clear all credentials and set flag to false
const envPath = path.join(process.cwd(), '.env');
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Remove AUTH_USERNAME and AUTH_PASSWORD_HASH
envContent = envContent.replace(/^AUTH_USERNAME=.*$/m, '');
envContent = envContent.replace(/^AUTH_PASSWORD_HASH=.*$/m, '');
// Update or add AUTH_ENABLED
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, 'AUTH_ENABLED=false');
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_ENABLED=false\n';
}
// Clean up empty lines
envContent = envContent.replace(/\n\n+/g, '\n');
fs.writeFileSync(envPath, envContent);
}
return NextResponse.json({
success: true,
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
});
} catch (error) {
console.error('Error updating auth enabled status:', error);
return NextResponse.json(
{ error: 'Failed to update auth status' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export async function POST(request: NextRequest) {
try {
const { enabled } = await request.json();
if (typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: 'Enabled must be a boolean value' },
{ status: 400 }
);
}
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Check if SERVER_COLOR_CODING_ENABLED already exists
const colorCodingRegex = /^SERVER_COLOR_CODING_ENABLED=.*$/m;
const colorCodingMatch = colorCodingRegex.exec(envContent);
if (colorCodingMatch) {
// Replace existing SERVER_COLOR_CODING_ENABLED
envContent = envContent.replace(colorCodingRegex, `SERVER_COLOR_CODING_ENABLED=${enabled}`);
} else {
// Add new SERVER_COLOR_CODING_ENABLED
envContent += (envContent.endsWith('\n') ? '' : '\n') + `SERVER_COLOR_CODING_ENABLED=${enabled}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
return NextResponse.json({ success: true, message: 'Color coding setting saved successfully' });
} catch (error) {
console.error('Error saving color coding setting:', error);
return NextResponse.json(
{ error: 'Failed to save color coding setting' },
{ status: 500 }
);
}
}
export async function GET() {
try {
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json({ enabled: false });
}
const envContent = fs.readFileSync(envPath, 'utf8');
// Extract SERVER_COLOR_CODING_ENABLED
const colorCodingRegex = /^SERVER_COLOR_CODING_ENABLED=(.*)$/m;
const colorCodingMatch = colorCodingRegex.exec(envContent);
const enabled = colorCodingMatch ? colorCodingMatch[1]?.trim().toLowerCase() === 'true' : false;
return NextResponse.json({ enabled });
} catch (error) {
console.error('Error reading color coding setting:', error);
return NextResponse.json(
{ error: 'Failed to read color coding setting' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,148 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export async function POST(request: NextRequest) {
try {
const { filters } = await request.json();
if (!filters || typeof filters !== 'object') {
return NextResponse.json(
{ error: 'Filters object is required' },
{ status: 400 }
);
}
// Validate filter structure
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
for (const field of requiredFields) {
if (!(field in filters)) {
return NextResponse.json(
{ error: `Missing required field: ${field}` },
{ status: 400 }
);
}
}
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Serialize filters to JSON string
const filtersJson = JSON.stringify(filters);
// Check if FILTERS already exists
const filtersRegex = /^FILTERS=.*$/m;
const filtersMatch = filtersRegex.exec(envContent);
if (filtersMatch) {
// Replace existing FILTERS
envContent = envContent.replace(filtersRegex, `FILTERS=${filtersJson}`);
} else {
// Add new FILTERS
envContent += (envContent.endsWith('\n') ? '' : '\n') + `FILTERS=${filtersJson}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
return NextResponse.json({ success: true, message: 'Filters saved successfully' });
} catch (error) {
console.error('Error saving filters:', error);
return NextResponse.json(
{ error: 'Failed to save filters' },
{ status: 500 }
);
}
}
export async function GET() {
try {
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json({ filters: null });
}
// Read .env file and extract FILTERS
const envContent = fs.readFileSync(envPath, 'utf8');
const filtersRegex = /^FILTERS=(.*)$/m;
const filtersMatch = filtersRegex.exec(envContent);
if (!filtersMatch) {
return NextResponse.json({ filters: null });
}
try {
const filtersJson = filtersMatch[1]?.trim();
// Check if filters JSON is empty or invalid
if (!filtersJson || filtersJson === '') {
return NextResponse.json({ filters: null });
}
const filters = JSON.parse(filtersJson);
// Validate the parsed filters
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
const isValid = requiredFields.every(field => field in filters);
if (!isValid) {
return NextResponse.json({ filters: null });
}
return NextResponse.json({ filters });
} catch (parseError) {
console.error('Error parsing saved filters:', parseError);
return NextResponse.json({ filters: null });
}
} catch (error) {
console.error('Error reading filters:', error);
return NextResponse.json(
{ error: 'Failed to read filters' },
{ status: 500 }
);
}
}
export async function DELETE() {
try {
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json({ success: true, message: 'No filters to clear' });
}
// Read existing .env file
let envContent = fs.readFileSync(envPath, 'utf8');
// Remove FILTERS line
const filtersRegex = /^FILTERS=.*$/m;
const filtersMatch = filtersRegex.exec(envContent);
if (filtersMatch) {
envContent = envContent.replace(filtersRegex, '');
}
// Clean up extra newlines
envContent = envContent.replace(/\n\n+/g, '\n');
// Write back to .env file
fs.writeFileSync(envPath, envContent);
return NextResponse.json({ success: true, message: 'Filters cleared successfully' });
} catch (error) {
console.error('Error clearing filters:', error);
return NextResponse.json(
{ error: 'Failed to clear filters' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export async function POST(request: NextRequest) {
try {
const { token } = await request.json();
if (!token || typeof token !== 'string') {
return NextResponse.json(
{ error: 'Token is required and must be a string' },
{ status: 400 }
);
}
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Check if GITHUB_TOKEN already exists
const githubTokenRegex = /^GITHUB_TOKEN=.*$/m;
const githubTokenMatch = githubTokenRegex.exec(envContent);
if (githubTokenMatch) {
// Replace existing GITHUB_TOKEN
envContent = envContent.replace(githubTokenRegex, `GITHUB_TOKEN=${token}`);
} else {
// Add new GITHUB_TOKEN
envContent += (envContent.endsWith('\n') ? '' : '\n') + `GITHUB_TOKEN=${token}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
return NextResponse.json({ success: true, message: 'GitHub token saved successfully' });
} catch (error) {
console.error('Error saving GitHub token:', error);
return NextResponse.json(
{ error: 'Failed to save GitHub token' },
{ status: 500 }
);
}
}
export async function GET() {
try {
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json({ token: null });
}
// Read .env file and extract GITHUB_TOKEN
const envContent = fs.readFileSync(envPath, 'utf8');
const githubTokenRegex = /^GITHUB_TOKEN=(.*)$/m;
const githubTokenMatch = githubTokenRegex.exec(envContent);
const token = githubTokenMatch ? githubTokenMatch[1] : null;
return NextResponse.json({ token });
} catch (error) {
console.error('Error reading GitHub token:', error);
return NextResponse.json(
{ error: 'Failed to read GitHub token' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export async function POST(request: NextRequest) {
try {
const { enabled } = await request.json();
if (typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: 'Enabled value must be a boolean' },
{ status: 400 }
);
}
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Check if SAVE_FILTER already exists
const saveFilterRegex = /^SAVE_FILTER=.*$/m;
const saveFilterMatch = saveFilterRegex.exec(envContent);
if (saveFilterMatch) {
// Replace existing SAVE_FILTER
envContent = envContent.replace(saveFilterRegex, `SAVE_FILTER=${enabled}`);
} else {
// Add new SAVE_FILTER
envContent += (envContent.endsWith('\n') ? '' : '\n') + `SAVE_FILTER=${enabled}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
return NextResponse.json({ success: true, message: 'Save filter setting saved successfully' });
} catch (error) {
console.error('Error saving save filter setting:', error);
return NextResponse.json(
{ error: 'Failed to save save filter setting' },
{ status: 500 }
);
}
}
export async function GET() {
try {
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json({ enabled: false });
}
// Read .env file and extract SAVE_FILTER
const envContent = fs.readFileSync(envPath, 'utf8');
const saveFilterRegex = /^SAVE_FILTER=(.*)$/m;
const saveFilterMatch = saveFilterRegex.exec(envContent);
const enabled = saveFilterMatch ? saveFilterMatch[1] === 'true' : false;
return NextResponse.json({ enabled });
} catch (error) {
console.error('Error reading save filter setting:', error);
return NextResponse.json(
{ error: 'Failed to read save filter setting' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,81 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export async function POST(request: NextRequest) {
try {
const { viewMode } = await request.json();
if (!viewMode || !['card', 'list'].includes(viewMode as string)) {
return NextResponse.json(
{ error: 'View mode must be either "card" or "list"' },
{ status: 400 }
);
}
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Check if VIEW_MODE already exists
const viewModeRegex = /^VIEW_MODE=.*$/m;
const viewModeMatch = viewModeRegex.exec(envContent);
if (viewModeMatch) {
// Replace existing VIEW_MODE
envContent = envContent.replace(viewModeRegex, `VIEW_MODE=${viewMode}`);
} else {
// Add new VIEW_MODE
envContent += (envContent.endsWith('\n') ? '' : '\n') + `VIEW_MODE=${viewMode}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
return NextResponse.json({ success: true, message: 'View mode saved successfully' });
} catch (error) {
console.error('Error saving view mode:', error);
return NextResponse.json(
{ error: 'Failed to save view mode' },
{ status: 500 }
);
}
}
export async function GET() {
try {
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json({ viewMode: 'card' }); // Default to card view
}
// Read .env file and extract VIEW_MODE
const envContent = fs.readFileSync(envPath, 'utf8');
const viewModeRegex = /^VIEW_MODE=(.*)$/m;
const viewModeMatch = viewModeRegex.exec(envContent);
if (!viewModeMatch) {
return NextResponse.json({ viewMode: 'card' }); // Default to card view
}
const viewMode = viewModeMatch[1]?.trim();
// Validate the view mode
if (!viewMode || !['card', 'list'].includes(viewMode)) {
return NextResponse.json({ viewMode: 'card' }); // Default to card view
}
return NextResponse.json({ viewMode });
} catch (error) {
console.error('Error reading view mode:', error);
return NextResponse.json({ viewMode: 'card' }); // Default to card view
}
}

View File

@@ -15,7 +15,7 @@ const handler = (req: NextRequest) =>
env.NODE_ENV === "development" env.NODE_ENV === "development"
? ({ path, error }) => { ? ({ path, error }) => {
console.error( console.error(
` tRPC failed on ${path ?? "<no-path>"}: ${error.message}`, `[ERROR] tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
); );
} }
: undefined, : undefined,

View File

@@ -1,15 +1,15 @@
import "~/styles/globals.css"; import "~/styles/globals.css";
import { type Metadata } from "next"; import { type Metadata, type Viewport } from "next";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react"; import { TRPCReactProvider } from "~/trpc/react";
import { DarkModeProvider } from "./_components/DarkModeProvider"; import { AuthProvider } from "./_components/AuthProvider";
import { DarkModeToggle } from "./_components/DarkModeToggle"; import { AuthGuard } from "./_components/AuthGuard";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PVE Scripts local", title: "PVE Scripts local",
description: "", description: "Manage and execute Proxmox helper scripts locally with live output streaming",
icons: [ icons: [
{ rel: "icon", url: "/favicon.png", type: "image/png" }, { rel: "icon", url: "/favicon.png", type: "image/png" },
{ rel: "icon", url: "/favicon.ico", sizes: "any" }, { rel: "icon", url: "/favicon.ico", sizes: "any" },
@@ -17,56 +17,43 @@ export const metadata: Metadata = {
], ],
}; };
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
const geist = Geist({ const geist = Geist({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-geist-sans", variable: "--font-jetbrains-mono",
}); });
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="en" className={`${geist.variable}`}> <html lang="en" className={`${geist.variable} dark`}>
<head> <head>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
(function() { // Force dark mode
try {
const stored = localStorage.getItem('theme');
const theme = stored && ['light', 'dark', 'system'].includes(stored) ? stored : 'system';
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = theme === 'dark' || (theme === 'system' && systemDark);
if (shouldBeDark) {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {
// Fallback to system preference if localStorage fails
const systemDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
if (systemDark) {
document.documentElement.classList.add('dark');
}
}
})();
`, `,
}} }}
/> />
</head> </head>
<body <body
className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors" className="bg-background text-foreground transition-colors"
suppressHydrationWarning={true} suppressHydrationWarning={true}
> >
<DarkModeProvider> <TRPCReactProvider>
{/* Dark Mode Toggle in top right corner */} <AuthProvider>
<div className="fixed top-4 right-4 z-50"> <AuthGuard>
<DarkModeToggle /> {children}
</div> </AuthGuard>
</AuthProvider>
<TRPCReactProvider>{children}</TRPCReactProvider> </TRPCReactProvider>
</DarkModeProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,19 +1,69 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef } from 'react';
import { ScriptsGrid } from './_components/ScriptsGrid'; import { ScriptsGrid } from './_components/ScriptsGrid';
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { InstalledScriptsTab } from './_components/InstalledScriptsTab'; import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
import { ResyncButton } from './_components/ResyncButton'; import { ResyncButton } from './_components/ResyncButton';
import { Terminal } from './_components/Terminal'; import { Terminal } from './_components/Terminal';
import { ServerSettingsButton } from './_components/ServerSettingsButton';
import { SettingsButton } from './_components/SettingsButton'; import { SettingsButton } from './_components/SettingsButton';
import { VersionDisplay } from './_components/VersionDisplay';
import { Button } from './_components/ui/button';
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
import { api } from '~/trpc/react';
export default function Home() { export default function Home() {
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null); const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'installed'>('scripts'); const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
const terminalRef = useRef<HTMLDivElement>(null);
// Fetch data for script counts
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } = api.scripts.getCtScripts.useQuery();
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
// Calculate script counts
const scriptCounts = {
available: scriptCardsData?.success ? scriptCardsData.cards?.length ?? 0 : 0,
downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// Count scripts that are both in GitHub data and have local versions
const githubScripts = scriptCardsData.cards ?? [];
const localScripts = localScriptsData.scripts ?? [];
return githubScripts.filter(script => {
if (!script?.name) return false;
return localScripts.some(local => {
if (!local?.name) return false;
const localName = local.name.replace(/\.sh$/, '');
return localName.toLowerCase() === script.name.toLowerCase() ||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
});
}).length;
})(),
installed: installedScriptsData?.scripts?.length ?? 0
};
const scrollToTerminal = () => {
if (terminalRef.current) {
// Get the element's position and scroll with a small offset for better mobile experience
const elementTop = terminalRef.current.offsetTop;
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
window.scrollTo({
top: elementTop - offset,
behavior: 'smooth'
});
}
};
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => { const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server }); setRunningScript({ path: scriptPath, name: scriptName, mode, server });
// Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100);
}; };
const handleCloseTerminal = () => { const handleCloseTerminal = () => {
@@ -21,54 +71,83 @@ export default function Home() {
}; };
return ( return (
<main className="min-h-screen bg-gray-100 dark:bg-gray-900"> <main className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-6 sm:mb-8">
<h1 className="text-4xl font-bold text-gray-800 dark:text-gray-100 mb-2"> <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-2 sm:gap-3">
🚀 PVE Scripts Management <Rocket className="h-6 w-6 sm:h-8 w-8 lg:h-9 lg:w-9" />
<span className="break-words">PVE Scripts Management</span>
</h1> </h1>
<p className="text-gray-600 dark:text-gray-300"> <p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
Manage and execute Proxmox helper scripts locally with live output streaming Manage and execute Proxmox helper scripts locally with live output streaming
</p> </p>
<div className="flex justify-center px-2">
<VersionDisplay />
</div>
</div> </div>
{/* Controls */} {/* Controls */}
<div className="mb-8"> <div className="mb-6 sm:mb-8">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6 p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"> <div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
<div className="flex flex-col sm:flex-row sm:items-center gap-4"> <ServerSettingsButton />
<SettingsButton /> <SettingsButton />
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<ResyncButton /> <ResyncButton />
</div> </div>
</div> </div>
</div>
{/* Tab Navigation */} {/* Tab Navigation */}
<div className="mb-8"> <div className="mb-6 sm:mb-8">
<div className="border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-border">
<nav className="-mb-px flex space-x-8"> <nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 lg:space-x-8">
<button <Button
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')} onClick={() => setActiveTab('scripts')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'scripts' activeTab === 'scripts'
? 'border-blue-500 text-blue-600 dark:text-blue-400' ? 'bg-accent text-accent-foreground'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600' : 'hover:bg-accent hover:text-accent-foreground'
}`} }`}>
> <Package className="h-4 w-4" />
📦 Available Scripts <span className="hidden sm:inline">Available Scripts</span>
</button> <span className="sm:hidden">Available</span>
<button <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.available}
</span>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('downloaded')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'downloaded'
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
}`}>
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.downloaded}
</span>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')} onClick={() => setActiveTab('installed')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'installed' activeTab === 'installed'
? 'border-blue-500 text-blue-600 dark:text-blue-400' ? 'bg-accent text-accent-foreground'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600' : 'hover:bg-accent hover:text-accent-foreground'
}`} }`}>
> <FolderOpen className="h-4 w-4" />
🗂 Installed Scripts <span className="hidden sm:inline">Installed Scripts</span>
</button> <span className="sm:hidden">Installed</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.installed}
</span>
</Button>
</nav> </nav>
</div> </div>
</div> </div>
@@ -77,7 +156,7 @@ export default function Home() {
{/* Running Script Terminal */} {/* Running Script Terminal */}
{runningScript && ( {runningScript && (
<div className="mb-8"> <div ref={terminalRef} className="mb-8">
<Terminal <Terminal
scriptPath={runningScript.path} scriptPath={runningScript.path}
onClose={handleCloseTerminal} onClose={handleCloseTerminal}
@@ -92,6 +171,10 @@ export default function Home() {
<ScriptsGrid onInstallScript={handleRunScript} /> <ScriptsGrid onInstallScript={handleRunScript} />
)} )}
{activeTab === 'downloaded' && (
<DownloadedScriptsTab onInstallScript={handleRunScript} />
)}
{activeTab === 'installed' && ( {activeTab === 'installed' && (
<InstalledScriptsTab /> <InstalledScriptsTab />
)} )}

View File

@@ -23,6 +23,16 @@ export const env = createEnv({
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"), ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
// WebSocket Configuration // WebSocket Configuration
WEBSOCKET_PORT: z.string().default("3001"), WEBSOCKET_PORT: z.string().default("3001"),
// GitHub Configuration
GITHUB_TOKEN: z.string().optional(),
// Authentication Configuration
AUTH_USERNAME: z.string().optional(),
AUTH_PASSWORD_HASH: z.string().optional(),
AUTH_ENABLED: z.string().optional(),
AUTH_SETUP_COMPLETED: z.string().optional(),
JWT_SECRET: z.string().optional(),
// Server Color Coding Configuration
SERVER_COLOR_CODING_ENABLED: z.string().optional(),
}, },
/** /**
@@ -52,6 +62,16 @@ export const env = createEnv({
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS, ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
// WebSocket Configuration // WebSocket Configuration
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT, WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
// GitHub Configuration
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
// Authentication Configuration
AUTH_USERNAME: process.env.AUTH_USERNAME,
AUTH_PASSWORD_HASH: process.env.AUTH_PASSWORD_HASH,
AUTH_ENABLED: process.env.AUTH_ENABLED,
AUTH_SETUP_COMPLETED: process.env.AUTH_SETUP_COMPLETED,
JWT_SECRET: process.env.JWT_SECRET,
// Server Color Coding Configuration
SERVER_COLOR_CODING_ENABLED: process.env.SERVER_COLOR_CODING_ENABLED,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
}, },
/** /**

240
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,240 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';
import fs from 'fs';
import path from 'path';
const SALT_ROUNDS = 10;
const JWT_EXPIRY = '7d'; // 7 days
// Cache for JWT secret to avoid multiple file reads
let jwtSecretCache: string | null = null;
/**
* Get or generate JWT secret
*/
export function getJwtSecret(): string {
// Return cached secret if available
if (jwtSecretCache) {
return jwtSecretCache;
}
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Check if JWT_SECRET already exists
const jwtSecretRegex = /^JWT_SECRET=(.*)$/m;
const jwtSecretMatch = jwtSecretRegex.exec(envContent);
if (jwtSecretMatch?.[1]?.trim()) {
jwtSecretCache = jwtSecretMatch[1].trim();
return jwtSecretCache;
}
// Generate new secret
const newSecret = randomBytes(64).toString('hex');
// Add to .env file
envContent += (envContent.endsWith('\n') ? '' : '\n') + `JWT_SECRET=${newSecret}\n`;
fs.writeFileSync(envPath, envContent);
// Cache the new secret
jwtSecretCache = newSecret;
return newSecret;
}
/**
* Hash a password using bcrypt
*/
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
/**
* Compare a password with a hash
*/
export async function comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* Generate a JWT token
*/
export function generateToken(username: string): string {
const secret = getJwtSecret();
return jwt.sign({ username }, secret, { expiresIn: JWT_EXPIRY });
}
/**
* Verify a JWT token
*/
export function verifyToken(token: string): { username: string } | null {
try {
const secret = getJwtSecret();
const decoded = jwt.verify(token, secret) as { username: string };
return decoded;
} catch {
return null;
}
}
/**
* Read auth configuration from .env
*/
export function getAuthConfig(): {
username: string | null;
passwordHash: string | null;
enabled: boolean;
hasCredentials: boolean;
setupCompleted: boolean;
} {
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return {
username: null,
passwordHash: null,
enabled: false,
hasCredentials: false,
setupCompleted: false,
};
}
const envContent = fs.readFileSync(envPath, 'utf8');
// Extract AUTH_USERNAME
const usernameRegex = /^AUTH_USERNAME=(.*)$/m;
const usernameMatch = usernameRegex.exec(envContent);
const username = usernameMatch ? usernameMatch[1]?.trim() : null;
// Extract AUTH_PASSWORD_HASH
const passwordHashRegex = /^AUTH_PASSWORD_HASH=(.*)$/m;
const passwordHashMatch = passwordHashRegex.exec(envContent);
const passwordHash = passwordHashMatch ? passwordHashMatch[1]?.trim() : null;
// Extract AUTH_ENABLED
const enabledRegex = /^AUTH_ENABLED=(.*)$/m;
const enabledMatch = enabledRegex.exec(envContent);
const enabled = enabledMatch ? enabledMatch[1]?.trim().toLowerCase() === 'true' : false;
// Extract AUTH_SETUP_COMPLETED
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=(.*)$/m;
const setupCompletedMatch = setupCompletedRegex.exec(envContent);
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false;
const hasCredentials = !!(username && passwordHash);
return {
username: username ?? null,
passwordHash: passwordHash ?? null,
enabled,
hasCredentials,
setupCompleted,
};
}
/**
* Update auth credentials in .env
*/
export async function updateAuthCredentials(
username: string,
password?: string,
enabled?: boolean
): Promise<void> {
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Hash the password if provided
const passwordHash = password ? await hashPassword(password) : null;
// Update or add AUTH_USERNAME
const usernameRegex = /^AUTH_USERNAME=.*$/m;
if (usernameRegex.test(envContent)) {
envContent = envContent.replace(usernameRegex, `AUTH_USERNAME=${username}`);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_USERNAME=${username}\n`;
}
// Update or add AUTH_PASSWORD_HASH only if password is provided
if (passwordHash) {
const passwordHashRegex = /^AUTH_PASSWORD_HASH=.*$/m;
if (passwordHashRegex.test(envContent)) {
envContent = envContent.replace(passwordHashRegex, `AUTH_PASSWORD_HASH=${passwordHash}`);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_PASSWORD_HASH=${passwordHash}\n`;
}
}
// Update or add AUTH_ENABLED if provided
if (enabled !== undefined) {
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
}
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
}
/**
* Set AUTH_SETUP_COMPLETED flag in .env
*/
export function setSetupCompleted(): void {
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Update or add AUTH_SETUP_COMPLETED
const setupCompletedRegex = /^AUTH_SETUP_COMPLETED=.*$/m;
if (setupCompletedRegex.test(envContent)) {
envContent = envContent.replace(setupCompletedRegex, 'AUTH_SETUP_COMPLETED=true');
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + 'AUTH_SETUP_COMPLETED=true\n';
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
}
/**
* Update AUTH_ENABLED flag in .env
*/
export function updateAuthEnabled(enabled: boolean): void {
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Update or add AUTH_ENABLED
const enabledRegex = /^AUTH_ENABLED=.*$/m;
if (enabledRegex.test(envContent)) {
envContent = envContent.replace(enabledRegex, `AUTH_ENABLED=${enabled}`);
} else {
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_ENABLED=${enabled}\n`;
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
}

35
src/lib/colorUtils.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Calculate the appropriate text color (black or white) for a given background color
* to ensure optimal readability based on luminance
*/
export function getContrastColor(hexColor: string): 'black' | 'white' {
if (!hexColor || hexColor.length !== 7 || !hexColor.startsWith('#')) {
return 'black'; // Default to black for invalid colors
}
// Remove the # and convert to RGB
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
// Calculate relative luminance using the standard formula
// https://www.w3.org/WAI/GL/wiki/Relative_luminance
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
// Return black for light backgrounds, white for dark backgrounds
return luminance > 0.5 ? 'black' : 'white';
}
/**
* Check if a color string is a valid hex color
*/
export function isValidHexColor(color: string): boolean {
return /^#[0-9A-F]{6}$/i.test(color);
}
/**
* Get a default color for servers that don't have one set
*/
export function getDefaultServerColor(): string {
return '#3b82f6'; // Blue-500 from Tailwind
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,6 +1,7 @@
import { scriptsRouter } from "~/server/api/routers/scripts"; import { scriptsRouter } from "~/server/api/routers/scripts";
import { installedScriptsRouter } from "~/server/api/routers/installedScripts"; import { installedScriptsRouter } from "~/server/api/routers/installedScripts";
import { serversRouter } from "~/server/api/routers/servers"; import { serversRouter } from "~/server/api/routers/servers";
import { versionRouter } from "~/server/api/routers/version";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
/** /**
@@ -12,6 +13,7 @@ export const appRouter = createTRPCRouter({
scripts: scriptsRouter, scripts: scriptsRouter,
installedScripts: installedScriptsRouter, installedScripts: installedScriptsRouter,
servers: serversRouter, servers: serversRouter,
version: versionRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -203,5 +203,342 @@ export const installedScriptsRouter = createTRPCRouter({
stats: null stats: null
}; };
} }
}),
// Auto-detect LXC containers with community-script tag
autoDetectLXCContainers: publicProcedure
.input(z.object({ serverId: z.number() }))
.mutation(async ({ input }) => {
console.log('=== AUTO-DETECT API ENDPOINT CALLED ===');
console.log('Input received:', input);
console.log('Timestamp:', new Date().toISOString());
try {
console.log('Starting auto-detect LXC containers for server ID:', input.serverId);
const db = getDatabase();
const server = db.getServerById(input.serverId);
if (!server) {
console.error('Server not found for ID:', input.serverId);
return {
success: false,
error: 'Server not found',
detectedContainers: []
};
}
console.log('Found server:', (server as any).name, 'at', (server as any).ip);
// Import SSH services
const { default: SSHService } = await import('~/server/ssh-service');
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
const sshService = new SSHService();
const sshExecutionService = new SSHExecutionService();
// Test SSH connection first
console.log('Testing SSH connection...');
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any);
console.log('SSH connection test result:', connectionTest);
if (!(connectionTest as any).success) {
return {
success: false,
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
detectedContainers: []
};
}
console.log('SSH connection successful, scanning for LXC containers...');
// Use the working approach - manual loop through all config files
const command = `for file in /etc/pve/lxc/*.conf; do if [ -f "$file" ]; then if grep -q "community-script" "$file"; then echo "$file"; fi; fi; done`;
let detectedContainers: any[] = [];
console.log('Executing manual loop command...');
console.log('Command:', command);
let commandOutput = '';
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
command,
(data: string) => {
commandOutput += data;
},
(error: string) => {
console.error('Command error:', error);
},
(exitCode: number) => {
console.log('Command exit code:', exitCode);
// Parse the complete output to get config file paths that contain community-script tag
const configFiles = commandOutput.split('\n')
.filter((line: string) => line.trim())
.map((line: string) => line.trim())
.filter((line: string) => line.endsWith('.conf'));
console.log('Found config files with community-script tag:', configFiles.length);
console.log('Config files:', configFiles);
// Process each config file to extract hostname
const processPromises = configFiles.map(async (configPath: string) => {
try {
const containerId = configPath.split('/').pop()?.replace('.conf', '');
if (!containerId) return null;
console.log('Processing container:', containerId, 'from', configPath);
// Read the config file content
const readCommand = `cat "${configPath}" 2>/dev/null`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return new Promise<any>((readResolve) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
readCommand,
(configData: string) => {
// Parse config file for hostname
const lines = configData.split('\n');
let hostname = '';
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('hostname:')) {
hostname = trimmedLine.substring(9).trim();
break;
}
}
if (hostname) {
const container = {
containerId,
hostname,
configPath,
serverId: (server as any).id,
serverName: (server as any).name
};
console.log('Adding container to detected list:', container);
readResolve(container);
} else {
console.log('No hostname found for', containerId);
readResolve(null);
}
},
(readError: string) => {
console.error(`Error reading config file ${configPath}:`, readError);
readResolve(null);
},
(_exitCode: number) => {
readResolve(null);
}
);
});
} catch (error) {
console.error(`Error processing config file ${configPath}:`, error);
return null;
}
});
// Wait for all config files to be processed
void Promise.all(processPromises).then((results) => {
detectedContainers = results.filter(result => result !== null);
console.log('Final detected containers:', detectedContainers.length);
resolve();
}).catch((error) => {
console.error('Error processing config files:', error);
reject(new Error(`Error processing config files: ${error}`));
});
}
);
});
console.log('Detected containers:', detectedContainers.length);
// Get existing scripts to check for duplicates
const existingScripts = db.getAllInstalledScripts();
// Create installed script records for detected containers (skip duplicates)
const createdScripts = [];
const skippedScripts = [];
for (const container of detectedContainers) {
try {
// Check if a script with this container_id and server_id already exists
const duplicate = existingScripts.find((script: any) =>
script.container_id === container.containerId &&
script.server_id === container.serverId
);
if (duplicate) {
console.log(`Skipping duplicate: ${container.hostname} (${container.containerId}) already exists`);
skippedScripts.push({
containerId: container.containerId,
hostname: container.hostname,
serverName: container.serverName
});
continue;
}
console.log('Creating script record for:', container.hostname, container.containerId);
const result = db.createInstalledScript({
script_name: container.hostname,
script_path: `detected/${container.hostname}`,
container_id: container.containerId,
server_id: container.serverId,
execution_mode: 'ssh',
status: 'success',
output_log: `Auto-detected from LXC config: ${container.configPath}`
});
createdScripts.push({
id: result.lastInsertRowid,
containerId: container.containerId,
hostname: container.hostname,
serverName: container.serverName
});
console.log('Created script record with ID:', result.lastInsertRowid);
} catch (error) {
console.error(`Error creating script record for ${container.hostname}:`, error);
}
}
const message = skippedScripts.length > 0
? `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.`
: `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts.`;
return {
success: true,
message: message,
detectedContainers: createdScripts,
skippedContainers: skippedScripts
};
} catch (error) {
console.error('Error in autoDetectLXCContainers:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to auto-detect LXC containers',
detectedContainers: []
};
}
}),
// Cleanup orphaned scripts (check if LXC containers still exist on servers)
cleanupOrphanedScripts: publicProcedure
.mutation(async () => {
try {
console.log('=== CLEANUP ORPHANED SCRIPTS API ENDPOINT CALLED ===');
console.log('Timestamp:', new Date().toISOString());
const db = getDatabase();
const allScripts = db.getAllInstalledScripts();
const allServers = db.getAllServers();
console.log('Found scripts:', allScripts.length);
console.log('Found servers:', allServers.length);
if (allScripts.length === 0) {
return {
success: true,
message: 'No scripts to check',
deletedCount: 0,
deletedScripts: []
};
}
// Import SSH services
const { default: SSHService } = await import('~/server/ssh-service');
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
const sshService = new SSHService();
const sshExecutionService = new SSHExecutionService();
const deletedScripts: string[] = [];
const scriptsToCheck = allScripts.filter((script: any) =>
script.execution_mode === 'ssh' &&
script.server_id &&
script.container_id
);
console.log('Scripts to check for cleanup:', scriptsToCheck.length);
for (const script of scriptsToCheck) {
try {
const scriptData = script as any;
const server = allServers.find((s: any) => s.id === scriptData.server_id);
if (!server) {
console.log(`Server not found for script ${scriptData.script_name}, marking for deletion`);
db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
continue;
}
console.log(`Checking script ${scriptData.script_name} on server ${(server as any).name}`);
// Test SSH connection
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any);
if (!(connectionTest as any).success) {
console.log(`SSH connection failed for server ${(server as any).name}, skipping script ${scriptData.script_name}`);
continue;
}
// Check if the container config file still exists
const checkCommand = `test -f "/etc/pve/lxc/${scriptData.container_id}.conf" && echo "exists" || echo "not_found"`;
const containerExists = await new Promise<boolean>((resolve) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
checkCommand,
(data: string) => {
resolve(data.trim() === 'exists');
},
(error: string) => {
console.error(`Error checking container ${scriptData.script_name}:`, error);
resolve(false);
},
(_exitCode: number) => {
resolve(false);
}
);
});
if (!containerExists) {
console.log(`Container ${scriptData.container_id} not found on server ${(server as any).name}, deleting script ${scriptData.script_name}`);
db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
} else {
console.log(`Container ${scriptData.container_id} still exists on server ${(server as any).name}, keeping script ${scriptData.script_name}`);
}
} catch (error) {
console.error(`Error checking script ${(script as any).script_name}:`, error);
}
}
console.log('Cleanup completed. Deleted scripts:', deletedScripts);
return {
success: true,
message: `Cleanup completed. ${deletedScripts.length} orphaned script(s) removed.`,
deletedCount: deletedScripts.length,
deletedScripts: deletedScripts
};
} catch (error) {
console.error('Error in cleanupOrphanedScripts:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to cleanup orphaned scripts',
deletedCount: 0,
deletedScripts: []
};
}
}) })
}); });

View File

@@ -163,12 +163,22 @@ export const scriptsRouter = createTRPCRouter({
const script = scripts.find(s => s.slug === card.slug); const script = scripts.find(s => s.slug === card.slug);
const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? []; const categoryNames: string[] = script?.categories?.map(id => categoryMap[id]).filter((name): name is string => typeof name === 'string') ?? [];
// Extract OS and version from first install method
const firstInstallMethod = script?.install_methods?.[0];
const os = firstInstallMethod?.resources?.os;
const version = firstInstallMethod?.resources?.version;
return { return {
...card, ...card,
categories: script?.categories ?? [], categories: script?.categories ?? [],
categoryNames: categoryNames, categoryNames: categoryNames,
// Add date_created from script // Add date_created from script
date_created: script?.date_created, date_created: script?.date_created,
// Add OS and version from install methods
os: os,
version: version,
// Add interface port
interface_port: script?.interface_port,
} as ScriptCard; } as ScriptCard;
}); });

View File

@@ -0,0 +1,225 @@
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { readFile, writeFile } from "fs/promises";
import { join } from "path";
import { spawn } from "child_process";
import { env } from "~/env";
import { existsSync, createWriteStream } from "fs";
import stripAnsi from "strip-ansi";
interface GitHubRelease {
tag_name: string;
name: string;
published_at: string;
html_url: string;
}
// Helper function to fetch from GitHub API with optional authentication
async function fetchGitHubAPI(url: string) {
const headers: HeadersInit = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'ProxmoxVE-Local'
};
// Add authentication header if token is available
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
return fetch(url, { headers });
}
export const versionRouter = createTRPCRouter({
// Get current local version
getCurrentVersion: publicProcedure
.query(async () => {
try {
const versionPath = join(process.cwd(), 'VERSION');
const version = await readFile(versionPath, 'utf-8');
return {
success: true,
version: version.trim()
};
} catch (error) {
console.error('Error reading VERSION file:', error);
return {
success: false,
error: 'Failed to read VERSION file',
version: null
};
}
}),
getLatestRelease: publicProcedure
.query(async () => {
try {
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const release: GitHubRelease = await response.json();
return {
success: true,
release: {
tagName: release.tag_name,
name: release.name,
publishedAt: release.published_at,
htmlUrl: release.html_url
}
};
} catch (error) {
console.error('Error fetching latest release:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch latest release',
release: null
};
}
}),
getVersionStatus: publicProcedure
.query(async () => {
try {
const versionPath = join(process.cwd(), 'VERSION');
const currentVersion = (await readFile(versionPath, 'utf-8')).trim();
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const release: GitHubRelease = await response.json();
const latestVersion = release.tag_name.replace('v', '');
const isUpToDate = currentVersion === latestVersion;
return {
success: true,
currentVersion,
latestVersion,
isUpToDate,
updateAvailable: !isUpToDate,
releaseInfo: {
tagName: release.tag_name,
name: release.name,
publishedAt: release.published_at,
htmlUrl: release.html_url
}
};
} catch (error) {
console.error('Error checking version status:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to check version status',
currentVersion: null,
latestVersion: null,
isUpToDate: false,
updateAvailable: false,
releaseInfo: null
};
}
}),
// Get update logs from the log file
getUpdateLogs: publicProcedure
.query(async () => {
try {
const logPath = join(process.cwd(), 'update.log');
if (!existsSync(logPath)) {
return {
success: true,
logs: [],
isComplete: false
};
}
const logs = await readFile(logPath, 'utf-8');
const logLines = logs.split('\n')
.filter(line => line.trim())
.map(line => stripAnsi(line)); // Strip ANSI color codes
// Check if update is complete by looking for completion indicators
const isComplete = logLines.some(line =>
line.includes('Update complete') ||
line.includes('Server restarting') ||
line.includes('npm start') ||
line.includes('Restarting server') ||
line.includes('Server started') ||
line.includes('Ready on http') ||
line.includes('Application started') ||
line.includes('Service enabled and started successfully') ||
line.includes('Service is running') ||
line.includes('Update completed successfully')
);
return {
success: true,
logs: logLines,
isComplete
};
} catch (error) {
console.error('Error reading update logs:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to read update logs',
logs: [],
isComplete: false
};
}
}),
// Execute update script
executeUpdate: publicProcedure
.mutation(async () => {
try {
const updateScriptPath = join(process.cwd(), 'update.sh');
const logPath = join(process.cwd(), 'update.log');
// Clear/create the log file
await writeFile(logPath, '', 'utf-8');
// Spawn the update script as a detached process using nohup
// This allows it to run independently and kill the parent Node.js process
// Redirect output to log file
const child = spawn('bash', [updateScriptPath], {
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
detached: true
});
// Capture stdout and stderr to log file
const logStream = createWriteStream(logPath, { flags: 'a' });
child.stdout?.pipe(logStream);
child.stderr?.pipe(logStream);
// Unref the child process so it doesn't keep the parent alive
child.unref();
// Immediately return success since we can't wait for completion
// The script will handle its own logging and restart
return {
success: true,
message: 'Update started in background. The server will restart automatically when complete.',
output: '',
error: ''
};
} catch (error) {
console.error('Error executing update script:', error);
return {
success: false,
message: `Failed to execute update script: ${error instanceof Error ? error.message : 'Unknown error'}`,
output: '',
error: error instanceof Error ? error.message : 'Unknown error'
};
}
})
});

View File

@@ -25,8 +25,11 @@ export class ScriptExecutionHandler {
private handleConnection(ws: WebSocket, _request: IncomingMessage) { private handleConnection(ws: WebSocket, _request: IncomingMessage) {
ws.on('message', (data) => { ws.on('message', (data) => {
try { try {
const message = JSON.parse(data.toString()) as { action: string; scriptPath?: string; executionId?: string }; const message = JSON.parse(data.toString()) as { action: string; scriptPath?: string; executionId?: string };
void this.handleMessage(ws, message); void this.handleMessage(ws, message);
} catch (error) { } catch (error) {
@@ -40,20 +43,20 @@ export class ScriptExecutionHandler {
}); });
ws.on('close', () => { ws.on('close', () => {
// Clean up any active executions for this connection // Clean up any active executions for this connection
this.cleanupActiveExecutions(ws); this.cleanupActiveExecutions(ws);
}); });
ws.on('error', (error) => { ws.on('error', (_error) => {
console.error('WebSocket error:', error);
this.cleanupActiveExecutions(ws); this.cleanupActiveExecutions(ws);
}); });
} }
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any }) { private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any; input?: string }) {
const { action, scriptPath, executionId, mode, server } = message; const { action, scriptPath, executionId, mode, server, input } = message;
console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
switch (action) { switch (action) {
case 'start': case 'start':
@@ -74,6 +77,20 @@ export class ScriptExecutionHandler {
} }
break; break;
case 'input':
if (executionId && input !== undefined) {
this.sendInputToExecution(executionId, input);
} else {
this.sendMessage(ws, {
type: 'error',
data: 'Missing executionId or input data',
timestamp: Date.now()
});
}
break;
default: default:
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'error', type: 'error',
@@ -84,7 +101,6 @@ export class ScriptExecutionHandler {
} }
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) { private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) {
console.log('startScriptExecution called with:', { scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
try { try {
// Check if execution is already running // Check if execution is already running
@@ -100,10 +116,7 @@ export class ScriptExecutionHandler {
let process: any; let process: any;
if (mode === 'ssh' && server) { if (mode === 'ssh' && server) {
// SSH execution
console.log('Starting SSH execution:', { scriptPath, server });
console.log('SSH execution mode detected, calling SSH service...');
console.log('Mode check: mode=', mode, 'server=', !!server);
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'start', type: 'start',
data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`, data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`,
@@ -111,13 +124,11 @@ export class ScriptExecutionHandler {
}); });
const sshService = getSSHExecutionService(); const sshService = getSSHExecutionService();
console.log('SSH service obtained, calling executeScript...');
console.log('SSH service object:', typeof sshService, sshService.constructor.name);
try { try {
const result = await sshService.executeScript(server as Server, scriptPath, const result = await sshService.executeScript(server as Server, scriptPath,
(data: string) => { (data: string) => {
console.log('SSH onData callback:', data.substring(0, 100) + '...');
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'output', type: 'output',
data: data, data: data,
@@ -125,7 +136,7 @@ export class ScriptExecutionHandler {
}); });
}, },
(error: string) => { (error: string) => {
console.log('SSH onError callback:', error);
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'error', type: 'error',
data: error, data: error,
@@ -133,7 +144,7 @@ export class ScriptExecutionHandler {
}); });
}, },
(code: number) => { (code: number) => {
console.log('SSH onExit callback, code:', code);
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'end', type: 'end',
data: `SSH script execution finished with code: ${code}`, data: `SSH script execution finished with code: ${code}`,
@@ -142,10 +153,10 @@ export class ScriptExecutionHandler {
this.activeExecutions.delete(executionId); this.activeExecutions.delete(executionId);
} }
); );
console.log('SSH service executeScript completed, result:', result);
process = (result as any).process; process = (result as any).process;
} catch (sshError) { } catch (sshError) {
console.error('SSH service executeScript failed:', sshError);
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'error', type: 'error',
data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`, data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`,
@@ -154,10 +165,7 @@ export class ScriptExecutionHandler {
return; return;
} }
} else { } else {
// Local execution
console.log('Starting local execution:', { scriptPath });
console.log('Local execution mode detected, calling local script manager...');
console.log('Mode check: mode=', mode, 'server=', !!server, 'condition result:', mode === 'ssh' && server);
// Validate script path // Validate script path
const validation = scriptManager.validateScriptPath(scriptPath); const validation = scriptManager.validateScriptPath(scriptPath);
@@ -249,6 +257,59 @@ export class ScriptExecutionHandler {
} }
} }
private sendInputToExecution(executionId: string, input: string) {
const execution = this.activeExecutions.get(executionId);
if (execution?.process) {
try {
// Check if it's a pty process (SSH) or regular process
if (typeof execution.process.write === 'function' && !execution.process.stdin) {
execution.process.write(input);
// Send confirmation back to client
this.sendMessage(execution.ws, {
type: 'output',
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
timestamp: Date.now()
});
} else if (execution.process.stdin && !execution.process.stdin.destroyed) {
execution.process.stdin.write(input);
this.sendMessage(execution.ws, {
type: 'output',
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
timestamp: Date.now()
});
} else {
this.sendMessage(execution.ws, {
type: 'error',
data: 'Process input not available',
timestamp: Date.now()
});
}
} catch (error) {
this.sendMessage(execution.ws, {
type: 'error',
data: `Failed to send input: ${error instanceof Error ? error.message : 'Unknown error'}`,
timestamp: Date.now()
});
}
} else {
// No active execution found - this case is already handled above
return;
}
}
private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) { private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message)); ws.send(JSON.stringify(message));

View File

@@ -16,12 +16,68 @@ class DatabaseService {
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL, ip TEXT NOT NULL,
user TEXT NOT NULL, user TEXT NOT NULL,
password TEXT NOT NULL, password TEXT,
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both')),
ssh_key TEXT,
ssh_key_passphrase TEXT,
ssh_port INTEGER DEFAULT 22,
color TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
`); `);
// Migration: Add new columns to existing servers table
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both'))
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN ssh_key TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN ssh_key_passphrase TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN ssh_port INTEGER DEFAULT 22
`);
} catch (e) {
// Column already exists, ignore error
}
try {
this.db.exec(`
ALTER TABLE servers ADD COLUMN color TEXT
`);
} catch (e) {
// Column already exists, ignore error
}
// Update existing servers to have auth_type='password' if not set
this.db.exec(`
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
`);
// Update existing servers to have ssh_port=22 if not set
this.db.exec(`
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
`);
// Create installed_scripts table if it doesn't exist // Create installed_scripts table if it doesn't exist
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS installed_scripts ( CREATE TABLE IF NOT EXISTS installed_scripts (
@@ -53,12 +109,12 @@ class DatabaseService {
* @param {import('../types/server').CreateServerData} serverData * @param {import('../types/server').CreateServerData} serverData
*/ */
createServer(serverData) { createServer(serverData) {
const { name, ip, user, password } = serverData; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
INSERT INTO servers (name, ip, user, password) INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
return stmt.run(name, ip, user, password); return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color);
} }
getAllServers() { getAllServers() {
@@ -79,13 +135,13 @@ class DatabaseService {
* @param {import('../types/server').CreateServerData} serverData * @param {import('../types/server').CreateServerData} serverData
*/ */
updateServer(id, serverData) { updateServer(id, serverData) {
const { name, ip, user, password } = serverData; const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
UPDATE servers UPDATE servers
SET name = ?, ip = ?, user = ?, password = ? SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, color = ?
WHERE id = ? WHERE id = ?
`); `);
return stmt.run(name, ip, user, password, id); return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color, id);
} }
/** /**
@@ -123,7 +179,8 @@ class DatabaseService {
s.name as server_name, s.name as server_name,
s.ip as server_ip, s.ip as server_ip,
s.user as server_user, s.user as server_user,
s.password as server_password s.password as server_password,
s.color as server_color
FROM installed_scripts inst FROM installed_scripts inst
LEFT JOIN servers s ON inst.server_id = s.id LEFT JOIN servers s ON inst.server_id = s.id
ORDER BY inst.installation_date DESC ORDER BY inst.installation_date DESC

View File

@@ -1,16 +1,131 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { spawn as ptySpawn } from 'node-pty'; import { spawn as ptySpawn } from 'node-pty';
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
/** /**
* @typedef {Object} Server * @typedef {Object} Server
* @property {string} ip - Server IP address * @property {string} ip - Server IP address
* @property {string} user - Username * @property {string} user - Username
* @property {string} password - Password * @property {string} [password] - Password (optional)
* @property {string} name - Server name * @property {string} name - Server name
* @property {string} [auth_type] - Authentication type ('password', 'key', 'both')
* @property {string} [ssh_key] - SSH private key content
* @property {string} [ssh_key_passphrase] - SSH key passphrase
* @property {number} [ssh_port] - SSH port (default: 22)
*/ */
class SSHExecutionService { class SSHExecutionService {
/**
* Create a temporary SSH key file for authentication
* @param {Server} server - Server configuration
* @returns {string} Path to temporary key file
*/
createTempKeyFile(server) {
const { ssh_key } = server;
if (!ssh_key) {
throw new Error('SSH key not provided');
}
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
const tempKeyPath = join(tempDir, 'private_key');
writeFileSync(tempKeyPath, ssh_key);
chmodSync(tempKeyPath, 0o600); // Set proper permissions
return tempKeyPath;
}
/**
* Build SSH command arguments based on authentication type
* @param {Server} server - Server configuration
* @param {string|null} [tempKeyPath=null] - Path to temporary key file (if using key auth)
* @returns {{command: string, args: string[]}} Command and arguments for SSH
*/
buildSSHCommand(server, tempKeyPath = null) {
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_port = 22 } = server;
const baseArgs = [
'-t',
'-p', ssh_port.toString(),
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'RequestTTY=yes',
'-o', 'SetEnv=TERM=xterm-256color',
'-o', 'SetEnv=COLUMNS=120',
'-o', 'SetEnv=LINES=30',
'-o', 'SetEnv=COLORTERM=truecolor',
'-o', 'SetEnv=FORCE_COLOR=1',
'-o', 'SetEnv=NO_COLOR=0',
'-o', 'SetEnv=CLICOLOR=1',
'-o', 'SetEnv=CLICOLOR_FORCE=1'
];
if (auth_type === 'key') {
// SSH key authentication
if (tempKeyPath) {
baseArgs.push('-i', tempKeyPath);
baseArgs.push('-o', 'PasswordAuthentication=no');
baseArgs.push('-o', 'PubkeyAuthentication=yes');
}
if (ssh_key_passphrase) {
return {
command: 'sshpass',
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
};
} else {
return {
command: 'ssh',
args: [...baseArgs, `${user}@${ip}`]
};
}
} else if (auth_type === 'both') {
// Try SSH key first, then password
if (tempKeyPath) {
baseArgs.push('-i', tempKeyPath);
baseArgs.push('-o', 'PasswordAuthentication=yes');
baseArgs.push('-o', 'PubkeyAuthentication=yes');
if (ssh_key_passphrase) {
return {
command: 'sshpass',
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
};
} else {
return {
command: 'ssh',
args: [...baseArgs, `${user}@${ip}`]
};
}
} else {
// Fallback to password
if (password) {
return {
command: 'sshpass',
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
};
} else {
throw new Error('Password is required for password authentication');
}
}
} else {
// Password authentication (default)
if (password) {
return {
command: 'sshpass',
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
};
} else {
throw new Error('Password is required for password authentication');
}
}
}
/** /**
* Execute a script on a remote server via SSH * Execute a script on a remote server via SSH
* @param {Server} server - Server configuration * @param {Server} server - Server configuration
@@ -21,7 +136,8 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information * @returns {Promise<Object>} Process information
*/ */
async executeScript(server, scriptPath, onData, onError, onExit) { async executeScript(server, scriptPath, onData, onError, onExit) {
const { ip, user, password } = server; /** @type {string|null} */
let tempKeyPath = null;
try { try {
await this.transferScriptsFolder(server, onData, onError); await this.transferScriptsFolder(server, onData, onError);
@@ -29,29 +145,20 @@ class SSHExecutionService {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath; const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
try {
// Create temporary key file if using key authentication
if (server.auth_type === 'key' || server.auth_type === 'both') {
tempKeyPath = this.createTempKeyFile(server);
}
// Build SSH command based on authentication type
const { command, args } = this.buildSSHCommand(server, tempKeyPath);
// Add the script execution command to the args
args.push(`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`);
// Use ptySpawn for proper terminal emulation and color support // Use ptySpawn for proper terminal emulation and color support
const sshCommand = ptySpawn('sshpass', [ const sshCommand = ptySpawn(command, args, {
'-p', password,
'ssh',
'-t',
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'PasswordAuthentication=yes',
'-o', 'PubkeyAuthentication=no',
'-o', 'RequestTTY=yes',
'-o', 'SetEnv=TERM=xterm-256color',
'-o', 'SetEnv=COLUMNS=120',
'-o', 'SetEnv=LINES=30',
'-o', 'SetEnv=COLORTERM=truecolor',
'-o', 'SetEnv=FORCE_COLOR=1',
'-o', 'SetEnv=NO_COLOR=0',
'-o', 'SetEnv=CLICOLOR=1',
'-o', 'SetEnv=CLICOLOR_FORCE=1',
`${user}@${ip}`,
`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`
], {
name: 'xterm-256color', name: 'xterm-256color',
cols: 120, cols: 120,
rows: 30, rows: 30,
@@ -82,8 +189,34 @@ class SSHExecutionService {
resolve({ resolve({
process: sshCommand, process: sshCommand,
kill: () => sshCommand.kill('SIGTERM') kill: () => {
sshCommand.kill('SIGTERM');
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
}
}); });
} catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
}
}); });
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
@@ -100,15 +233,44 @@ class SSHExecutionService {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async transferScriptsFolder(server, onData, onError) { async transferScriptsFolder(server, onData, onError) {
const { ip, user, password } = server; const { ip, user, password, auth_type = 'password', ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
/** @type {string|null} */
let tempKeyPath = null;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try {
// Create temporary key file if using key authentication
if (auth_type === 'key' || auth_type === 'both') {
if (ssh_key) {
tempKeyPath = this.createTempKeyFile(server);
}
}
// Build rsync command based on authentication type
let rshCommand;
if (auth_type === 'key' && tempKeyPath) {
if (ssh_key_passphrase) {
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} else {
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
}
} else if (auth_type === 'both' && tempKeyPath) {
if (ssh_key_passphrase) {
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} else {
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
}
} else {
// Fallback to password authentication
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
}
const rsyncCommand = spawn('rsync', [ const rsyncCommand = spawn('rsync', [
'-avz', '-avz',
'--delete', '--delete',
'--exclude=*.log', '--exclude=*.log',
'--exclude=*.tmp', '--exclude=*.tmp',
'--rsh=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null', `--rsh=${rshCommand}`,
'scripts/', 'scripts/',
`${user}@${ip}:/tmp/scripts/` `${user}@${ip}:/tmp/scripts/`
], { ], {
@@ -128,6 +290,17 @@ class SSHExecutionService {
}); });
rsyncCommand.on('close', (code) => { rsyncCommand.on('close', (code) => {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
if (code === 0) { if (code === 0) {
resolve(); resolve();
} else { } else {
@@ -136,8 +309,32 @@ class SSHExecutionService {
}); });
rsyncCommand.on('error', (error) => { rsyncCommand.on('error', (error) => {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error); reject(error);
}); });
} catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
}
}); });
} }
@@ -151,31 +348,24 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information * @returns {Promise<Object>} Process information
*/ */
async executeCommand(server, command, onData, onError, onExit) { async executeCommand(server, command, onData, onError, onExit) {
const { ip, user, password } = server; /** @type {string|null} */
let tempKeyPath = null;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try {
// Create temporary key file if using key authentication
if (server.auth_type === 'key' || server.auth_type === 'both') {
tempKeyPath = this.createTempKeyFile(server);
}
// Build SSH command based on authentication type
const { command: sshCommandName, args } = this.buildSSHCommand(server, tempKeyPath);
// Add the command to execute to the args
args.push(command);
// Use ptySpawn for proper terminal emulation and color support // Use ptySpawn for proper terminal emulation and color support
const sshCommand = ptySpawn('sshpass', [ const sshCommand = ptySpawn(sshCommandName, args, {
'-p', password,
'ssh',
'-t',
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'PasswordAuthentication=yes',
'-o', 'PubkeyAuthentication=no',
'-o', 'RequestTTY=yes',
'-o', 'SetEnv=TERM=xterm-256color',
'-o', 'SetEnv=COLUMNS=120',
'-o', 'SetEnv=LINES=30',
'-o', 'SetEnv=COLORTERM=truecolor',
'-o', 'SetEnv=FORCE_COLOR=1',
'-o', 'SetEnv=NO_COLOR=0',
'-o', 'SetEnv=CLICOLOR=1',
`${user}@${ip}`,
command
], {
name: 'xterm-color', name: 'xterm-color',
cols: 120, cols: 120,
rows: 30, rows: 30,
@@ -188,10 +378,49 @@ class SSHExecutionService {
}); });
sshCommand.onExit((e) => { sshCommand.onExit((e) => {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
onExit(e.exitCode); onExit(e.exitCode);
}); });
resolve({ process: sshCommand }); resolve({
process: sshCommand,
kill: () => {
sshCommand.kill('SIGTERM');
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
}
});
} catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
}
}); });
} }

View File

@@ -1,6 +1,7 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { writeFileSync, unlinkSync, chmodSync } from 'fs'; import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { tmpdir } from 'os';
class SSHService { class SSHService {
/** /**
@@ -10,39 +11,43 @@ class SSHService {
* @returns {Promise<Object>} Connection test result * @returns {Promise<Object>} Connection test result
*/ */
async testConnection(server) { async testConnection(server) {
const { ip, user, password } = server; const { auth_type = 'password' } = server;
return new Promise((resolve) => { return new Promise((resolve) => {
const timeout = 15000; // 15 seconds timeout for login test const timeout = 15000; // 15 seconds timeout for login test
let resolved = false; let resolved = false;
// Try sshpass first if available // Choose authentication method based on auth_type
this.testWithSshpass(server).then(result => { let authPromise;
if (auth_type === 'key') {
authPromise = this.testWithSSHKey(server);
} else if (auth_type === 'both') {
// Try SSH key first, then password
authPromise = this.testWithSSHKey(server).catch(() => this.testWithSshpass(server));
} else {
// Default to password authentication
authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server));
}
authPromise.then(result => {
if (!resolved) { if (!resolved) {
resolved = true; resolved = true;
resolve(result); resolve(result);
} }
}).catch(() => { }).catch(() => {
// If sshpass fails, try expect // If primary method fails, return error
this.testWithExpect(server).then(result => {
if (!resolved) {
resolved = true;
resolve(result);
}
}).catch(() => {
// If both fail, return error
if (!resolved) { if (!resolved) {
resolved = true; resolved = true;
resolve({ resolve({
success: false, success: false,
message: 'SSH login test requires sshpass or expect - neither available or working', message: `SSH login test failed for ${auth_type} authentication`,
details: { details: {
method: 'no_auth_tools' method: 'auth_failed',
auth_type: auth_type
} }
}); });
} }
}); });
});
// Set up overall timeout // Set up overall timeout
setTimeout(() => { setTimeout(() => {
@@ -64,7 +69,11 @@ class SSHService {
* @returns {Promise<Object>} Connection test result * @returns {Promise<Object>} Connection test result
*/ */
async testWithSshpass(server) { async testWithSshpass(server) {
const { ip, user, password } = server; const { ip, user, password, ssh_port = 22 } = server;
if (!password) {
throw new Error('Password is required for password authentication');
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = 10000; const timeout = 10000;
@@ -73,6 +82,7 @@ class SSHService {
const sshCommand = spawn('sshpass', [ const sshCommand = spawn('sshpass', [
'-p', password, '-p', password,
'ssh', 'ssh',
'-p', ssh_port.toString(),
'-o', 'ConnectTimeout=10', '-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no', '-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null', '-o', 'UserKnownHostsFile=/dev/null',
@@ -156,7 +166,7 @@ class SSHService {
* @returns {Promise<Object>} Connection test result * @returns {Promise<Object>} Connection test result
*/ */
async testWithExpect(server) { async testWithExpect(server) {
const { ip, user, password } = server; const { ip, user, password, ssh_port = 22 } = server;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = 10000; const timeout = 10000;
@@ -164,7 +174,7 @@ class SSHService {
const expectScript = `#!/usr/bin/expect -f const expectScript = `#!/usr/bin/expect -f
set timeout 10 set timeout 10
spawn ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS" spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
expect { expect {
"password:" { "password:" {
send "${password}\r" send "${password}\r"
@@ -428,13 +438,14 @@ expect {
* @returns {Promise<Object>} Connection test result * @returns {Promise<Object>} Connection test result
*/ */
async testSSHConnection(server) { async testSSHConnection(server) {
const { ip, user } = server; const { ip, user, ssh_port = 22 } = server;
return new Promise((resolve) => { return new Promise((resolve) => {
const timeout = 5000; const timeout = 5000;
let resolved = false; let resolved = false;
const sshCommand = spawn('ssh', [ const sshCommand = spawn('ssh', [
'-p', ssh_port.toString(),
'-o', 'ConnectTimeout=5', '-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=no', '-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null', '-o', 'UserKnownHostsFile=/dev/null',
@@ -523,6 +534,148 @@ expect {
}); });
} }
/**
* Test SSH connection using SSH key authentication
* @param {import('../types/server').Server} server - Server configuration
* @returns {Promise<Object>} Connection test result
*/
async testWithSSHKey(server) {
const { ip, user, ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
if (!ssh_key) {
throw new Error('SSH key not provided');
}
return new Promise((resolve, reject) => {
const timeout = 10000;
let resolved = false;
let tempKeyPath = null;
try {
// Create temporary key file
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
tempKeyPath = join(tempDir, 'private_key');
// Write the private key to temporary file
writeFileSync(tempKeyPath, ssh_key);
chmodSync(tempKeyPath, 0o600); // Set proper permissions
// Build SSH command
const sshArgs = [
'-i', tempKeyPath,
'-p', ssh_port.toString(),
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
'-o', 'PasswordAuthentication=no',
'-o', 'PubkeyAuthentication=yes',
`${user}@${ip}`,
'echo "SSH_LOGIN_SUCCESS"'
];
// Use sshpass if passphrase is provided
let command, args;
if (ssh_key_passphrase) {
command = 'sshpass';
args = ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...sshArgs];
} else {
command = 'ssh';
args = sshArgs;
}
const sshCommand = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe']
});
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
sshCommand.kill('SIGTERM');
reject(new Error('SSH key login timeout'));
}
}, timeout);
let output = '';
let errorOutput = '';
sshCommand.stdout.on('data', (data) => {
output += data.toString();
});
sshCommand.stderr.on('data', (data) => {
errorOutput += data.toString();
});
sshCommand.on('close', (code) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
if (code === 0 && output.includes('SSH_LOGIN_SUCCESS')) {
resolve({
success: true,
message: 'SSH key authentication successful - credentials verified',
details: {
server: server.name || 'Unknown',
ip: ip,
user: user,
method: 'ssh_key_verified'
}
});
} else {
let errorMessage = 'SSH key authentication failed';
if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) {
errorMessage = 'SSH key authentication failed - check key and permissions';
} else if (errorOutput.includes('Connection refused')) {
errorMessage = 'Connection refused - server may be down or SSH not running';
} else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) {
errorMessage = 'Host not found - check IP address';
} else if (errorOutput.includes('Connection timed out')) {
errorMessage = 'Connection timeout - server may be unreachable';
} else if (errorOutput.includes('Load key') || errorOutput.includes('invalid format')) {
errorMessage = 'Invalid SSH key format';
} else if (errorOutput.includes('Enter passphrase')) {
errorMessage = 'SSH key passphrase required but not provided';
} else {
errorMessage = `SSH key authentication failed: ${errorOutput.trim()}`;
}
reject(new Error(errorMessage));
}
}
});
sshCommand.on('error', (error) => {
if (!resolved) {
resolved = true;
clearTimeout(timer);
reject(error);
}
});
} catch (error) {
if (!resolved) {
resolved = true;
reject(error);
}
} finally {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
// Also remove the temp directory
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
}
});
}
} }
// Singleton instance // Singleton instance

View File

@@ -1,15 +1,134 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @layer base {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, :root {
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
} }
@variant dark (&:is(.dark, .dark *)); ::selection {
background-color: hsl(var(--accent));
color: hsl(var(--foreground));
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
/* Semantic color utility classes */
.bg-background { background-color: hsl(var(--background)); }
.text-foreground { color: hsl(var(--foreground)); }
.bg-card { background-color: hsl(var(--card)) !important; }
.text-card-foreground { color: hsl(var(--card-foreground)); }
.bg-popover { background-color: hsl(var(--popover)); }
.text-popover-foreground { color: hsl(var(--popover-foreground)); }
.bg-primary { background-color: hsl(var(--primary)); }
.text-primary { color: hsl(var(--primary)); }
.text-primary-foreground { color: hsl(var(--primary-foreground)); }
.bg-secondary { background-color: hsl(var(--secondary)); }
.text-secondary { color: hsl(var(--secondary)); }
.text-secondary-foreground { color: hsl(var(--secondary-foreground)); }
.bg-muted { background-color: hsl(var(--muted)); }
.text-muted-foreground { color: hsl(var(--muted-foreground)); }
.bg-accent { background-color: hsl(var(--accent)); }
.text-accent { color: hsl(var(--accent)); }
.text-accent-foreground { color: hsl(var(--accent-foreground)); }
.bg-destructive { background-color: hsl(var(--destructive)); }
.text-destructive { color: hsl(var(--destructive)); }
.text-destructive-foreground { color: hsl(var(--destructive-foreground)); }
.border-border { border-color: hsl(var(--border)); }
.border-input { border-color: hsl(var(--input)); }
.ring-ring { --tw-ring-color: hsl(var(--ring)); }
/* Hover states for semantic colors */
.hover\:bg-accent:hover { background-color: hsl(var(--accent)); }
.hover\:text-accent-foreground:hover { color: hsl(var(--accent-foreground)); }
.hover\:text-foreground:hover { color: hsl(var(--foreground)); }
.hover\:bg-primary:hover { background-color: hsl(var(--primary)); }
.hover\:bg-primary\/90:hover { background-color: hsl(var(--primary) / 0.9); }
.hover\:bg-secondary:hover { background-color: hsl(var(--secondary)); }
.hover\:bg-secondary\/80:hover { background-color: hsl(var(--secondary) / 0.8); }
.hover\:bg-muted:hover { background-color: hsl(var(--muted)); }
.hover\:text-primary:hover { color: hsl(var(--primary)); }
.hover\:text-primary\/80:hover { color: hsl(var(--primary) / 0.8); }
.hover\:border-primary:hover { border-color: hsl(var(--primary)); }
.hover\:border-border:hover { border-color: hsl(var(--border)); }
.hover\:ring-primary:hover { --tw-ring-color: hsl(var(--primary)); }
.hover\:ring-2:hover { --tw-ring-width: 2px; }
.hover\:ring-offset-2:hover { --tw-ring-offset-width: 2px; }
* {
-ms-overflow-style: none;
}
::-webkit-scrollbar {
width: 9px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.25);
border-radius: 20px;
border: transparent;
}
.glass {
backdrop-filter: blur(15px) saturate(100%);
-webkit-backdrop-filter: blur(15px) saturate(100%);
}
/* Terminal-specific styles for ANSI escape code rendering */ /* Terminal-specific styles for ANSI escape code rendering */
.terminal-output { .terminal-output {
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
line-height: 1.2; line-height: 1.2;
} }
@@ -22,3 +141,94 @@
color: inherit; color: inherit;
background-color: inherit; background-color: inherit;
} }
/* Enhanced terminal styling */
.xterm {
padding: 0.5rem;
}
/* Set basic background - let ANSI colors work naturally */
.xterm .xterm-viewport {
background-color: #0d1117;
}
.xterm .xterm-screen {
background-color: #0d1117;
}
/* Better selection colors */
.xterm .xterm-selection {
background-color: #264f78;
}
/* Mobile-specific improvements */
@media (max-width: 640px) {
/* Improve touch targets */
button, .cursor-pointer {
min-height: 44px;
min-width: 44px;
}
/* Better text sizing on mobile */
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
/* Improve form elements on mobile */
input, select, textarea {
font-size: 16px; /* Prevents zoom on iOS */
}
/* Better spacing for mobile */
.space-y-2 > * + * {
margin-top: 0.5rem;
}
.space-y-4 > * + * {
margin-top: 1rem;
}
/* Improve modal and overlay positioning */
.fixed.inset-0 {
padding: 1rem;
}
/* Better scroll behavior */
.overflow-x-auto {
-webkit-overflow-scrolling: touch;
}
}
/* Tablet improvements */
@media (min-width: 641px) and (max-width: 1024px) {
/* Better spacing for tablets */
.container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
/* Ensure proper viewport handling */
html {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Mobile terminal centering - simple approach */
.mobile-terminal {
display: flex !important;
justify-content: center !important;
align-items: center !important;
}
.mobile-terminal .xterm {
margin: 0 auto !important;
width: 100% !important;
max-width: 100% !important;
}

View File

@@ -57,6 +57,9 @@ export interface ScriptCard {
categories?: number[]; categories?: number[];
categoryNames?: string[]; categoryNames?: string[];
date_created?: string; date_created?: string;
os?: string;
version?: string;
interface_port?: number | null;
} }
export interface GitHubFile { export interface GitHubFile {

View File

@@ -3,7 +3,12 @@ export interface Server {
name: string; name: string;
ip: string; ip: string;
user: string; user: string;
password: string; password?: string;
auth_type?: 'password' | 'key' | 'both';
ssh_key?: string;
ssh_key_passphrase?: string;
ssh_port?: number;
color?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -12,7 +17,12 @@ export interface CreateServerData {
name: string; name: string;
ip: string; ip: string;
user: string; user: string;
password: string; password?: string;
auth_type?: 'password' | 'key' | 'both';
ssh_key?: string;
ssh_key_passphrase?: string;
ssh_port?: number;
color?: string;
} }
export interface UpdateServerData extends CreateServerData { export interface UpdateServerData extends CreateServerData {

View File

@@ -27,7 +27,8 @@
/* Path Aliases */ /* Path Aliases */
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"~/*": ["./src/*"] "~/*": ["./src/*"],
"@/*": ["./src/*"]
} }
}, },
"include": [ "include": [

787
update.sh Executable file
View File

@@ -0,0 +1,787 @@
#!/bin/bash
# Enhanced update script for ProxmoxVE-Local
# Fetches latest release from GitHub and backs up data directory
set -euo pipefail # Exit on error, undefined vars, pipe failures
# Add error trap for debugging
trap 'echo "Error occurred at line $LINENO, command: $BASH_COMMAND"' ERR
# Configuration
REPO_OWNER="community-scripts"
REPO_NAME="ProxmoxVE-Local"
GITHUB_API="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}"
BACKUP_DIR="/tmp/pve-scripts-backup-$(date +%Y%m%d-%H%M%S)"
DATA_DIR="./data"
LOG_FILE="/tmp/update.log"
# GitHub Personal Access Token for higher rate limits (optional)
# Set GITHUB_TOKEN environment variable or create .github_token file
GITHUB_TOKEN=""
# Global variable to track if service was running before update
SERVICE_WAS_RUNNING=false
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Load GitHub token
load_github_token() {
# Try environment variable first
if [ -n "${GITHUB_TOKEN:-}" ]; then
log "Using GitHub token from environment variable"
return 0
fi
# Try .env file
if [ -f ".env" ]; then
local env_token
env_token=$(grep "^GITHUB_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' | tr -d "'" | tr -d '\n\r')
if [ -n "$env_token" ]; then
GITHUB_TOKEN="$env_token"
log "Using GitHub token from .env file"
return 0
fi
fi
# Try .github_token file
if [ -f ".github_token" ]; then
GITHUB_TOKEN=$(cat .github_token | tr -d '\n\r')
log "Using GitHub token from .github_token file"
return 0
fi
# Try ~/.github_token file
if [ -f "$HOME/.github_token" ]; then
GITHUB_TOKEN=$(cat "$HOME/.github_token" | tr -d '\n\r')
log "Using GitHub token from ~/.github_token file"
return 0
fi
log_warning "No GitHub token found. Using unauthenticated requests (lower rate limits)"
log_warning "To use a token, add GITHUB_TOKEN=your_token to .env file or set GITHUB_TOKEN environment variable"
return 1
}
# Initialize log file
init_log() {
# Clear/create log file
> "$LOG_FILE"
log "Starting ProxmoxVE-Local update process..."
log "Log file: $LOG_FILE"
}
# Logging function
log() {
echo -e "${BLUE}[$(date '+%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE" >&2
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1" | tee -a "$LOG_FILE" >&2
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1" | tee -a "$LOG_FILE" >&2
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1" | tee -a "$LOG_FILE" >&2
}
# Check if required tools are available
check_dependencies() {
log "Checking dependencies..."
local missing_deps=()
if ! command -v curl &> /dev/null; then
missing_deps+=("curl")
fi
if ! command -v jq &> /dev/null; then
missing_deps+=("jq")
fi
if ! command -v npm &> /dev/null; then
missing_deps+=("npm")
fi
if ! command -v node &> /dev/null; then
missing_deps+=("node")
fi
if [ ${#missing_deps[@]} -ne 0 ]; then
log_error "Missing dependencies: ${missing_deps[*]}"
log_error "Please install the missing dependencies and try again."
exit 1
fi
log_success "All dependencies are available"
}
# Get latest release info from GitHub API
get_latest_release() {
log "Fetching latest release information from GitHub..."
local curl_opts="-s --connect-timeout 15 --max-time 60 --retry 2 --retry-delay 3"
# Add authentication header if token is available
if [ -n "$GITHUB_TOKEN" ]; then
curl_opts="$curl_opts -H \"Authorization: token $GITHUB_TOKEN\""
log "Using authenticated GitHub API request"
else
log "Using unauthenticated GitHub API request (lower rate limits)"
fi
local release_info
if ! release_info=$(eval "curl $curl_opts \"$GITHUB_API/releases/latest\""); then
log_error "Failed to fetch release information from GitHub API (timeout or network error)"
exit 1
fi
# Check if response is valid JSON
if ! echo "$release_info" | jq empty 2>/dev/null; then
log_error "Invalid JSON response from GitHub API"
log "Response: $release_info"
exit 1
fi
local tag_name
local download_url
local published_at
tag_name=$(echo "$release_info" | jq -r '.tag_name')
download_url=$(echo "$release_info" | jq -r '.tarball_url')
published_at=$(echo "$release_info" | jq -r '.published_at')
if [ "$tag_name" = "null" ] || [ "$download_url" = "null" ] || [ -z "$tag_name" ] || [ -z "$download_url" ]; then
log_error "Failed to parse release information from API response"
log "Tag name: $tag_name"
log "Download URL: $download_url"
exit 1
fi
log_success "Latest release: $tag_name (published: $published_at)"
echo "$tag_name|$download_url"
}
# Backup data directory and .env file
backup_data() {
log "Creating backup directory at $BACKUP_DIR..."
if ! mkdir -p "$BACKUP_DIR"; then
log_error "Failed to create backup directory"
exit 1
fi
# Backup data directory
if [ -d "$DATA_DIR" ]; then
log "Backing up data directory..."
if ! cp -r "$DATA_DIR" "$BACKUP_DIR/data"; then
log_error "Failed to backup data directory"
exit 1
else
log_success "Data directory backed up successfully"
fi
else
log_warning "Data directory not found, skipping backup"
fi
# Backup .env file
if [ -f ".env" ]; then
log "Backing up .env file..."
if ! cp ".env" "$BACKUP_DIR/.env"; then
log_error "Failed to backup .env file"
exit 1
else
log_success ".env file backed up successfully"
fi
else
log_warning ".env file not found, skipping backup"
fi
}
# Download and extract latest release
download_release() {
local release_info="$1"
local tag_name="${release_info%|*}"
local download_url="${release_info#*|}"
log "Downloading release $tag_name..."
local temp_dir="/tmp/pve-update-$$"
local archive_file="$temp_dir/release.tar.gz"
# Create temporary directory
if ! mkdir -p "$temp_dir"; then
log_error "Failed to create temporary directory"
exit 1
fi
# Download release with timeout and progress
if ! curl -L --connect-timeout 30 --max-time 300 --retry 3 --retry-delay 5 -o "$archive_file" "$download_url" 2>/dev/null; then
log_error "Failed to download release from GitHub"
rm -rf "$temp_dir"
exit 1
fi
# Verify download
if [ ! -f "$archive_file" ] || [ ! -s "$archive_file" ]; then
log_error "Downloaded file is empty or missing"
rm -rf "$temp_dir"
exit 1
fi
log_success "Downloaded release"
# Extract release
if ! tar -xzf "$archive_file" -C "$temp_dir" 2>/dev/null; then
log_error "Failed to extract release"
rm -rf "$temp_dir"
exit 1
fi
# Find the extracted directory (GitHub tarballs have a root directory)
local extracted_dir
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d -name "community-scripts-ProxmoxVE-Local-*" 2>/dev/null | head -1)
# Try alternative patterns if not found
if [ -z "$extracted_dir" ]; then
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d -name "${REPO_NAME}-*" 2>/dev/null | head -1)
fi
if [ -z "$extracted_dir" ]; then
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d ! -name "$temp_dir" 2>/dev/null | head -1)
fi
if [ -z "$extracted_dir" ]; then
log_error "Could not find extracted directory"
rm -rf "$temp_dir"
exit 1
fi
log_success "Release extracted successfully"
echo "$extracted_dir"
}
# Clear the original directory before updating
clear_original_directory() {
log "Clearing original directory..."
# Remove old lock files and node_modules before update
rm -f package-lock.json 2>/dev/null
rm -rf node_modules 2>/dev/null
# List of files/directories to preserve (already backed up)
local preserve_patterns=(
"data"
".env"
"*.log"
"update.log"
"*.backup"
"*.bak"
".git"
)
# Remove all files except preserved ones
while IFS= read -r file; do
local should_preserve=false
local filename=$(basename "$file")
for pattern in "${preserve_patterns[@]}"; do
if [[ "$filename" == $pattern ]]; then
should_preserve=true
break
fi
done
if [ "$should_preserve" = false ]; then
rm -f "$file"
fi
done < <(find . -maxdepth 1 -type f ! -name ".*")
# Remove all directories except preserved ones
while IFS= read -r dir; do
local should_preserve=false
local dirname=$(basename "$dir")
for pattern in "${preserve_patterns[@]}"; do
if [[ "$dirname" == $pattern ]]; then
should_preserve=true
break
fi
done
if [ "$should_preserve" = false ]; then
rm -rf "$dir"
fi
done < <(find . -maxdepth 1 -type d ! -name "." ! -name "..")
log_success "Original directory cleared"
}
# Restore backup files before building
restore_backup_files() {
log "Restoring .env and data directory from backup..."
if [ -d "$BACKUP_DIR" ]; then
# Restore .env file
if [ -f "$BACKUP_DIR/.env" ]; then
if [ -f ".env" ]; then
rm -f ".env"
fi
if mv "$BACKUP_DIR/.env" ".env"; then
log_success ".env file restored from backup"
else
log_error "Failed to restore .env file"
return 1
fi
else
log_warning "No .env file backup found"
fi
# Restore data directory
if [ -d "$BACKUP_DIR/data" ]; then
if [ -d "data" ]; then
rm -rf "data"
fi
if mv "$BACKUP_DIR/data" "data"; then
log_success "Data directory restored from backup"
else
log_error "Failed to restore data directory"
return 1
fi
else
log_warning "No data directory backup found"
fi
else
log_error "No backup directory found for restoration"
return 1
fi
}
# Check if systemd service exists
check_service() {
# systemctl status returns 0-3 if service exists (running, exited, failed, etc.)
# and returns 4 if service unit is not found
systemctl status pvescriptslocal.service &>/dev/null
local exit_code=$?
if [ $exit_code -le 3 ]; then
return 0
else
return 1
fi
}
# Stop the application before updating
stop_application() {
# Change to the application directory if we're not already there
local app_dir
if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)"
else
# Try to find the application directory
app_dir=$(find /root -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
cd "$app_dir" || {
log_error "Failed to change to application directory: $app_dir"
return 1
}
else
log_error "Could not find application directory"
return 1
fi
fi
log "Working from application directory: $(pwd)"
# Check if systemd service is running and disable it temporarily
if check_service && systemctl is-active --quiet pvescriptslocal.service; then
log "Disabling systemd service temporarily to prevent auto-restart..."
if systemctl disable pvescriptslocal.service; then
log_success "Service disabled successfully"
else
log_error "Failed to disable service"
return 1
fi
else
log "No running systemd service found"
fi
# Kill any remaining npm/node processes
log "Killing any remaining npm/node processes..."
local pids
pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$pids" ]; then
log "Found running processes: $pids"
pkill -9 -f "node server.js" 2>/dev/null || true
pkill -9 -f "npm start" 2>/dev/null || true
sleep 2
log_success "Processes killed"
else
log "No running processes found"
fi
}
# Update application files
update_files() {
local source_dir="$1"
log "Updating application files..."
# List of files/directories to exclude from update
local exclude_patterns=(
"data"
"node_modules"
".git"
".env"
"*.log"
"update.log"
"*.backup"
"*.bak"
)
# Find the actual source directory (strip the top-level directory)
local actual_source_dir
actual_source_dir=$(find "$source_dir" -maxdepth 1 -type d -name "community-scripts-ProxmoxVE-Local-*" | head -1)
if [ -z "$actual_source_dir" ]; then
log_error "Could not find the actual source directory in $source_dir"
return 1
fi
# Verify critical files exist in source
if [ ! -f "$actual_source_dir/package.json" ]; then
log_error "package.json not found in source directory!"
return 1
fi
# Use process substitution instead of pipe to avoid subshell issues
local files_copied=0
local files_excluded=0
# Create a temporary file list to avoid process substitution issues
local file_list="/tmp/file_list_$$.txt"
find "$actual_source_dir" -type f > "$file_list"
while IFS= read -r file; do
local rel_path="${file#$actual_source_dir/}"
local should_exclude=false
for pattern in "${exclude_patterns[@]}"; do
if [[ "$rel_path" == $pattern ]]; then
should_exclude=true
break
fi
done
if [ "$should_exclude" = false ]; then
local target_dir
target_dir=$(dirname "$rel_path")
if [ "$target_dir" != "." ]; then
mkdir -p "$target_dir"
fi
if ! cp "$file" "$rel_path"; then
log_error "Failed to copy $rel_path"
rm -f "$file_list"
return 1
fi
files_copied=$((files_copied + 1))
else
files_excluded=$((files_excluded + 1))
fi
done < "$file_list"
# Clean up temporary file
rm -f "$file_list"
# Verify critical files were copied
if [ ! -f "package.json" ]; then
log_error "package.json was not copied to target directory!"
return 1
fi
if [ ! -f "package-lock.json" ]; then
log_warning "package-lock.json was not copied!"
fi
log_success "Application files updated successfully ($files_copied files)"
}
# Install dependencies and build
install_and_build() {
log "Installing dependencies..."
# Verify package.json exists
if [ ! -f "package.json" ]; then
log_error "package.json not found! Cannot install dependencies."
return 1
fi
if [ ! -f "package-lock.json" ]; then
log_warning "No package-lock.json found, npm will generate one"
fi
# Create temporary file for npm output
local npm_log="/tmp/npm_install_$$.log"
# Ensure NODE_ENV is not set to production during install (we need devDependencies for build)
local old_node_env="${NODE_ENV:-}"
export NODE_ENV=development
# Run npm install to get ALL dependencies including devDependencies
if ! npm install --include=dev > "$npm_log" 2>&1; then
log_error "Failed to install dependencies"
log_error "npm install output (last 30 lines):"
tail -30 "$npm_log" | while read -r line; do
log_error "NPM: $line"
done
rm -f "$npm_log"
return 1
fi
# Restore NODE_ENV
if [ -n "$old_node_env" ]; then
export NODE_ENV="$old_node_env"
else
unset NODE_ENV
fi
log_success "Dependencies installed successfully"
rm -f "$npm_log"
log "Building application..."
# Set NODE_ENV to production for build
export NODE_ENV=production
# Create temporary file for npm build output
local build_log="/tmp/npm_build_$$.log"
if ! npm run build > "$build_log" 2>&1; then
log_error "Failed to build application"
log_error "npm run build output:"
cat "$build_log" | while read -r line; do
log_error "BUILD: $line"
done
rm -f "$build_log"
return 1
fi
# Log success and clean up
log_success "Application built successfully"
rm -f "$build_log"
log_success "Dependencies installed and application built successfully"
}
# Start the application after updating
start_application() {
log "Starting application..."
# Use the global variable to determine how to start
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
log "Service was running before update, re-enabling and starting systemd service..."
if systemctl enable --now pvescriptslocal.service; then
systemctl restart pvescriptslocal.service
log_success "Service enabled and started successfully"
# Wait a moment and check if it's running
sleep 2
if systemctl is-active --quiet pvescriptslocal.service; then
log_success "Service is running"
else
log_warning "Service started but may not be running properly"
fi
else
log_error "Failed to enable/start service, falling back to npm start"
start_with_npm
fi
else
log "Service was not running before update or no service exists, starting with npm..."
start_with_npm
fi
}
# Start application with npm
start_with_npm() {
log "Starting application with npm start..."
# Start in background
nohup npm start > server.log 2>&1 &
local npm_pid=$!
# Wait a moment and check if it started
sleep 3
if kill -0 $npm_pid 2>/dev/null; then
log_success "Application started with PID: $npm_pid"
else
log_error "Failed to start application with npm"
return 1
fi
}
# Rollback function
rollback() {
log_warning "Rolling back to previous version..."
if [ -d "$BACKUP_DIR" ]; then
log "Restoring from backup directory: $BACKUP_DIR"
# Restore data directory
if [ -d "$BACKUP_DIR/data" ]; then
log "Restoring data directory..."
if [ -d "$DATA_DIR" ]; then
rm -rf "$DATA_DIR"
fi
if mv "$BACKUP_DIR/data" "$DATA_DIR"; then
log_success "Data directory restored from backup"
else
log_error "Failed to restore data directory"
fi
else
log_warning "No data directory backup found"
fi
# Restore .env file
if [ -f "$BACKUP_DIR/.env" ]; then
log "Restoring .env file..."
if [ -f ".env" ]; then
rm -f ".env"
fi
if mv "$BACKUP_DIR/.env" ".env"; then
log_success ".env file restored from backup"
else
log_error "Failed to restore .env file"
fi
else
log_warning "No .env file backup found"
fi
# Clean up backup directory
log "Cleaning up backup directory..."
rm -rf "$BACKUP_DIR"
else
log_error "No backup directory found for rollback"
fi
log_error "Update failed. Please check the logs and try again."
exit 1
}
# Main update process
main() {
# Check if this is the relocated/detached version first
if [ "${1:-}" = "--relocated" ]; then
export PVE_UPDATE_RELOCATED=1
init_log
log "Running as detached process"
sleep 3
else
init_log
fi
# Check if we're running from the application directory and not already relocated
if [ -z "${PVE_UPDATE_RELOCATED:-}" ] && [ -f "package.json" ] && [ -f "server.js" ]; then
log "Detected running from application directory"
bash "$0" --relocated
exit $?
fi
# Ensure we're in the application directory
local app_dir
# First check if we're already in the right directory
if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)"
else
# Try multiple common locations:
for search_path in /opt /root /home /usr/local; do
if [ -d "$search_path" ]; then
app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
break
fi
fi
done
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
cd "$app_dir" || {
log_error "Failed to change to application directory: $app_dir"
exit 1
}
else
log_error "Could not find application directory"
exit 1
fi
fi
# Check dependencies
check_dependencies
# Load GitHub token for higher rate limits
load_github_token
# Check if service was running before update
if check_service && systemctl is-active --quiet pvescriptslocal.service; then
SERVICE_WAS_RUNNING=true
else
SERVICE_WAS_RUNNING=false
fi
# Get latest release info
local release_info
release_info=$(get_latest_release)
# Backup data directory
backup_data
# Stop the application before updating
stop_application
# Download and extract release
local source_dir
source_dir=$(download_release "$release_info")
# Clear the original directory before updating
clear_original_directory
# Update files
if ! update_files "$source_dir"; then
log_error "File update failed, rolling back..."
rollback
fi
# Restore .env and data directory before building
restore_backup_files
# Install dependencies and build
if ! install_and_build; then
log_error "Install and build failed, rolling back..."
rollback
fi
# Cleanup
rm -rf "$source_dir"
rm -rf "/tmp/pve-update-$$"
# Start the application
start_application
log_success "Update completed successfully!"
}
# Run main function with error handling
if ! main "$@"; then
log_error "Update script failed with exit code $?"
exit 1
fi