Compare commits

...

109 Commits

Author SHA1 Message Date
github-actions[bot]
cb5056508d chore: add VERSION v0.4.3 2025-10-17 07:20:03 +00:00
Michel Roegl-Brunner
b793c57000 refactor: migrate from better-sqlite3 to Prisma (#170)
* refactor: migrate from better-sqlite3 to Prisma

- Install Prisma dependencies and initialize with SQLite
- Create Prisma schema matching existing database structure
- Replace database.js with Prisma-based database service
- Update all API routes, tRPC routers, and WebSocket handler
- Convert TypeScript types to match Prisma schema
- Update build process to include Prisma migrations
- Remove better-sqlite3 dependency

All database operations now use Prisma while maintaining SQLite backend.

* fix: flatten server data in installed scripts API responses

- Transform Prisma nested server objects to flattened fields expected by frontend
- Update getAllInstalledScripts, getInstalledScriptsByServer, and getInstalledScriptById
- Server names should now display correctly in the installed scripts table
- Use nullish coalescing operators for better null handling

* fix: ensure DATABASE_URL is set in .env for Prisma during updates

- Add ensure_database_url() function to update.sh
- Function checks if .env exists and creates from .env.example if needed
- Automatically adds DATABASE_URL if not present
- Call function after restore_backup_files() in update flow
- Fixes Prisma client generation error during updates
2025-10-17 09:17:20 +02:00
dependabot[bot]
6b45c41334 build(deps-dev): Bump @types/node from 24.7.2 to 24.8.0 (#167) 2025-10-16 22:30:27 +02:00
dependabot[bot]
a8eb41e087 build(deps): Bump lucide-react from 0.545.0 to 0.546.0 (#168) 2025-10-16 22:29:52 +02:00
Michel Roegl-Brunner
52adbd9f5c Merge pull request #169 from community-scripts/dependabot/npm_and_yarn/tanstack/react-query-5.90.5 2025-10-16 22:29:28 +02:00
dependabot[bot]
73d3aeec99 build(deps): Bump @tanstack/react-query from 5.90.3 to 5.90.5
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.90.3 to 5.90.5.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.90.5/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.90.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-16 19:56:41 +00:00
Michel Roegl-Brunner
1635bb17da Add Breaking Changes category to release drafter 2025-10-16 15:57:56 +02:00
github-actions[bot]
b4b8da5725 chore: add VERSION v0.4.2 (#165)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-16 13:53:51 +00:00
Michel Roegl-Brunner
d95a85435b Merge pull request #164 from community-scripts/fix/show_modal_during_operation
fix: add loading modal for container operations
2025-10-16 15:52:06 +02:00
Michel Roegl-Brunner
962e2877e3 feat: add loading modal for container operations
- Add LoadingModal component with spinning circle animation
- Show loading modal during start/stop/destroy container operations
- Display current action being performed (e.g., 'Starting container 101...')
- Close loading modal when operation completes (success or error)
- Maintains consistent modal styling with existing components

Fixes user experience by providing clear visual feedback during
background operations instead of silent processing.
2025-10-16 15:50:33 +02:00
Michel Roegl-Brunner
3459fe3fa4 Merge pull request #163 from community-scripts/fix/ssh_keys
fix: implement persistent SSH key storage with key generation
2025-10-16 15:46:41 +02:00
Michel Roegl-Brunner
6580f3100a Delete scripts/install/debian-install.sh 2025-10-16 15:45:14 +02:00
Michel Roegl-Brunner
15ffa98ea8 Delete scripts/ct/debian.sh 2025-10-16 15:44:36 +02:00
Michel Roegl-Brunner
4c3b66a26b Delete 2025-10-16 15:43:47 +02:00
Michel Roegl-Brunner
94e97a7366 fix: implement persistent SSH key storage with key generation
- Fix 'error in libcrypto' issue by using persistent key files instead of temporary ones
- Add SSH key pair generation feature with 'Generate Key Pair' button
- Add 'View Public Key' button for generated keys with copy-to-clipboard functionality
- Remove confusing 'both' authentication option, now only supports 'password' OR 'key'
- Add persistent storage in data/ssh-keys/ directory with proper permissions
- Update database schema with ssh_key_path and key_generated columns
- Add API endpoints for key generation and public key retrieval
- Enhance UX by hiding manual key input when key pair is generated
- Update HelpModal documentation to reflect new SSH key features
- Fix all TypeScript compilation errors and linting issues

Resolves SSH authentication failures during script execution
2025-10-16 15:42:26 +02:00
Michel Roegl-Brunner
0e95c125d3 Merge pull request #162 from community-scripts/fix/ui
UI Fixes: Modal Layout and Filter Message Positioning
2025-10-16 14:31:03 +02:00
Michel Roegl-Brunner
fa2cb457fa Move 'Filters are being saved automatically' message to bottom left
- Relocated message from top center to bottom left next to script count
- Positioned alongside 'Clear all filters' button for better layout
- Maintains green checkmark icon and styling consistency
2025-10-16 14:27:59 +02:00
Michel Roegl-Brunner
02680aed29 Move status banner down in ScriptDetailModal
- Repositioned status banner from after action buttons to within content section
- Creates better visual separation and spacing as shown in design
- Maintains all functionality while improving layout hierarchy
2025-10-16 14:26:41 +02:00
Michel Roegl-Brunner
63459a650d Merge pull request #156 from community-scripts/dependabot/npm_and_yarn/eslint-config-next-15.5.5 2025-10-14 21:40:06 +02:00
dependabot[bot]
343989474d build(deps-dev): Bump prettier-plugin-tailwindcss from 0.6.14 to 0.7.0
Bumps [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) from 0.6.14 to 0.7.0.
- [Release notes](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.14...v0.7.0)

---
updated-dependencies:
- dependency-name: prettier-plugin-tailwindcss
  dependency-version: 0.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-14 21:39:47 +02:00
dependabot[bot]
a0a6a11838 build(deps-dev): Bump eslint-config-next from 15.5.4 to 15.5.5
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 15.5.4 to 15.5.5.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.5/packages/eslint-config-next)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-14 19:29:32 +00:00
Michel Roegl-Brunner
695232c711 Amend readme 2025-10-14 16:26:34 +02:00
Michel Roegl-Brunner
5b11a6bad8 refactor: optimize UI button layout and fix dependency loop (#149)
- Add Open UI button next to IP:Port in installed scripts table
- Move Re-detect button to Actions dropdown for better space usage
- Fix dependency array loop in fetchContainerStatuses useCallback
- Hide buttons for stopped containers to prevent invalid actions
- Enhance auto-detect success message with LXC ID and hostname
- Improve font consistency by removing monospace from IP:Port text
- Optimize screen real estate with cleaner, more scannable layout
2025-10-14 16:22:38 +02:00
Michel Roegl-Brunner
67ac02ea1a feat: improve release notes markdown rendering (#148)
- Add react-markdown and remark-gfm for proper markdown parsing
- Add @tailwindcss/typography plugin for prose styling
- Replace plain text rendering with custom ReactMarkdown components
- Headers now render with proper sizing and hierarchy
- Lists display with bullets and proper indentation
- Links are clickable and styled appropriately
- Emojis render correctly
- Maintain dark mode compatibility
2025-10-14 15:53:27 +02:00
github-actions[bot]
efa924cb82 chore: add VERSION v0.4.1 (#147)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-14 13:37:33 +00:00
Michel Roegl-Brunner
ceef5c7bb9 feat: Add UI Access button and rearrange the Action Buttons in a Dropdown. (#146)
* feat: Add Web UI IP:Port tracking and access functionality

- Add web_ui_ip and web_ui_port columns to installed_scripts table with migration
- Update database CRUD methods to handle new Web UI fields
- Add terminal output parsing to auto-detect Web UI URLs during installation
- Create autoDetectWebUI mutation that runs hostname -I in containers via SSH
- Add Web UI column to desktop table with editable IP and port fields
- Add Open UI button that opens http://ip:port in new tab
- Add Re-detect button for manual IP detection using script metadata
- Update mobile card view with Web UI fields and buttons
- Fix nested button hydration error in ContextualHelpIcon
- Prioritize script metadata interface_port over existing database values
- Use pct exec instead of pct enter for container command execution
- Add comprehensive error handling and user feedback
- Style auto-detect button with muted colors and Re-detect text

Features:
- Automatic Web UI detection during script installation
- Manual IP detection with port lookup from script metadata
- Editable IP and port fields in both desktop and mobile views
- Clickable Web UI links that open in new tabs
- Support for both local and SSH script executions
- Proper port detection from script JSON metadata (e.g., actualbudget:5006)
- Clean UI with subtle button styling and clear text labels

* feat: Disable Open UI button when container is stopped

- Add disabled state to Open UI button in desktop table when container is stopped
- Update mobile card Open UI button to be disabled when container is stopped
- Apply consistent styling with Shell and Update buttons
- Prevent users from accessing Web UI when container is not running
- Add cursor-not-allowed styling for disabled clickable IP links

* feat: Align Re-detect buttons consistently in Web UI column

- Change flex layout from space-x-2 to justify-between for consistent button alignment
- Add flex-shrink-0 to prevent IP:port text and buttons from shrinking
- Add ml-2 margin to Re-detect button for proper spacing
- Apply changes to both desktop table and mobile card views
- Buttons now align vertically regardless of IP:port text length

* feat: Add actions dropdown menu with conditional Start/Stop colors and update help

- Create dropdown-menu.tsx component using Radix UI primitives
- Move all action buttons except Edit into dropdown menu
- Keep Edit and Save/Cancel buttons always visible
- Add conditional styling: Start (green), Stop (red)
- Apply changes to both desktop table and mobile card views
- Add smart visibility - dropdown only shows when actions available
- Auto-close dropdown after clicking any action
- Style dropdown to match existing button theme
- Fix syntax error in dropdown-menu.tsx component
- Update help section with Web UI Access and Actions Dropdown documentation
- Add detailed explanations of auto-detection, IP/port tracking, and color coding

* Fix TypeScript build error in server.js

- Updated parseWebUIUrl JSDoc return type from Object|null to {ip: string, port: number}|null
- This fixes the TypeScript error where 'ip' property was not recognized on type 'Object'
- Build now completes successfully without errors
2025-10-14 15:35:21 +02:00
Michel Roegl-Brunner
58e1fb3cea fix: delete associated scripts when removing server (#145)
- Update database foreign key constraint to ON DELETE CASCADE
- Add deleteInstalledScriptsByServer method to DatabaseService
- Update server DELETE API to remove associated scripts before server deletion
- Add confirmation modal with type-to-confirm for server deletion
- Require users to type server name to confirm deletion
- Show warning about deleting associated scripts in confirmation modal
2025-10-14 10:26:41 +02:00
Michel Roegl-Brunner
546d7290ee feat: Add Shell button for interactive LXC container access (#144)
* feat: Add Shell button for interactive LXC container access

- Add Shell button to ScriptInstallationCard for SSH scripts with container_id
- Implement shell state management in InstalledScriptsTab
- Add shell execution methods in server.js (local and SSH)
- Add isShell prop to Terminal component
- Implement smooth scrolling to terminal when opened
- Add highlight effect for better UX
- Shell sessions are interactive (no auto-commands like update)

The Shell button provides direct interactive access to LXC containers
without automatically sending update commands, allowing users to
manually execute commands in the container shell.

* fix: Include SSH authentication fields in installed scripts data

- Add SSH key fields (auth_type, ssh_key, ssh_key_passphrase, ssh_port) to database query
- Update InstalledScript interface to include SSH authentication fields
- Fix server data construction in handleOpenShell and handleUpdateScript
- Now properly supports SSH key authentication for shell and update operations

This fixes the issue where SSH key authentication was not being used
even when configured in server settings, as the installed scripts data
was missing the SSH authentication fields.

* fix: Resolve TypeScript and ESLint build errors

- Replace logical OR (||) with nullish coalescing (??) operators
- Remove unnecessary type assertion for container_id
- Add missing dependencies to useEffect and useCallback hooks
- Remove unused variable in SSHKeyInput component
- Add isShell property to WebSocketMessage type definition
- Fix ServerInfo type to allow null in shell execution methods

All TypeScript and ESLint errors resolved, build now passes successfully.
2025-10-14 10:19:52 +02:00
Michel Roegl-Brunner
a5b67b183b Delete scripts/vm/openwrt-vm.sh 2025-10-14 09:55:04 +02:00
Michel Roegl-Brunner
8efff60025 fix: correct script counters to use deduplicated counts (#143)
- Update available scripts counter to deduplicate by slug using Map
- Update downloaded scripts counter to use deduplicated GitHub scripts
- Ensures tab header counts match CategorySidebar counts
- Fixes inconsistency where tab headers showed different numbers than displayed content

Resolves counter discrepancies:
- Available Scripts: now shows deduplicated count (403) instead of raw count (408)
- Downloaded Scripts: now shows deduplicated count matching sidebar display
2025-10-14 09:50:29 +02:00
Michel Roegl-Brunner
ec9bdf54ba Update OpenWrt script structure (#142)
- Move openwrt.json to openwrt-vm.sh in scripts/vm/ directory
- Remove petio.json (no longer needed)
- Reorganize script structure for better organization
2025-10-14 09:36:54 +02:00
Michel Roegl-Brunner
0555e4c0dd Fix SSH ED25519 key loading and status check loops (#140)
- Fix SSH ED25519 key loading error by ensuring temporary key files end with newline
- Fix cleanup logic using rmdirSync instead of unlinkSync for directories
- Add timeout protection to prevent hanging SSH connections
- Fix endless status checking loops by removing problematic dependencies
- Add debouncing and proper cleanup for status checks
- Support file upload without extensions for Windows-generated keys
- Improve SSH key type detection for OpenSSH format keys

Resolves libcrypto errors and prevents resource leaks in SSH operations.
2025-10-14 09:32:13 +02:00
Michel Roegl-Brunner
08e0c82f4e Fix build errors and warnings
- Fix optional chain expression errors in DownloadedScriptsTab, ScriptsGrid, and colorUtils
- Fix React hooks dependency warnings in InstalledScriptsTab
- Update useCallback dependency array to include containerStatusMutation
- Update useEffect dependency array to include fetchContainerStatuses
- Build now passes successfully with no errors or warnings
2025-10-14 09:06:37 +02:00
dependabot[bot]
e3fccca0fc build(deps-dev): Bump typescript-eslint from 8.46.0 to 8.46.1 (#132)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.46.0 to 8.46.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.46.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 09:03:46 +02:00
Michel Roegl-Brunner
7454799971 UI/UX Improvements and Bug Fixes (#138)
* Improve navigation layout: reduce spacing and move help icons inside nav buttons

- Reduced horizontal spacing between nav items from sm:space-x-2 lg:space-x-8 to sm:space-x-1
- Moved ContextualHelpIcon components inside Button components for better UX
- Removed unnecessary wrapper divs to create more compact layout
- Help symbols are now part of the clickable nav area

* Enhance stop/destroy button hover effects and colors

- Added scaling and shadow hover effects to stop/start and destroy buttons
- Enhanced color scheme with high-contrast colors:
  - Stop button: vibrant red (bg-red-600) with red glow shadow
  - Start button: vibrant green (bg-green-600) with green glow shadow
  - Destroy button: dark red (bg-red-800) with intense red glow shadow
- Added colored borders and smooth transitions for better visual feedback
- Improved button visibility and user experience with color-coded actions

* Fix input field alignment in editing mode

- Improved vertical alignment of script name and container ID input fields
- Removed script path line during editing for cleaner interface
- Added consistent min-height and flex centering for both input fields
- Enhanced input styling with better padding and focus states
- Input fields now align perfectly at the same height level

* Improve button styling on available scripts page

- Made download button smaller and less colorful with subtle blue theme
- Updated Clear Selection and Select All Visible buttons to match Settings button styling
- Changed buttons to outline variant with default size for consistency
- Removed custom border styling to use standard outline appearance
- Improved visual hierarchy and button cohesion across the interface

* Reduce footer size by approximately 50%

- Changed vertical padding from py-6 to py-3 (3rem to 1.5rem)
- Reduced gaps between elements from gap-4 to gap-2 throughout
- Made footer more compact while maintaining functionality
- Improved screen space utilization for main content

* Add tab persistence with localStorage

- Implement localStorage persistence for active tab selection
- Tab selection now survives page reloads and browser sessions
- Added lazy initialization to read saved tab from localStorage
- Added useEffect to automatically save tab changes to localStorage
- Includes SSR safety checks for Next.js compatibility
- Defaults to 'scripts' tab if no saved tab found

* Clean up code: remove unused comments and variables

- Remove unnecessary comments in InstalledScriptsTab
- Clean up unused error variable in installedScripts router
- Minor code cleanup for better readability
2025-10-14 09:03:30 +02:00
dependabot[bot]
892b3ae5df build(deps-dev): Bump @types/react-dom from 19.2.1 to 19.2.2 (#133)
Bumps [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) from 19.2.1 to 19.2.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 08:44:05 +02:00
dependabot[bot]
bb52d5a077 build(deps): Bump @tanstack/react-query from 5.90.2 to 5.90.3 (#134)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.90.2 to 5.90.3.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.90.3/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.90.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 08:43:52 +02:00
dependabot[bot]
1d8c8685f5 build(deps-dev): Bump @types/node from 24.7.1 to 24.7.2 (#135)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.7.1 to 24.7.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 08:43:34 +02:00
dependabot[bot]
68981c98d5 build(deps): Bump next from 15.5.4 to 15.5.5 (#136)
Bumps [next](https://github.com/vercel/next.js) from 15.5.4 to 15.5.5.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.4...v15.5.5)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 08:34:06 +02:00
Michel Roegl-Brunner
ff1ce89ecb Change release drafter versioning to patch version 2025-10-13 16:37:23 +02:00
github-actions[bot]
cde534735f chore: add VERSION v0.4.0 (#125)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-13 14:37:15 +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
Michel Roegl-Brunner
ca2cbd5a7f Fix terminal colors and functionality issues (#86)
* Fix terminal colors and stop button functionality

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

* Fix downloaded scripts terminal functionality

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

* Improve mobile terminal focus behavior

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

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

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

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

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

* feat: Add filter persistence with settings integration

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

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

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

* fix: improve mobile terminal input handling for SSH processes

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

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

* debug: add comprehensive debugging for mobile terminal input

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

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

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

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

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

* debug: add WebSocket message routing debugging

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

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

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

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

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

* debug: add WebSocket connection tracking and message debugging

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

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

* fix: correct WebSocket message format for keyboard input

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

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

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

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

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

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

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

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

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

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

This completes the mobile terminal navigation controls for touch devices.

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

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

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

* feat: add backspace button to mobile terminal controls

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

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

* feat: improve mobile terminal scaling and responsiveness

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

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

* fix: improve ANSI escape sequence handling for whiptail dialogs

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

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

* debug: add whiptail/dialog detection and logging

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

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

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

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

This aggressive approach should finally fix the terminal rerendering issue.

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

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

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

* debug: add whiptail session detection and enhanced debugging

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

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

* feat: improve whiptail centering on mobile devices

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

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

* feat: improve whiptail horizontal centering on mobile

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

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

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

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

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

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

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

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

* fix: improve mobile terminal centering with specific dimensions

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

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

* feat: implement virtual terminal overflow approach for mobile whiptail

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

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

* revert: simplify mobile terminal approach and reduce font size

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

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

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

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

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

* feat: increase mobile font size and fix whiptail duplication

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

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

* fix: implement more aggressive terminal clearing for whiptail

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

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

* fix: implement terminal reset approach for whiptail duplication

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

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

* cleanup: remove all debug logging from terminal component

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

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

* feat: make InstalledScriptsTab mobile-friendly with responsive layout

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

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

* fix: resolve React hooks dependency warnings in Terminal component

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

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

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

* Delete scripts/ct/debian.sh

* Delete scripts/install/debian-install.sh

* Fix linting errors in Terminal.tsx

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

* Remove duplicate handleMessage function

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

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

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

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

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

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

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

* Update update.sh

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

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

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

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

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

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

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Workflow

* Workflow

* Workflow

* Update update script

* Update update script

* Update update script

* Update update script

* Update update script

* Update update.sh

* Update update.sh

* Update update.sh

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: bump dependencies to latest versions

---------

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

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

* fix: Resolve ESLint errors in DownloadedScriptsTab

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

View File

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

1
.github/CODEOWNERS vendored
View File

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

View File

@@ -1,7 +1,15 @@
# 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'
# Exclude PRs with this label from release notes
exclude-labels:
- automated
categories:
- title: "Breaking Changes"
labels:
- breaking
- title: "🚀 Features"
labels:
- feature
@@ -17,6 +25,7 @@ categories:
labels:
- dependencies
- javascript
change-template: '- $TITLE (#$NUMBER) by @$AUTHOR'
change-title-template: '### $TITLE'
template: |

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

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

4
.gitignore vendored
View File

@@ -16,6 +16,9 @@
db.sqlite
data/settings.db
# ssh keys (sensitive)
data/ssh-keys/
# next.js
/.next/
/out/
@@ -47,3 +50,4 @@ yarn-error.log*
# idea files
.idea
/generated/prisma

248
README.md
View File

@@ -2,6 +2,11 @@
A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming. No more need for curl -> bash calls, it all happens in your enviroment.
<img width="1725" height="1088" alt="image" src="https://github.com/user-attachments/assets/75323765-7375-4346-a41e-08d219275248" />
## 🎯 Deployment Options
This application can be deployed in multiple ways to suit different environments:
@@ -205,6 +210,249 @@ The application uses SQLite for storing server configurations:
- **Backup**: Copy `data/settings.db` to backup your server configurations
- **Reset**: Delete `data/settings.db` to reset all server configurations
## 📖 Feature Guide
This section provides detailed information about the application's key features and how to use them effectively.
### Server Settings
Manage your Proxmox VE servers and configure connection settings.
**Adding PVE Servers:**
- **Server Name**: A friendly name to identify your server
- **IP Address**: The IP address or hostname of your PVE server
- **Username**: PVE user account (usually root or a dedicated user)
- **SSH Port**: Default is 22, change if your server uses a different port
**Authentication Types:**
- **Password**: Use username and password authentication
- **SSH Key**: Use SSH key pair for secure authentication
- **Both**: Try SSH key first, fallback to password if needed
**Server Color Coding:**
Assign colors to servers for visual distinction throughout the application. This helps identify which server you're working with when managing scripts. This needs to be enabled in the General Settings.
### General Settings
Configure application preferences and behavior.
**Save Filters:**
When enabled, your script filter preferences (search terms, categories, sorting) will be automatically saved and restored when you return to the application:
- Search queries are preserved
- Selected script types are remembered
- Sort preferences are maintained
- Category selections are saved
**Server Color Coding:**
Enable visual color coding for servers throughout the application. This makes it easier to identify which server you're working with.
**GitHub Integration:**
Add a GitHub Personal Access Token to increase API rate limits and improve performance:
- Bypasses GitHub's rate limiting for unauthenticated requests
- Improves script loading and syncing performance
- Token is stored securely and only used for API calls
**Authentication:**
Secure your application with username and password authentication:
- Set up username and password for app access
- Enable/disable authentication as needed
- Credentials are stored securely
### Sync Button
Synchronize script metadata from the ProxmoxVE GitHub repository.
**What Does Syncing Do?**
- **Updates Script Metadata**: Downloads the latest script information (JSON files)
- **Refreshes Available Scripts**: Updates the list of scripts you can download
- **Updates Categories**: Refreshes script categories and organization
- **Checks for Updates**: Identifies which downloaded scripts have newer versions
**Important Notes:**
- **Metadata Only**: Syncing only updates script information, not the actual script files
- **No Downloads**: Script files are downloaded separately when you choose to install them
- **Last Sync Time**: Shows when the last successful sync occurred
- **Rate Limits**: GitHub API limits may apply without a personal access token
**When to Sync:**
- When you want to see the latest available scripts
- To check for updates to your downloaded scripts
- If you notice scripts are missing or outdated
- After the ProxmoxVE repository has been updated
### Available Scripts
Browse and discover scripts from the ProxmoxVE repository.
**Browsing Scripts:**
- **Category Sidebar**: Filter scripts by category (Storage, Network, Security, etc.)
- **Search**: Find scripts by name or description
- **View Modes**: Switch between card and list view
- **Sorting**: Sort by name or creation date
**Filtering Options:**
- **Script Types**: Filter by CT (Container) or other script types
- **Update Status**: Show only scripts with available updates
- **Search Query**: Search within script names and descriptions
- **Categories**: Filter by specific script categories
**Script Actions:**
- **View Details**: Click on a script to see full information and documentation
- **Download**: Download script files to your local system
- **Install**: Run scripts directly on your PVE servers
- **Preview**: View script content before downloading
### Downloaded Scripts
Manage scripts that have been downloaded to your local system.
**What Are Downloaded Scripts?**
These are scripts that you've downloaded from the repository and are stored locally on your system:
- Script files are stored in your local scripts directory
- You can run these scripts on your PVE servers
- Scripts can be updated when newer versions are available
**Update Detection:**
The system automatically checks if newer versions of your downloaded scripts are available:
- Scripts with updates available are marked with an update indicator
- You can filter to show only scripts with available updates
- Update detection happens when you sync with the repository
**Managing Downloaded Scripts:**
- **Update Scripts**: Download the latest version of a script
- **View Details**: See script information and documentation
- **Install/Run**: Execute scripts on your PVE servers
- **Filter & Search**: Use the same filtering options as Available Scripts
### Installed Scripts
Track and manage scripts that are installed on your PVE servers.
**Auto-Detection (Primary Feature):**
The system can automatically detect LXC containers that have community-script tags on your PVE servers:
- **Automatic Discovery**: Scans your PVE servers for containers with community-script tags
- **Container Detection**: Identifies LXC containers running Proxmox helper scripts
- **Server Association**: Links detected scripts to the specific PVE server
- **Bulk Import**: Automatically creates records for all detected scripts
**How Auto-Detection Works:**
1. Connects to your configured PVE servers
2. Scans LXC container configurations
3. Looks for containers with community-script tags
4. Creates installed script records automatically
**Manual Script Management:**
- **Add Scripts Manually**: Create records for scripts not auto-detected
- **Edit Script Details**: Update script names and container IDs
- **Delete Scripts**: Remove scripts from tracking
- **Bulk Operations**: Clean up old or invalid script records
**Script Tracking Features:**
- **Installation Status**: Track success, failure, or in-progress installations
- **Server Association**: Know which server each script is installed on
- **Container ID**: Link scripts to specific LXC containers
- **Web UI Access**: Track and access Web UI IP addresses and ports
- **Execution Logs**: View output and logs from script installations
- **Filtering**: Filter by server, status, or search terms
**Managing Installed Scripts:**
- **View All Scripts**: See all tracked scripts across all servers
- **Filter by Server**: Show scripts for a specific PVE server
- **Filter by Status**: Show successful, failed, or in-progress installations
- **Sort Options**: Sort by name, container ID, server, status, or date
- **Update Scripts**: Re-run or update existing script installations
**Web UI Access:**
Automatically detect and access Web UI interfaces for your installed scripts:
- **Auto-Detection**: Automatically detects Web UI URLs from script installation output
- **IP & Port Tracking**: Stores and displays Web UI IP addresses and ports
- **One-Click Access**: Click IP:port to open Web UI in new tab
- **Manual Detection**: Re-detect IP using `hostname -I` inside container
- **Port Detection**: Uses script metadata to get correct port (e.g., actualbudget:5006)
- **Editable Fields**: Manually edit IP and port values as needed
**Actions Dropdown:**
Clean interface with all actions organized in a dropdown menu:
- **Edit Button**: Always visible for quick script editing
- **Actions Dropdown**: Contains Update, Shell, Open UI, Start/Stop, Destroy, Delete
- **Smart Visibility**: Dropdown only appears when actions are available
- **Auto-Close**: Dropdown closes after clicking any action
- **Disabled States**: Actions are disabled when container is stopped
**Container Control:**
Directly control LXC containers from the installed scripts page via SSH:
- **Start/Stop Button**: Control container state with `pct start/stop <ID>`
- **Container Status**: Real-time status indicator (running/stopped/unknown)
- **Destroy Button**: Permanently remove LXC container with `pct destroy <ID>`
- **Confirmation Modals**: Simple OK/Cancel for start/stop, type container ID to confirm destroy
- **SSH Execution**: All commands executed remotely via configured SSH connections
**Safety Features:**
- Start/Stop actions require simple confirmation
- Destroy action requires typing the container ID to confirm
- All actions show loading states and error handling
- Only works with SSH scripts that have valid container IDs
### Update System
Keep your PVE Scripts Management application up to date with the latest features and improvements.
**What Does Updating Do?**
- **Downloads Latest Version**: Fetches the newest release from the GitHub repository
- **Updates Application Files**: Replaces current files with the latest version
- **Installs Dependencies**: Updates Node.js packages and dependencies
- **Rebuilds Application**: Compiles the application with latest changes
- **Restarts Server**: Automatically restarts the application server
**How to Update:**
**Automatic Update (Recommended):**
- Click the "Update Now" button when an update is available
- The system will handle everything automatically
- You'll see a progress overlay with update logs
- The page will reload automatically when complete
**Manual Update (Advanced):**
If automatic update fails, you can update manually:
```bash
# Navigate to the application directory
cd $PVESCRIPTLOCAL_DIR
# Pull latest changes
git pull
# Install dependencies
npm install
# Build the application
npm run build
# Start the application
npm start
```
**Update Process:**
1. **Check for Updates**: System automatically checks GitHub for new releases
2. **Download Update**: Downloads the latest release files
3. **Backup Current Version**: Creates backup of current installation
4. **Install New Version**: Replaces files and updates dependencies
5. **Build Application**: Compiles the updated code
6. **Restart Server**: Stops old server and starts new version
7. **Reload Page**: Automatically refreshes the browser
**Release Notes:**
Click the external link icon next to the update button to view detailed release notes on GitHub:
- See what's new in each version
- Read about bug fixes and improvements
- Check for any breaking changes
- View installation requirements
**Important Notes:**
- **Backup**: Your data and settings are preserved during updates
- **Downtime**: Brief downtime occurs during the update process
- **Compatibility**: Updates maintain backward compatibility with your data
- **Rollback**: If issues occur, you can manually revert to previous version
## 📁 Project Structure
```

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.4.3

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;

4673
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,51 +22,65 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/client": "^6.17.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8",
"@tanstack/react-query": "^5.87.4",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.5",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"better-sqlite3": "^9.6.0",
"next": "^15.5.3",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.546.0",
"next": "^15.5.5",
"node-pty": "^1.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"refractor": "^5.0.0",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"strip-ansi": "^7.1.2",
"superjson": "^2.2.1",
"tailwind-merge": "^3.3.1",
"ws": "^8.18.3",
"zod": "^3.24.2"
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/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.8.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.2",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
"jsdom": "^26.1.0",
"eslint-config-next": "^15.5.5",
"jsdom": "^27.0.0",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.15",
"prettier-plugin-tailwindcss": "^0.7.0",
"prisma": "^6.17.1",
"tailwindcss": "^4.1.14",
"typescript": "^5.8.2",
"typescript-eslint": "^8.27.0",
"typescript-eslint": "^8.46.1",
"vitest": "^3.2.4"
},
"ct3aMetadata": {

45
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,45 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model InstalledScript {
id Int @id @default(autoincrement())
script_name String
script_path String
container_id String?
server_id Int?
execution_mode String
installation_date DateTime? @default(now())
status String
output_log String?
web_ui_ip String?
web_ui_port Int?
server Server? @relation(fields: [server_id], references: [id], onDelete: SetNull)
@@map("installed_scripts")
}
model Server {
id Int @id @default(autoincrement())
name String @unique
ip String
user String
password String?
auth_type String? @default("password")
ssh_key String?
ssh_key_passphrase String?
ssh_port Int? @default(22)
color String?
created_at DateTime? @default(now())
updated_at DateTime? @updatedAt
ssh_key_path String?
key_generated Boolean? @default(false)
installed_scripts InstalledScript[]
@@map("servers")
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

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

@@ -1,41 +0,0 @@
{
"name": "OpenWrt",
"slug": "openwrt",
"categories": [
4,
2
],
"date_created": "2024-05-02",
"type": "vm",
"updateable": true,
"privileged": false,
"interface_port": null,
"documentation": "https://openwrt.org/docs/start",
"website": "https://openwrt.org/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/openwrt.webp",
"config_path": "",
"description": "OpenWrt is a powerful open-source firmware that can transform a wide range of networking devices into highly customizable and feature-rich routers, providing users with greater control and flexibility over their network infrastructure.",
"install_methods": [
{
"type": "default",
"script": "vm/openwrt.sh",
"resources": {
"cpu": 1,
"ram": 256,
"hdd": 0.5,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "If you use VLANs (default LAN is set to VLAN 999), make sure the Proxmox Linux Bridge is configured as VLAN-aware, otherwise the VM may fail to start.",
"type": "info"
}
]
}

View File

@@ -1,35 +0,0 @@
{
"name": "Petio",
"slug": "petio",
"categories": [
13
],
"date_created": "2024-06-12",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 7777,
"documentation": "https://docs.petio.tv/",
"website": "https://petio.tv/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/petio.webp",
"config_path": "",
"description": "Petio is a third party companion app available to Plex server owners to allow their users to request, review and discover content.",
"install_methods": [
{
"type": "default",
"script": "ct/petio.sh",
"resources": {
"cpu": 2,
"ram": 1024,
"hdd": 4,
"os": "ubuntu",
"version": "20.04"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

View File

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

View File

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

251
server.js
View File

@@ -7,7 +7,7 @@ import { join, resolve } from 'path';
import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty';
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
import { getDatabase } from './src/server/database.js';
import { getDatabase } from './src/server/database-prisma.js';
const dev = process.env.NODE_ENV !== 'production';
const hostname = '0.0.0.0';
@@ -51,6 +51,7 @@ const handle = app.getRequestHandler();
* @property {string} [mode]
* @property {ServerInfo} [server]
* @property {boolean} [isUpdate]
* @property {boolean} [isShell]
* @property {string} [containerId]
*/
@@ -130,17 +131,66 @@ class ScriptExecutionHandler {
return null;
}
/**
* Parse Web UI URL from terminal output
* @param {string} output - Terminal output to parse
* @returns {{ip: string, port: number}|null} - Object with ip and port if found, null otherwise
*/
parseWebUIUrl(output) {
// First, strip ANSI color codes to make pattern matching more reliable
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
// Look for URL patterns with any valid IP address (private or public)
const patterns = [
// HTTP/HTTPS URLs with IP and port
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)/gi,
// URLs without explicit port (assume default ports)
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/|$|\s)/gi,
// URLs with trailing slash and port
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)\//gi,
// URLs with just IP and port (no protocol)
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)(?:\s|$)/gi,
// URLs with just IP (no protocol, no port)
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\s|$)/gi,
];
// Try patterns on both original and cleaned output
const outputsToTry = [output, cleanOutput];
for (const testOutput of outputsToTry) {
for (const pattern of patterns) {
const matches = [...testOutput.matchAll(pattern)];
for (const match of matches) {
if (match[1]) {
const ip = match[1];
const port = match[2] || (match[0].startsWith('https') ? '443' : '80');
// Validate IP address format
if (ip.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
return {
ip: ip,
port: parseInt(port, 10)
};
}
}
}
}
}
return null;
}
/**
* Create installation record
* @param {string} scriptName - Name of the script
* @param {string} scriptPath - Path to the script
* @param {string} executionMode - 'local' or 'ssh'
* @param {number|null} serverId - Server ID for SSH executions
* @returns {number|null} - Installation record ID
* @returns {Promise<number|null>} - Installation record ID
*/
createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
try {
const result = this.db.createInstalledScript({
const result = await this.db.createInstalledScript({
script_name: scriptName,
script_path: scriptPath,
container_id: undefined,
@@ -149,7 +199,7 @@ class ScriptExecutionHandler {
status: 'in_progress',
output_log: ''
});
return Number(result.lastInsertRowid);
return Number(result.id);
} catch (error) {
console.error('Error creating installation record:', error);
return null;
@@ -161,9 +211,9 @@ class ScriptExecutionHandler {
* @param {number} installationId - Installation record ID
* @param {Object} updateData - Data to update
*/
updateInstallationRecord(installationId, updateData) {
async updateInstallationRecord(installationId, updateData) {
try {
this.db.updateInstalledScript(installationId, updateData);
await this.db.updateInstalledScript(installationId, updateData);
} catch (error) {
console.error('Error updating installation record:', error);
}
@@ -207,13 +257,15 @@ class ScriptExecutionHandler {
* @param {WebSocketMessage} message
*/
async handleMessage(ws, message) {
const { action, scriptPath, executionId, input, mode, server, isUpdate, containerId } = message;
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message;
switch (action) {
case 'start':
if (scriptPath && executionId) {
if (isUpdate && containerId) {
await this.startUpdateExecution(ws, containerId, executionId, mode, server);
} else if (isShell && containerId) {
await this.startShellExecution(ws, containerId, executionId, mode, server);
} else {
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
}
@@ -275,7 +327,7 @@ class ScriptExecutionHandler {
// Create installation record
const serverId = server ? (server.id ?? null) : null;
installationId = this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
if (!installationId) {
console.error('Failed to create installation record');
@@ -304,7 +356,7 @@ class ScriptExecutionHandler {
// Update installation record with failure
if (installationId) {
this.updateInstallationRecord(installationId, { status: 'failed' });
await this.updateInstallationRecord(installationId, { status: 'failed' });
}
return;
}
@@ -342,7 +394,7 @@ class ScriptExecutionHandler {
});
// Handle pty data (both stdout and stderr combined)
childProcess.onData((data) => {
childProcess.onData(async (data) => {
const output = data.toString();
// Store output in buffer for logging
@@ -358,7 +410,19 @@ class ScriptExecutionHandler {
// Parse for Container ID
const containerId = this.parseContainerId(output);
if (containerId && installationId) {
this.updateInstallationRecord(installationId, { container_id: containerId });
await this.updateInstallationRecord(installationId, { container_id: containerId });
}
// Parse for Web UI URL
const webUIUrl = this.parseWebUIUrl(output);
if (webUIUrl && installationId) {
const { ip, port } = webUIUrl;
if (ip && port) {
await this.updateInstallationRecord(installationId, {
web_ui_ip: ip,
web_ui_port: port
});
}
}
this.sendMessage(ws, {
@@ -400,7 +464,7 @@ class ScriptExecutionHandler {
// Update installation record with failure
if (installationId) {
this.updateInstallationRecord(installationId, { status: 'failed' });
await this.updateInstallationRecord(installationId, { status: 'failed' });
}
}
}
@@ -427,7 +491,7 @@ class ScriptExecutionHandler {
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
server,
scriptPath,
/** @param {string} data */ (data) => {
/** @param {string} data */ async (data) => {
// Store output in buffer for logging
const exec = this.activeExecutions.get(executionId);
if (exec) {
@@ -441,7 +505,19 @@ class ScriptExecutionHandler {
// Parse for Container ID
const containerId = this.parseContainerId(data);
if (containerId && installationId) {
this.updateInstallationRecord(installationId, { container_id: containerId });
await this.updateInstallationRecord(installationId, { container_id: containerId });
}
// Parse for Web UI URL
const webUIUrl = this.parseWebUIUrl(data);
if (webUIUrl && installationId) {
const { ip, port } = webUIUrl;
if (ip && port) {
await this.updateInstallationRecord(installationId, {
web_ui_ip: ip,
web_ui_port: port
});
}
}
// Handle data output
@@ -469,13 +545,13 @@ class ScriptExecutionHandler {
timestamp: Date.now()
});
},
/** @param {number} code */ (code) => {
/** @param {number} code */ async (code) => {
const exec = this.activeExecutions.get(executionId);
const isSuccess = code === 0;
// Update installation record with final status and output
if (installationId && exec) {
this.updateInstallationRecord(installationId, {
await this.updateInstallationRecord(installationId, {
status: isSuccess ? 'success' : 'failed',
output_log: exec.outputBuffer
});
@@ -510,7 +586,7 @@ class ScriptExecutionHandler {
// Update installation record with failure
if (installationId) {
this.updateInstallationRecord(installationId, { status: 'failed' });
await this.updateInstallationRecord(installationId, { status: 'failed' });
}
}
}
@@ -709,6 +785,145 @@ class ScriptExecutionHandler {
});
}
}
/**
* Start shell execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
* @param {string} mode
* @param {ServerInfo|null} server
*/
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) {
try {
// Send start message
this.sendMessage(ws, {
type: 'start',
data: `Starting shell session for container ${containerId}...`,
timestamp: Date.now()
});
if (mode === 'ssh' && server) {
await this.startSSHShellExecution(ws, containerId, executionId, server);
} else {
await this.startLocalShellExecution(ws, containerId, executionId);
}
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `Failed to start shell: ${error instanceof Error ? error.message : String(error)}`,
timestamp: Date.now()
});
}
}
/**
* Start local shell execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
*/
async startLocalShellExecution(ws, containerId, executionId) {
const { spawn } = await import('node-pty');
// Create a shell process that will run pct enter
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: process.cwd(),
env: process.env
});
// Store the execution
this.activeExecutions.set(executionId, {
process: childProcess,
ws
});
// Handle pty data
childProcess.onData((data) => {
this.sendMessage(ws, {
type: 'output',
data: data.toString(),
timestamp: Date.now()
});
});
// Note: No automatic command is sent - user can type commands interactively
// Handle process exit
childProcess.onExit((e) => {
this.sendMessage(ws, {
type: 'end',
data: `Shell session ended with exit code: ${e.exitCode}`,
timestamp: Date.now()
});
this.activeExecutions.delete(executionId);
});
}
/**
* Start SSH shell execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
* @param {ServerInfo} server
*/
async startSSHShellExecution(ws, containerId, executionId, server) {
const sshService = getSSHExecutionService();
try {
const execution = await sshService.executeCommand(
server,
`pct enter ${containerId}`,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */
(code) => {
this.sendMessage(ws, {
type: 'end',
data: `Shell session ended with exit code: ${code}`,
timestamp: Date.now()
});
this.activeExecutions.delete(executionId);
}
);
// Store the execution
this.activeExecutions.set(executionId, {
process: /** @type {any} */ (execution).process,
ws
});
// Note: No automatic command is sent - user can type commands interactively
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `SSH shell execution failed: ${error instanceof Error ? error.message : String(error)}`,
timestamp: Date.now()
});
}
}
}
// TerminalHandler removed - not used by current application

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: (
@@ -195,24 +196,27 @@ export function CategorySidebar({
});
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 transition-all duration-300 ${
isCollapsed ? 'w-16' : 'w-80'
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
isCollapsed ? 'w-16' : 'w-full lg:w-80'
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between p-4 border-b border-border">
{!isCollapsed && (
<div className="flex items-center justify-between w-full">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Categories</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{totalScripts} Total scripts</p>
<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
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
title={isCollapsed ? 'Expand categories' : 'Collapse categories'}
>
<svg
className={`w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform ${
className={`w-5 h-5 text-muted-foreground transition-transform ${
isCollapsed ? 'rotate-180' : ''
}`}
fill="none"
@@ -233,21 +237,21 @@ export function CategorySidebar({
onClick={() => onCategorySelect(null)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
selectedCategory === null
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
}`}
>
<div className="flex items-center space-x-3">
<CategoryIcon
iconName="template"
className={`w-5 h-5 ${selectedCategory === null ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400'}`}
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground'}`}
/>
<span className="font-medium">All Categories</span>
</div>
<span className={`text-sm px-2 py-1 rounded-full ${
selectedCategory === null
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
{totalScripts}
</span>
@@ -263,14 +267,14 @@ export function CategorySidebar({
onClick={() => onCategorySelect(category)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
isSelected
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
}`}
>
<div className="flex items-center space-x-3">
<CategoryIcon
iconName={categoryIconMapping[category] ?? 'box'}
className={`w-5 h-5 ${isSelected ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400'}`}
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`}
/>
<span className="font-medium capitalize">
{category.replace(/[_-]/g, ' ')}
@@ -278,8 +282,8 @@ export function CategorySidebar({
</div>
<span className={`text-sm px-2 py-1 rounded-full ${
isSelected
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
{count}
</span>
@@ -292,32 +296,32 @@ export function CategorySidebar({
{/* Collapsed state - show only icons with counters and tooltips */}
{isCollapsed && (
<div className="p-2 flex flex-col space-y-2">
<div className="p-2 flex flex-row lg:flex-col space-x-2 lg:space-x-0 lg:space-y-2 overflow-x-auto lg:overflow-x-visible">
{/* "All Categories" option */}
<div className="group relative">
<button
onClick={() => onCategorySelect(null)}
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
selectedCategory === null
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
}`}
>
<CategoryIcon
iconName="template"
className={`w-5 h-5 ${selectedCategory === null ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200'}`}
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className={`text-xs mt-1 px-1 rounded ${
selectedCategory === null
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
{totalScripts}
</span>
</button>
{/* Tooltip */}
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 dark:bg-gray-700 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
All Categories ({totalScripts})
</div>
</div>
@@ -332,25 +336,25 @@ export function CategorySidebar({
onClick={() => onCategorySelect(category)}
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
isSelected
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
}`}
>
<CategoryIcon
iconName={categoryIconMapping[category] ?? 'box'}
className={`w-5 h-5 ${isSelected ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200'}`}
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className={`text-xs mt-1 px-1 rounded ${
isSelected
? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
{count}
</span>
</button>
{/* Tooltip */}
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 dark:bg-gray-700 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
{category} ({count})
</div>
</div>

View File

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

View File

@@ -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,43 @@
'use client';
import { useState } from 'react';
import { HelpModal } from './HelpModal';
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 (
<>
<div
onClick={() => setIsOpen(true)}
className={`${sizeClasses} text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer inline-flex items-center justify-center rounded-md transition-colors ${className}`}
title={tooltip}
>
<HelpCircle className="w-4 h-4" />
</div>
<HelpModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
initialSection={section}
/>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

@@ -2,6 +2,10 @@
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;
@@ -14,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) {
@@ -23,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);
@@ -32,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 {
@@ -41,166 +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 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Execution Mode</h2>
<button
<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}
className="text-gray-400 hover:text-gray-600 transition-colors"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Button>
</div>
{/* Content */}
<div className="p-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-gray-900 mb-2">
Where would you like to execute &quot;{scriptName}&quot;?
</h3>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
<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-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => handleModeChange('ssh')}
>
<div className="flex items-center">
<input
type="radio"
id="ssh"
name="executionMode"
value="ssh"
checked={selectedMode === 'ssh'}
onChange={() => handleModeChange('ssh')}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<label htmlFor="ssh" className="ml-3 flex-1 cursor-pointer">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-gray-900">SSH Execution</h4>
<p className="text-sm text-gray-500">Run the script on a remote server</p>
</div>
</div>
</label>
</div>
</div>
</div>
{/* Server Selection (only for SSH mode) */}
{selectedMode === 'ssh' && (
<div className="mb-6">
<label htmlFor="server" className="block text-sm font-medium text-gray-700 mb-2">
Select Server
</label>
{loading ? (
<div className="text-center py-4">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<p className="mt-2 text-sm text-gray-600">Loading servers...</p>
<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-4 text-gray-500">
<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 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-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
<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"
>
<option value="">Select a server...</option>
{servers.map((server) => (
<option key={server.id} value={server.id}>
{server.name} ({server.ip}) - {server.user}
</option>
))}
</select>
)}
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
<Button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
variant="outline"
size="default"
>
Cancel
</button>
<button
</Button>
<Button
onClick={handleExecute}
disabled={selectedMode === 'ssh' && !selectedServer}
className={`px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
selectedMode === 'ssh' && !selectedServer
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
variant="default"
size="default"
>
{selectedMode === 'local' ? 'Run Locally' : 'Run on Server'}
</button>
</div>
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>
{/* Server Settings Modal */}
<SettingsModal
isOpen={settingsModalOpen}
onClose={handleSettingsModalClose}
/>
</>
);
}

View File

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

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-3 backdrop-blur-sm">
<div className="container mx-auto px-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<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-2">
<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

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

View File

@@ -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,562 @@
'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>
</ul>
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-md">
<h5 className="font-medium text-blue-900 dark:text-blue-100 mb-2">SSH Key Features:</h5>
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
<li> <strong>Generate Key Pair:</strong> Create new SSH keys automatically</li>
<li> <strong>View Public Key:</strong> Copy public key for server setup</li>
<li> <strong>Persistent Storage:</strong> Keys are stored securely on disk</li>
</ul>
</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">
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>Web UI Access:</strong> Track and access Web UI IP addresses and ports</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-blue-900/20 border-blue-700/50">
<h4 className="font-medium text-foreground mb-2">Web UI Access </h4>
<p className="text-sm text-muted-foreground mb-3">
Automatically detect and access Web UI interfaces for your installed scripts.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Auto-Detection:</strong> Automatically detects Web UI URLs from script installation output</li>
<li> <strong>IP & Port Tracking:</strong> Stores and displays Web UI IP addresses and ports</li>
<li> <strong>One-Click Access:</strong> Click IP:port to open Web UI in new tab</li>
<li> <strong>Manual Detection:</strong> Re-detect IP using <code>hostname -I</code> inside container</li>
<li> <strong>Port Detection:</strong> Uses script metadata to get correct port (e.g., actualbudget:5006)</li>
<li> <strong>Editable Fields:</strong> Manually edit IP and port values as needed</li>
</ul>
<div className="mt-3 p-3 bg-blue-900/30 rounded-lg border border-blue-700/30">
<p className="text-sm font-medium text-blue-300">💡 How it works:</p>
<ul className="text-sm text-muted-foreground mt-1 space-y-1">
<li> Scripts automatically detect URLs like <code>http://10.10.10.1:3000</code> during installation</li>
<li> Re-detect button runs <code>hostname -I</code> inside the container via SSH</li>
<li> Port defaults to 80, but uses script metadata when available</li>
<li> Web UI buttons are disabled when container is stopped</li>
</ul>
</div>
</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">Actions Dropdown </h4>
<p className="text-sm text-muted-foreground mb-3">
Clean interface with all actions organized in a dropdown menu.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Edit Button:</strong> Always visible for quick script editing</li>
<li> <strong>Actions Dropdown:</strong> Contains Update, Shell, Open UI, Start/Stop, Destroy, Delete</li>
<li> <strong>Smart Visibility:</strong> Dropdown only appears when actions are available</li>
<li> <strong>Color Coding:</strong> Start (green), Stop (red), Update (cyan), Shell (gray), Open UI (blue)</li>
<li> <strong>Auto-Close:</strong> Dropdown closes after clicking any action</li>
<li> <strong>Disabled States:</strong> Actions are disabled when container is stopped</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</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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
'use client';
import { Loader2 } from 'lucide-react';
interface LoadingModalProps {
isOpen: boolean;
action: string;
}
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
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 p-8">
<div className="flex flex-col items-center space-y-4">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-card-foreground mb-2">
Processing
</h3>
<p className="text-sm text-muted-foreground">
{action}
</p>
<p className="text-xs text-muted-foreground mt-2">
Please wait...
</p>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import { X, Copy, Check, Server, Globe } from 'lucide-react';
import { Button } from './ui/button';
interface PublicKeyModalProps {
isOpen: boolean;
onClose: () => void;
publicKey: string;
serverName: string;
serverIp: string;
}
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
const [copied, setCopied] = useState(false);
if (!isOpen) return null;
const handleCopy = async () => {
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(publicKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
// Fallback for older browsers or non-HTTPS
const textArea = document.createElement('textarea');
textArea.value = publicKey;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (fallbackError) {
console.error('Fallback copy failed:', fallbackError);
// If all else fails, show the key in an alert
alert('Please manually copy this key:\n\n' + publicKey);
}
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
// Fallback: show the key in an alert
alert('Please manually copy this key:\n\n' + publicKey);
}
};
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-2xl w-full border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Server className="h-6 w-6 text-blue-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-card-foreground">SSH Public Key</h2>
<p className="text-sm text-muted-foreground">Add this key to your server&apos;s authorized_keys</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Server Info */}
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Server className="h-4 w-4" />
<span className="font-medium">{serverName}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="h-4 w-4" />
<span>{serverIp}</span>
</div>
</div>
{/* Instructions */}
<div className="space-y-2">
<h3 className="font-medium text-foreground">Instructions:</h3>
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
<li>Copy the public key below</li>
<li>SSH into your server: <code className="bg-muted px-1 rounded">ssh root@{serverIp}</code></li>
<li>Add the key to authorized_keys: <code className="bg-muted px-1 rounded">echo &quot;&lt;paste-key&gt;&quot; &gt;&gt; ~/.ssh/authorized_keys</code></li>
<li>Set proper permissions: <code className="bg-muted px-1 rounded">chmod 600 ~/.ssh/authorized_keys</code></li>
</ol>
</div>
{/* Public Key */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Public Key:</label>
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="gap-2"
>
{copied ? (
<>
<Check className="h-4 w-4" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</Button>
</div>
<textarea
value={publicKey}
readOnly
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[120px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Public key will appear here..."
/>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,218 @@
'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';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
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">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({children}) => <h1 className="text-2xl font-bold text-card-foreground mb-4 mt-6">{children}</h1>,
h2: ({children}) => <h2 className="text-xl font-semibold text-card-foreground mb-3 mt-5">{children}</h2>,
h3: ({children}) => <h3 className="text-lg font-medium text-card-foreground mb-2 mt-4">{children}</h3>,
p: ({children}) => <p className="text-card-foreground mb-3 leading-relaxed">{children}</p>,
ul: ({children}) => <ul className="list-disc list-inside text-card-foreground mb-3 space-y-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal list-inside text-card-foreground mb-3 space-y-1">{children}</ol>,
li: ({children}) => <li className="text-card-foreground">{children}</li>,
a: ({href, children}) => <a href={href} className="text-blue-500 hover:text-blue-400 underline" target="_blank" rel="noopener noreferrer">{children}</a>,
strong: ({children}) => <strong className="font-semibold text-card-foreground">{children}</strong>,
em: ({children}) => <em className="italic text-card-foreground">{children}</em>,
}}
>
{release.body}
</ReactMarkdown>
</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

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

View File

@@ -0,0 +1,204 @@
'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) {
let keyType = 'Unknown';
// Check for traditional PEM format keys
if (keyLine.includes('RSA')) {
keyType = 'RSA';
} else if (keyLine.includes('ED25519')) {
keyType = 'ED25519';
} else if (keyLine.includes('ECDSA')) {
keyType = 'ECDSA';
} else if (keyLine.includes('OPENSSH PRIVATE KEY')) {
// For OpenSSH format keys, try to detect type from the key content
// This is a heuristic - OpenSSH ED25519 keys typically start with specific patterns
// We'll default to "OpenSSH" for now since we can't reliably detect the type
keyType = 'OpenSSH';
}
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,ed25519,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, ed25519, 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-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg dark:hover:shadow-xl transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 h-full flex flex-col"
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col 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">
@@ -36,20 +65,20 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
onError={handleImageError}
/>
) : (
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400 text-lg font-semibold">
<div className="w-12 h-12 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>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate">
<h3 className="text-lg font-semibold text-foreground truncate">
{script.name || 'Unnamed Script'}
</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>
@@ -60,7 +89,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
}`}></div>
<span className={`text-xs font-medium ${
script.isDownloaded ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'
script.isDownloaded ? 'text-green-700' : 'text-destructive'
}`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
</span>
@@ -70,7 +99,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
</div>
{/* Description */}
<p className="text-gray-600 dark:text-gray-300 text-sm line-clamp-3 mb-4 flex-1">
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
{script.description || 'No description available'}
</p>
@@ -81,7 +110,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm font-medium flex items-center space-x-1"
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1"
onClick={(e) => e.stopPropagation()}
>
<span>Website</span>

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

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

View File

@@ -0,0 +1,370 @@
'use client';
import { Button } from './ui/button';
import { StatusBadge } from './Badge';
import { getContrastColor } from '../../lib/colorUtils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from './ui/dropdown-menu';
interface InstalledScript {
id: number;
script_name: string;
script_path: string;
container_id: string | null;
server_id: number | null;
server_name: string | null;
server_ip: string | null;
server_user: string | null;
server_password: string | null;
server_auth_type: string | null;
server_ssh_key: string | null;
server_ssh_key_passphrase: string | null;
server_ssh_port: number | 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';
web_ui_ip: string | null;
web_ui_port: number | null;
}
interface ScriptInstallationCardProps {
script: InstalledScript;
isEditing: boolean;
editFormData: { script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string };
onInputChange: (field: 'script_name' | 'container_id' | 'web_ui_ip' | 'web_ui_port', value: string) => void;
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
onUpdate: () => void;
onShell: () => void;
onDelete: () => void;
isUpdating: boolean;
isDeleting: boolean;
// New container control props
containerStatus?: 'running' | 'stopped' | 'unknown';
onStartStop: (action: 'start' | 'stop') => void;
onDestroy: () => void;
isControlling: boolean;
// Web UI props
onOpenWebUI: () => void;
onAutoDetectWebUI: () => void;
isAutoDetecting: boolean;
}
export function ScriptInstallationCard({
script,
isEditing,
editFormData,
onInputChange,
onEdit,
onSave,
onCancel,
onUpdate,
onShell,
onDelete,
isUpdating,
isDeleting,
containerStatus,
onStartStop,
onDestroy,
isControlling,
onOpenWebUI,
onAutoDetectWebUI,
isAutoDetecting
}: ScriptInstallationCardProps) {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// Helper function to check if a script has any actions available
const hasActions = (script: InstalledScript) => {
if (script.container_id && script.execution_mode === 'ssh') return true;
if (script.web_ui_ip != null) return true;
if (!script.container_id || script.execution_mode !== 'ssh') return true;
return false;
};
return (
<div
className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
style={{ borderLeft: `4px solid ${script.server_color ?? 'transparent'}` }}
>
{/* Header with Script Name and Status */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="space-y-2">
<input
type="text"
value={editFormData.script_name}
onChange={(e) => onInputChange('script_name', e.target.value)}
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Script name"
/>
<div className="text-xs text-muted-foreground">{script.script_path}</div>
</div>
) : (
<div>
<div className="text-sm font-medium text-foreground truncate">{script.script_name}</div>
<div className="text-xs text-muted-foreground truncate">{script.script_path}</div>
</div>
)}
</div>
<div className="ml-2 flex-shrink-0">
<StatusBadge status={script.status}>
{script.status.replace('_', ' ').toUpperCase()}
</StatusBadge>
</div>
</div>
{/* Details Grid */}
<div className="grid grid-cols-1 gap-3 mb-4">
{/* Container ID */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Container ID</div>
{isEditing ? (
<input
type="text"
value={editFormData.container_id}
onChange={(e) => onInputChange('container_id', e.target.value)}
className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Container ID"
/>
) : (
<div className="text-sm font-mono text-foreground break-all">
{script.container_id ? (
<div 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>
{/* Web UI */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">IP:PORT</div>
{isEditing ? (
<div className="flex items-center space-x-2">
<input
type="text"
value={editFormData.web_ui_ip}
onChange={(e) => onInputChange('web_ui_ip', e.target.value)}
className="flex-1 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="IP"
/>
<span className="text-muted-foreground">:</span>
<input
type="number"
value={editFormData.web_ui_port}
onChange={(e) => onInputChange('web_ui_port', e.target.value)}
className="w-20 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Port"
/>
</div>
) : (
<div className="text-sm font-mono text-foreground">
{script.web_ui_ip ? (
<div className="flex items-center justify-between w-full">
<button
onClick={onOpenWebUI}
disabled={containerStatus === 'stopped'}
className={`text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline flex-shrink-0 ${
containerStatus === 'stopped' ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{script.web_ui_ip}:{script.web_ui_port ?? 80}
</button>
{script.container_id && script.execution_mode === 'ssh' && (
<button
onClick={onAutoDetectWebUI}
disabled={isAutoDetecting}
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors flex-shrink-0 ml-2"
title="Re-detect IP and port"
>
{isAutoDetecting ? '...' : 'Re-detect'}
</button>
)}
</div>
) : (
<div className="flex items-center space-x-2">
<span className="text-muted-foreground">-</span>
{script.container_id && script.execution_mode === 'ssh' && (
<button
onClick={onAutoDetectWebUI}
disabled={isAutoDetecting}
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors"
title="Re-detect IP and port"
>
{isAutoDetecting ? '...' : 'Re-detect'}
</button>
)}
</div>
)}
</div>
)}
</div>
{/* Server */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
<span
className="text-sm px-3 py-1 rounded inline-block"
style={{
backgroundColor: script.server_color ?? 'transparent',
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
}}
>
{script.server_name ?? '-'}
</span>
</div>
{/* Installation Date */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Installation Date</div>
<div className="text-sm text-muted-foreground">
{formatDate(String(script.installation_date))}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
{isEditing ? (
<>
<Button
onClick={onSave}
disabled={isUpdating}
variant="save"
size="sm"
className="flex-1 min-w-0"
>
{isUpdating ? 'Saving...' : 'Save'}
</Button>
<Button
onClick={onCancel}
variant="cancel"
size="sm"
className="flex-1 min-w-0"
>
Cancel
</Button>
</>
) : (
<>
<Button
onClick={onEdit}
variant="edit"
size="sm"
className="flex-1 min-w-0"
>
Edit
</Button>
{hasActions(script) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-1 min-w-0 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"
>
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48 bg-gray-900 border-gray-700">
{script.container_id && (
<DropdownMenuItem
onClick={onUpdate}
disabled={containerStatus === 'stopped'}
className="text-cyan-300 hover:text-cyan-200 hover:bg-cyan-900/20 focus:bg-cyan-900/20"
>
Update
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<DropdownMenuItem
onClick={onShell}
disabled={containerStatus === 'stopped'}
className="text-gray-300 hover:text-gray-200 hover:bg-gray-800/20 focus:bg-gray-800/20"
>
Shell
</DropdownMenuItem>
)}
{script.web_ui_ip && (
<DropdownMenuItem
onClick={onOpenWebUI}
disabled={containerStatus === 'stopped'}
className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
>
Open UI
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
onClick={() => onStartStop(containerStatus === 'running' ? 'stop' : 'start')}
disabled={isControlling || containerStatus === 'unknown'}
className={containerStatus === 'running'
? "text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
: "text-green-300 hover:text-green-200 hover:bg-green-900/20 focus:bg-green-900/20"
}
>
{isControlling ? 'Working...' : containerStatus === 'running' ? 'Stop' : 'Start'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onDestroy}
disabled={isControlling}
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
>
{isControlling ? 'Working...' : 'Destroy'}
</DropdownMenuItem>
</>
)}
{(!script.container_id || script.execution_mode !== 'ssh') && (
<>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
onClick={onDelete}
disabled={isDeleting}
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -3,9 +3,12 @@
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';
@@ -18,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,
@@ -25,15 +31,109 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
sortBy: 'name',
sortOrder: 'asc',
});
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getCtScripts.useQuery();
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
{ slug: selectedSlug ?? '' },
{ enabled: !!selectedSlug }
);
// 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 {
// Load SAVE_FILTER setting
const saveFilterResponse = await fetch('/api/settings/save-filter');
let saveFilterEnabled = false;
if (saveFilterResponse.ok) {
const saveFilterData = await saveFilterResponse.json();
saveFilterEnabled = saveFilterData.enabled ?? false;
setSaveFiltersEnabled(saveFilterEnabled);
}
// Load saved filters if SAVE_FILTER is enabled
if (saveFilterEnabled) {
const filtersResponse = await fetch('/api/settings/filters');
if (filtersResponse.ok) {
const filtersData = await filtersResponse.json();
if (filtersData.filters) {
setFilters(filtersData.filters as FilterState);
}
}
}
// Load view mode
const viewModeResponse = await fetch('/api/settings/view-mode');
if (viewModeResponse.ok) {
const viewModeData = await viewModeResponse.json();
const viewMode = viewModeData.viewMode;
if (viewMode && typeof viewMode === 'string' && (viewMode === 'card' || viewMode === 'list')) {
setViewMode(viewMode);
}
}
} catch (error) {
console.error('Error loading settings:', error);
} finally {
setIsLoadingFilters(false);
}
};
void loadSettings();
}, []);
// Save filters when they change (if SAVE_FILTER is enabled)
useEffect(() => {
if (!saveFiltersEnabled || isLoadingFilters) return;
const saveFilters = async () => {
try {
await fetch('/api/settings/filters', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filters }),
});
} catch (error) {
console.error('Error saving filters:', error);
}
};
// Debounce the save operation
const timeoutId = setTimeout(() => void saveFilters(), 500);
return () => clearTimeout(timeoutId);
}, [filters, saveFiltersEnabled, isLoadingFilters]);
// Save view mode when it changes
useEffect(() => {
if (isLoadingFilters) return;
const saveViewMode = async () => {
try {
await fetch('/api/settings/view-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ viewMode }),
});
} catch (error) {
console.error('Error saving view mode:', error);
}
};
// Debounce the save operation
const timeoutId = setTimeout(() => void saveViewMode(), 300);
return () => clearTimeout(timeoutId);
}, [viewMode, isLoadingFilters]);
// Extract categories from metadata
const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
@@ -233,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);
@@ -253,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
@@ -269,7 +542,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">Loading scripts...</span>
<span className="ml-2 text-muted-foreground">Loading scripts...</span>
</div>
);
}
@@ -282,29 +555,31 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p className="text-lg font-medium">Failed to load scripts</p>
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-muted-foreground mt-1">
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
</p>
</div>
<button
<Button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
variant="default"
size="default"
className="mt-4"
>
Try Again
</button>
</Button>
</div>
);
}
if (!scriptsWithStatus || scriptsWithStatus.length === 0) {
if (!scriptsWithStatus?.length) {
return (
<div className="text-center py-12">
<div className="text-gray-500">
<div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-lg font-medium">No scripts found</p>
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-muted-foreground mt-1">
No script files were found in the repository or local directory.
</p>
</div>
@@ -313,9 +588,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
}
return (
<div className="flex gap-6">
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
{/* Category Sidebar */}
<div className="flex-shrink-0">
<div className="flex-shrink-0 order-2 lg:order-1">
<CategorySidebar
categories={categories}
categoryCounts={categoryCounts}
@@ -326,7 +601,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
</div>
{/* Main Content */}
<div className="flex-1 min-w-0" ref={gridRef}>
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
{/* Enhanced Filter Bar */}
<FilterBar
filters={filters}
@@ -334,13 +609,172 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
totalScripts={scriptsWithStatus.length}
filteredCount={filteredScripts.length}
updatableCount={filterCounts.updatableCount}
saveFiltersEnabled={saveFiltersEnabled}
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}
variant="outline"
size="sm"
className="bg-blue-500/10 hover:bg-blue-500/20 border-blue-500/30 text-blue-300 hover:text-blue-200 hover:border-blue-400/50"
>
{loadSingleScriptMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div>
Downloading...
</>
) : (
`Download Selected (${selectedSlugs.size})`
)}
</Button>
) : (
<Button
onClick={handleDownloadAllFiltered}
disabled={filteredScripts.length === 0 || loadSingleScriptMutation.isPending}
variant="outline"
size="sm"
>
{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="outline"
size="default"
>
Clear Selection
</Button>
)}
{filteredScripts.length > 0 && (
<Button
onClick={selectAllVisible}
variant="outline"
size="default"
>
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">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="h-5 w-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
@@ -349,12 +783,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
placeholder="Search scripts by name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-800 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-gray-100 focus:outline-none focus:placeholder-gray-400 dark:focus:placeholder-gray-300 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 text-sm"
className="block w-full pl-10 pr-3 py-3 border border-border rounded-lg leading-5 bg-card placeholder-muted-foreground text-foreground focus:outline-none focus:placeholder-muted-foreground focus:ring-2 focus:ring-ring focus:border-ring text-sm"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
className="absolute inset-y-0 right-0 pr-3 flex items-center text-muted-foreground hover:text-foreground"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -363,7 +797,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
)}
</div>
{(searchQuery || selectedCategory) && (
<div className="text-center mt-2 text-sm text-gray-600">
<div className="text-center mt-2 text-sm text-muted-foreground">
{filteredScripts.length === 0 ? (
<span>No scripts found{searchQuery ? ` matching "${searchQuery}"` : ''}{selectedCategory ? ` in category "${selectedCategory}"` : ''}</span>
) : (
@@ -380,35 +814,38 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{/* Scripts Grid */}
{filteredScripts.length === 0 && (filters.searchQuery || selectedCategory || filters.showUpdatable !== null || filters.selectedTypes.length > 0) ? (
<div className="text-center py-12">
<div className="text-gray-500">
<div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p className="text-lg font-medium">No matching scripts found</p>
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-muted-foreground mt-1">
Try different filter settings or clear all filters.
</p>
<div className="flex justify-center gap-2 mt-4">
{filters.searchQuery && (
<button
<Button
onClick={() => handleFiltersChange({ ...filters, searchQuery: '' })}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
variant="default"
size="default"
>
Clear Search
</button>
</Button>
)}
{selectedCategory && (
<button
<Button
onClick={() => handleCategorySelect(null)}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
variant="secondary"
size="default"
>
Clear Category
</button>
</Button>
)}
</div>
</div>
</div>
) : (
viewMode === 'card' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties
@@ -424,10 +861,35 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
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,7 +1,11 @@
'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';
import { PublicKeyModal } from './PublicKeyModal';
import { Key } from 'lucide-react';
interface ServerFormProps {
onSubmit: (data: CreateServerData) => void;
@@ -17,13 +21,40 @@ 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);
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
const [, setIsGeneratedKey] = useState(false);
const [, setGeneratedServerId] = useState<number | null>(null);
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';
@@ -43,12 +74,29 @@ 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') {
if (!formData.password?.trim()) {
newErrors.password = 'Password is required for password authentication';
}
}
if (authType === 'key') {
if (!formData.ssh_key?.trim()) {
newErrors.ssh_key = 'SSH key is required for key authentication';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
return Object.keys(newErrors).length === 0 && !sshKeyError;
};
const handleSubmit = (e: React.FormEvent) => {
@@ -56,26 +104,92 @@ 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
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
// Reset generated key state when switching auth types
if (field === 'auth_type') {
setIsGeneratedKey(false);
setGeneratedPublicKey('');
}
};
const handleGenerateKeyPair = async () => {
setIsGeneratingKey(true);
try {
const response = await fetch('/api/servers/generate-keypair', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to generate key pair');
}
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
if (data.success) {
const serverId = data.serverId ?? 0;
const keyPath = `data/ssh-keys/server_${serverId}_key`;
setFormData(prev => ({
...prev,
ssh_key: data.privateKey ?? '',
ssh_key_path: keyPath,
key_generated: true
}));
setGeneratedPublicKey(data.publicKey ?? '');
setGeneratedServerId(serverId);
setIsGeneratedKey(true);
setShowPublicKeyModal(true);
setSshKeyError('');
} else {
throw new Error(data.error ?? 'Failed to generate key pair');
}
} catch (error) {
console.error('Error generating key pair:', error);
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
} finally {
setIsGeneratingKey(false);
}
};
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">
<div className="grid grid-cols-1 md:grid-cols-2 gap-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-gray-700 dark:text-gray-300 mb-1">
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
Server Name *
</label>
<input
@@ -83,16 +197,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="name"
value={formData.name}
onChange={handleChange('name')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
errors.name ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.name ? 'border-destructive' : 'border-border'
}`}
placeholder="e.g., Production Server"
/>
{errors.name && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.name}</p>}
{errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
</div>
<div>
<label htmlFor="ip" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
IP Address *
</label>
<input
@@ -100,16 +214,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="ip"
value={formData.ip}
onChange={handleChange('ip')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
errors.ip ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ip ? 'border-destructive' : 'border-border'
}`}
placeholder="e.g., 192.168.1.100"
/>
{errors.ip && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.ip}</p>}
{errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
</div>
<div>
<label htmlFor="user" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label htmlFor="user" className="block text-sm font-medium text-muted-foreground mb-1">
Username *
</label>
<input
@@ -117,50 +231,192 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="user"
value={formData.user}
onChange={handleChange('user')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
errors.user ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.user ? 'border-destructive' : 'border-border'
}`}
placeholder="e.g., root"
/>
{errors.user && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.user}</p>}
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
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>
</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' && (
<div>
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
Password *
</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-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 ${
errors.password ? 'border-red-300 dark:border-red-600' : 'border-gray-300 dark:border-gray-600'
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'
}`}
placeholder="Enter password"
/>
{errors.password && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.password}</p>}
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
</div>
)}
{/* SSH Key Authentication */}
{formData.auth_type === 'key' && (
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-muted-foreground">
SSH Private Key *
</label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerateKeyPair}
disabled={isGeneratingKey}
className="gap-2"
>
<Key className="h-4 w-4" />
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
</Button>
</div>
<div className="flex justify-end space-x-3 pt-4">
{/* Show manual key input only if no key has been generated */}
{!formData.key_generated && (
<>
<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>}
</>
)}
{/* Show generated key status */}
{formData.key_generated && (
<div className="p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium text-green-800 dark:text-green-200">
SSH key pair generated successfully
</span>
</div>
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
The private key has been generated and will be saved with the server.
</p>
</div>
)}
</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 && (
<button
<Button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400"
variant="outline"
size="default"
className="w-full sm:w-auto order-2 sm:order-1"
>
Cancel
</button>
</Button>
)}
<button
<Button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 dark:bg-blue-700 border border-transparent rounded-md hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-500 dark:focus:ring-blue-400"
variant="default"
size="default"
className="w-full sm:w-auto order-1 sm:order-2"
>
{isEditing ? 'Update Server' : 'Add Server'}
</button>
</Button>
</div>
</form>
{/* Public Key Modal */}
<PublicKeyModal
isOpen={showPublicKeyModal}
onClose={() => setShowPublicKeyModal(false)}
publicKey={generatedPublicKey}
serverName={formData.name || 'New Server'}
serverIp={formData.ip}
/>
</>
);
}

View File

@@ -3,6 +3,10 @@
import { useState } from 'react';
import type { Server, CreateServerData } from '../../types/server';
import { ServerForm } from './ServerForm';
import { Button } from './ui/button';
import { ConfirmationModal } from './ConfirmationModal';
import { PublicKeyModal } from './PublicKeyModal';
import { Key } from 'lucide-react';
interface ServerListProps {
servers: Server[];
@@ -14,6 +18,20 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
const [editingId, setEditingId] = useState<number | null>(null);
const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set());
const [connectionResults, setConnectionResults] = useState<Map<number, { success: boolean; message: string }>>(new Map());
const [confirmationModal, setConfirmationModal] = useState<{
isOpen: boolean;
variant: 'danger';
title: string;
message: string;
confirmText: string;
onConfirm: () => void;
} | null>(null);
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
const [publicKeyData, setPublicKeyData] = useState<{
publicKey: string;
serverName: string;
serverIp: string;
} | null>(null);
const handleEdit = (server: Server) => {
setEditingId(server.id);
@@ -30,10 +48,47 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
setEditingId(null);
};
const handleDelete = (id: number) => {
if (window.confirm('Are you sure you want to delete this server configuration?')) {
onDelete(id);
const handleViewPublicKey = async (server: Server) => {
try {
const response = await fetch(`/api/servers/${server.id}/public-key`);
if (!response.ok) {
throw new Error('Failed to retrieve public key');
}
const data = await response.json() as { success: boolean; publicKey?: string; serverName?: string; serverIp?: string; error?: string };
if (data.success) {
setPublicKeyData({
publicKey: data.publicKey ?? '',
serverName: data.serverName ?? '',
serverIp: data.serverIp ?? ''
});
setShowPublicKeyModal(true);
} else {
throw new Error(data.error ?? 'Failed to retrieve public key');
}
} catch (error) {
console.error('Error retrieving public key:', error);
// You could show a toast notification here
}
};
const handleDelete = (id: number) => {
const server = servers.find(s => s.id === id);
if (!server) return;
setConfirmationModal({
isOpen: true,
variant: 'danger',
title: 'Delete Server',
message: `This will permanently delete the server configuration "${server.name}" (${server.ip}) and all associated installed scripts. This action cannot be undone!`,
confirmText: server.name,
onConfirm: () => {
onDelete(id);
setConfirmationModal(null);
}
});
};
const handleTestConnection = async (server: Server) => {
@@ -71,12 +126,12 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
if (servers.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="text-center py-8 text-muted-foreground">
<svg className="mx-auto h-12 w-12 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No servers configured</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding a new server configuration above.</p>
<h3 className="mt-2 text-sm font-medium text-foreground">No servers configured</h3>
<p className="mt-1 text-sm text-muted-foreground">Get started by adding a new server configuration above.</p>
</div>
);
}
@@ -84,16 +139,25 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
return (
<div className="space-y-4">
{servers.map((server) => (
<div key={server.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div
key={server.id}
className="bg-card border border-border rounded-lg p-4 shadow-sm"
style={{ borderLeft: `4px solid ${server.color ?? 'transparent'}` }}
>
{editingId === server.id ? (
<div>
<h4 className="text-lg font-medium text-gray-900 mb-4">Edit Server</h4>
<h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4>
<ServerForm
initialData={{
name: server.name,
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}
@@ -101,35 +165,35 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
/>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3">
<div className="flex flex-col sm:flex-row sm:items-center justify-between space-y-4 sm:space-y-0">
<div className="flex-1 min-w-0">
<div className="flex items-start sm:items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 sm:w-6 sm:h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-gray-900 truncate">{server.name}</h3>
<div className="mt-1 flex items-center space-x-4 text-sm text-gray-500">
<h3 className="text-base sm:text-lg font-medium text-foreground truncate">{server.name}</h3>
<div className="mt-1 flex flex-col sm:flex-row sm:items-center space-y-1 sm:space-y-0 sm:space-x-4 text-sm text-muted-foreground">
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
</svg>
{server.ip}
<span className="truncate">{server.ip}</span>
</span>
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{server.user}
<span className="truncate">{server.user}</span>
</span>
</div>
<div className="mt-1 text-xs text-gray-400">
Created: {new Date(server.created_at).toLocaleDateString()}
{server.updated_at !== server.created_at && (
<div className="mt-1 text-xs text-muted-foreground">
Created: {server.created_at ? new Date(server.created_at).toLocaleDateString() : 'Unknown'}
{server.updated_at && server.updated_at !== server.created_at && (
<span> Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
)}
</div>
@@ -161,51 +225,105 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
<Button
onClick={() => handleTestConnection(server)}
disabled={testingConnections.has(server.id)}
className="inline-flex items-center px-3 py-1.5 border border-green-300 text-xs font-medium rounded text-green-700 bg-white hover:bg-green-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
variant="outline"
size="sm"
className="w-full sm:w-auto border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
>
{testingConnections.has(server.id) ? (
<>
<svg className="w-4 h-4 mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Testing...
<span className="hidden sm:inline">Testing...</span>
<span className="sm:hidden">Test...</span>
</>
) : (
<>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Test Connection
<span className="hidden sm:inline">Test Connection</span>
<span className="sm:hidden">Test</span>
</>
)}
</button>
<button
</Button>
<div className="flex space-x-2">
{/* View Public Key button - only show for generated keys */}
{server.key_generated === true && (
<Button
onClick={() => handleViewPublicKey(server)}
variant="outline"
size="sm"
className="flex-1 sm:flex-none border-blue-500/20 text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
>
<Key className="w-4 h-4 mr-1" />
<span className="hidden sm:inline">View Public Key</span>
<span className="sm:hidden">Key</span>
</Button>
)}
<Button
onClick={() => handleEdit(server)}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
variant="outline"
size="sm"
className="flex-1 sm:flex-none"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
<button
<span className="hidden sm:inline">Edit</span>
<span className="sm:hidden"></span>
</Button>
<Button
onClick={() => handleDelete(server.id)}
className="inline-flex items-center px-3 py-1.5 border border-red-300 text-xs font-medium rounded text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
variant="outline"
size="sm"
className="flex-1 sm:flex-none border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
<span className="hidden sm:inline">Delete</span>
<span className="sm:hidden">🗑</span>
</Button>
</div>
</div>
</div>
)}
</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}
confirmButtonText="Delete Server"
cancelButtonText="Cancel"
/>
)}
{/* Public Key Modal */}
{publicKeyData && (
<PublicKeyModal
isOpen={showPublicKeyModal}
onClose={() => {
setShowPublicKeyModal(false);
setPublicKeyData(null);
}}
publicKey={publicKeyData.publicKey}
serverName={publicKeyData.serverName}
serverIp={publicKeyData.serverIp}
/>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,198 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "~/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database-prisma.js';
import { getSSHService } from '../../../../../server/ssh-service';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid server ID' },
{ status: 400 }
);
}
const db = getDatabase();
const server = await db.getServerById(id);
if (!server) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
);
}
// Only allow viewing public key if it was generated by the system
if (!(server as any).key_generated) {
return NextResponse.json(
{ error: 'Public key not available for user-provided keys' },
{ status: 403 }
);
}
if (!(server as any).ssh_key_path) {
return NextResponse.json(
{ error: 'SSH key path not found' },
{ status: 404 }
);
}
const sshService = getSSHService();
const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
return NextResponse.json({
success: true,
publicKey,
serverName: (server as any).name,
serverIp: (server as any).ip
});
} catch (error) {
console.error('Error retrieving public key:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../server/database';
import { getDatabase } from '../../../../server/database-prisma.js';
import type { CreateServerData } from '../../../../types/server';
export async function GET(
@@ -18,7 +18,7 @@ export async function GET(
}
const db = getDatabase();
const server = db.getServerById(id);
const server = await db.getServerById(id);
if (!server) {
return NextResponse.json(
@@ -52,20 +52,50 @@ 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, key_generated, ssh_key_path }: 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') {
if (!password?.trim()) {
return NextResponse.json(
{ error: 'Password is required for password authentication' },
{ status: 400 }
);
}
}
if (authType === 'key') {
if (!ssh_key?.trim()) {
return NextResponse.json(
{ error: 'SSH key is required for key authentication' },
{ status: 400 }
);
}
}
const db = getDatabase();
// Check if server exists
const existingServer = db.getServerById(id);
const existingServer = await db.getServerById(id);
if (!existingServer) {
return NextResponse.json(
{ error: 'Server not found' },
@@ -73,12 +103,24 @@ export async function PUT(
);
}
const result = db.updateServer(id, { name, ip, user, password });
await db.updateServer(id, {
name,
ip,
user,
password,
auth_type: authType,
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
color,
key_generated: key_generated ?? false,
ssh_key_path
});
return NextResponse.json(
{
message: 'Server updated successfully',
changes: result.changes
changes: 1
}
);
} catch (error) {
@@ -116,7 +158,7 @@ export async function DELETE(
const db = getDatabase();
// Check if server exists
const existingServer = db.getServerById(id);
const existingServer = await db.getServerById(id);
if (!existingServer) {
return NextResponse.json(
{ error: 'Server not found' },
@@ -124,12 +166,15 @@ export async function DELETE(
);
}
const result = db.deleteServer(id);
// Delete all installed scripts associated with this server
await db.deleteInstalledScriptsByServer(id);
await db.deleteServer(id);
return NextResponse.json(
{
message: 'Server deleted successfully',
changes: result.changes
changes: 1
}
);
} catch (error) {

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database';
import { getDatabase } from '../../../../../server/database-prisma.js';
import { getSSHService } from '../../../../../server/ssh-service';
import type { Server } from '../../../../../types/server';
@@ -19,7 +19,7 @@ export async function POST(
}
const db = getDatabase();
const server = db.getServerById(id) as Server;
const server = await db.getServerById(id) as Server;
if (!server) {
return NextResponse.json(

View File

@@ -0,0 +1,32 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getSSHService } from '../../../../server/ssh-service';
import { getDatabase } from '../../../../server/database-prisma.js';
export async function POST(_request: NextRequest) {
try {
const sshService = getSSHService();
const db = getDatabase();
// Get the next available server ID for key file naming
const serverId = await db.getNextServerId();
const keyPair = await sshService.generateKeyPair(serverId);
return NextResponse.json({
success: true,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
serverId: serverId
});
} catch (error) {
console.error('Error generating SSH key pair:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to generate SSH key pair'
},
{ status: 500 }
);
}
}

View File

@@ -1,12 +1,12 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../server/database';
import { getDatabase } from '../../../server/database-prisma.js';
import type { CreateServerData } from '../../../types/server';
export async function GET() {
try {
const db = getDatabase();
const servers = db.getAllServers();
const servers = await db.getAllServers();
return NextResponse.json(servers);
} catch (error) {
console.error('Error fetching servers:', error);
@@ -20,23 +20,65 @@ 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, key_generated, ssh_key_path }: 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') {
if (!password?.trim()) {
return NextResponse.json(
{ error: 'Password is required for password authentication' },
{ status: 400 }
);
}
}
if (authType === 'key') {
if (!ssh_key?.trim()) {
return NextResponse.json(
{ error: 'SSH key is required for key authentication' },
{ status: 400 }
);
}
}
const db = getDatabase();
const result = db.createServer({ name, ip, user, password });
const result = await db.createServer({
name,
ip,
user,
password,
auth_type: authType,
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
color,
key_generated: key_generated ?? false,
ssh_key_path
});
return NextResponse.json(
{
message: 'Server created successfully',
id: result.lastInsertRowid
id: result.id
},
{ status: 201 }
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,140 @@
'use client';
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { ScriptsGrid } from './_components/ScriptsGrid';
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
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' | 'installed'>('scripts');
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
if (typeof window !== 'undefined') {
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
return savedTab || 'scripts';
}
return '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();
// Save active tab to localStorage whenever it changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('activeTab', activeTab);
}
}, [activeTab]);
// 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: (() => {
if (!scriptCardsData?.success) return 0;
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
}
});
return scriptMap.size;
})(),
downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) {
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
}
});
const deduplicatedGithubScripts = Array.from(scriptMap.values());
const localScripts = localScriptsData.scripts ?? [];
// Count scripts that are both in deduplicated GitHub data and have local versions
return deduplicatedGithubScripts.filter(script => {
if (!script?.name) return false;
return localScripts.some(local => {
if (!local?.name) return false;
const localName = local.name.replace(/\.sh$/, '');
return localName.toLowerCase() === script.name.toLowerCase() ||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
});
}).length;
})(),
installed: installedScriptsData?.scripts?.length ?? 0
};
const scrollToTerminal = () => {
if (terminalRef.current) {
// Get the element's position and scroll with a small offset for better mobile experience
const elementTop = terminalRef.current.offsetTop;
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
window.scrollTo({
top: elementTop - offset,
behavior: 'smooth'
});
}
};
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
// Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100);
};
const handleCloseTerminal = () => {
@@ -21,54 +142,87 @@ export default function Home() {
};
return (
<main className="min-h-screen bg-gray-100 dark:bg-gray-900">
<div className="container mx-auto px-4 py-8">
<main className="min-h-screen bg-background">
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 dark:text-gray-100 mb-2">
🚀 PVE Scripts Management
<div className="text-center mb-6 sm:mb-8">
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-2 sm:gap-3">
<span className="break-words">PVE Scripts Management</span>
</h1>
<p className="text-gray-600 dark:text-gray-300">
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
Manage and execute Proxmox helper scripts locally with live output streaming
</p>
<div className="flex justify-center px-2">
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
</div>
</div>
{/* Controls */}
<div className="mb-8">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6 p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="mb-6 sm:mb-8">
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
<ServerSettingsButton />
<SettingsButton />
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<ResyncButton />
</div>
<HelpButton />
</div>
</div>
{/* Tab Navigation */}
<div className="mb-8">
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
<button
<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-1">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'scripts'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
📦 Available Scripts
</button>
<button
? '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>
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
</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 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>
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'installed'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
🗂 Installed Scripts
</button>
? '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>
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
</Button>
</nav>
</div>
</div>
@@ -77,7 +231,7 @@ export default function Home() {
{/* Running Script Terminal */}
{runningScript && (
<div className="mb-8">
<div ref={terminalRef} className="mb-8">
<Terminal
scriptPath={runningScript.path}
onClose={handleCloseTerminal}
@@ -92,10 +246,24 @@ export default function Home() {
<ScriptsGrid onInstallScript={handleRunScript} />
)}
{activeTab === 'downloaded' && (
<DownloadedScriptsTab onInstallScript={handleRunScript} />
)}
{activeTab === 'installed' && (
<InstalledScriptsTab />
)}
</div>
{/* Footer */}
<Footer onOpenReleaseNotes={handleOpenReleaseNotes} />
{/* Release Notes Modal */}
<ReleaseNotesModal
isOpen={releaseNotesOpen}
onClose={handleCloseReleaseNotes}
highlightVersion={highlightVersion}
/>
</main>
);
}

View File

@@ -23,6 +23,16 @@ export const env = createEnv({
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
// WebSocket Configuration
WEBSOCKET_PORT: z.string().default("3001"),
// GitHub Configuration
GITHUB_TOKEN: z.string().optional(),
// Authentication Configuration
AUTH_USERNAME: z.string().optional(),
AUTH_PASSWORD_HASH: z.string().optional(),
AUTH_ENABLED: z.string().optional(),
AUTH_SETUP_COMPLETED: z.string().optional(),
JWT_SECRET: z.string().optional(),
// Server Color Coding Configuration
SERVER_COLOR_CODING_ENABLED: z.string().optional(),
},
/**
@@ -52,6 +62,16 @@ export const env = createEnv({
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
// WebSocket Configuration
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?.length || hexColor.length !== 7 || !hexColor.startsWith('#')) {
return 'black'; // Default to black for invalid colors
}
// Remove the # and convert to RGB
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
// Calculate relative luminance using the standard formula
// https://www.w3.org/WAI/GL/wiki/Relative_luminance
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
// Return black for light backgrounds, white for dark backgrounds
return luminance > 0.5 ? 'black' : 'white';
}
/**
* Check if a color string is a valid hex color
*/
export function isValidHexColor(color: string): boolean {
return /^#[0-9A-F]{6}$/i.test(color);
}
/**
* Get a default color for servers that don't have one set
*/
export function getDefaultServerColor(): string {
return '#3b82f6'; // Blue-500 from Tailwind
}

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

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

View File

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

File diff suppressed because it is too large Load Diff

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

@@ -1,13 +1,13 @@
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { getDatabase } from "~/server/database";
import { getDatabase } from "~/server/database-prisma.js";
export const serversRouter = createTRPCRouter({
getAllServers: publicProcedure
.query(async () => {
try {
const db = getDatabase();
const servers = db.getAllServers();
const servers = await db.getAllServers();
return { success: true, servers };
} catch (error) {
console.error('Error fetching servers:', error);
@@ -24,7 +24,7 @@ export const serversRouter = createTRPCRouter({
.query(async ({ input }) => {
try {
const db = getDatabase();
const server = db.getServerById(input.id);
const server = await db.getServerById(input.id);
if (!server) {
return { success: false, error: 'Server not found', server: null };
}

View File

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

View File

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

View File

@@ -0,0 +1,254 @@
import { prisma } from './db.js';
import { join } from 'path';
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
import { existsSync } from 'fs';
class DatabaseServicePrisma {
constructor() {
this.init();
}
init() {
// Ensure data/ssh-keys directory exists
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { mode: 0o700 });
}
}
// Server CRUD operations
async createServer(serverData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
let ssh_key_path = null;
// If using SSH key authentication, create persistent key file
if (auth_type === 'key' && ssh_key) {
const serverId = await this.getNextServerId();
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
}
return await prisma.server.create({
data: {
name,
ip,
user,
password,
auth_type: auth_type ?? 'password',
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
ssh_key_path,
key_generated: Boolean(key_generated),
color,
}
});
}
async getAllServers() {
return await prisma.server.findMany({
orderBy: { created_at: 'desc' }
});
}
async getServerById(id) {
return await prisma.server.findUnique({
where: { id }
});
}
async updateServer(id, serverData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
// Get existing server to check for key changes
const existingServer = await this.getServerById(id);
let ssh_key_path = existingServer?.ssh_key_path;
// Handle SSH key changes
if (auth_type === 'key' && ssh_key) {
// Delete old key file if it exists
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
unlinkSync(existingServer.ssh_key_path);
// Also delete public key file if it exists
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete old SSH key file:', error);
}
}
// Create new key file
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
} else if (auth_type !== 'key') {
// If switching away from key auth, delete key files
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
unlinkSync(existingServer.ssh_key_path);
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
ssh_key_path = null;
}
return await prisma.server.update({
where: { id },
data: {
name,
ip,
user,
password,
auth_type: auth_type ?? 'password',
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
ssh_key_path,
key_generated: key_generated !== undefined ? Boolean(key_generated) : (existingServer?.key_generated ?? false),
color,
}
});
}
async deleteServer(id) {
// Get server info before deletion to clean up key files
const server = await this.getServerById(id);
// Delete SSH key files if they exist
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
try {
unlinkSync(server.ssh_key_path);
const pubKeyPath = server.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
return await prisma.server.delete({
where: { id }
});
}
// Installed Scripts CRUD operations
async createInstalledScript(scriptData) {
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
return await prisma.installedScript.create({
data: {
script_name,
script_path,
container_id: container_id ?? null,
server_id: server_id ?? null,
execution_mode,
status,
output_log: output_log ?? null,
web_ui_ip: web_ui_ip ?? null,
web_ui_port: web_ui_port ?? null,
}
});
}
async getAllInstalledScripts() {
return await prisma.installedScript.findMany({
include: {
server: true
},
orderBy: { installation_date: 'desc' }
});
}
async getInstalledScriptById(id) {
return await prisma.installedScript.findUnique({
where: { id },
include: {
server: true
}
});
}
async getInstalledScriptsByServer(server_id) {
return await prisma.installedScript.findMany({
where: { server_id },
include: {
server: true
},
orderBy: { installation_date: 'desc' }
});
}
async updateInstalledScript(id, updateData) {
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
const updateFields = {};
if (script_name !== undefined) updateFields.script_name = script_name;
if (container_id !== undefined) updateFields.container_id = container_id;
if (status !== undefined) updateFields.status = status;
if (output_log !== undefined) updateFields.output_log = output_log;
if (web_ui_ip !== undefined) updateFields.web_ui_ip = web_ui_ip;
if (web_ui_port !== undefined) updateFields.web_ui_port = web_ui_port;
if (Object.keys(updateFields).length === 0) {
return { changes: 0 };
}
return await prisma.installedScript.update({
where: { id },
data: updateFields
});
}
async deleteInstalledScript(id) {
return await prisma.installedScript.delete({
where: { id }
});
}
async deleteInstalledScriptsByServer(server_id) {
return await prisma.installedScript.deleteMany({
where: { server_id }
});
}
async getNextServerId() {
const result = await prisma.server.findFirst({
orderBy: { id: 'desc' },
select: { id: true }
});
return (result?.id ?? 0) + 1;
}
createSSHKeyFile(serverId, sshKey) {
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = sshKey.trimEnd() + '\n';
writeFileSync(keyPath, normalizedKey);
chmodSync(keyPath, 0o600); // Set proper permissions
return keyPath;
}
async close() {
await prisma.$disconnect();
}
}
// Singleton instance
let dbInstance = null;
export function getDatabase() {
dbInstance ??= new DatabaseServicePrisma();
return dbInstance;
}
export default DatabaseServicePrisma;

View File

@@ -0,0 +1,279 @@
import { prisma } from './db';
import { join } from 'path';
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
import { existsSync } from 'fs';
import type { CreateServerData } from '../types/server';
class DatabaseServicePrisma {
constructor() {
this.init();
}
init() {
// Ensure data/ssh-keys directory exists
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
if (!existsSync(sshKeysDir)) {
mkdirSync(sshKeysDir, { mode: 0o700 });
}
}
// Server CRUD operations
async createServer(serverData: CreateServerData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
let ssh_key_path = null;
// If using SSH key authentication, create persistent key file
if (auth_type === 'key' && ssh_key) {
const serverId = await this.getNextServerId();
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
}
return await prisma.server.create({
data: {
name,
ip,
user,
password,
auth_type: auth_type ?? 'password',
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
ssh_key_path,
key_generated: Boolean(key_generated),
color,
}
});
}
async getAllServers() {
return await prisma.server.findMany({
orderBy: { created_at: 'desc' }
});
}
async getServerById(id: number) {
return await prisma.server.findUnique({
where: { id }
});
}
async updateServer(id: number, serverData: CreateServerData) {
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
// Get existing server to check for key changes
const existingServer = await this.getServerById(id);
let ssh_key_path = existingServer?.ssh_key_path;
// Handle SSH key changes
if (auth_type === 'key' && ssh_key) {
// Delete old key file if it exists
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
unlinkSync(existingServer.ssh_key_path);
// Also delete public key file if it exists
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete old SSH key file:', error);
}
}
// Create new key file
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
} else if (auth_type !== 'key') {
// If switching away from key auth, delete key files
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
try {
unlinkSync(existingServer.ssh_key_path);
const pubKeyPath = existingServer.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
ssh_key_path = null;
}
return await prisma.server.update({
where: { id },
data: {
name,
ip,
user,
password,
auth_type: auth_type ?? 'password',
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
ssh_key_path,
key_generated: key_generated !== undefined ? Boolean(key_generated) : (existingServer?.key_generated ?? false),
color,
}
});
}
async deleteServer(id: number) {
// Get server info before deletion to clean up key files
const server = await this.getServerById(id);
// Delete SSH key files if they exist
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
try {
unlinkSync(server.ssh_key_path);
const pubKeyPath = server.ssh_key_path + '.pub';
if (existsSync(pubKeyPath)) {
unlinkSync(pubKeyPath);
}
} catch (error) {
console.warn('Failed to delete SSH key file:', error);
}
}
return await prisma.server.delete({
where: { id }
});
}
// Installed Scripts CRUD operations
async createInstalledScript(scriptData: {
script_name: string;
script_path: string;
container_id?: string;
server_id?: number;
execution_mode: string;
status: 'in_progress' | 'success' | 'failed';
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
}) {
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
return await prisma.installedScript.create({
data: {
script_name,
script_path,
container_id: container_id ?? null,
server_id: server_id ?? null,
execution_mode,
status,
output_log: output_log ?? null,
web_ui_ip: web_ui_ip ?? null,
web_ui_port: web_ui_port ?? null,
}
});
}
async getAllInstalledScripts() {
return await prisma.installedScript.findMany({
include: {
server: true
},
orderBy: { installation_date: 'desc' }
});
}
async getInstalledScriptById(id: number) {
return await prisma.installedScript.findUnique({
where: { id },
include: {
server: true
}
});
}
async getInstalledScriptsByServer(server_id: number) {
return await prisma.installedScript.findMany({
where: { server_id },
include: {
server: true
},
orderBy: { installation_date: 'desc' }
});
}
async updateInstalledScript(id: number, updateData: {
script_name?: string;
container_id?: string;
status?: 'in_progress' | 'success' | 'failed';
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
}) {
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
const updateFields: {
script_name?: string;
container_id?: string;
status?: 'in_progress' | 'success' | 'failed';
output_log?: string;
web_ui_ip?: string;
web_ui_port?: number;
} = {};
if (script_name !== undefined) updateFields.script_name = script_name;
if (container_id !== undefined) updateFields.container_id = container_id;
if (status !== undefined) updateFields.status = status;
if (output_log !== undefined) updateFields.output_log = output_log;
if (web_ui_ip !== undefined) updateFields.web_ui_ip = web_ui_ip;
if (web_ui_port !== undefined) updateFields.web_ui_port = web_ui_port;
if (Object.keys(updateFields).length === 0) {
return { changes: 0 };
}
return await prisma.installedScript.update({
where: { id },
data: updateFields
});
}
async deleteInstalledScript(id: number) {
return await prisma.installedScript.delete({
where: { id }
});
}
async deleteInstalledScriptsByServer(server_id: number) {
return await prisma.installedScript.deleteMany({
where: { server_id }
});
}
async getNextServerId() {
const result = await prisma.server.findFirst({
orderBy: { id: 'desc' },
select: { id: true }
});
return (result?.id ?? 0) + 1;
}
createSSHKeyFile(serverId: number, sshKey: string) {
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = sshKey.trimEnd() + '\n';
writeFileSync(keyPath, normalizedKey);
chmodSync(keyPath, 0o600); // Set proper permissions
return keyPath;
}
async close() {
await prisma.$disconnect();
}
}
// Singleton instance
let dbInstance: DatabaseServicePrisma | null = null;
export function getDatabase() {
dbInstance ??= new DatabaseServicePrisma();
return dbInstance;
}
export default DatabaseServicePrisma;

View File

@@ -1,235 +0,0 @@
import Database from 'better-sqlite3';
import { join } from 'path';
class DatabaseService {
constructor() {
const dbPath = join(process.cwd(), 'data', 'settings.db');
this.db = new Database(dbPath);
this.init();
}
init() {
// Create servers table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL,
user TEXT NOT NULL,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Create installed_scripts table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS installed_scripts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
script_name TEXT NOT NULL,
script_path TEXT NOT NULL,
container_id TEXT,
server_id INTEGER,
execution_mode TEXT NOT NULL CHECK(execution_mode IN ('local', 'ssh')),
installation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL CHECK(status IN ('in_progress', 'success', 'failed')),
output_log TEXT,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
)
`);
// Create trigger to update updated_at on row update
this.db.exec(`
CREATE TRIGGER IF NOT EXISTS update_servers_timestamp
AFTER UPDATE ON servers
BEGIN
UPDATE servers SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END
`);
}
// Server CRUD operations
/**
* @param {import('../types/server').CreateServerData} serverData
*/
createServer(serverData) {
const { name, ip, user, password } = serverData;
const stmt = this.db.prepare(`
INSERT INTO servers (name, ip, user, password)
VALUES (?, ?, ?, ?)
`);
return stmt.run(name, ip, user, password);
}
getAllServers() {
const stmt = this.db.prepare('SELECT * FROM servers ORDER BY created_at DESC');
return stmt.all();
}
/**
* @param {number} id
*/
getServerById(id) {
const stmt = this.db.prepare('SELECT * FROM servers WHERE id = ?');
return stmt.get(id);
}
/**
* @param {number} id
* @param {import('../types/server').CreateServerData} serverData
*/
updateServer(id, serverData) {
const { name, ip, user, password } = serverData;
const stmt = this.db.prepare(`
UPDATE servers
SET name = ?, ip = ?, user = ?, password = ?
WHERE id = ?
`);
return stmt.run(name, ip, user, password, id);
}
/**
* @param {number} id
*/
deleteServer(id) {
const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?');
return stmt.run(id);
}
// Installed Scripts CRUD operations
/**
* @param {Object} scriptData
* @param {string} scriptData.script_name
* @param {string} scriptData.script_path
* @param {string} [scriptData.container_id]
* @param {number} [scriptData.server_id]
* @param {string} scriptData.execution_mode
* @param {string} scriptData.status
* @param {string} [scriptData.output_log]
*/
createInstalledScript(scriptData) {
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log } = scriptData;
const stmt = this.db.prepare(`
INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null);
}
getAllInstalledScripts() {
const stmt = this.db.prepare(`
SELECT
inst.*,
s.name as server_name,
s.ip as server_ip,
s.user as server_user,
s.password as server_password
FROM installed_scripts inst
LEFT JOIN servers s ON inst.server_id = s.id
ORDER BY inst.installation_date DESC
`);
return stmt.all();
}
/**
* @param {number} id
*/
getInstalledScriptById(id) {
const stmt = this.db.prepare(`
SELECT
inst.*,
s.name as server_name,
s.ip as server_ip
FROM installed_scripts inst
LEFT JOIN servers s ON inst.server_id = s.id
WHERE inst.id = ?
`);
return stmt.get(id);
}
/**
* @param {number} server_id
*/
getInstalledScriptsByServer(server_id) {
const stmt = this.db.prepare(`
SELECT
inst.*,
s.name as server_name,
s.ip as server_ip
FROM installed_scripts inst
LEFT JOIN servers s ON inst.server_id = s.id
WHERE inst.server_id = ?
ORDER BY inst.installation_date DESC
`);
return stmt.all(server_id);
}
/**
* @param {number} id
* @param {Object} updateData
* @param {string} [updateData.script_name]
* @param {string} [updateData.container_id]
* @param {string} [updateData.status]
* @param {string} [updateData.output_log]
*/
updateInstalledScript(id, updateData) {
const { script_name, container_id, status, output_log } = updateData;
const updates = [];
const values = [];
if (script_name !== undefined) {
updates.push('script_name = ?');
values.push(script_name);
}
if (container_id !== undefined) {
updates.push('container_id = ?');
values.push(container_id);
}
if (status !== undefined) {
updates.push('status = ?');
values.push(status);
}
if (output_log !== undefined) {
updates.push('output_log = ?');
values.push(output_log);
}
if (updates.length === 0) {
return { changes: 0 };
}
values.push(id);
const stmt = this.db.prepare(`
UPDATE installed_scripts
SET ${updates.join(', ')}
WHERE id = ?
`);
return stmt.run(...values);
}
/**
* @param {number} id
*/
deleteInstalledScript(id) {
const stmt = this.db.prepare('DELETE FROM installed_scripts WHERE id = ?');
return stmt.run(id);
}
close() {
this.db.close();
}
}
// Singleton instance
/** @type {DatabaseService | null} */
let dbInstance = null;
export function getDatabase() {
if (!dbInstance) {
dbInstance = new DatabaseService();
}
return dbInstance;
}
export default DatabaseService;

7
src/server/db.js Normal file
View File

@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis;
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

9
src/server/db.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

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,83 @@
import { spawn } from 'child_process';
import { spawn as ptySpawn } from 'node-pty';
import { existsSync } from 'fs';
/**
* @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')
* @property {string} [ssh_key] - SSH private key content
* @property {string} [ssh_key_passphrase] - SSH key passphrase
* @property {string} [ssh_key_path] - Path to persistent SSH key file
* @property {number} [ssh_port] - SSH port (default: 22)
*/
class SSHExecutionService {
/**
* Build SSH command arguments based on authentication type
* @param {Server} server - Server configuration
* @returns {{command: string, args: string[]}} Command and arguments for SSH
*/
buildSSHCommand(server) {
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, 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 (!ssh_key_path || !existsSync(ssh_key_path)) {
throw new Error('SSH key file not found');
}
baseArgs.push('-i', ssh_key_path);
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 {
// 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,37 +88,21 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information
*/
async executeScript(server, scriptPath, onData, onError, onExit) {
const { ip, user, password } = server;
try {
await this.transferScriptsFolder(server, onData, onError);
return new Promise((resolve, reject) => {
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
try {
// Build SSH command based on authentication type
const { command, args } = this.buildSSHCommand(server);
// 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('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}`
], {
const sshCommand = ptySpawn(command, args, {
name: 'xterm-256color',
cols: 120,
rows: 30,
@@ -82,8 +133,14 @@ class SSHExecutionService {
resolve({
process: sshCommand,
kill: () => sshCommand.kill('SIGTERM')
kill: () => {
sshCommand.kill('SIGTERM');
}
});
} catch (error) {
reject(error);
}
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -100,15 +157,33 @@ class SSHExecutionService {
* @returns {Promise<void>}
*/
async transferScriptsFolder(server, onData, onError) {
const { ip, user, password } = server;
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
return new Promise((resolve, reject) => {
try {
// Build rsync command based on authentication type
let rshCommand;
if (auth_type === 'key') {
if (!ssh_key_path || !existsSync(ssh_key_path)) {
throw new Error('SSH key file not found');
}
if (ssh_key_passphrase) {
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} else {
rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
}
} else {
// 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=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null',
`--rsh=${rshCommand}`,
'scripts/',
`${user}@${ip}:/tmp/scripts/`
], {
@@ -138,6 +213,10 @@ class SSHExecutionService {
rsyncCommand.on('error', (error) => {
reject(error);
});
} catch (error) {
reject(error);
}
});
}
@@ -151,31 +230,16 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information
*/
async executeCommand(server, command, onData, onError, onExit) {
const { ip, user, password } = server;
return new Promise((resolve, reject) => {
try {
// Build SSH command based on authentication type
const { command: sshCommandName, args } = this.buildSSHCommand(server);
// Add the command to execute to the args
args.push(command);
// 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
], {
const sshCommand = ptySpawn(sshCommandName, args, {
name: 'xterm-color',
cols: 120,
rows: 30,
@@ -191,7 +255,16 @@ class SSHExecutionService {
onExit(e.exitCode);
});
resolve({ process: sshCommand });
resolve({
process: sshCommand,
kill: () => {
sshCommand.kill('SIGTERM');
}
});
} catch (error) {
reject(error);
}
});
}

Some files were not shown because too many files have changed in this diff Show More