Compare commits

...

25 Commits

Author SHA1 Message Date
github-actions[bot]
b0e7e94a23 chore: add VERSION v0.4.0 2025-10-13 14:37:11 +00:00
Michel Roegl-Brunner
5b45293b4d feat: Add LXC Container Control Features (#124)
* feat: Add LXC container control functionality to Installed Scripts page

- Add reusable ConfirmationModal component with simple and type-to-confirm variants
- Add three new tRPC endpoints for container control:
  - getContainerStatus: Check container running/stopped state via pct status
  - controlContainer: Start/stop containers via pct start/stop commands
  - destroyContainer: Destroy containers via pct destroy and delete DB records
- Enhance InstalledScriptsTab with container status management and confirmation flows
- Update ScriptInstallationCard with Start/Stop and Destroy buttons for SSH scripts
- Add container control buttons to desktop table view with proper status handling
- Update help documentation with comprehensive container control feature guide
- Implement safety features:
  - Simple OK/Cancel confirmation for start/stop actions
  - Type-to-confirm modal requiring container ID for destroy actions
  - SSH connection validation and error handling
  - Loading states and user feedback for all operations
- Only show control buttons for SSH scripts with valid container IDs
- Maintain backward compatibility with existing delete functionality for non-SSH scripts

All container control operations execute via SSH using existing infrastructure.
Real-time container status checking and caching for optimal performance.

* fix: Resolve linting errors in LXC control functionality

- Remove unused getStatusMutation variable
- Fix floating promises by adding void operator
- Add missing dependencies to useEffect hooks
- Fix unsafe argument types by casting server IDs to Number
- Remove unused commandOutput variables
- Use useCallback for fetchContainerStatus to fix dependency issues
- Move function definition before usage to resolve hoisting errors

* fix: Add missing execution_mode property to InstalledScript interface in ScriptInstallationCard

- Add execution_mode: local | ssh property to InstalledScript interface
- Fixes TypeScript build error when checking script.execution_mode === ssh
- Ensures type consistency across all components

* fix: Resolve status detection conflicts by using unified bulk fetching

- Remove individual fetchContainerStatus function that was conflicting with bulk fetching
- Update controlContainerMutation to use fetchContainerStatuses instead of individual calls
- Remove unused utils variable to clean up linting warnings
- Simplify status detection to use only the bulk getContainerStatuses endpoint
- This should resolve the status detection issues by eliminating competing fetch mechanisms

* fix: Stop infinite API call loops that were overwhelming the server

- Remove fetchContainerStatuses from useEffect dependencies to prevent infinite loops
- Use useRef to access current scripts without causing dependency cycles
- Reduce multiple useEffect hooks that were all triggering status checks
- This should stop the 30+ simultaneous API calls that were redlining the server
- Status checks now happen only when needed: on load, after operations, and every 60s

* feat: Implement efficient pct list approach for container status checking

- Replace individual container status checks with bulk pct list per server
- Update getContainerStatuses to run pct list once per server and parse all results
- Simplify frontend to just pass server IDs instead of individual container data
- Much more efficient: 1 SSH call per server instead of 1 call per container
- Parse pct list output format: CTID Status Name
- Map pct list status (running/stopped) to our status format
- This should resolve the server overload issues while maintaining functionality

* fix: Remove duplicate container status display from STATUS column

- Remove container runtime status from STATUS column in both desktop and mobile views
- Keep container status display next to container ID where it belongs
- STATUS column now only shows installation status (SUCCESS/FAILED)
- Container runtime status (running/stopped) remains next to container ID
- Cleaner UI with no duplicate status information

* feat: Trigger status check when switching to installed scripts tab

- Add useEffect hook that triggers fetchContainerStatuses when component mounts
- This ensures container statuses are refreshed every time user switches to the tab
- Improves user experience by always showing current container states
- Uses empty dependency array to run only once per tab switch

* cleanup: Remove all console.log statements from codebase

- Remove console.log statements from InstalledScriptsTab.tsx
- Remove console.log statements from installedScripts.ts router
- Remove console.log statements from VersionDisplay.tsx
- Remove console.log statements from ScriptsGrid.tsx
- Keep console.error statements for proper error logging
- Cleaner production logs without debug output

* feat: Display detailed SSH error messages for container operations

- Capture both stdout and stderr from pct start/stop/destroy commands
- Show actual SSH error output to users instead of generic error messages
- Update controlContainer and destroyContainer to return detailed error messages
- Improve frontend error handling to display backend error messages
- Users now see specific error details like permission denied, container not found, etc.
- Better debugging experience with meaningful error feedback

* feat: Auto-stop containers before destroy and improve error UI

- Automatically stop running containers before destroying them
- Create custom ErrorModal component to replace ugly browser alerts
- Support both error and success modal types with appropriate styling
- Show detailed SSH error messages in a beautiful modal interface
- Update destroy success message to indicate if container was stopped first
- Better UX with consistent design language and proper error handling
- Auto-close modals after 10 seconds for better user experience

* fix: Replace dialog component with custom modal implementation

- Remove dependency on non-existent dialog component
- Use same modal pattern as ConfirmationModal for consistency
- Custom modal with backdrop, proper styling, and responsive design
- Maintains all functionality while fixing module resolution error
- Consistent with existing codebase patterns

* feat: Add instant success feedback for container start/stop operations

- Show success modal immediately after start/stop operations
- Update container status in UI instantly before background status check
- Prevents user confusion by showing expected status change immediately
- Add containerId to backend response for proper script identification
- Success modals show appropriate messages for start vs stop operations
- Background status check still runs to ensure accuracy
- Better UX with instant visual feedback

* fix: Improve Container Control section styling in help modal

- Replace bright red styling with subtle accent colors
- Use consistent design language that matches the rest of the interface
- Change safety features from red to yellow warning styling
- Better visual hierarchy and readability
- Maintains warning importance while being less jarring

* fix: Make safety features section much more subtle in help modal

- Replace bright yellow with muted background colors
- Use standard text colors (text-foreground, text-muted-foreground)
- Maintains warning icon but with consistent styling
- Much less jarring against dark theme
- Better integration with overall design language

* feat: Replace update script alerts with custom confirmation modal

- Replace browser alert() with custom ErrorModal for validation errors
- Replace browser confirm() with custom ConfirmationModal for update confirmation
- Add type-to-confirm safety feature requiring container ID input
- Include data loss warning and backup recommendation in confirmation message
- Consistent UI/UX with other confirmation dialogs
- Better error messaging with detailed information

* fix: Resolve all build errors and warnings

- Fix nullish coalescing operator warnings (|| to ??)
- Remove unused imports and variables
- Fix TypeScript type errors with proper casting
- Update ConfirmationModal state type to include missing properties
- Fix useEffect dependency warnings
- All build errors resolved, only minor unused variable warning remains
- Build now passes successfully

* feat: Disable update button when container is stopped

- Add disabled condition to update button in table view
- Add disabled condition to update button in mobile card view
- Prevents users from updating stopped containers
- Uses containerStatus to determine if button should be disabled
- Improves UX by preventing invalid operations on stopped containers

* fix: Resolve infinite loop in status updates

- Remove containerStatusMutation from fetchContainerStatuses dependencies
- Use empty dependency array for fetchContainerStatuses useCallback
- Remove fetchContainerStatuses from useEffect dependencies
- Only depend on scripts.length to prevent infinite loops
- Status updates now run only when scripts change, not on every render

* fix: Correct misleading text in update confirmation modal

- Change "will re-run the script installation process" to "will update the script"
- More accurate description of what the update operation actually does
- Maintains warning about potential container impact and backup recommendation
- Better user understanding of the actual operation being performed

* refactor: Remove all comments from InstalledScriptsTab.tsx

- Remove all single-line comments (//)
- Remove all multi-line comments (/* */)
- Clean up excessive empty lines
- Improve code readability and reduce file size
- Maintain all functionality while removing documentation comments

* refactor: Improve code organization and add comprehensive comments

- Add clear section comments for better code organization
- Document all major state variables and their purposes
- Add detailed comments for complex logic and operations
- Improve readability with better spacing and structure
- Maintain all existing functionality while improving maintainability
- Add comments for container control, mutations, and UI sections
2025-10-13 16:36:11 +02:00
Michel Roegl-Brunner
53b5074f35 feat: Add container running status indicators with auto-refresh (#123)
* feat: add container running status indicators with auto-refresh

- Add getContainerStatuses tRPC endpoint for checking container status
- Support both local and SSH remote container status checking
- Add 60-second auto-refresh for real-time status updates
- Display green/red dots next to container IDs showing running/stopped status
- Update both desktop table and mobile card views
- Add proper error handling and fallback to 'unknown' status
- Full TypeScript support with updated interfaces

Resolves container status visibility in installed scripts tab

* feat: update default sorting to group by server then container ID

- Change default sort field from 'script_name' to 'server_name'
- Implement multi-level sorting: server name first, then container ID
- Use numeric sorting for container IDs for proper ordering
- Maintain existing sorting behavior for other fields
- Improves organization by grouping related containers together

* feat: improve container status check triggering

- Add multiple triggers for container status checks:
  - When component mounts (tab becomes active)
  - When scripts data loads
  - Every 60 seconds via interval
- Add manual 'Refresh Container Status' button for on-demand checking
- Add console logging for debugging status check triggers
- Ensure status checks happen reliably when switching to installed scripts tab

Fixes issue where status checks weren't triggering when tab loads

* perf: optimize container status checking by batching per server

- Group containers by server and make one pct list call per server
- Replace individual container checks with batch processing
- Parse all container statuses from single pct list response per server
- Add proper TypeScript safety checks for undefined values
- Significantly reduce SSH calls from N containers to 1 call per server

This should dramatically speed up status loading for multiple containers on the same server

* fix: resolve all linting errors

- Fix React Hook dependency warnings by using useCallback and useMemo
- Fix TypeScript unsafe argument errors with proper Server type
- Fix nullish coalescing operator preferences
- Fix floating promise warnings with void operator
- All ESLint warnings and errors now resolved

Ensures clean code quality for PR merge
2025-10-13 15:30:04 +02:00
Michel Roegl-Brunner
aaa09b4745 feat: Implement comprehensive help system with contextual icons (#122)
* feat: implement comprehensive help system with contextual icons

- Add HelpModal component with navigation sidebar and 7 help sections
- Add HelpButton component for main header controls
- Add ContextualHelpIcon component for contextual help throughout UI
- Add help icons to all major UI sections:
  - Settings modals (Server Settings, General Settings)
  - Sync button with update system help
  - Tab headers (Available, Downloaded, Installed Scripts)
  - FilterBar and CategorySidebar
- Add comprehensive help content covering:
  - Server Settings: PVE server management, auth types, color coding
  - General Settings: Save filters, GitHub integration, authentication
  - Sync Button: Script metadata syncing explanation
  - Available Scripts: Browsing, filtering, downloading
  - Downloaded Scripts: Local script management and updates
  - Installed Scripts: Auto-detection feature (primary focus), manual management
  - Update System: Automatic/manual update process, release notes
- Improve VersionDisplay: remove 'Update Available' text, add 'Release Notes:' label
- Make help icons more noticeable with increased size
- Fix dark theme compatibility issues in help modal

* fix: resolve linting errors in HelpModal component

- Remove unused Filter import
- Fix unescaped entities by replacing apostrophes and quotes with HTML entities
- All linting errors resolved

* feat: implement release notes modal system

- Add getAllReleases API endpoint to fetch GitHub releases with notes
- Create ReleaseNotesModal component with localStorage version tracking
- Add sticky Footer component with release notes link
- Make version badge clickable to open release notes
- Auto-show modal after updates when version changes
- Track last seen version in localStorage to prevent repeated shows
- Highlight new version in modal when opened after update
- Add manual access via footer and version badge clicks

* fix: use nullish coalescing operator in ReleaseNotesModal

- Replace logical OR (||) with nullish coalescing (??) operator
- Fixes ESLint prefer-nullish-coalescing rule violation
- Ensures build passes successfully
2025-10-13 15:05:23 +02:00
Michel Roegl-Brunner
24afce49a3 feat: Multi-select script download with progress tracking (#121)
* feat: Add multi-select script download with progress tracking

- Add checkbox selection to script cards (both card and list views)
- Implement individual script downloads with real-time progress
- Add progress bar with visual indicators (✓ success, ✗ failed, ⟳ in-progress)
- Add batch download buttons (Download Selected, Download All Filtered)
- Add user-friendly error messages with specific guidance
- Add persistent progress bar with manual dismiss option
- Clear selection when switching between card/list views
- Update card download status immediately after completion

Features:
- Multi-select with checkboxes on script cards
- Real-time progress tracking during downloads
- Detailed error reporting with actionable messages
- Visual progress indicators for each script
- Batch download functionality for selected or filtered scripts
- Persistent progress bar until manually dismissed
- Automatic card status updates after download completion

* fix: Resolve ESLint errors

- Replace logical OR operators (||) with nullish coalescing (??) for safer null/undefined handling
- Replace for loop with for-of loop for better iteration
- Remove unused variable 'results'
- Fix all TypeScript ESLint warnings and errors

* fix: Resolve TypeScript error in loadMultipleScripts

- Use type guard to check for 'error' property before accessing
- Fix 'Property error does not exist' TypeScript error
- Ensure safe access to error property in result object
2025-10-13 14:17:11 +02:00
Michel Roegl-Brunner
9d83697d45 Fix: Detect downloaded scripts from all directories (ct, tools, vm, vw) (#120)
* Fix: Detect downloaded scripts from all directories (ct, tools, vm, vw)

- Add getAllDownloadedScripts() method to scan all script directories
- Update DownloadedScriptsTab to use new API endpoint
- Update ScriptsGrid to use new API endpoint for download status detection
- Update main page script counts to use new API endpoint
- Add recursive directory scanning to handle subdirectories
- Maintain backward compatibility with existing getCtScripts endpoint

Fixes issue where scripts downloaded to tools/, vm/, or vw/ directories
were not showing in Downloaded Scripts tab or showing correct download
status in Available Scripts tab.

* Fix: Remove redundant type annotation and method call arguments

- Remove redundant string type annotation from relativePath parameter
- Fix getScriptsFromDirectory method call to match updated signature
2025-10-13 13:38:58 +02:00
Michel Roegl-Brunner
c12c96cfb9 UI Fixes: Button Styling and Tab Navigation Improvements (#119)
* Fix server column alignment in installed scripts table

- Add text-left class to server column td element
- Add inline-block class to server name span element
- Ensures server column content aligns with header like other columns

* Fix UpdateableBadge overflow on smaller screens

- Add flex-wrap and gap classes to badge containers in ScriptCard and ScriptCardList
- Prevents badges from overflowing card boundaries on narrow screens
- Maintains proper spacing with gap-1/gap-2 for wrapped elements
- Improves responsive design for mobile and laptop screens

* Enhance action buttons with blueish theme and hover animations

- Update Edit, Update, Delete, Save, and Cancel buttons with color-coded themes
- Edit: Blue theme (bg-blue-50, text-blue-700)
- Update: Cyan theme (bg-cyan-50, text-cyan-700)
- Delete: Red theme (bg-red-50, text-red-700)
- Save: Green theme (bg-green-50, text-green-700)
- Cancel: Gray theme (bg-gray-50, text-gray-700)
- Add hover effects: scale-105, shadow-md, color transitions
- Apply consistent styling to both table and mobile card views
- Disabled buttons prevent scale animation and show proper cursor states

* Tone down button colors for better dark theme compatibility

- Replace bright flashbang colors with subtle dark theme variants
- Use 900-level colors with 20% opacity for backgrounds
- Use 300-level colors for text with 200-level on hover
- Use 700-level colors with 50% opacity for borders
- Maintain color coding but with much more subtle appearance
- Better contrast and readability against dark backgrounds
- No more flashbang effect! 😅

* Refactor button styling with semantic variants for uniform appearance

- Add semantic button variants: edit, update, delete, save, cancel
- Each variant has consistent dark theme colors and hover effects
- Remove custom className overrides from all button instances
- Centralize styling in Button component for maintainability
- All action buttons now use semantic variants instead of custom classes
- Much cleaner code and consistent styling across the application

* Remove rounded bottom border from active tab

- Add rounded-t-md rounded-b-none classes to active tab styling
- Creates flat bottom edge that connects seamlessly with content below
- Applied to all three tabs: Available Scripts, Downloaded Scripts, Installed Scripts
- Maintains rounded top corners for visual appeal while removing bottom rounding

* Apply flat bottom edge to tab hover states

- Add hover:rounded-t-md hover:rounded-b-none to inactive tab hover states
- Ensures consistent flat bottom edge on both active and hover states
- Creates uniform visual behavior across all tab interactions
- Maintains rounded top corners while removing bottom rounding on hover
2025-10-13 13:14:11 +02:00
Michel Roegl-Brunner
7a550bbd61 feat: Add backup and restore functionality for scripts directories (#118)
- Enhanced backup_data() function to backup ct/, install/, tools/, and vm/ directories
- Enhanced restore_backup_files() function to restore scripts directories after update
- Updated exclude patterns to preserve scripts directories during file updates
- Enhanced rollback() function to restore scripts directories on update failure
- Added comprehensive logging for all backup/restore operations
- Ensures custom scripts are preserved during updates and restored after successful updates
2025-10-13 12:57:43 +02:00
dependabot[bot]
99b639e6d8 build(deps-dev): Bump @types/bcryptjs from 2.4.6 to 3.0.0 (#110)
Bumps [@types/bcryptjs](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/bcryptjs) from 2.4.6 to 3.0.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/bcryptjs)

---
updated-dependencies:
- dependency-name: "@types/bcryptjs"
  dependency-version: 3.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-10 21:37:53 +02:00
github-actions[bot]
f0f22fde83 chore: add VERSION v0.3.0 (#107)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-10 13:10:42 +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
63 changed files with 6614 additions and 701 deletions

View File

@@ -21,3 +21,8 @@ WEBSOCKET_PORT="3001"
GITHUB_TOKEN=
SAVE_FILTER=false
FILTERS=
AUTH_USERNAME=
AUTH_PASSWORD_HASH=
AUTH_ENABLED=false
AUTH_SETUP_COMPLETED=false
JWT_SECRET=

View File

@@ -1,6 +1,6 @@
# Template for release drafts
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
tag-template: 'v$NEXT_PATCH_VERSION'
name-template: 'v$NEXT_MINOR_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
tag-template: 'v$NEXT_MINOR_VERSION'
# Exclude PRs with this label from release notes
exclude-labels:

View File

@@ -1 +1 @@
0.2.3
0.4.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;

163
package-lock.json generated
View File

@@ -19,9 +19,11 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.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",
"node-pty": "^1.0.0",
@@ -42,8 +44,10 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^3.0.0",
"@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-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.2",
@@ -2986,6 +2990,17 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/bcryptjs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"bcryptjs": "*"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -3043,10 +3058,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.7.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz",
"integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==",
"version": "24.7.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
"integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.14.0"
@@ -4259,6 +4292,15 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bcryptjs": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-sqlite3": {
"version": "12.4.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
@@ -4385,6 +4427,12 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -4954,6 +5002,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.232",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz",
@@ -7166,6 +7223,40 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -7182,6 +7273,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7481,6 +7593,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -7488,6 +7636,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -7731,7 +7885,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nan": {

View File

@@ -33,9 +33,11 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.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",
"node-pty": "^1.0.0",
@@ -56,8 +58,10 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^3.0.0",
"@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-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.2",

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(dirname "$0")"
source "$SCRIPT_DIR/../core/build.func"
# Copyright (c) 2021-2025 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://www.debian.org/
APP="Debian"
var_tags="${var_tags:-os}"
var_cpu="${var_cpu:-1}"
var_ram="${var_ram:-512}"
var_disk="${var_disk:-2}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /var ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
msg_info "Updating $APP LXC"
$STD apt update
$STD apt -y upgrade
msg_ok "Updated $APP LXC"
exit
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# Copyright (c) 2021-2025 tteck
# Author: tteck (tteckster)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://www.debian.org/
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
color
verb_ip6
catch_errors
setting_up_container
network_check
update_os
motd_ssh
customize
msg_info "Cleaning up"
$STD apt -y autoremove
$STD apt -y autoclean
$STD apt -y clean
msg_ok "Cleaned"

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

@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { ContextualHelpIcon } from './ContextualHelpIcon';
interface CategorySidebarProps {
categories: string[];
@@ -40,7 +41,7 @@ const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; c
),
key: (
<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>
),
archive: (
@@ -201,9 +202,12 @@ export function CategorySidebar({
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
{!isCollapsed && (
<div>
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
<div className="flex items-center justify-between w-full">
<div>
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
</div>
<ContextualHelpIcon section="available-scripts" tooltip="Help with categories" />
</div>
)}
<button

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

@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import { Button } from './ui/button';
import { AlertTriangle, Info } from 'lucide-react';
interface ConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
variant: 'simple' | 'danger';
confirmText?: string; // What the user must type for danger variant
confirmButtonText?: string;
cancelButtonText?: string;
}
export function ConfirmationModal({
isOpen,
onClose,
onConfirm,
title,
message,
variant,
confirmText,
confirmButtonText = 'Confirm',
cancelButtonText = 'Cancel'
}: ConfirmationModalProps) {
const [typedText, setTypedText] = useState('');
if (!isOpen) return null;
const isDanger = variant === 'danger';
const isConfirmEnabled = isDanger ? typedText === confirmText : true;
const handleConfirm = () => {
if (isConfirmEnabled) {
onConfirm();
setTypedText(''); // Reset for next time
}
};
const handleClose = () => {
onClose();
setTypedText(''); // Reset when closing
};
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">
{isDanger ? (
<AlertTriangle className="h-8 w-8 text-red-600" />
) : (
<Info className="h-8 w-8 text-blue-600" />
)}
<h2 className="text-2xl font-bold text-card-foreground">{title}</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-sm text-muted-foreground mb-6">
{message}
</p>
{/* Type-to-confirm input for danger variant */}
{isDanger && confirmText && (
<div className="mb-6">
<label className="block text-sm font-medium text-foreground mb-2">
Type <code className="bg-muted px-2 py-1 rounded text-sm">{confirmText}</code> to confirm:
</label>
<input
type="text"
value={typedText}
onChange={(e) => setTypedText(e.target.value)}
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={`Type "${confirmText}" here`}
autoComplete="off"
/>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-end gap-3">
<Button
onClick={handleClose}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
{cancelButtonText}
</Button>
<Button
onClick={handleConfirm}
disabled={!isConfirmEnabled}
variant={isDanger ? "destructive" : "default"}
size="default"
className="w-full sm:w-auto"
>
{confirmButtonText}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import { useState } from 'react';
import { HelpModal } from './HelpModal';
import { Button } from './ui/button';
import { HelpCircle } from 'lucide-react';
interface ContextualHelpIconProps {
section: string;
className?: string;
size?: 'sm' | 'default';
tooltip?: string;
}
export function ContextualHelpIcon({
section,
className = '',
size = 'sm',
tooltip = 'Help'
}: ContextualHelpIconProps) {
const [isOpen, setIsOpen] = useState(false);
const sizeClasses = size === 'sm'
? 'h-7 w-7 p-1.5'
: 'h-9 w-9 p-2';
return (
<>
<Button
onClick={() => setIsOpen(true)}
variant="ghost"
size="icon"
className={`${sizeClasses} text-muted-foreground hover:text-foreground hover:bg-muted ${className}`}
title={tooltip}
>
<HelpCircle className="w-4 h-4" />
</Button>
<HelpModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
initialSection={section}
/>
</>
);
}

View File

@@ -3,9 +3,11 @@
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';
@@ -22,6 +24,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
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,
@@ -34,13 +37,13 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
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: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
{ slug: selectedSlug ?? '' },
{ enabled: !!selectedSlug }
);
// Load SAVE_FILTER setting and saved filters on component mount
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
useEffect(() => {
const loadSettings = async () => {
try {
@@ -63,6 +66,16 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
}
}
}
// 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 {
@@ -96,6 +109,29 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
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 [];
@@ -367,25 +403,8 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
return (
<div className="space-y-6">
{/* Header with Stats */}
<div className="bg-card rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-foreground mb-4">Downloaded Scripts</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-400">{downloadedScripts.length}</div>
<div className="text-sm text-blue-300">Total Downloaded</div>
</div>
<div className="bg-green-500/10 border border-green-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-400">{filterCounts.updatableCount}</div>
<div className="text-sm text-green-300">Updatable</div>
</div>
<div className="bg-purple-500/10 border border-purple-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-purple-400">{filteredScripts.length}</div>
<div className="text-sm text-purple-300">Filtered Results</div>
</div>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
{/* Category Sidebar */}
@@ -412,6 +431,12 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
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">
@@ -446,25 +471,47 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
</div>
</div>
) : (
<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>
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

View File

@@ -0,0 +1,87 @@
'use client';
import { useEffect } from 'react';
import { Button } from './ui/button';
import { AlertCircle, CheckCircle } from 'lucide-react';
interface ErrorModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
message: string;
details?: string;
type?: 'error' | 'success';
}
export function ErrorModal({
isOpen,
onClose,
title,
message,
details,
type = 'error'
}: ErrorModalProps) {
// Auto-close after 10 seconds
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => {
onClose();
}, 10000);
return () => clearTimeout(timer);
}
}, [isOpen, onClose]);
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-lg 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">
{type === 'success' ? (
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
)}
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-sm text-foreground mb-4">{message}</p>
{details && (
<div className={`rounded-lg p-3 ${
type === 'success'
? 'bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800'
}`}>
<p className={`text-xs font-medium mb-1 ${
type === 'success'
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}>
{type === 'success' ? 'Details:' : 'Error Details:'}
</p>
<pre className={`text-xs whitespace-pre-wrap break-words ${
type === 'success'
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}>
{details}
</pre>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-border">
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
);
}

View File

@@ -3,6 +3,9 @@
import { useState, useEffect } from 'react';
import type { Server } from '../../types/server';
import { Button } from './ui/button';
import { ColorCodedDropdown } from './ColorCodedDropdown';
import { SettingsModal } from './SettingsModal';
interface ExecutionModeModalProps {
isOpen: boolean;
@@ -15,8 +18,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedMode, setSelectedMode] = useState<'local' | 'ssh'>('local');
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
useEffect(() => {
if (isOpen) {
@@ -24,6 +27,20 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
}
}, [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 () => {
setLoading(true);
setError(null);
@@ -33,7 +50,11 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
throw new Error('Failed to fetch servers');
}
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) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
@@ -42,167 +63,175 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
};
const handleExecute = () => {
if (selectedMode === 'ssh' && !selectedServer) {
if (!selectedServer) {
setError('Please select a server for SSH execution');
return;
}
onExecute(selectedMode, selectedServer ?? undefined);
onExecute('ssh', selectedServer);
onClose();
};
const handleModeChange = (mode: 'local' | 'ssh') => {
setSelectedMode(mode);
if (mode === 'local') {
setSelectedServer(null);
}
const handleServerSelect = (server: Server | null) => {
setSelectedServer(server);
};
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-between p-6 border-b border-border">
<h2 className="text-xl font-bold text-foreground">Execution Mode</h2>
<Button
onClick={onClose}
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
{/* Content */}
<div className="p-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-foreground mb-2">
Where would you like to execute &quot;{scriptName}&quot;?
</h3>
</div>
{error && (
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<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" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-destructive">{error}</p>
</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-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
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-primary focus:ring-primary border-border"
/>
<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-primary/10 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-primary" 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-foreground">SSH Execution</h4>
<p className="text-sm text-muted-foreground">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-foreground mb-2">
Select Server
</label>
{loading ? (
<div className="text-center py-4">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
</div>
) : servers.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
<p className="text-sm">No servers configured</p>
<p className="text-xs mt-1">Add servers in Settings to use SSH execution</p>
</div>
) : (
<select
id="server"
value={selectedServer?.id ?? ''}
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-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary bg-background text-foreground"
>
<option value="">Select a server...</option>
{servers.map((server) => (
<option key={server.id} value={server.id}>
{server.name} ({server.ip}) - {server.user}
</option>
))}
</select>
)}
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<>
<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-between p-6 border-b border-border">
<h2 className="text-xl font-bold text-foreground">Select Server</h2>
<Button
onClick={onClose}
variant="outline"
size="default"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
Cancel
</Button>
<Button
onClick={handleExecute}
disabled={selectedMode === 'ssh' && !selectedServer}
variant="default"
size="default"
className={selectedMode === 'ssh' && !selectedServer ? 'bg-gray-400 cursor-not-allowed' : ''}
>
{selectedMode === 'local' ? 'Run Locally' : 'Run on Server'}
<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" />
</svg>
</Button>
</div>
{/* Content */}
<div className="p-6">
{error && (
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<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" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
</div>
)}
{loading ? (
<div className="text-center py-8">
<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-muted-foreground">Loading servers...</p>
</div>
) : servers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No servers configured</p>
<p className="text-xs mt-1">Add servers in Settings to execute scripts</p>
<Button
onClick={() => setSettingsModalOpen(true)}
variant="outline"
size="sm"
className="mt-3"
>
Open Server Settings
</Button>
</div>
) : servers.length === 1 ? (
/* Single Server Confirmation View */
<div className="space-y-6">
<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>
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<Button
onClick={onClose}
variant="outline"
size="default"
>
Cancel
</Button>
<Button
onClick={handleExecute}
variant="default"
size="default"
>
Install
</Button>
</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>
{/* Server Settings Modal */}
<SettingsModal
isOpen={settingsModalOpen}
onClose={handleSettingsModalClose}
/>
</>
);
}

View File

@@ -2,6 +2,7 @@
import React, { useState } from "react";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
export interface FilterState {
@@ -104,6 +105,14 @@ export function FilterBar({
</div>
)}
{/* Filter Header */}
{!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
</div>
)}
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">

View File

@@ -0,0 +1,64 @@
'use client';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { ExternalLink, FileText } from 'lucide-react';
interface FooterProps {
onOpenReleaseNotes: () => void;
}
export function Footer({ onOpenReleaseNotes }: FooterProps) {
const { data: versionData } = api.version.getCurrentVersion.useQuery();
return (
<footer className="sticky bottom-0 mt-auto border-t border-border bg-muted/30 py-6 backdrop-blur-sm">
<div className="container mx-auto px-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-4">
<span>© 2024 PVE Scripts Local</span>
{versionData?.success && versionData.version && (
<Button
variant="ghost"
size="sm"
onClick={onOpenReleaseNotes}
className="h-auto p-1 text-xs hover:text-foreground"
>
v{versionData.version}
</Button>
)}
</div>
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={onOpenReleaseNotes}
className="h-auto p-2 text-xs hover:text-foreground"
>
<FileText className="h-3 w-3 mr-1" />
Release Notes
</Button>
<Button
variant="ghost"
size="sm"
asChild
className="h-auto p-2 text-xs hover:text-foreground"
>
<a
href="https://github.com/community-scripts/ProxmoxVE-Local"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
GitHub
</a>
</Button>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Toggle } from './ui/toggle';
import { ContextualHelpIcon } from './ContextualHelpIcon';
interface GeneralSettingsModalProps {
isOpen: boolean;
@@ -11,13 +12,23 @@ interface GeneralSettingsModalProps {
}
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
const [activeTab, setActiveTab] = useState<'general' | 'github'>('general');
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(() => {
@@ -25,6 +36,8 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
void loadGithubToken();
void loadSaveFilter();
void loadSavedFilters();
void loadAuthCredentials();
void loadColorCodingSetting();
}
}, [isOpen]);
@@ -138,6 +151,129 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
}
};
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 (
@@ -145,7 +281,10 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
<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>
<div className="flex items-center gap-2">
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
<ContextualHelpIcon section="general-settings" tooltip="Help with General Settings" />
</div>
<Button
onClick={onClose}
variant="ghost"
@@ -185,6 +324,18 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
>
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>
@@ -237,6 +388,16 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</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>
)}
@@ -301,6 +462,134 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</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

@@ -0,0 +1,40 @@
'use client';
import { useState } from 'react';
import { HelpModal } from './HelpModal';
import { Button } from './ui/button';
import { HelpCircle } from 'lucide-react';
interface HelpButtonProps {
initialSection?: string;
}
export function HelpButton({ initialSection }: HelpButtonProps) {
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">
Need help?
</div>
<Button
onClick={() => setIsOpen(true)}
variant="outline"
size="default"
className="inline-flex items-center"
title="Open Help"
>
<HelpCircle className="w-5 h-5 mr-2" />
Help
</Button>
</div>
<HelpModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
initialSection={initialSection}
/>
</>
);
}

View File

@@ -0,0 +1,515 @@
'use client';
import { useState } from 'react';
import { Button } from './ui/button';
import { HelpCircle, Server, Settings, RefreshCw, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
interface HelpModalProps {
isOpen: boolean;
onClose: () => void;
initialSection?: string;
}
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'update-system';
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection);
if (!isOpen) return null;
const sections = [
{ id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server },
{ id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings },
{ id: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
{ id: 'update-system' as HelpSection, label: 'Update System', icon: Download },
];
const renderContent = () => {
switch (activeSection) {
case 'server-settings':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Server Settings</h3>
<p className="text-muted-foreground mb-6">
Manage your Proxmox VE servers and configure connection settings.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Adding PVE Servers</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Server Name:</strong> A friendly name to identify your server</li>
<li> <strong>IP Address:</strong> The IP address or hostname of your PVE server</li>
<li> <strong>Username:</strong> PVE user account (usually root or a dedicated user)</li>
<li> <strong>SSH Port:</strong> Default is 22, change if your server uses a different port</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Authentication Types</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Password:</strong> Use username and password authentication</li>
<li> <strong>SSH Key:</strong> Use SSH key pair for secure authentication</li>
<li> <strong>Both:</strong> Try SSH key first, fallback to password if needed</li>
</ul>
</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">
Assign colors to servers for visual distinction throughout the application.
This helps identify which server you&apos;re working with when managing scripts.
This needs to be enabled in the General Settings.
</p>
</div>
</div>
</div>
);
case 'general-settings':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">General Settings</h3>
<p className="text-muted-foreground mb-6">
Configure application preferences and behavior.
</p>
</div>
<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-2">
When enabled, your script filter preferences (search terms, categories, sorting)
will be automatically saved and restored when you return to the application.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Search queries are preserved</li>
<li> Selected script types are remembered</li>
<li> Sort preferences are maintained</li>
<li> Category selections are saved</li>
</ul>
</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">
Enable visual color coding for servers throughout the application.
This makes it easier to identify which server you&apos;re working with.
</p>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">GitHub Integration</h4>
<p className="text-sm text-muted-foreground mb-2">
Add a GitHub Personal Access Token to increase API rate limits and improve performance.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Bypasses GitHub&apos;s rate limiting for unauthenticated requests</li>
<li> Improves script loading and syncing performance</li>
<li> Token is stored securely and only used for API calls</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Authentication</h4>
<p className="text-sm text-muted-foreground mb-2">
Secure your application with username and password authentication.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Set up username and password for app access</li>
<li> Enable/disable authentication as needed</li>
<li> Credentials are stored securely</li>
</ul>
</div>
</div>
</div>
);
case 'sync-button':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Sync Button</h3>
<p className="text-muted-foreground mb-6">
Synchronize script metadata from the ProxmoxVE GitHub repository.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">What Does Syncing Do?</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Updates Script Metadata:</strong> Downloads the latest script information (JSON files)</li>
<li> <strong>Refreshes Available Scripts:</strong> Updates the list of scripts you can download</li>
<li> <strong>Updates Categories:</strong> Refreshes script categories and organization</li>
<li> <strong>Checks for Updates:</strong> Identifies which downloaded scripts have newer versions</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Important Notes</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Metadata Only:</strong> Syncing only updates script information, not the actual script files</li>
<li> <strong>No Downloads:</strong> Script files are downloaded separately when you choose to install them</li>
<li> <strong>Last Sync Time:</strong> Shows when the last successful sync occurred</li>
<li> <strong>Rate Limits:</strong> GitHub API limits may apply without a personal access token</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">When to Sync</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> When you want to see the latest available scripts</li>
<li> To check for updates to your downloaded scripts</li>
<li> If you notice scripts are missing or outdated</li>
<li> After the ProxmoxVE repository has been updated</li>
</ul>
</div>
</div>
</div>
);
case 'available-scripts':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Available Scripts</h3>
<p className="text-muted-foreground mb-6">
Browse and discover scripts from the ProxmoxVE repository.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Browsing Scripts</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Category Sidebar:</strong> Filter scripts by category (Storage, Network, Security, etc.)</li>
<li> <strong>Search:</strong> Find scripts by name or description</li>
<li> <strong>View Modes:</strong> Switch between card and list view</li>
<li> <strong>Sorting:</strong> Sort by name or creation date</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Filtering Options</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Script Types:</strong> Filter by CT (Container) or other script types</li>
<li> <strong>Update Status:</strong> Show only scripts with available updates</li>
<li> <strong>Search Query:</strong> Search within script names and descriptions</li>
<li> <strong>Categories:</strong> Filter by specific script categories</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Script Actions</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>View Details:</strong> Click on a script to see full information and documentation</li>
<li> <strong>Download:</strong> Download script files to your local system</li>
<li> <strong>Install:</strong> Run scripts directly on your PVE servers</li>
<li> <strong>Preview:</strong> View script content before downloading</li>
</ul>
</div>
</div>
</div>
);
case 'downloaded-scripts':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Downloaded Scripts</h3>
<p className="text-muted-foreground mb-6">
Manage scripts that have been downloaded to your local system.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">What Are Downloaded Scripts?</h4>
<p className="text-sm text-muted-foreground mb-2">
These are scripts that you&apos;ve downloaded from the repository and are stored locally on your system.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Script files are stored in your local scripts directory</li>
<li> You can run these scripts on your PVE servers</li>
<li> Scripts can be updated when newer versions are available</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Update Detection</h4>
<p className="text-sm text-muted-foreground mb-2">
The system automatically checks if newer versions of your downloaded scripts are available.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Scripts with updates available are marked with an update indicator</li>
<li> You can filter to show only scripts with available updates</li>
<li> Update detection happens when you sync with the repository</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Managing Downloaded Scripts</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Update Scripts:</strong> Download the latest version of a script</li>
<li> <strong>View Details:</strong> See script information and documentation</li>
<li> <strong>Install/Run:</strong> Execute scripts on your PVE servers</li>
<li> <strong>Filter & Search:</strong> Use the same filtering options as Available Scripts</li>
</ul>
</div>
</div>
</div>
);
case 'installed-scripts':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Installed Scripts</h3>
<p className="text-muted-foreground mb-6">
Track and manage scripts that are installed on your PVE servers.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg bg-muted/50 border-primary/20">
<h4 className="font-medium text-foreground mb-2 flex items-center gap-2">
<Search className="w-4 h-4" />
Auto-Detection (Primary Feature)
</h4>
<p className="text-sm text-muted-foreground mb-3">
The system can automatically detect LXC containers that have community-script tags on your PVE servers.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Automatic Discovery:</strong> Scans your PVE servers for containers with community-script tags</li>
<li> <strong>Container Detection:</strong> Identifies LXC containers running Proxmox helper scripts</li>
<li> <strong>Server Association:</strong> Links detected scripts to the specific PVE server</li>
<li> <strong>Bulk Import:</strong> Automatically creates records for all detected scripts</li>
</ul>
<div className="mt-3 p-3 bg-primary/10 rounded-lg border border-primary/20">
<p className="text-sm font-medium text-primary">How Auto-Detection Works:</p>
<ol className="text-sm text-muted-foreground mt-1 space-y-1">
<li>1. Connects to your configured PVE servers</li>
<li>2. Scans LXC container configurations</li>
<li>3. Looks for containers with community-script tags</li>
<li>4. Creates installed script records automatically</li>
</ol>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Manual Script Management</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Add Scripts Manually:</strong> Create records for scripts not auto-detected</li>
<li> <strong>Edit Script Details:</strong> Update script names and container IDs</li>
<li> <strong>Delete Scripts:</strong> Remove scripts from tracking</li>
<li> <strong>Bulk Operations:</strong> Clean up old or invalid script records</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Script Tracking Features</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Installation Status:</strong> Track success, failure, or in-progress installations</li>
<li> <strong>Server Association:</strong> Know which server each script is installed on</li>
<li> <strong>Container ID:</strong> Link scripts to specific LXC containers</li>
<li> <strong>Execution Logs:</strong> View output and logs from script installations</li>
<li> <strong>Filtering:</strong> Filter by server, status, or search terms</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Managing Installed Scripts</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>View All Scripts:</strong> See all tracked scripts across all servers</li>
<li> <strong>Filter by Server:</strong> Show scripts for a specific PVE server</li>
<li> <strong>Filter by Status:</strong> Show successful, failed, or in-progress installations</li>
<li> <strong>Sort Options:</strong> Sort by name, container ID, server, status, or date</li>
<li> <strong>Update Scripts:</strong> Re-run or update existing script installations</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg bg-accent/50 dark:bg-accent/20">
<h4 className="font-medium text-foreground mb-2">Container Control (NEW)</h4>
<p className="text-sm text-muted-foreground mb-3">
Directly control LXC containers from the installed scripts page via SSH.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Start/Stop Button:</strong> Control container state with <code>pct start/stop &lt;ID&gt;</code></li>
<li> <strong>Container Status:</strong> Real-time status indicator (running/stopped/unknown)</li>
<li> <strong>Destroy Button:</strong> Permanently remove LXC container with <code>pct destroy &lt;ID&gt;</code></li>
<li> <strong>Confirmation Modals:</strong> Simple OK/Cancel for start/stop, type container ID to confirm destroy</li>
<li> <strong>SSH Execution:</strong> All commands executed remotely via configured SSH connections</li>
</ul>
<div className="mt-3 p-3 bg-muted/30 dark:bg-muted/20 rounded-lg border border-border">
<p className="text-sm font-medium text-foreground"> Safety Features:</p>
<ul className="text-sm text-muted-foreground mt-1 space-y-1">
<li> Start/Stop actions require simple confirmation</li>
<li> Destroy action requires typing the container ID to confirm</li>
<li> All actions show loading states and error handling</li>
<li> Only works with SSH scripts that have valid container IDs</li>
</ul>
</div>
</div>
</div>
</div>
);
case 'update-system':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Update System</h3>
<p className="text-muted-foreground mb-6">
Keep your PVE Scripts Management application up to date with the latest features and improvements.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">What Does Updating Do?</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Downloads Latest Version:</strong> Fetches the newest release from the GitHub repository</li>
<li> <strong>Updates Application Files:</strong> Replaces current files with the latest version</li>
<li> <strong>Installs Dependencies:</strong> Updates Node.js packages and dependencies</li>
<li> <strong>Rebuilds Application:</strong> Compiles the application with latest changes</li>
<li> <strong>Restarts Server:</strong> Automatically restarts the application server</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">How to Update</h4>
<div className="space-y-3">
<div>
<h5 className="font-medium text-foreground mb-2">Automatic Update (Recommended)</h5>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Click the &quot;Update Now&quot; button when an update is available</li>
<li> The system will handle everything automatically</li>
<li> You&apos;ll see a progress overlay with update logs</li>
<li> The page will reload automatically when complete</li>
</ul>
</div>
<div>
<h5 className="font-medium text-foreground mb-2">Manual Update (Advanced)</h5>
<p className="text-sm text-muted-foreground mb-2">If automatic update fails, you can update manually:</p>
<div className="bg-muted p-3 rounded-lg font-mono text-sm">
<div className="text-muted-foreground"># Navigate to the application directory</div>
<div>cd $PVESCRIPTLOCAL_DIR</div>
<div className="text-muted-foreground"># Pull latest changes</div>
<div>git pull</div>
<div className="text-muted-foreground"># Install dependencies</div>
<div>npm install</div>
<div className="text-muted-foreground"># Build the application</div>
<div>npm run build</div>
<div className="text-muted-foreground"># Start the application</div>
<div>npm start</div>
</div>
</div>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Update Process</h4>
<ol className="text-sm text-muted-foreground space-y-2">
<li><strong>1. Check for Updates:</strong> System automatically checks GitHub for new releases</li>
<li><strong>2. Download Update:</strong> Downloads the latest release files</li>
<li><strong>3. Backup Current Version:</strong> Creates backup of current installation</li>
<li><strong>4. Install New Version:</strong> Replaces files and updates dependencies</li>
<li><strong>5. Build Application:</strong> Compiles the updated code</li>
<li><strong>6. Restart Server:</strong> Stops old server and starts new version</li>
<li><strong>7. Reload Page:</strong> Automatically refreshes the browser</li>
</ol>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Release Notes</h4>
<p className="text-sm text-muted-foreground mb-2">
Click the external link icon next to the update button to view detailed release notes on GitHub.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> See what&apos;s new in each version</li>
<li> Read about bug fixes and improvements</li>
<li> Check for any breaking changes</li>
<li> View installation requirements</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg bg-muted/50">
<h4 className="font-medium text-foreground mb-2">Important Notes</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Backup:</strong> Your data and settings are preserved during updates</li>
<li> <strong>Downtime:</strong> Brief downtime occurs during the update process</li>
<li> <strong>Compatibility:</strong> Updates maintain backward compatibility with your data</li>
<li> <strong>Rollback:</strong> If issues occur, you can manually revert to previous version</li>
</ul>
</div>
</div>
</div>
);
default:
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-6xl 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 flex items-center gap-2">
<HelpCircle className="w-6 h-6" />
Help & Documentation
</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>
<div className="flex h-[calc(95vh-120px)] sm:h-[calc(90vh-140px)]">
{/* Sidebar Navigation */}
<div className="w-64 border-r border-border bg-muted/30 overflow-y-auto">
<nav className="p-4 space-y-2">
{sections.map((section) => {
const Icon = section.icon;
return (
<Button
key={section.id}
onClick={() => setActiveSection(section.id)}
variant={activeSection === section.id ? "default" : "ghost"}
size="sm"
className="w-full justify-start gap-2 text-left"
>
<Icon className="w-4 h-4" />
{section.label}
</Button>
);
})}
</nav>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto">
<div className="p-4 sm:p-6">
{renderContent()}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,14 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { api } from '~/trpc/react';
import { Terminal } from './Terminal';
import { StatusBadge } from './Badge';
import { Button } from './ui/button';
import { ScriptInstallationCard } from './ScriptInstallationCard';
import { ConfirmationModal } from './ConfirmationModal';
import { ErrorModal } from './ErrorModal';
import { getContrastColor } from '../../lib/colorUtils';
interface InstalledScript {
id: number;
@@ -17,15 +20,20 @@ interface InstalledScript {
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;
execution_mode: 'local' | 'ssh';
container_status?: 'running' | 'stopped' | 'unknown';
}
export function InstalledScriptsTab() {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
const [serverFilter, setServerFilter] = useState<string>('all');
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_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 [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
@@ -37,6 +45,30 @@ export function InstalledScriptsTab() {
const [cleanupStatus, setCleanupStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
const cleanupRunRef = useRef(false);
// Container control state
const [containerStatuses, setContainerStatuses] = useState<Map<number, 'running' | 'stopped' | 'unknown'>>(new Map());
const [confirmationModal, setConfirmationModal] = useState<{
isOpen: boolean;
variant: 'simple' | 'danger';
title: string;
message: string;
confirmText?: string;
confirmButtonText?: string;
cancelButtonText?: string;
onConfirm: () => void;
} | null>(null);
const [controllingScriptId, setControllingScriptId] = useState<number | null>(null);
const scriptsRef = useRef<InstalledScript[]>([]);
// Error modal state
const [errorModal, setErrorModal] = useState<{
isOpen: boolean;
title: string;
message: string;
details?: string;
type?: 'error' | 'success';
} | null>(null);
// Fetch installed scripts
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: statsData } = api.installedScripts.getInstallationStats.useQuery();
@@ -76,7 +108,6 @@ 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('');
@@ -110,10 +141,42 @@ export function InstalledScriptsTab() {
}
});
// Get container statuses mutation
const containerStatusMutation = api.installedScripts.getContainerStatuses.useMutation({
onSuccess: (data) => {
if (data.success) {
// Map container IDs to script IDs
const currentScripts = scriptsRef.current;
const statusMap = new Map<number, 'running' | 'stopped' | 'unknown'>();
// For each script, find its container status
currentScripts.forEach(script => {
if (script.container_id && data.statusMap) {
const containerStatus = (data.statusMap as Record<string, 'running' | 'stopped' | 'unknown'>)[script.container_id];
if (containerStatus) {
statusMap.set(script.id, containerStatus);
} else {
statusMap.set(script.id, 'unknown');
}
} else {
statusMap.set(script.id, 'unknown');
}
});
setContainerStatuses(statusMap);
} else {
console.error('Container status fetch failed:', data.error);
}
},
onError: (error) => {
console.error('Error fetching container statuses:', error);
}
});
// Cleanup orphaned scripts mutation
const cleanupMutation = api.installedScripts.cleanupOrphanedScripts.useMutation({
onSuccess: (data) => {
console.log('Cleanup success:', data);
void refetchScripts();
if (data.deletedCount > 0) {
@@ -141,33 +204,226 @@ export function InstalledScriptsTab() {
}
});
// Container control mutations
// Note: getStatusMutation removed - using direct API calls instead
const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
const controlContainerMutation = api.installedScripts.controlContainer.useMutation({
onSuccess: (data, variables) => {
setControllingScriptId(null);
if (data.success) {
// Update container status immediately in UI for instant feedback
const newStatus = variables.action === 'start' ? 'running' : 'stopped';
setContainerStatuses(prev => {
const newMap = new Map(prev);
// Find the script ID for this container using the container ID from the response
const currentScripts = scriptsRef.current;
const script = currentScripts.find(s => s.container_id === data.containerId);
if (script) {
newMap.set(script.id, newStatus);
}
return newMap;
});
// Show success modal
setErrorModal({
isOpen: true,
title: `Container ${variables.action === 'start' ? 'Started' : 'Stopped'}`,
message: data.message ?? `Container has been ${variables.action === 'start' ? 'started' : 'stopped'} successfully.`,
details: undefined,
type: 'success'
});
// Re-fetch status for all containers using bulk method (in background)
fetchContainerStatuses();
} else {
// Show error message from backend
const errorMessage = data.error ?? 'Unknown error occurred';
setErrorModal({
isOpen: true,
title: 'Container Control Failed',
message: 'Failed to control the container. Please check the error details below.',
details: errorMessage
});
}
},
onError: (error) => {
console.error('Container control error:', error);
setControllingScriptId(null);
// Show detailed error message
const errorMessage = error.message ?? 'Unknown error occurred';
setErrorModal({
isOpen: true,
title: 'Container Control Failed',
message: 'An unexpected error occurred while controlling the container.',
details: errorMessage
});
}
});
const destroyContainerMutation = api.installedScripts.destroyContainer.useMutation({
onSuccess: (data) => {
setControllingScriptId(null);
if (data.success) {
void refetchScripts();
setErrorModal({
isOpen: true,
title: 'Container Destroyed',
message: data.message ?? 'The container has been successfully destroyed and removed from the database.',
details: undefined,
type: 'success'
});
} else {
// Show error message from backend
const errorMessage = data.error ?? 'Unknown error occurred';
setErrorModal({
isOpen: true,
title: 'Container Destroy Failed',
message: 'Failed to destroy the container. Please check the error details below.',
details: errorMessage
});
}
},
onError: (error) => {
console.error('Container destroy error:', error);
setControllingScriptId(null);
// Show detailed error message
const errorMessage = error.message ?? 'Unknown error occurred';
setErrorModal({
isOpen: true,
title: 'Container Destroy Failed',
message: 'An unexpected error occurred while destroying the container.',
details: errorMessage
});
}
});
const scripts: InstalledScript[] = useMemo(() => (scriptsData?.scripts as InstalledScript[]) ?? [], [scriptsData?.scripts]);
const stats = statsData?.stats;
// Update ref when scripts change
useEffect(() => {
scriptsRef.current = scripts;
}, [scripts]);
// Function to fetch container statuses - simplified to just check all servers
const fetchContainerStatuses = useCallback(() => {
const currentScripts = scriptsRef.current;
// Get unique server IDs from scripts
const serverIds = [...new Set(currentScripts
.filter(script => script.server_id)
.map(script => script.server_id!))];
if (serverIds.length > 0) {
containerStatusMutation.mutate({ serverIds });
}
}, []); // Empty dependency array to prevent infinite loops
// Run cleanup when component mounts and scripts are loaded (only once)
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 scripts based on search and filters
const filteredScripts = scripts.filter((script: InstalledScript) => {
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(script.container_id?.includes(searchTerm) ?? false) ||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
const matchesServer = serverFilter === 'all' ||
(serverFilter === 'local' && !script.server_name) ||
(script.server_name === serverFilter);
return matchesSearch && matchesStatus && matchesServer;
});
// Note: Individual status fetching removed - using bulk fetchContainerStatuses instead
// Trigger status check when tab becomes active (component mounts)
useEffect(() => {
if (scripts.length > 0) {
fetchContainerStatuses();
}
}, [scripts.length]); // Only depend on scripts.length to prevent infinite loops
// Update scripts with container statuses
const scriptsWithStatus = scripts.map(script => ({
...script,
container_status: script.container_id ? containerStatuses.get(script.id) ?? 'unknown' : undefined
}));
// Filter and sort scripts
const filteredScripts = scriptsWithStatus
.filter((script: InstalledScript) => {
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(script.container_id?.includes(searchTerm) ?? false) ||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
const matchesServer = serverFilter === 'all' ||
(serverFilter === 'local' && !script.server_name) ||
(script.server_name === serverFilter);
return matchesSearch && matchesStatus && matchesServer;
})
.sort((a: InstalledScript, b: InstalledScript) => {
// Default sorting: group by server, then by container ID
if (sortField === 'server_name') {
const aServer = a.server_name ?? 'Local';
const bServer = b.server_name ?? 'Local';
// First sort by server name
if (aServer !== bServer) {
return sortDirection === 'asc' ?
aServer.localeCompare(bServer) :
bServer.localeCompare(aServer);
}
// If same server, sort by container ID
const aContainerId = a.container_id ?? '';
const bContainerId = b.container_id ?? '';
if (aContainerId !== bContainerId) {
// Convert to numbers for proper numeric sorting
const aNum = parseInt(aContainerId) || 0;
const bNum = parseInt(bContainerId) || 0;
return sortDirection === 'asc' ? aNum - bNum : bNum - aNum;
}
return 0;
}
// For other sort fields, use the original logic
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 '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
const uniqueServers: string[] = [];
@@ -185,31 +441,86 @@ export function InstalledScriptsTab() {
}
};
const handleUpdateScript = (script: InstalledScript) => {
// Container control handlers
const handleStartStop = (script: InstalledScript, action: 'start' | 'stop') => {
if (!script.container_id) {
alert('No Container ID available for this script');
return;
}
if (confirm(`Are you sure you want to update ${script.script_name}?`)) {
// Get server info if it's SSH mode
let server = null;
if (script.server_id && script.server_user && script.server_password) {
server = {
id: script.server_id,
name: script.server_name,
ip: script.server_ip,
user: script.server_user,
password: script.server_password
};
setConfirmationModal({
isOpen: true,
variant: 'simple',
title: `${action === 'start' ? 'Start' : 'Stop'} Container`,
message: `Are you sure you want to ${action} container ${script.container_id} (${script.script_name})?`,
onConfirm: () => {
setControllingScriptId(script.id);
void controlContainerMutation.mutate({ id: script.id, action });
setConfirmationModal(null);
}
setUpdatingScript({
id: script.id,
containerId: script.container_id,
server: server
});
});
};
const handleDestroy = (script: InstalledScript) => {
if (!script.container_id) {
alert('No Container ID available for this script');
return;
}
setConfirmationModal({
isOpen: true,
variant: 'danger',
title: 'Destroy Container',
message: `This will permanently destroy the LXC container ${script.container_id} (${script.script_name}) and all its data. This action cannot be undone!`,
confirmText: script.container_id,
onConfirm: () => {
setControllingScriptId(script.id);
void destroyContainerMutation.mutate({ id: script.id });
setConfirmationModal(null);
}
});
};
const handleUpdateScript = (script: InstalledScript) => {
if (!script.container_id) {
setErrorModal({
isOpen: true,
title: 'Update Failed',
message: 'No Container ID available for this script',
details: 'This script does not have a valid container ID and cannot be updated.'
});
return;
}
// Show confirmation modal with type-to-confirm for update
setConfirmationModal({
isOpen: true,
title: 'Confirm Script Update',
message: `Are you sure you want to update "${script.script_name}"?\n\n⚠ WARNING: This will update the script and may affect the container. Consider backing up your data beforehand.`,
variant: 'danger',
confirmText: script.container_id,
confirmButtonText: 'Update Script',
onConfirm: () => {
// Get server info if it's SSH mode
let server = null;
if (script.server_id && script.server_user && script.server_password) {
server = {
id: script.server_id,
name: script.server_name,
ip: script.server_ip,
user: script.server_user,
password: script.server_password
};
}
setUpdatingScript({
id: script.id,
containerId: script.container_id!,
server: server
});
setConfirmationModal(null);
}
});
};
const handleCloseUpdateTerminal = () => {
@@ -231,7 +542,12 @@ export function InstalledScriptsTab() {
const handleSaveEdit = () => {
if (!editFormData.script_name.trim()) {
alert('Script name is required');
setErrorModal({
isOpen: true,
title: 'Validation Error',
message: 'Script name is required',
details: 'Please enter a valid script name before saving.'
});
return;
}
@@ -289,7 +605,6 @@ export function InstalledScriptsTab() {
}
setAutoDetectStatus({ type: null, message: '' });
console.log('Starting auto-detect for server ID:', autoDetectServerId);
autoDetectMutation.mutate({ serverId: Number(autoDetectServerId) });
};
@@ -298,6 +613,15 @@ export function InstalledScriptsTab() {
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) => {
return new Date(dateString).toLocaleString();
@@ -368,6 +692,14 @@ export function InstalledScriptsTab() {
>
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
</Button>
<Button
onClick={fetchContainerStatuses}
disabled={containerStatusMutation.isPending || scripts.length === 0}
variant="outline"
size="default"
>
{containerStatusMutation.isPending ? '🔄 Checking...' : '🔄 Refresh Container Status'}
</Button>
</div>
{/* Add Script Form */}
@@ -643,6 +975,10 @@ export function InstalledScriptsTab() {
onDelete={() => handleDeleteScript(Number(script.id))}
isUpdating={updateScriptMutation.isPending}
isDeleting={deleteScriptMutation.isPending}
containerStatus={containerStatuses.get(script.id) ?? 'unknown'}
onStartStop={(action) => handleStartStop(script, action)}
onDestroy={() => handleDestroy(script)}
isControlling={controllingScriptId === script.id}
/>
))}
</div>
@@ -652,20 +988,70 @@ export function InstalledScriptsTab() {
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Script Name
<th
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 className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Container ID
<th
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 className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Server
<th
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 className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Status
<th
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 className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Installation Date
<th
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 className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
@@ -674,7 +1060,11 @@ export function InstalledScriptsTab() {
</thead>
<tbody className="bg-card divide-y divide-gray-200">
{filteredScripts.map((script) => (
<tr key={script.id} className="hover:bg-accent">
<tr
key={script.id}
className="hover:bg-accent"
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="space-y-2">
@@ -705,15 +1095,41 @@ export function InstalledScriptsTab() {
/>
) : (
script.container_id ? (
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
<div className="flex items-center space-x-2">
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
{script.container_status && (
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
script.container_status === 'running' ? 'bg-green-500' :
script.container_status === 'stopped' ? 'bg-red-500' :
'bg-gray-400'
}`}></div>
<span className={`text-xs font-medium ${
script.container_status === 'running' ? 'text-green-700 dark:text-green-300' :
script.container_status === 'stopped' ? 'text-red-700 dark:text-red-300' :
'text-gray-500 dark:text-gray-400'
}`}>
{script.container_status === 'running' ? 'Running' :
script.container_status === 'stopped' ? 'Stopped' :
'Unknown'}
</span>
</div>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-muted-foreground">
{script.server_name ?? 'Local'}
<td className="px-6 py-4 whitespace-nowrap text-left">
<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>
</td>
<td className="px-6 py-4 whitespace-nowrap">
@@ -731,14 +1147,14 @@ export function InstalledScriptsTab() {
<Button
onClick={handleSaveEdit}
disabled={updateScriptMutation.isPending}
variant="default"
variant="save"
size="sm"
>
{updateScriptMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button
onClick={handleCancelEdit}
variant="outline"
variant="cancel"
size="sm"
>
Cancel
@@ -748,7 +1164,7 @@ export function InstalledScriptsTab() {
<>
<Button
onClick={() => handleEditScript(script)}
variant="default"
variant="edit"
size="sm"
>
Edit
@@ -756,20 +1172,45 @@ export function InstalledScriptsTab() {
{script.container_id && (
<Button
onClick={() => handleUpdateScript(script)}
variant="link"
variant="update"
size="sm"
disabled={containerStatuses.get(script.id) === 'stopped'}
>
Update
</Button>
)}
<Button
onClick={() => handleDeleteScript(Number(script.id))}
variant="destructive"
size="sm"
disabled={deleteScriptMutation.isPending}
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
{/* Container Control Buttons - only show for SSH scripts with container_id */}
{script.container_id && script.execution_mode === 'ssh' && (
<>
<Button
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'}
variant={(containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'destructive' : 'default'}
size="sm"
>
{controllingScriptId === script.id ? 'Working...' : (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'Stop' : 'Start'}
</Button>
<Button
onClick={() => handleDestroy(script)}
disabled={controllingScriptId === script.id}
variant="destructive"
size="sm"
>
{controllingScriptId === script.id ? 'Working...' : 'Destroy'}
</Button>
</>
)}
{/* Fallback to old Delete button for non-SSH scripts */}
{(!script.container_id || script.execution_mode !== 'ssh') && (
<Button
onClick={() => handleDeleteScript(Number(script.id))}
variant="delete"
size="sm"
disabled={deleteScriptMutation.isPending}
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
)}
</>
)}
</div>
@@ -782,6 +1223,31 @@ export function InstalledScriptsTab() {
</>
)}
</div>
{/* Confirmation Modal */}
{confirmationModal && (
<ConfirmationModal
isOpen={confirmationModal.isOpen}
onClose={() => setConfirmationModal(null)}
onConfirm={confirmationModal.onConfirm}
title={confirmationModal.title}
message={confirmationModal.message}
variant={confirmationModal.variant}
confirmText={confirmationModal.confirmText}
/>
)}
{/* Error/Success Modal */}
{errorModal && (
<ErrorModal
isOpen={errorModal.isOpen}
onClose={() => setErrorModal(null)}
title={errorModal.title}
message={errorModal.message}
details={errorModal.details}
type={errorModal.type ?? 'error'}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,202 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { X, ExternalLink, Calendar, Tag, Loader2 } from 'lucide-react';
interface ReleaseNotesModalProps {
isOpen: boolean;
onClose: () => void;
highlightVersion?: string;
}
interface Release {
tagName: string;
name: string;
publishedAt: string;
htmlUrl: string;
body: string;
}
// Helper functions for localStorage
const getLastSeenVersion = (): string | null => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('LAST_SEEN_RELEASE_VERSION');
};
const markVersionAsSeen = (version: string): void => {
if (typeof window === 'undefined') return;
localStorage.setItem('LAST_SEEN_RELEASE_VERSION', version);
};
export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: ReleaseNotesModalProps) {
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
const { data: releasesData, isLoading, error } = api.version.getAllReleases.useQuery(undefined, {
enabled: isOpen
});
const { data: versionData } = api.version.getCurrentVersion.useQuery(undefined, {
enabled: isOpen
});
// Get current version when modal opens
useEffect(() => {
if (isOpen && versionData?.success && versionData.version) {
setCurrentVersion(versionData.version);
}
}, [isOpen, versionData]);
// Mark version as seen when modal closes
const handleClose = () => {
if (currentVersion) {
markVersionAsSeen(currentVersion);
}
onClose();
};
if (!isOpen) return null;
const releases: Release[] = releasesData?.success ? releasesData.releases ?? [] : [];
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-4xl w-full max-h-[90vh] flex flex-col border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<Tag className="h-6 w-6 text-blue-600" />
<h2 className="text-2xl font-bold text-card-foreground">Release Notes</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<div className="flex items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span className="text-muted-foreground">Loading release notes...</span>
</div>
</div>
) : error || !releasesData?.success ? (
<div className="flex items-center justify-center p-8">
<div className="text-center">
<p className="text-destructive mb-2">Failed to load release notes</p>
<p className="text-sm text-muted-foreground">
{releasesData?.error ?? 'Please try again later'}
</p>
</div>
</div>
) : releases.length === 0 ? (
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">No releases found</p>
</div>
) : (
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{releases.map((release, index) => {
const isHighlighted = highlightVersion && release.tagName.replace('v', '') === highlightVersion;
const isLatest = index === 0;
return (
<div
key={release.tagName}
className={`border rounded-lg p-6 ${
isHighlighted
? 'border-blue-500 bg-blue-50/10 dark:bg-blue-950/10'
: 'border-border bg-card'
} ${isLatest ? 'ring-2 ring-primary/20' : ''}`}
>
{/* Release Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-card-foreground">
{release.name || release.tagName}
</h3>
{isLatest && (
<Badge variant="default" className="text-xs">
Latest
</Badge>
)}
{isHighlighted && (
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
New
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Tag className="h-4 w-4" />
<span>{release.tagName}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>
{new Date(release.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</span>
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
asChild
className="h-8 w-8 p-0"
>
<a
href={release.htmlUrl}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
{/* Release Body */}
{release.body && (
<div className="prose prose-sm max-w-none dark:prose-invert">
<div className="whitespace-pre-wrap text-sm text-card-foreground leading-relaxed">
{release.body}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-border bg-muted/30">
<div className="text-sm text-muted-foreground">
{currentVersion && (
<span>Current version: <span className="font-medium text-card-foreground">v{currentVersion}</span></span>
)}
</div>
<Button onClick={handleClose} variant="default">
Close
</Button>
</div>
</div>
</div>
);
}
// Export helper functions for use in other components
export { getLastSeenVersion, markVersionAsSeen };

View File

@@ -3,6 +3,7 @@
import { useState } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { ContextualHelpIcon } from './ContextualHelpIcon';
export function ResyncButton() {
const [isResyncing, setIsResyncing] = useState(false);
@@ -44,27 +45,30 @@ export function ResyncButton() {
Sync scripts with ProxmoxVE repo
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<Button
onClick={handleResync}
disabled={isResyncing}
variant="outline"
size="default"
className="inline-flex items-center"
>
{isResyncing ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
<span>Syncing...</span>
</>
) : (
<>
<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" />
</svg>
<span>Sync Json Files</span>
</>
)}
</Button>
<div className="flex items-center gap-2">
<Button
onClick={handleResync}
disabled={isResyncing}
variant="outline"
size="default"
className="inline-flex items-center"
>
{isResyncing ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
<span>Syncing...</span>
</>
) : (
<>
<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" />
</svg>
<span>Sync Json Files</span>
</>
)}
</Button>
<ContextualHelpIcon section="sync-button" tooltip="Help with Sync Button" />
</div>
{lastSync && (
<div className="text-xs text-muted-foreground">

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

@@ -8,20 +8,49 @@ import { TypeBadge, UpdateableBadge } from './Badge';
interface ScriptCardProps {
script: ScriptCard;
onClick: (script: ScriptCard) => void;
isSelected?: boolean;
onToggleSelect?: (slug: string) => void;
}
export function ScriptCard({ script, onClick }: ScriptCardProps) {
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
setImageError(true);
};
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (onToggleSelect && script.slug) {
onToggleSelect(script.slug);
}
};
return (
<div
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"
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 relative"
onClick={() => onClick(script)}
>
{/* Checkbox in top-left corner */}
{onToggleSelect && (
<div className="absolute top-2 left-2 z-10">
<div
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
}`}
onClick={handleCheckboxClick}
>
{isSelected && (
<svg className="w-3 h-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>
)}
</div>
</div>
)}
<div className="p-6 flex-1 flex flex-col">
{/* Header with logo and name */}
<div className="flex items-start space-x-4 mb-4">
@@ -49,7 +78,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
</h3>
<div className="mt-2 space-y-2">
{/* Type and Updateable status on first row */}
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 flex-wrap gap-1">
<TypeBadge type={script.type ?? 'unknown'} />
{script.updateable && <UpdateableBadge />}
</div>

View File

@@ -0,0 +1,193 @@
'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;
isSelected?: boolean;
onToggleSelect?: (slug: string) => void;
}
export function ScriptCardList({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardListProps) {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
setImageError(true);
};
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (onToggleSelect && script.slug) {
onToggleSelect(script.slug);
}
};
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 relative"
onClick={() => onClick(script)}
>
{/* Checkbox */}
{onToggleSelect && (
<div className="absolute top-4 left-4 z-10">
<div
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
}`}
onClick={handleCheckboxClick}
>
{isSelected && (
<svg className="w-3 h-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>
)}
</div>
</div>
)}
<div className={`p-6 ${onToggleSelect ? 'pl-12' : ''}`}>
<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 flex-wrap gap-2">
<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

@@ -2,6 +2,7 @@
import { Button } from './ui/button';
import { StatusBadge } from './Badge';
import { getContrastColor } from '../../lib/colorUtils';
interface InstalledScript {
id: number;
@@ -13,9 +14,12 @@ interface InstalledScript {
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;
execution_mode: 'local' | 'ssh';
container_status?: 'running' | 'stopped' | 'unknown';
}
interface ScriptInstallationCardProps {
@@ -30,6 +34,11 @@ interface ScriptInstallationCardProps {
onDelete: () => void;
isUpdating: boolean;
isDeleting: boolean;
// New container control props
containerStatus?: 'running' | 'stopped' | 'unknown';
onStartStop: (action: 'start' | 'stop') => void;
onDestroy: () => void;
isControlling: boolean;
}
export function ScriptInstallationCard({
@@ -43,14 +52,21 @@ export function ScriptInstallationCard({
onUpdate,
onDelete,
isUpdating,
isDeleting
isDeleting,
containerStatus,
onStartStop,
onDestroy,
isControlling
}: 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">
<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">
@@ -94,7 +110,29 @@ export function ScriptInstallationCard({
/>
) : (
<div className="text-sm font-mono text-foreground break-all">
{script.container_id ?? '-'}
{script.container_id ? (
<div className="flex items-center space-x-2">
<span>{script.container_id}</span>
{script.container_status && (
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
script.container_status === 'running' ? 'bg-green-500' :
script.container_status === 'stopped' ? 'bg-red-500' :
'bg-gray-400'
}`}></div>
<span className={`text-xs font-medium ${
script.container_status === 'running' ? 'text-green-700 dark:text-green-300' :
script.container_status === 'stopped' ? 'text-red-700 dark:text-red-300' :
'text-gray-500 dark:text-gray-400'
}`}>
{script.container_status === 'running' ? 'Running' :
script.container_status === 'stopped' ? 'Stopped' :
'Unknown'}
</span>
</div>
)}
</div>
) : '-'}
</div>
)}
</div>
@@ -102,9 +140,15 @@ export function ScriptInstallationCard({
{/* Server */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
<div className="text-sm text-muted-foreground">
{script.server_name ?? 'Local'}
</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 */}
@@ -123,7 +167,7 @@ export function ScriptInstallationCard({
<Button
onClick={onSave}
disabled={isUpdating}
variant="default"
variant="save"
size="sm"
className="flex-1 min-w-0"
>
@@ -131,7 +175,7 @@ export function ScriptInstallationCard({
</Button>
<Button
onClick={onCancel}
variant="outline"
variant="cancel"
size="sm"
className="flex-1 min-w-0"
>
@@ -142,7 +186,7 @@ export function ScriptInstallationCard({
<>
<Button
onClick={onEdit}
variant="default"
variant="edit"
size="sm"
className="flex-1 min-w-0"
>
@@ -151,22 +195,49 @@ export function ScriptInstallationCard({
{script.container_id && (
<Button
onClick={onUpdate}
variant="link"
variant="update"
size="sm"
className="flex-1 min-w-0"
disabled={containerStatus === 'stopped'}
>
Update
</Button>
)}
<Button
onClick={onDelete}
variant="destructive"
size="sm"
disabled={isDeleting}
className="flex-1 min-w-0"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
{/* Container Control Buttons - only show for SSH scripts with container_id */}
{script.container_id && script.execution_mode === 'ssh' && (
<>
<Button
onClick={() => onStartStop(containerStatus === 'running' ? 'stop' : 'start')}
disabled={isControlling || containerStatus === 'unknown'}
variant={containerStatus === 'running' ? 'destructive' : 'default'}
size="sm"
className="flex-1 min-w-0"
>
{isControlling ? 'Working...' : containerStatus === 'running' ? 'Stop' : 'Start'}
</Button>
<Button
onClick={onDestroy}
disabled={isControlling}
variant="destructive"
size="sm"
className="flex-1 min-w-0"
>
{isControlling ? 'Working...' : 'Destroy'}
</Button>
</>
)}
{/* Fallback to old Delete button for non-SSH scripts */}
{(!script.container_id || script.execution_mode !== 'ssh') && (
<Button
onClick={onDelete}
variant="delete"
size="sm"
disabled={isDeleting}
className="flex-1 min-w-0"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
)}
</>
)}
</div>

View File

@@ -3,9 +3,11 @@
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';
@@ -19,6 +21,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [selectedSlugs, setSelectedSlugs] = useState<Set<string>>(new Set());
const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number; currentScript: string; failed: Array<{ slug: string; error: string }> } | null>(null);
const [filters, setFilters] = useState<FilterState>({
searchQuery: '',
showUpdatable: null,
@@ -31,13 +36,16 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
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: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
{ slug: selectedSlug ?? '' },
{ enabled: !!selectedSlug }
);
// Load SAVE_FILTER setting and saved filters on component mount
// Individual script download mutation
const loadSingleScriptMutation = api.scripts.loadScript.useMutation();
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
useEffect(() => {
const loadSettings = async () => {
try {
@@ -60,6 +68,16 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
}
}
}
// 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 {
@@ -93,6 +111,29 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
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 [];
@@ -292,6 +333,167 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
setSearchQuery(newFilters.searchQuery);
};
// Selection management functions
const toggleScriptSelection = (slug: string) => {
setSelectedSlugs(prev => {
const newSet = new Set(prev);
if (newSet.has(slug)) {
newSet.delete(slug);
} else {
newSet.add(slug);
}
return newSet;
});
};
const selectAllVisible = () => {
const visibleSlugs = new Set(filteredScripts.map(script => script.slug).filter(Boolean));
setSelectedSlugs(visibleSlugs);
};
const clearSelection = () => {
setSelectedSlugs(new Set());
};
const getFriendlyErrorMessage = (error: string, slug: string): string => {
const errorLower = error.toLowerCase();
// Exact matches first (most specific)
if (error === 'Script not found') {
return `Script "${slug}" is not available for download. It may not exist in the repository or has been removed.`;
}
if (error === 'Failed to load script') {
return `Unable to download script "${slug}". Please check your internet connection and try again.`;
}
// Network/Connection errors
if (errorLower.includes('network') || errorLower.includes('connection') || errorLower.includes('timeout')) {
return 'Network connection failed. Please check your internet connection and try again.';
}
// GitHub API errors
if (errorLower.includes('not found') || errorLower.includes('404')) {
return `Script "${slug}" not found in the repository. It may have been removed or renamed.`;
}
if (errorLower.includes('rate limit') || errorLower.includes('403')) {
return 'GitHub API rate limit exceeded. Please wait a few minutes and try again.';
}
if (errorLower.includes('unauthorized') || errorLower.includes('401')) {
return 'Access denied. The script may be private or require authentication.';
}
// File system errors
if (errorLower.includes('permission') || errorLower.includes('eacces')) {
return 'Permission denied. Please check file system permissions.';
}
if (errorLower.includes('no space') || errorLower.includes('enospc')) {
return 'Insufficient disk space. Please free up some space and try again.';
}
if (errorLower.includes('read-only') || errorLower.includes('erofs')) {
return 'Cannot write to read-only file system. Please check your installation directory.';
}
// Script-specific errors
if (errorLower.includes('script not found')) {
return `Script "${slug}" not found in the local scripts directory.`;
}
if (errorLower.includes('invalid script') || errorLower.includes('malformed')) {
return `Script "${slug}" appears to be corrupted or invalid.`;
}
if (errorLower.includes('already exists') || errorLower.includes('file exists')) {
return `Script "${slug}" already exists locally. Skipping download.`;
}
// Generic fallbacks
if (errorLower.includes('timeout')) {
return 'Download timed out. The script may be too large or the connection is slow.';
}
if (errorLower.includes('server error') || errorLower.includes('500')) {
return 'Server error occurred. Please try again later.';
}
// If we can't categorize it, return a more helpful generic message
if (error.length > 100) {
return `Download failed: ${error.substring(0, 100)}...`;
}
return `Download failed: ${error}`;
};
const downloadScriptsIndividually = async (slugsToDownload: string[]) => {
setDownloadProgress({ current: 0, total: slugsToDownload.length, currentScript: '', failed: [] });
const successful: Array<{ slug: string; files: string[] }> = [];
const failed: Array<{ slug: string; error: string }> = [];
for (let i = 0; i < slugsToDownload.length; i++) {
const slug = slugsToDownload[i];
// Update progress with current script
setDownloadProgress(prev => prev ? {
...prev,
current: i,
currentScript: slug ?? ''
} : null);
try {
// Download individual script
const result = await loadSingleScriptMutation.mutateAsync({ slug: slug ?? '' });
if (result.success) {
successful.push({ slug: slug ?? '', files: result.files ?? [] });
} else {
const error = 'error' in result ? result.error : 'Failed to load script';
const userFriendlyError = getFriendlyErrorMessage(error, slug ?? '');
failed.push({ slug: slug ?? '', error: userFriendlyError });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load script';
const userFriendlyError = getFriendlyErrorMessage(errorMessage, slug ?? '');
failed.push({
slug: slug ?? '',
error: userFriendlyError
});
}
}
// Final progress update
setDownloadProgress(prev => prev ? {
...prev,
current: slugsToDownload.length,
failed
} : null);
// Clear selection and refetch to update card download status
setSelectedSlugs(new Set());
void refetch();
// Keep progress bar visible until user navigates away or manually dismisses
// Progress bar will stay visible to show final results
};
const handleBatchDownload = () => {
const slugsToDownload = Array.from(selectedSlugs);
if (slugsToDownload.length > 0) {
void downloadScriptsIndividually(slugsToDownload);
}
};
const handleDownloadAllFiltered = () => {
const slugsToDownload = filteredScripts.map(script => script.slug).filter(Boolean);
if (slugsToDownload.length > 0) {
void downloadScriptsIndividually(slugsToDownload);
}
};
// Handle category selection with auto-scroll
const handleCategorySelect = (category: string | null) => {
setSelectedCategory(category);
@@ -312,6 +514,18 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
}
}, [selectedCategory]);
// Clear selection when switching between card/list views
useEffect(() => {
setSelectedSlugs(new Set());
}, [viewMode]);
// Clear progress bar when component unmounts
useEffect(() => {
return () => {
setDownloadProgress(null);
};
}, []);
const handleCardClick = (scriptCard: { slug: string }) => {
// All scripts are GitHub scripts, open modal
@@ -399,6 +613,160 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
isLoadingFilters={isLoadingFilters}
/>
{/* View Toggle */}
<ViewToggle
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2 mb-4">
{selectedSlugs.size > 0 ? (
<Button
onClick={handleBatchDownload}
disabled={loadSingleScriptMutation.isPending}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{loadSingleScriptMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Downloading...
</>
) : (
`Download Selected (${selectedSlugs.size})`
)}
</Button>
) : (
<Button
onClick={handleDownloadAllFiltered}
disabled={filteredScripts.length === 0 || loadSingleScriptMutation.isPending}
variant="outline"
>
{loadSingleScriptMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div>
Downloading...
</>
) : (
`Download All Filtered (${filteredScripts.length})`
)}
</Button>
)}
{selectedSlugs.size > 0 && (
<Button
onClick={clearSelection}
variant="ghost"
size="sm"
>
Clear Selection
</Button>
)}
{filteredScripts.length > 0 && (
<Button
onClick={selectAllVisible}
variant="ghost"
size="sm"
>
Select All Visible
</Button>
)}
</div>
{/* Progress Bar */}
{downloadProgress && (
<div className="mb-4 p-4 bg-card border border-border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">
{downloadProgress.current >= downloadProgress.total ? 'Download completed' : 'Downloading scripts'}... {downloadProgress.current} of {downloadProgress.total}
</span>
{downloadProgress.currentScript && downloadProgress.current < downloadProgress.total && (
<span className="text-xs text-muted-foreground">
Currently downloading: {downloadProgress.currentScript}
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{Math.round((downloadProgress.current / downloadProgress.total) * 100)}%
</span>
{downloadProgress.current >= downloadProgress.total && (
<button
onClick={() => setDownloadProgress(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
title="Dismiss progress bar"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
</div>
{/* Progress Bar */}
<div className="w-full bg-muted rounded-full h-2 mb-2">
<div
className={`h-2 rounded-full transition-all duration-300 ease-out ${
downloadProgress.failed.length > 0 ? 'bg-yellow-500' : 'bg-primary'
}`}
style={{ width: `${(downloadProgress.current / downloadProgress.total) * 100}%` }}
/>
</div>
{/* Progress Visualization */}
<div className="flex items-center text-xs text-muted-foreground mb-2">
<span className="mr-2">Progress:</span>
<div className="flex flex-wrap gap-1">
{Array.from({ length: downloadProgress.total }, (_, i) => {
const isCompleted = i < downloadProgress.current;
const isCurrent = i === downloadProgress.current;
const isFailed = downloadProgress.failed.some(f => f.slug === downloadProgress.currentScript);
return (
<span
key={i}
className={`px-1 py-0.5 rounded text-xs ${
isCompleted
? isFailed ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: isCurrent
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 animate-pulse'
: 'bg-muted text-muted-foreground'
}`}
>
{isCompleted ? (isFailed ? '✗' : '✓') : isCurrent ? '⟳' : '○'}
</span>
);
})}
</div>
</div>
{/* Failed Scripts Details */}
{downloadProgress.failed.length > 0 && (
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center mb-2">
<svg className="w-4 h-4 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<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>
<span className="text-sm font-medium text-red-800 dark:text-red-200">
Failed Downloads ({downloadProgress.failed.length})
</span>
</div>
<div className="space-y-1">
{downloadProgress.failed.map((failed, index) => (
<div key={index} className="text-xs text-red-700 dark:text-red-300">
<span className="font-medium">{failed.slug}:</span> {failed.error}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
<div className="hidden mb-8">
<div className="relative max-w-md mx-auto">
@@ -474,25 +842,51 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
</div>
</div>
) : (
<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>
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}
isSelected={selectedSlugs.has(script.slug ?? '')}
onToggleSelect={toggleScriptSelection}
/>
);
})}
</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}
isSelected={selectedSlugs.has(script.slug ?? '')}
onToggleSelect={toggleScriptSelection}
/>
);
})}
</div>
)
)}
<ScriptDetailModal

View File

@@ -1,8 +1,9 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import type { CreateServerData } from '../../types/server';
import { Button } from './ui/button';
import { SSHKeyInput } from './SSHKeyInput';
interface ServerFormProps {
onSubmit: (data: CreateServerData) => void;
@@ -18,13 +19,35 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
ip: '',
user: '',
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 newErrors: Partial<CreateServerData> = {};
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
if (!formData.name.trim()) {
newErrors.name = 'Server name is required';
@@ -44,12 +67,36 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
newErrors.user = 'Username is required';
}
if (!formData.password.trim()) {
newErrors.password = 'Password is required';
// Validate SSH port
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);
return Object.keys(newErrors).length === 0;
return Object.keys(newErrors).length === 0 && !sshKeyError;
};
const handleSubmit = (e: React.FormEvent) => {
@@ -57,13 +104,23 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
if (validateForm()) {
onSubmit(formData);
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) => (
e: React.ChangeEvent<HTMLInputElement>
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user starts typing
@@ -72,8 +129,15 @@ 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 (
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
@@ -126,14 +190,72 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
</div>
<div>
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
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 *
Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
</label>
<input
type="password"
id="password"
value={formData.password}
value={formData.password ?? ''}
onChange={handleChange('password')}
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-destructive' : 'border-border'
@@ -142,7 +264,42 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
/>
{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>
<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 && (

View File

@@ -85,7 +85,11 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
return (
<div className="space-y-4">
{servers.map((server) => (
<div key={server.id} className="bg-card border border-border 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 ? (
<div>
<h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4>
@@ -95,6 +99,11 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
ip: server.ip,
user: server.user,
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}
isEditing={true}

View File

@@ -5,6 +5,7 @@ import type { Server, CreateServerData } from '../../types/server';
import { ServerForm } from './ServerForm';
import { ServerList } from './ServerList';
import { Button } from './ui/button';
import { ContextualHelpIcon } from './ContextualHelpIcon';
interface SettingsModalProps {
isOpen: boolean;
@@ -31,7 +32,11 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
throw new Error('Failed to fetch servers');
}
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) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
@@ -102,7 +107,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<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>
<div className="flex items-center gap-2">
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
<ContextualHelpIcon section="server-settings" tooltip="Help with Server Settings" />
</div>
<Button
onClick={onClose}
variant="ghost"

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

@@ -27,14 +27,15 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const [mobileInput, setMobileInput] = useState('');
const [showMobileInput, setShowMobileInput] = useState(false);
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
const [inWhiptailSession, setInWhiptailSession] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [isStopped, setIsStopped] = useState(false);
const [isTerminalReady, setIsTerminalReady] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<any>(null);
const fitAddonRef = useRef<any>(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 hasConnectedRef = useRef<boolean>(false);
@@ -53,22 +54,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
break;
case 'output':
// Write directly to terminal - xterm.js handles ANSI codes natively
// Detect whiptail sessions and clear immediately
if (message.data.includes('whiptail') || message.data.includes('dialog') || message.data.includes('Choose an option')) {
setInWhiptailSession(true);
// Clear terminal immediately when whiptail starts
xtermRef.current.clear();
xtermRef.current.write('\x1b[2J\x1b[H');
}
// Check for screen clearing sequences and handle them properly
if (message.data.includes('\x1b[2J') || message.data.includes('\x1b[H\x1b[2J')) {
// This is a clear screen sequence, ensure it's processed correctly
xtermRef.current.write(message.data);
} else {
// Let xterm handle all ANSI sequences naturally
xtermRef.current.write(message.data);
}
xtermRef.current.write(message.data);
break;
case 'error':
// Check if this looks like ANSI terminal output (contains escape codes)
@@ -87,8 +73,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
}
break;
case 'end':
// Reset whiptail session
setInWhiptailSession(false);
setIsRunning(false);
// Check if this is an LXC creation script
const isLxcCreation = scriptPath.includes('ct/') ||
@@ -107,10 +92,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
} else {
xtermRef.current.writeln(`${prefix}${message.data}`);
}
setIsRunning(false);
break;
}
}, [scriptPath, containerId, scriptName, inWhiptailSession]);
}, [scriptPath, containerId, scriptName]);
// Ensure we're on the client side
useEffect(() => {
@@ -198,6 +182,20 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
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
@@ -231,18 +229,10 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// Store references
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Mark terminal as ready
setIsTerminalReady(true);
// Handle terminal input
terminal.onData((data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
action: 'input',
executionId,
input: data
};
wsRef.current.send(JSON.stringify(message));
}
});
return () => {
terminal.dispose();
@@ -254,18 +244,51 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
void initTerminal();
}, 50);
return () => {
clearTimeout(timeoutId);
if (terminalElement && (terminalElement as any).resizeHandler) {
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
}
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
return () => {
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) {
xtermRef.current.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
setIsTerminalReady(false);
}
};
}, [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));
}
};
}, [executionId, isClient, inWhiptailSession, isMobile]);
// 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(() => {
// Prevent multiple connections in React Strict Mode
@@ -298,10 +321,14 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// 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 = {
action: 'start',
scriptPath,
executionId,
executionId: newExecutionId,
mode,
server,
isUpdate,
@@ -314,7 +341,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data as string) as TerminalMessage;
console.log('WebSocket message received:', message);
handleMessage(message);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
@@ -346,15 +372,19 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
wsRef.current.close();
}
};
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]);
}, [scriptPath, mode, server, isUpdate, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
const startScript = () => {
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({
action: 'start',
scriptPath,
executionId,
executionId: newExecutionId,
mode,
server,
isUpdate,

View File

@@ -3,10 +3,15 @@
import { api } from "~/trpc/react";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
import { useState, useEffect, useRef } from "react";
interface VersionDisplayProps {
onOpenReleaseNotes?: () => void;
}
// Loading overlay component with log streaming
function LoadingOverlay({
isNetworkError = false,
@@ -72,7 +77,7 @@ function LoadingOverlay({
);
}
export function VersionDisplay() {
export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) {
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
const [isUpdating, setIsUpdating] = useState(false);
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
@@ -137,7 +142,6 @@ export function VersionDisplay() {
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...']);
@@ -230,31 +234,16 @@ export function VersionDisplay() {
{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">
<Badge
variant={isUpToDate ? "default" : "secondary"}
className={`text-xs ${onOpenReleaseNotes ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
onClick={onOpenReleaseNotes}
>
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}
@@ -278,6 +267,11 @@ export function VersionDisplay() {
)}
</Button>
<ContextualHelpIcon section="update-system" tooltip="Help with updates" />
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">Release Notes:</span>
<a
href={releaseInfo.htmlUrl}
target="_blank"

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

@@ -34,6 +34,12 @@ const buttonVariants = cva(
"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",
// Dark theme action button variants
edit: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
update: "bg-cyan-900/20 hover:bg-cyan-900/30 border border-cyan-700/50 text-cyan-300 hover:text-cyan-200 hover:border-cyan-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
delete: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
save: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
cancel: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
},
size: {
default: "h-10 px-4 py-2",

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 { 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
if (!name || !ip || !user || !password) {
if (!name || !ip || !user) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ error: 'Missing required fields: name, ip, and user are required' },
{ 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();
// 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(
{

View File

@@ -20,18 +20,67 @@ export async function GET() {
export async function POST(request: NextRequest) {
try {
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
if (!name || !ip || !user || !password) {
if (!name || !ip || !user) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ error: 'Missing required fields: name, ip, and user are required' },
{ 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 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(
{

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

@@ -81,7 +81,14 @@ export async function GET() {
}
try {
const filters = JSON.parse(filtersMatch[1]!);
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'];

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

@@ -1,14 +1,15 @@
import "~/styles/globals.css";
import { type Metadata } from "next";
import { type Metadata, type Viewport } from "next";
import { Geist } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react";
import { AuthProvider } from "./_components/AuthProvider";
import { AuthGuard } from "./_components/AuthGuard";
export const metadata: Metadata = {
title: "PVE Scripts local",
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
icons: [
{ rel: "icon", url: "/favicon.png", type: "image/png" },
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
@@ -16,6 +17,12 @@ export const metadata: Metadata = {
],
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
const geist = Geist({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
@@ -40,7 +47,13 @@ export default function RootLayout({
className="bg-background text-foreground transition-colors"
suppressHydrationWarning={true}
>
<TRPCReactProvider>{children}</TRPCReactProvider>
<TRPCReactProvider>
<AuthProvider>
<AuthGuard>
{children}
</AuthGuard>
</AuthProvider>
</TRPCReactProvider>
</body>
</html>
);

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { ScriptsGrid } from './_components/ScriptsGrid';
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
@@ -9,15 +9,75 @@ import { ResyncButton } from './_components/ResyncButton';
import { Terminal } from './_components/Terminal';
import { ServerSettingsButton } from './_components/ServerSettingsButton';
import { SettingsButton } from './_components/SettingsButton';
import { HelpButton } from './_components/HelpButton';
import { VersionDisplay } from './_components/VersionDisplay';
import { Button } from './_components/ui/button';
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
import { Footer } from './_components/Footer';
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
import { api } from '~/trpc/react';
export default function Home() {
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
const terminalRef = useRef<HTMLDivElement>(null);
// Fetch data for script counts
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: versionData } = api.version.getCurrentVersion.useQuery();
// Auto-show release notes modal after update
useEffect(() => {
if (versionData?.success && versionData.version) {
const currentVersion = versionData.version;
const lastSeenVersion = getLastSeenVersion();
// If we have a current version and either no last seen version or versions don't match
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
setHighlightVersion(currentVersion);
setReleaseNotesOpen(true);
}
}
}, [versionData]);
const handleOpenReleaseNotes = () => {
setHighlightVersion(undefined);
setReleaseNotesOpen(true);
};
const handleCloseReleaseNotes = () => {
setReleaseNotesOpen(false);
setHighlightVersion(undefined);
};
// 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
@@ -54,7 +114,7 @@ export default function Home() {
Manage and execute Proxmox helper scripts locally with live output streaming
</p>
<div className="flex justify-center px-2">
<VersionDisplay />
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
</div>
</div>
@@ -64,6 +124,7 @@ export default function Home() {
<ServerSettingsButton />
<SettingsButton />
<ResyncButton />
<HelpButton />
</div>
</div>
@@ -71,45 +132,63 @@ export default function Home() {
<div className="mb-6 sm:mb-8">
<div className="border-b border-border">
<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
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'scripts'
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
}`}>
<Package className="h-4 w-4" />
<span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">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>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'installed'
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
}`}>
<FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">Installed</span>
</Button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'scripts'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<Package className="h-4 w-4" />
<span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">Available</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.available}
</span>
</Button>
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
</div>
<div className="flex items-center gap-2">
<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 rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<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>
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'installed'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">Installed Scripts</span>
<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>
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
</div>
</nav>
</div>
</div>
@@ -141,6 +220,16 @@ export default function Home() {
<InstalledScriptsTab />
)}
</div>
{/* Footer */}
<Footer onOpenReleaseNotes={handleOpenReleaseNotes} />
{/* Release Notes Modal */}
<ReleaseNotesModal
isOpen={releaseNotesOpen}
onClose={handleCloseReleaseNotes}
highlightVersion={highlightVersion}
/>
</main>
);
}

View File

@@ -25,6 +25,14 @@ export const env = createEnv({
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(),
},
/**
@@ -56,6 +64,14 @@ export const env = createEnv({
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,
},
/**

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
}

View File

@@ -1,6 +1,8 @@
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { getDatabase } from "~/server/database";
// Removed unused imports
export const installedScriptsRouter = createTRPCRouter({
// Get all installed scripts
@@ -209,12 +211,8 @@ export const installedScriptsRouter = createTRPCRouter({
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);
@@ -228,7 +226,6 @@ export const installedScriptsRouter = createTRPCRouter({
};
}
console.log('Found server:', (server as any).name, 'at', (server as any).ip);
// Import SSH services
const { default: SSHService } = await import('~/server/ssh-service');
@@ -237,10 +234,8 @@ export const installedScriptsRouter = createTRPCRouter({
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 {
@@ -250,14 +245,11 @@ export const installedScriptsRouter = createTRPCRouter({
};
}
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 = '';
@@ -268,15 +260,12 @@ export const installedScriptsRouter = createTRPCRouter({
server as any,
command,
(data: string) => {
console.log('Command output chunk:', data);
commandOutput += data;
},
(error: string) => {
console.error('Command error:', error);
},
(exitCode: number) => {
console.log('Command exit code:', exitCode);
console.log('Full command output:', commandOutput);
(_exitCode: number) => {
// Parse the complete output to get config file paths that contain community-script tag
const configFiles = commandOutput.split('\n')
@@ -284,8 +273,6 @@ export const installedScriptsRouter = createTRPCRouter({
.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) => {
@@ -293,7 +280,6 @@ export const installedScriptsRouter = createTRPCRouter({
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`;
@@ -306,8 +292,6 @@ export const installedScriptsRouter = createTRPCRouter({
server as any,
readCommand,
(configData: string) => {
console.log('Config data for', containerId, ':', configData.substring(0, 300) + '...');
// Parse config file for hostname
const lines = configData.split('\n');
let hostname = '';
@@ -316,7 +300,6 @@ export const installedScriptsRouter = createTRPCRouter({
const trimmedLine = line.trim();
if (trimmedLine.startsWith('hostname:')) {
hostname = trimmedLine.substring(9).trim();
console.log('Found hostname for', containerId, ':', hostname);
break;
}
}
@@ -326,13 +309,11 @@ export const installedScriptsRouter = createTRPCRouter({
containerId,
hostname,
configPath,
serverId: (server as any).id,
serverId: Number((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);
}
},
@@ -354,7 +335,6 @@ export const installedScriptsRouter = createTRPCRouter({
// 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);
@@ -364,11 +344,9 @@ export const installedScriptsRouter = createTRPCRouter({
);
});
console.log('Detected containers:', detectedContainers.length);
// Get existing scripts to check for duplicates
const existingScripts = db.getAllInstalledScripts();
console.log('Existing scripts in database:', existingScripts.length);
// Create installed script records for detected containers (skip duplicates)
const createdScripts = [];
@@ -383,7 +361,6 @@ export const installedScriptsRouter = createTRPCRouter({
);
if (duplicate) {
console.log(`Skipping duplicate: ${container.hostname} (${container.containerId}) already exists`);
skippedScripts.push({
containerId: container.containerId,
hostname: container.hostname,
@@ -392,7 +369,6 @@ export const installedScriptsRouter = createTRPCRouter({
continue;
}
console.log('Creating script record for:', container.hostname, container.containerId);
const result = db.createInstalledScript({
script_name: container.hostname,
script_path: `detected/${container.hostname}`,
@@ -409,7 +385,6 @@ export const installedScriptsRouter = createTRPCRouter({
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);
}
@@ -439,15 +414,11 @@ export const installedScriptsRouter = createTRPCRouter({
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 {
@@ -471,26 +442,22 @@ export const installedScriptsRouter = createTRPCRouter({
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;
}
@@ -504,7 +471,6 @@ export const installedScriptsRouter = createTRPCRouter({
server as any,
checkCommand,
(data: string) => {
console.log(`Container check result for ${scriptData.script_name}:`, data.trim());
resolve(data.trim() === 'exists');
},
(error: string) => {
@@ -518,11 +484,9 @@ export const installedScriptsRouter = createTRPCRouter({
});
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) {
@@ -530,7 +494,6 @@ export const installedScriptsRouter = createTRPCRouter({
}
}
console.log('Cleanup completed. Deleted scripts:', deletedScripts);
return {
success: true,
@@ -547,5 +510,460 @@ export const installedScriptsRouter = createTRPCRouter({
deletedScripts: []
};
}
}),
// Get container running statuses
getContainerStatuses: publicProcedure
.input(z.object({
serverIds: z.array(z.number()).optional() // Optional: check specific servers, or all if empty
}))
.mutation(async ({ input }) => {
try {
const db = getDatabase();
const allServers = db.getAllServers();
const statusMap: Record<string, 'running' | 'stopped' | 'unknown'> = {};
// 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();
// Determine which servers to check
const serversToCheck = input.serverIds
? allServers.filter((s: any) => input.serverIds!.includes(Number(s.id)))
: allServers;
// Check status for each server
for (const server of serversToCheck) {
try {
// 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) {
continue;
}
// Run pct list to get all container statuses at once
const listCommand = 'pct list';
let listOutput = '';
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
listCommand,
(data: string) => {
listOutput += data;
},
(error: string) => {
console.error(`pct list error on server ${(server as any).name}:`, error);
reject(new Error(error));
},
(_exitCode: number) => {
resolve();
}
);
});
// Parse pct list output
const lines = listOutput.split('\n').filter(line => line.trim());
for (const line of lines) {
// pct list format: CTID Status Name
// Example: "100 running my-container"
const parts = line.trim().split(/\s+/);
if (parts.length >= 3) {
const containerId = parts[0];
const status = parts[1];
if (containerId && status) {
// Map pct list status to our status
let mappedStatus: 'running' | 'stopped' | 'unknown' = 'unknown';
if (status === 'running') {
mappedStatus = 'running';
} else if (status === 'stopped') {
mappedStatus = 'stopped';
}
statusMap[containerId] = mappedStatus;
}
}
}
} catch (error) {
console.error(`Error processing server ${(server as any).name}:`, error);
}
}
return {
success: true,
statusMap
};
} catch (error) {
console.error('Error in getContainerStatuses:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch container statuses',
statusMap: {}
};
}
}),
// Get container status (running/stopped)
getContainerStatus: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
try {
const db = getDatabase();
const script = db.getInstalledScriptById(input.id);
if (!script) {
return {
success: false,
error: 'Script not found',
status: 'unknown' as const
};
}
const scriptData = script as any;
// Only check status for SSH scripts with container_id
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
return {
success: false,
error: 'Script is not an SSH script with container ID',
status: 'unknown' as const
};
}
// Get server info
const server = db.getServerById(Number(scriptData.server_id));
if (!server) {
return {
success: false,
error: 'Server not found',
status: 'unknown' as const
};
}
// 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
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any);
if (!(connectionTest as any).success) {
return {
success: false,
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
status: 'unknown' as const
};
}
// Check container status
const statusCommand = `pct status ${scriptData.container_id}`;
let statusOutput = '';
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
statusCommand,
(data: string) => {
statusOutput += data;
},
(error: string) => {
console.error('Status command error:', error);
reject(new Error(error));
},
(_exitCode: number) => {
resolve();
}
);
});
// Parse status from output
let status: 'running' | 'stopped' | 'unknown' = 'unknown';
if (statusOutput.includes('status: running')) {
status = 'running';
} else if (statusOutput.includes('status: stopped')) {
status = 'stopped';
}
return {
success: true,
status,
error: undefined
};
} catch (error) {
console.error('Error in getContainerStatus:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get container status',
status: 'unknown' as const
};
}
}),
// Control container (start/stop)
controlContainer: publicProcedure
.input(z.object({
id: z.number(),
action: z.enum(['start', 'stop'])
}))
.mutation(async ({ input }) => {
try {
const db = getDatabase();
const script = db.getInstalledScriptById(input.id);
if (!script) {
return {
success: false,
error: 'Script not found'
};
}
const scriptData = script as any;
// Only control SSH scripts with container_id
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
return {
success: false,
error: 'Script is not an SSH script with container ID'
};
}
// Get server info
const server = db.getServerById(Number(scriptData.server_id));
if (!server) {
return {
success: false,
error: 'Server not found'
};
}
// 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
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any);
if (!(connectionTest as any).success) {
return {
success: false,
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
};
}
// Execute control command
const controlCommand = `pct ${input.action} ${scriptData.container_id}`;
let commandOutput = '';
let commandError = '';
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
controlCommand,
(data: string) => {
commandOutput += data;
},
(error: string) => {
commandError += error;
},
(exitCode: number) => {
if (exitCode !== 0) {
const errorMessage = commandError || commandOutput || `Command failed with exit code ${exitCode}`;
reject(new Error(errorMessage));
} else {
resolve();
}
}
);
});
return {
success: true,
message: `Container ${scriptData.container_id} ${input.action} command executed successfully`,
containerId: scriptData.container_id
};
} catch (error) {
console.error('Error in controlContainer:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to control container'
};
}
}),
// Destroy container and delete DB record
destroyContainer: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
try {
const db = getDatabase();
const script = db.getInstalledScriptById(input.id);
if (!script) {
return {
success: false,
error: 'Script not found'
};
}
const scriptData = script as any;
// Only destroy SSH scripts with container_id
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
return {
success: false,
error: 'Script is not an SSH script with container ID'
};
}
// Get server info
const server = db.getServerById(Number(scriptData.server_id));
if (!server) {
return {
success: false,
error: 'Server not found'
};
}
// 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
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any);
if (!(connectionTest as any).success) {
return {
success: false,
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
};
}
// First check if container is running and stop it if necessary
const statusCommand = `pct status ${scriptData.container_id}`;
let statusOutput = '';
try {
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
statusCommand,
(data: string) => {
statusOutput += data;
},
(error: string) => {
reject(new Error(error));
},
(_exitCode: number) => {
resolve();
}
);
});
// Check if container is running
if (statusOutput.includes('status: running')) {
// Stop the container first
const stopCommand = `pct stop ${scriptData.container_id}`;
let stopOutput = '';
let stopError = '';
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
stopCommand,
(data: string) => {
stopOutput += data;
},
(error: string) => {
stopError += error;
},
(exitCode: number) => {
if (exitCode !== 0) {
const errorMessage = stopError || stopOutput || `Stop command failed with exit code ${exitCode}`;
reject(new Error(`Failed to stop container: ${errorMessage}`));
} else {
resolve();
}
}
);
});
}
} catch (_error) {
// If status check fails, continue with destroy attempt
// The destroy command will handle the error appropriately
}
// Execute destroy command
const destroyCommand = `pct destroy ${scriptData.container_id}`;
let commandOutput = '';
let commandError = '';
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
destroyCommand,
(data: string) => {
commandOutput += data;
},
(error: string) => {
commandError += error;
},
(exitCode: number) => {
if (exitCode !== 0) {
const errorMessage = commandError || commandOutput || `Destroy command failed with exit code ${exitCode}`;
reject(new Error(errorMessage));
} else {
resolve();
}
}
);
});
// If destroy was successful, delete the database record
const deleteResult = db.deleteInstalledScript(input.id);
if (deleteResult.changes === 0) {
return {
success: false,
error: 'Container destroyed but failed to delete database record'
};
}
// Determine if container was stopped first
const wasStopped = statusOutput.includes('status: running');
const message = wasStopped
? `Container ${scriptData.container_id} stopped and destroyed successfully, database record deleted`
: `Container ${scriptData.container_id} destroyed successfully, database record deleted`;
return {
success: true,
message
};
} catch (error) {
console.error('Error in destroyContainer:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to destroy container'
};
}
})
});

View File

@@ -27,6 +27,16 @@ export const scriptsRouter = createTRPCRouter({
};
}),
// Get all downloaded scripts from all directories
getAllDownloadedScripts: publicProcedure
.query(async () => {
const scripts = await scriptManager.getAllDownloadedScripts();
return {
scripts,
directoryInfo: scriptManager.getScriptsDirectoryInfo()
};
}),
// Get script content for viewing
getScriptContent: publicProcedure
@@ -163,12 +173,22 @@ export const scriptsRouter = createTRPCRouter({
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') ?? [];
// 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 {
...card,
categories: script?.categories ?? [],
categoryNames: categoryNames,
// Add date_created from script
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;
});
@@ -234,6 +254,58 @@ export const scriptsRouter = createTRPCRouter({
}
}),
// Load multiple scripts from GitHub
loadMultipleScripts: publicProcedure
.input(z.object({ slugs: z.array(z.string()) }))
.mutation(async ({ input }) => {
try {
const successful = [];
const failed = [];
for (const slug of input.slugs) {
try {
// Get the script details
const script = await localScriptsService.getScriptBySlug(slug);
if (!script) {
failed.push({ slug, error: 'Script not found' });
continue;
}
// Load the script files
const result = await scriptDownloaderService.loadScript(script);
if (result.success) {
successful.push({ slug, files: result.files });
} else {
const error = 'error' in result ? result.error : 'Failed to load script';
failed.push({ slug, error });
}
} catch (error) {
failed.push({
slug,
error: error instanceof Error ? error.message : 'Failed to load script'
});
}
}
return {
success: true,
message: `Downloaded ${successful.length} scripts successfully, ${failed.length} failed`,
successful,
failed,
total: input.slugs.length
};
} catch (error) {
console.error('Error in loadMultipleScripts:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to load multiple scripts',
successful: [],
failed: [],
total: 0
};
}
}),
// Check if script files exist locally
checkScriptFiles: publicProcedure
.input(z.object({ slug: z.string() }))

View File

@@ -11,6 +11,7 @@ interface GitHubRelease {
name: string;
published_at: string;
html_url: string;
body: string;
}
// Helper function to fetch from GitHub API with optional authentication
@@ -127,6 +128,43 @@ export const versionRouter = createTRPCRouter({
}
}),
// Get all releases for release notes
getAllReleases: publicProcedure
.query(async () => {
try {
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases');
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const releases: GitHubRelease[] = await response.json();
// Sort by published date (newest first)
const sortedReleases = releases
.filter(release => !release.tag_name.includes('beta') && !release.tag_name.includes('alpha'))
.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime());
return {
success: true,
releases: sortedReleases.map(release => ({
tagName: release.tag_name,
name: release.name,
publishedAt: release.published_at,
htmlUrl: release.html_url,
body: release.body
}))
};
} catch (error) {
console.error('Error fetching all releases:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch releases',
releases: []
};
}
}),
// Get update logs from the log file
getUpdateLogs: publicProcedure
.query(async () => {

View File

@@ -16,12 +16,68 @@ class DatabaseService {
name TEXT NOT NULL UNIQUE,
ip 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,
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
this.db.exec(`
CREATE TABLE IF NOT EXISTS installed_scripts (
@@ -53,12 +109,12 @@ class DatabaseService {
* @param {import('../types/server').CreateServerData} 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(`
INSERT INTO servers (name, ip, user, password)
VALUES (?, ?, ?, ?)
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color)
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() {
@@ -79,13 +135,13 @@ class DatabaseService {
* @param {import('../types/server').CreateServerData} 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(`
UPDATE servers
SET name = ?, ip = ?, user = ?, password = ?
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, color = ?
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.ip as server_ip,
s.user as server_user,
s.password as server_password
s.password as server_password,
s.color as server_color
FROM installed_scripts inst
LEFT JOIN servers s ON inst.server_id = s.id
ORDER BY inst.installation_date DESC

View File

@@ -141,6 +141,95 @@ export class ScriptManager {
}
}
/**
* Get all downloaded scripts from all directories (ct, tools, vm, vw)
*/
async getAllDownloadedScripts(): Promise<ScriptInfo[]> {
this.initializeConfig();
const allScripts: ScriptInfo[] = [];
// Define all script directories to scan
const scriptDirs = ['ct', 'tools', 'vm', 'vw'];
for (const dirName of scriptDirs) {
try {
const dirPath = join(this.scriptsDir!, dirName);
// Check if directory exists
try {
await stat(dirPath);
} catch {
// Directory doesn't exist, skip it
continue;
}
const scripts = await this.getScriptsFromDirectory(dirPath);
allScripts.push(...scripts);
} catch (error) {
console.error(`Error reading ${dirName} scripts directory:`, error);
// Continue with other directories even if one fails
}
}
return allScripts.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Get scripts from a specific directory (recursively)
*/
private async getScriptsFromDirectory(dirPath: string): Promise<ScriptInfo[]> {
const scripts: ScriptInfo[] = [];
const scanDirectory = async (currentPath: string, relativePath = ''): Promise<void> => {
const files = await readdir(currentPath);
for (const file of files) {
const filePath = join(currentPath, file);
const stats = await stat(filePath);
if (stats.isFile()) {
const extension = extname(file);
// Check if file extension is allowed
if (this.allowedExtensions!.includes(extension)) {
// Check if file is executable
const executable = await this.isExecutable(filePath);
// Extract slug from filename (remove .sh extension)
const slug = file.replace(/\.sh$/, '');
// Try to get logo from JSON data
let logo: string | undefined;
try {
const scriptData = await localScriptsService.getScriptBySlug(slug);
logo = scriptData?.logo ?? undefined;
} catch {
// JSON file might not exist, that's okay
}
scripts.push({
name: file,
path: filePath,
extension,
size: stats.size,
lastModified: stats.mtime,
executable,
logo,
slug
});
}
} else if (stats.isDirectory()) {
// Recursively scan subdirectories
const subRelativePath = relativePath ? join(relativePath, file) : file;
await scanDirectory(filePath, subRelativePath);
}
}
};
await scanDirectory(dirPath);
return scripts;
}
/**
* Check if a file is executable
*/

View File

@@ -1,16 +1,131 @@
import { spawn } from 'child_process';
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
* @property {string} ip - Server IP address
* @property {string} user - Username
* @property {string} password - Password
* @property {string} [password] - Password (optional)
* @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 {
/**
* 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
* @param {Server} server - Server configuration
@@ -21,7 +136,8 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information
*/
async executeScript(server, scriptPath, onData, onError, onExit) {
const { ip, user, password } = server;
/** @type {string|null} */
let tempKeyPath = null;
try {
await this.transferScriptsFolder(server, onData, onError);
@@ -29,46 +145,37 @@ class SSHExecutionService {
return new Promise((resolve, reject) => {
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
// Use ptySpawn for proper terminal emulation and color support
const sshCommand = ptySpawn('sshpass', [
'-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',
cols: 120,
rows: 30,
cwd: process.cwd(),
env: {
...process.env,
TERM: 'xterm-256color',
COLUMNS: '120',
LINES: '30',
SHELL: '/bin/bash',
COLORTERM: 'truecolor',
FORCE_COLOR: '1',
NO_COLOR: '0',
CLICOLOR: '1',
CLICOLOR_FORCE: '1'
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
const sshCommand = ptySpawn(command, args, {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: process.cwd(),
env: {
...process.env,
TERM: 'xterm-256color',
COLUMNS: '120',
LINES: '30',
SHELL: '/bin/bash',
COLORTERM: 'truecolor',
FORCE_COLOR: '1',
NO_COLOR: '0',
CLICOLOR: '1',
CLICOLOR_FORCE: '1'
}
});
// Use pty's onData method which handles both stdout and stderr combined
sshCommand.onData((data) => {
@@ -82,8 +189,34 @@ class SSHExecutionService {
resolve({
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) {
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -100,20 +233,49 @@ class SSHExecutionService {
* @returns {Promise<void>}
*/
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) => {
const rsyncCommand = spawn('rsync', [
'-avz',
'--delete',
'--exclude=*.log',
'--exclude=*.tmp',
'--rsh=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null',
'scripts/',
`${user}@${ip}:/tmp/scripts/`
], {
stdio: ['pipe', 'pipe', 'pipe']
});
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', [
'-avz',
'--delete',
'--exclude=*.log',
'--exclude=*.tmp',
`--rsh=${rshCommand}`,
'scripts/',
`${user}@${ip}:/tmp/scripts/`
], {
stdio: ['pipe', 'pipe', 'pipe']
});
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
// Ensure proper UTF-8 encoding for ANSI colors
@@ -128,6 +290,17 @@ class SSHExecutionService {
});
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) {
resolve();
} else {
@@ -136,8 +309,32 @@ class SSHExecutionService {
});
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);
});
} 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,47 +348,79 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information
*/
async executeCommand(server, command, onData, onError, onExit) {
const { ip, user, password } = server;
/** @type {string|null} */
let tempKeyPath = null;
return new Promise((resolve, reject) => {
// Use ptySpawn for proper terminal emulation and color support
const sshCommand = ptySpawn('sshpass', [
'-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',
cols: 120,
rows: 30,
cwd: process.cwd(),
env: process.env
});
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
const sshCommand = ptySpawn(sshCommandName, args, {
name: 'xterm-color',
cols: 120,
rows: 30,
cwd: process.cwd(),
env: process.env
});
sshCommand.onData((data) => {
onData(data);
});
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);
});
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 { writeFileSync, unlinkSync, chmodSync } from 'fs';
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
class SSHService {
/**
@@ -10,38 +11,42 @@ class SSHService {
* @returns {Promise<Object>} Connection test result
*/
async testConnection(server) {
const { ip, user, password } = server;
const { auth_type = 'password' } = server;
return new Promise((resolve) => {
const timeout = 15000; // 15 seconds timeout for login test
let resolved = false;
// Try sshpass first if available
this.testWithSshpass(server).then(result => {
// Choose authentication method based on auth_type
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) {
resolved = true;
resolve(result);
}
}).catch(() => {
// If sshpass fails, try expect
this.testWithExpect(server).then(result => {
if (!resolved) {
resolved = true;
resolve(result);
}
}).catch(() => {
// If both fail, return error
if (!resolved) {
resolved = true;
resolve({
success: false,
message: 'SSH login test requires sshpass or expect - neither available or working',
details: {
method: 'no_auth_tools'
}
});
}
});
// If primary method fails, return error
if (!resolved) {
resolved = true;
resolve({
success: false,
message: `SSH login test failed for ${auth_type} authentication`,
details: {
method: 'auth_failed',
auth_type: auth_type
}
});
}
});
// Set up overall timeout
@@ -64,7 +69,11 @@ class SSHService {
* @returns {Promise<Object>} Connection test result
*/
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) => {
const timeout = 10000;
@@ -73,6 +82,7 @@ class SSHService {
const sshCommand = spawn('sshpass', [
'-p', password,
'ssh',
'-p', ssh_port.toString(),
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
@@ -156,7 +166,7 @@ class SSHService {
* @returns {Promise<Object>} Connection test result
*/
async testWithExpect(server) {
const { ip, user, password } = server;
const { ip, user, password, ssh_port = 22 } = server;
return new Promise((resolve, reject) => {
const timeout = 10000;
@@ -164,7 +174,7 @@ class SSHService {
const expectScript = `#!/usr/bin/expect -f
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 {
"password:" {
send "${password}\r"
@@ -428,13 +438,14 @@ expect {
* @returns {Promise<Object>} Connection test result
*/
async testSSHConnection(server) {
const { ip, user } = server;
const { ip, user, ssh_port = 22 } = server;
return new Promise((resolve) => {
const timeout = 5000;
let resolved = false;
const sshCommand = spawn('ssh', [
'-p', ssh_port.toString(),
'-o', 'ConnectTimeout=5',
'-o', 'StrictHostKeyChecking=no',
'-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

View File

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

View File

@@ -3,7 +3,12 @@ export interface Server {
name: string;
ip: 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;
updated_at: string;
}
@@ -12,7 +17,12 @@ export interface CreateServerData {
name: string;
ip: 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 {

View File

@@ -170,7 +170,7 @@ get_latest_release() {
echo "$tag_name|$download_url"
}
# Backup data directory and .env file
# Backup data directory, .env file, and scripts directories
backup_data() {
log "Creating backup directory at $BACKUP_DIR..."
@@ -205,6 +205,23 @@ backup_data() {
else
log_warning ".env file not found, skipping backup"
fi
# Backup scripts directories
local scripts_dirs=("scripts/ct" "scripts/install" "scripts/tools" "scripts/vm")
for scripts_dir in "${scripts_dirs[@]}"; do
if [ -d "$scripts_dir" ]; then
log "Backing up $scripts_dir directory..."
local backup_name=$(basename "$scripts_dir")
if ! cp -r "$scripts_dir" "$BACKUP_DIR/$backup_name"; then
log_error "Failed to backup $scripts_dir directory"
exit 1
else
log_success "$scripts_dir directory backed up successfully"
fi
else
log_warning "$scripts_dir directory not found, skipping backup"
fi
done
}
# Download and extract latest release
@@ -287,6 +304,7 @@ clear_original_directory() {
"*.backup"
"*.bak"
".git"
"scripts"
)
# Remove all files except preserved ones
@@ -328,7 +346,7 @@ clear_original_directory() {
# Restore backup files before building
restore_backup_files() {
log "Restoring .env and data directory from backup..."
log "Restoring .env, data directory, and scripts directories from backup..."
if [ -d "$BACKUP_DIR" ]; then
# Restore .env file
@@ -360,6 +378,34 @@ restore_backup_files() {
else
log_warning "No data directory backup found"
fi
# Restore scripts directories
local scripts_dirs=("ct" "install" "tools" "vm")
for backup_name in "${scripts_dirs[@]}"; do
if [ -d "$BACKUP_DIR/$backup_name" ]; then
local target_dir="scripts/$backup_name"
log "Restoring $target_dir directory from backup..."
# Ensure scripts directory exists
if [ ! -d "scripts" ]; then
mkdir -p "scripts"
fi
# Remove existing directory if it exists
if [ -d "$target_dir" ]; then
rm -rf "$target_dir"
fi
if mv "$BACKUP_DIR/$backup_name" "$target_dir"; then
log_success "$target_dir directory restored from backup"
else
log_error "Failed to restore $target_dir directory"
return 1
fi
else
log_warning "No $backup_name directory backup found"
fi
done
else
log_error "No backup directory found for restoration"
return 1
@@ -448,6 +494,7 @@ update_files() {
"update.log"
"*.backup"
"*.bak"
"scripts"
)
# Find the actual source directory (strip the top-level directory)
@@ -592,6 +639,7 @@ start_application() {
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
@@ -665,6 +713,33 @@ rollback() {
log_warning "No .env file backup found"
fi
# Restore scripts directories
local scripts_dirs=("ct" "install" "tools" "vm")
for backup_name in "${scripts_dirs[@]}"; do
if [ -d "$BACKUP_DIR/$backup_name" ]; then
local target_dir="scripts/$backup_name"
log "Restoring $target_dir directory from backup..."
# Ensure scripts directory exists
if [ ! -d "scripts" ]; then
mkdir -p "scripts"
fi
# Remove existing directory if it exists
if [ -d "$target_dir" ]; then
rm -rf "$target_dir"
fi
if mv "$BACKUP_DIR/$backup_name" "$target_dir"; then
log_success "$target_dir directory restored from backup"
else
log_error "Failed to restore $target_dir directory"
fi
else
log_warning "No $backup_name directory backup found"
fi
done
# Clean up backup directory
log "Cleaning up backup directory..."
rm -rf "$BACKUP_DIR"
@@ -703,7 +778,7 @@ main() {
if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)"
else
# Try multiple common locations
# 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)