Compare commits

...

54 Commits

Author SHA1 Message Date
github-actions[bot]
af58ba856e chore: add VERSION v0.2.2 2025-10-08 11:57:18 +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
Michel Roegl-Brunner
e09c1bbf5d Merge development into main (#42)
* feat: improve button layout and UI organization (#35)

- Reorganize control buttons into a structured container with proper spacing
- Add responsive design for mobile and desktop layouts
- Improve SettingsButton and ResyncButton component structure
- Enhance visual hierarchy with better typography and spacing
- Add background container with shadow and border for better grouping
- Make layout responsive with proper flexbox arrangements

* Add category sidebar and filtering to scripts grid (#36)

* Add category sidebar and filtering to scripts grid

Introduces a CategorySidebar component with icon mapping and category selection. Updates metadata.json to include icons for each category. Enhances ScriptsGrid to support category-based filtering and integrates the sidebar, improving script navigation and discoverability. Also refines ScriptDetailModal layout for better modal presentation.

* Add category metadata to scripts and improve filtering

Introduces category metadata loading and exposes it via new API endpoints. Script cards are now enhanced with category information, allowing for accurate category-based filtering and counting in the ScriptsGrid component. Removes hardcoded category logic and replaces it with dynamic data from metadata.json.

* Add reusable Badge component and refactor badge usage (#37)

Introduces a new Badge component with variants for type, updateable, privileged, status, execution mode, and note. Refactors ScriptCard, ScriptDetailModal, and InstalledScriptsTab to use the new Badge components, improving consistency and maintainability. Also updates DarkModeProvider and layout.tsx for better dark mode handling and fallback.

* Add advanced filtering and sorting to ScriptsGrid (#38)

Introduces a new FilterBar component for ScriptsGrid, enabling filtering by search query, updatable status, script types, and sorting by name or creation date. Updates scripts API to include creation date in card data, improves deduplication and category counting logic, and adds error handling for missing script directories.

* refactore installed scipts tab (#41)

* feat: Add inline editing and manual script entry functionality

- Add inline editing for script names and container IDs in installed scripts table
- Add manual script entry form for pre-installed containers
- Update database and API to support script_name editing
- Improve dark mode hover effects for table rows
- Add form validation and error handling
- Support both local and SSH execution modes for manual entries

* feat: implement installed scripts functionality and clean up test files

- Add installed scripts tab with filtering and execution capabilities
- Update scripts grid with better type safety and error handling
- Remove outdated test files and update test configuration
- Fix TypeScript and ESLint issues in components
- Update .gitattributes for proper line ending handling

* fix: resolve TypeScript error with categoryNames type mismatch

- Fixed categoryNames type from (string | undefined)[] to string[] in scripts router
- Added proper type filtering and assertion in getScriptCardsWithCategories
- Added missing ScriptCard import in scripts router
- Ensures type safety for categoryNames property throughout the application

---------

Co-authored-by: CanbiZ <47820557+MickLesk@users.noreply.github.com>
2025-10-06 16:24:19 +02:00
Michel Roegl-Brunner
a05185db1b Revert "feat: Add inline editing and manual script entry functionality (#39)" (#40)
This reverts commit a410aeacf7.
2025-10-06 16:21:49 +02:00
Michel Roegl-Brunner
a410aeacf7 feat: Add inline editing and manual script entry functionality (#39)
* feat: improve button layout and UI organization (#35)

- Reorganize control buttons into a structured container with proper spacing
- Add responsive design for mobile and desktop layouts
- Improve SettingsButton and ResyncButton component structure
- Enhance visual hierarchy with better typography and spacing
- Add background container with shadow and border for better grouping
- Make layout responsive with proper flexbox arrangements

* Add category sidebar and filtering to scripts grid (#36)

* Add category sidebar and filtering to scripts grid

Introduces a CategorySidebar component with icon mapping and category selection. Updates metadata.json to include icons for each category. Enhances ScriptsGrid to support category-based filtering and integrates the sidebar, improving script navigation and discoverability. Also refines ScriptDetailModal layout for better modal presentation.

* Add category metadata to scripts and improve filtering

Introduces category metadata loading and exposes it via new API endpoints. Script cards are now enhanced with category information, allowing for accurate category-based filtering and counting in the ScriptsGrid component. Removes hardcoded category logic and replaces it with dynamic data from metadata.json.

* Add reusable Badge component and refactor badge usage (#37)

Introduces a new Badge component with variants for type, updateable, privileged, status, execution mode, and note. Refactors ScriptCard, ScriptDetailModal, and InstalledScriptsTab to use the new Badge components, improving consistency and maintainability. Also updates DarkModeProvider and layout.tsx for better dark mode handling and fallback.

* Add advanced filtering and sorting to ScriptsGrid (#38)

Introduces a new FilterBar component for ScriptsGrid, enabling filtering by search query, updatable status, script types, and sorting by name or creation date. Updates scripts API to include creation date in card data, improves deduplication and category counting logic, and adds error handling for missing script directories.

* feat: Add inline editing and manual script entry functionality

- Add inline editing for script names and container IDs in installed scripts table
- Add manual script entry form for pre-installed containers
- Update database and API to support script_name editing
- Improve dark mode hover effects for table rows
- Add form validation and error handling
- Support both local and SSH execution modes for manual entries

* feat: implement installed scripts functionality and clean up test files

- Add installed scripts tab with filtering and execution capabilities
- Update scripts grid with better type safety and error handling
- Remove outdated test files and update test configuration
- Fix TypeScript and ESLint issues in components
- Update .gitattributes for proper line ending handling

* fix: resolve TypeScript error with categoryNames type mismatch

- Fixed categoryNames type from (string | undefined)[] to string[] in scripts router
- Added proper type filtering and assertion in getScriptCardsWithCategories
- Added missing ScriptCard import in scripts router
- Ensures type safety for categoryNames property throughout the application

---------

Co-authored-by: CanbiZ <47820557+MickLesk@users.noreply.github.com>
2025-10-06 16:20:07 +02:00
CanbiZ
7fd1351579 remove install method if pve-tool / addon & Improve dark mode initialization and modal UI #32 (#34)
* Add dark mode support across UI

Introduces DarkModeProvider and DarkModeToggle components for theme management. Updates all major UI components and pages to support dark mode styling using Tailwind CSS dark variants, improving accessibility and user experience for users preferring dark themes.

* Improve dark mode initialization and modal UI (#32)

Adds a script to layout.tsx to set dark mode before hydration, preventing UI flicker. Refactors DarkModeProvider to initialize theme and dark state after mount. Updates ScriptDetailModal for improved readability, consistent styling, and better handling of script status, install methods, and notes.
2025-10-06 13:56:34 +02:00
Michel Roegl-Brunner
74b89575fe Add workflow_dispatch trigger to release drafter 2025-10-06 13:56:10 +02:00
Michel Roegl-Brunner
6b39fd7e0f Add 'Dependencies' section to release drafter 2025-10-06 13:53:38 +02:00
Michel Roegl-Brunner
2fcb267649 Delete install.sh 2025-10-06 13:44:23 +02:00
CanbiZ
ec20e0322a Add dark mode support across UI (#33)
* Add dark mode support across UI

Introduces DarkModeProvider and DarkModeToggle components for theme management. Updates all major UI components and pages to support dark mode styling using Tailwind CSS dark variants, improving accessibility and user experience for users preferring dark themes.

* Improve dark mode initialization and modal UI (#32)

Adds a script to layout.tsx to set dark mode before hydration, preventing UI flicker. Refactors DarkModeProvider to initialize theme and dark state after mount. Updates ScriptDetailModal for improved readability, consistent styling, and better handling of script status, install methods, and notes.
2025-10-06 13:35:53 +02:00
Michel Roegl-Brunner
b77554a7b5 Update Readme 2025-10-06 12:51:48 +02:00
CanbiZ
d9271804dc add logo 2025-10-06 12:28:38 +02:00
Michel Roegl-Brunner
5582d288d7 Update note from 'beat' to 'beta' in README 2025-10-03 22:55:11 +02:00
Michel Roegl-Brunner
5823e54464 Add GitHub templates and configuration (#8)
- Add CODEOWNERS file for code review assignments
- Add bug report issue template
- Add feature request issue template
- Add pull request template
2025-10-03 15:45:27 +02:00
Michel Roegl-Brunner
1557d589cf chore: Update readme.md for first release 2025-10-03 14:45:29 +02:00
Michel Roegl-Brunner
6fdd336be6 chore: Update readme.md for first release 2025-10-03 14:43:04 +02:00
132 changed files with 8130 additions and 4118 deletions

View File

@@ -16,3 +16,4 @@ ALLOWED_SCRIPT_PATHS="scripts/"
# WebSocket Configuration # WebSocket Configuration
WEBSOCKET_PORT="3001" WEBSOCKET_PORT="3001"
GITHUB_TOKEN=your_github_token_here

40
.gitattributes vendored Normal file
View File

@@ -0,0 +1,40 @@
# Set default behavior to automatically normalize line endings
* text=auto
# Shell scripts should always use LF
*.sh text eol=lf
*.func text eol=lf
*.bash text eol=lf
# Windows batch files should use CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# Configuration files should use LF
*.conf text eol=lf
*.config text eol=lf
*.ini text eol=lf
*.toml text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.json text eol=lf
# Source code files should use LF
*.js text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.jsx text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.xml text eol=lf
# Binary files
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.db binary
*.exe binary
*.dll binary

16
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,16 @@
#
# CODEOWNERS for ProxmoxVE
#
# Order is important; the last matching pattern takes the most
# precedence.
# Codeowners for specific folders and files
# Remember ending folders with /
# Set default reviewers
* @michelroegl-brunner
* @community-scripts/Contributor

50
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: "🐞 Script Issue Report"
description: Report a specific issue.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
## ⚠️ **IMPORTANT - READ FIRST**
- 🔍 **Search first:** Before submitting, check if the issue has already been reported or resolved in [closed issues](https://github.com/community-scripts/ProxmoxVE-Local/issues?q=is%3Aissue+is%3Aclosed). If found, comment on that issue instead of creating a new one.
Thank you for taking the time to report an issue! Please provide as much detail as possible to help us address the problem efficiently.
- type: input
id: guidelines
attributes:
label: ✅ Have you read and understood the above guidelines?
placeholder: "yes"
validations:
required: true
- type: textarea
id: issue_description
attributes:
label: 📝 Provide a clear and concise description of the issue.
validations:
required: true
- type: textarea
id: steps_to_reproduce
attributes:
label: 🔄 Steps to reproduce the issue.
placeholder: "e.g., Step 1: ..., Step 2: ..."
validations:
required: true
- type: textarea
id: error_output
attributes:
label: ❌ Paste the full error output (if available).
placeholder: "Include any relevant logs or error messages."
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: 🖼️ Additional context (optional).
placeholder: "Include screenshots, code blocks (use triple backticks ```), or any other relevant information."
validations:
required: false

View File

@@ -0,0 +1,33 @@
name: "✨ Feature Request"
description: "Suggest a new feature or enhancement."
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
# ✨ **Feature Request**
Have an idea for a new feature? Share your thoughts below!
- type: input
id: feature_summary
attributes:
label: "🌟 Briefly describe the feature"
placeholder: "e.g., Add support for XYZ"
validations:
required: true
- type: textarea
id: feature_description
attributes:
label: "📝 Detailed description"
placeholder: "Explain the feature in detail"
validations:
required: true
- type: textarea
id: use_case
attributes:
label: "💡 Why is this useful?"
placeholder: "Describe the benefit of this feature"
validations:
required: true

BIN
.github/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

24
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,24 @@
<!--🛑 All Pull Requests need to made against the development branch. PRs against main will get closed. -->
## ✍️ Description
## 🔗 Related PR / Issue
Link: #
## ✅ Prerequisites (**X** in brackets)
- [ ] **Self-review completed** Code follows project standards.
- [ ] **Tested thoroughly** Changes work as expected.
- [ ] **No security risks** No hardcoded secrets, unnecessary privilege escalations, or permission issues.
## Screenshot for frontend Change
---
## 🛠️ Type of Change (**X** in brackets)
- [ ] 🐞 **Bug fix** Resolves an issue without breaking functionality.
- [ ]**New feature** Adds new, non-breaking functionality.
- [ ] 💥 **Breaking change** Alters existing functionality in a way that may require updates.

View File

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

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

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

View File

@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- main - main
workflow_dispatch:
jobs: jobs:
update_release_draft: update_release_draft:

162
README.md
View File

@@ -1,14 +1,18 @@
# PVE Scripts Local 🚀 # PVE Scripts Local 🚀
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. A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming. No more need for curl -> bash calls, it all happens in your enviroment.
<img width="1725" height="1088" alt="image" src="https://github.com/user-attachments/assets/75323765-7375-4346-a41e-08d219275248" />
## 🎯 Deployment Options ## 🎯 Deployment Options
This application can be deployed in multiple ways to suit different environments: This application can be deployed in multiple ways to suit different environments:
- **🐧 Proxmox Host**: Run directly on your Proxmox VE host system
- **📦 Debian LXC Container**: Deploy inside a Debian LXC container for better isolation - **📦 Debian LXC Container**: Deploy inside a Debian LXC container for better isolation
- **⚡ Quick Install**: Use the automated `install.sh` script for easy setup - **🔧 Helper Script**: Use the automated helper script for easy setup
All deployment methods provide the same functionality and web interface. All deployment methods provide the same functionality and web interface.
@@ -57,9 +61,6 @@ All deployment methods provide the same functionality and web interface.
- **Proxmox VE environment** (host or access to Proxmox cluster) - **Proxmox VE environment** (host or access to Proxmox cluster)
- **SQLite** (included with Node.js better-sqlite3 package) - **SQLite** (included with Node.js better-sqlite3 package)
### For Proxmox Host Installation
- **build-essentials**: `apt install build-essential`
- Direct access to Proxmox host system
### For Debian LXC Container Installation ### For Debian LXC Container Installation
- **Debian LXC container** (Debian 11+ recommended) - **Debian LXC container** (Debian 11+ recommended)
@@ -68,40 +69,11 @@ All deployment methods provide the same functionality and web interface.
- Network access from container to Proxmox host - Network access from container to Proxmox host
- Optional: Privileged container for full Proxmox integration - Optional: Privileged container for full Proxmox integration
### For Quick Install (install.sh)
- **Proxmox VE host** (script automatically detects and configures)
- Internet connectivity for downloading dependencies
## 🚀 Installation ## 🚀 Installation
Choose the installation method that best fits your environment: Choose the installation method that best fits your environment:
### Option 1: Quick Install with install.sh (Recommended for Proxmox Host) ### Option 1: Debian LXC Container Installation
Run this command directly on your Proxmox VE host or on any Debian based lxc:
```bash
bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)"
```
**What the script does:**
- ✅ Installs required dependencies (build-essential, git, Node.js 24.x)
- ✅ Clones the repository into `/opt/PVESciptslocal` (or your chosen path)
- ✅ Runs npm install and builds the project
- ✅ Sets up `.env` from `.env.example` if missing
- ✅ Creates database directory (`data/`) for SQLite storage
- ✅ Creates a systemd service (`pvescriptslocal.service`) for easy management
**After installation:**
- 🌐 Access the app at: `http://<YOUR_PVE_OR_LXC_IP>:3000`
- 🔧 Manage the service with:
```bash
systemctl start pvescriptslocal
systemctl stop pvescriptslocal
systemctl status pvescriptslocal
```
### Option 2: Debian LXC Container Installation
For better isolation and security, you can run PVE Scripts Local inside a Debian LXC container: For better isolation and security, you can run PVE Scripts Local inside a Debian LXC container:
@@ -110,13 +82,8 @@ For better isolation and security, you can run PVE Scripts Local inside a Debian
```bash ```bash
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/debian.sh)" bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/debian.sh)"
``` ```
Then run the installer:
```bash #### Step 2: Install Dependencies in Container
bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)"
```
#### Step 2: Install Dependencies in Container when installer is not used
```bash ```bash
# Enter the container # Enter the container
pct enter 100 pct enter 100
@@ -132,15 +99,13 @@ apt install -y nodejs
#### Step 3: Clone and Setup Application #### Step 3: Clone and Setup Application
```bash ```bash
# Clone the repository # Clone the repository
git clone https://github.com/michelroegl-brunner/PVESciptslocal.git /opt/PVESciptslocal git clone https://github.com/community-scripts/ProxmoxVE-Local.git /opt/PVESciptslocal
cd /opt/PVESciptslocal cd PVESciptslocal
# Install dependencies and build # Install dependencies and build
npm install npm install
npm run build
# Setup environment
cp .env.example .env cp .env.example .env
npm run build
# Create database directory # Create database directory
mkdir -p data mkdir -p data
@@ -153,89 +118,33 @@ chmod 755 data
npm start npm start
# Or create a systemd service (optional) # Or create a systemd service (optional)
# Follow the same systemd setup as the install.sh script # Create systemd service for easy management
``` ```
**Access the application:** **Access the application:**
- 🌐 Container IP: `http://<CONTAINER_IP>:3000` - 🌐 Container IP: `http://<CONTAINER_IP>:3000`
- 🔧 Container management: `pct start 100`, `pct stop 100`, `pct status 100` - 🔧 Container management: `pct start 100`, `pct stop 100`, `pct status 100`
### Option 3: Manual Installation (Proxmox Host) ### Option 2: Use the helper script
This creates the LXC and installs the APP for you.
#### Step 1: Clone the Repository
```bash ```bash
git clone https://github.com/michelroegl-brunner/PVESciptslocal.git bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/pve-scripts-local.sh)"
cd PVESciptslocal
``` ```
#### Step 2: Install Dependencies
```bash
npm install
```
#### Step 3: Environment Configuration
```bash
cp .env.example .env
# Edit .env file with your specific settings if needed
```
#### Step 4: Database Setup
```bash
# Create database directory
mkdir -p data
chmod 755 data
```
#### Step 5: Build and Start
```bash
# Production mode
npm run build
npm start
# Development mode
npm run dev:server
```
**Access the application:**
- 🌐 Available at: `http://<YOUR_IP>:3000`
## 📝 LXC Container Specific Notes
### Container Requirements
- **OS**: Debian 11+ (Debian 12 recommended)
- **Resources**: Minimum 2GB RAM, 4GB storage
- **Network**: Bridge connection to Proxmox network
- **Privileges**: Unprivileged containers work, but privileged containers provide better Proxmox integration
### Container Configuration Tips
- **Privileged Container**: Use `--unprivileged 0` for full Proxmox API access
- **Resource Allocation**: Allocate at least 2 CPU cores and 2GB RAM for optimal performance
- **Storage**: Use at least 8GB for the container to accommodate Node.js and dependencies
- **Network**: Ensure the container can reach the Proxmox host API
### Security Considerations
- **Unprivileged Containers**: More secure but may have limited Proxmox functionality
- **Privileged Containers**: Full Proxmox access but less secure isolation
- **Network Access**: Ensure proper firewall rules for the container
### Troubleshooting LXC Installation
- **Permission Issues**: Ensure the container has proper permissions for Proxmox API access
- **Network Connectivity**: Verify the container can reach the Proxmox host
- **Resource Limits**: Check if the container has sufficient resources allocated
## 🎯 Usage ## 🎯 Usage
### 1. Access the Web Interface ### 1. Access the Web Interface
The web interface is accessible regardless of your deployment method: The web interface is accessible regardless of your deployment method:
- **Proxmox Host Installation**: `http://<PROXMOX_HOST_IP>:3000`
- **LXC Container Installation**: `http://<CONTAINER_IP>:3000` - **LXC Container Installation**: `http://<CONTAINER_IP>:3000`
- **Custom Installation**: `http://<YOUR_IP>:3000` - **Custom Installation**: `http://<YOUR_IP>:3000`
### 2. Service Management ### 2. Service Management
#### For install.sh installations (systemd service): #### For helper-script installations (systemd service):
```bash ```bash
# Start the service # Start the service
systemctl start pvescriptslocal systemctl start pvescriptslocal
@@ -253,20 +162,6 @@ systemctl enable pvescriptslocal
journalctl -u pvescriptslocal -f journalctl -u pvescriptslocal -f
``` ```
#### For LXC container installations:
```bash
# Container management
pct start <container_id> # Start container
pct stop <container_id> # Stop container
pct status <container_id> # Check container status
# Access container shell
pct enter <container_id>
# Inside container - start application
cd /opt/PVESciptslocal
npm start
```
#### For manual installations: #### For manual installations:
```bash ```bash
@@ -289,7 +184,7 @@ npm run build
### 4. Download Scripts ### 4. Download Scripts
- Click on any script card to view details - Click on any script card to view details
- Use the "Download" button to fetch scripts from GitHub - Use the "Download" button to fetch scripts from the ProxmoxVE GitHub
- Downloaded scripts are stored locally in the `scripts/` directory - Downloaded scripts are stored locally in the `scripts/` directory
### 5. Execute Scripts ### 5. Execute Scripts
@@ -379,23 +274,6 @@ npm run dev:server
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
### Logs
- Server logs: Check console output or `server.log`
- Script execution: View in web terminal
## 🎯 Quick Start Summary
Choose your preferred deployment method:
| Method | Best For | Command |
|--------|----------|---------|
| **Quick Install** | Proxmox hosts or Debian LXC, easy setup | `bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)"` |
| **LXC Container** | Better isolation, security | Create Debian LXC → Install dependencies → Clone repo → `npm start` |
| **Manual Install** | Custom setups, development | `git clone` → `npm install` → `npm run build` → `npm start` |
All methods provide the same web interface at `http://<IP>:3000` with full Proxmox script management capabilities.
--- ---
**Note**: This is alpha software. Use with caution in production environments and always backup your Proxmox configuration before running scripts. **Note**: This is beta software. Use with caution in production environments and always backup your Proxmox configuration before running scripts.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.2.2

View File

@@ -1,136 +0,0 @@
#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Installer for PVESciptslocal with systemd integration
# ------------------------------------------------------------------------------
# --- Core ---------------------------------------------------------------------
RD=$(echo -e "\033[01;31m")
GN=$(echo -e "\033[1;92m")
YW=$(echo -e "\033[33m")
CL=$(echo -e "\033[m")
msg_info() { echo -e "$YW$1$CL"; }
msg_ok() { echo -e "✔️ $GN$1$CL"; }
msg_err() { echo -e "$RD$1$CL"; }
# --- Dependency Check & Install -----------------------------------------------
check_dependencies() {
msg_info "Checking required packages (build-essential, git)..."
apt-get update
apt-get install -y build-essential git sshpass expect
msg_ok "Dependencies installed."
}
check_nodejs() {
if ! command -v node >/dev/null 2>&1; then
msg_info "Node.js not found, installing Node.js 24.x..."
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y nodejs
msg_ok "Node.js installed: $(node -v)"
else
msg_ok "Node.js already available: $(node -v)"
fi
}
# --- Repository Handling ------------------------------------------------------
clone_or_update_repo() {
read -rp "Installation directory [default: /opt/PVESciptslocal]: " INSTALL_DIR
INSTALL_DIR=${INSTALL_DIR:-/opt/PVESciptslocal}
if [ ! -d "$INSTALL_DIR/.git" ]; then
msg_info "Cloning repository into $INSTALL_DIR..."
git clone https://github.com/michelroegl-brunner/PVESciptslocal.git "$INSTALL_DIR"
msg_ok "Repository cloned."
else
msg_info "Directory already exists. Pulling latest changes..."
git -C "$INSTALL_DIR" pull
msg_ok "Repository updated."
fi
cd "$INSTALL_DIR"
}
# --- Application Setup --------------------------------------------------------
setup_app() {
msg_info "Installing npm dependencies..."
npm install
msg_ok "Dependencies installed."
if [ ! -f .env ]; then
msg_info "Creating environment file from example..."
cp .env.example .env
msg_ok ".env file created."
else
msg_ok ".env file already exists, keeping it."
fi
msg_info "Setting up database directory..."
mkdir -p data
chmod 755 data
msg_ok "Database directory created."
msg_info "Building application..."
npm run build
msg_ok "Build completed."
}
# --- Systemd Service ----------------------------------------------------------
setup_systemd_service() {
SERVICE_NAME="pvescriptslocal"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
msg_info "Creating systemd service at $SERVICE_FILE..."
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=PVEScriptslocal Service
After=network.target
[Service]
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/npm start
Restart=always
RestartSec=10
Environment=NODE_ENV=production
User=root
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reexec
msg_ok "Systemd service created."
read -rp "Enable and start the service now? (y/N): " START_SERVICE
if [[ "$START_SERVICE" =~ ^[Yy]$ ]]; then
systemctl enable --now "$SERVICE_NAME"
msg_ok "Service enabled and started."
else
msg_info "You can start it manually with: systemctl start $SERVICE_NAME"
fi
echo
echo "---------------------------------------------"
echo " Service installed: $SERVICE_NAME"
echo " Manage it with:"
echo " systemctl start $SERVICE_NAME"
echo " systemctl stop $SERVICE_NAME"
echo " systemctl status $SERVICE_NAME"
echo " App will be available at: http://<IP>:3000"
echo "---------------------------------------------"
}
# --- Main ---------------------------------------------------------------------
main() {
check_pve
check_dependencies
check_nodejs
clone_or_update_repo
setup_app
setup_systemd_service
}
main "$@"

1740
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -23,7 +23,7 @@
"ram": 512, "ram": 512,
"hdd": 2, "hdd": 2,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -23,7 +23,7 @@
"ram": 2048, "ram": 2048,
"hdd": 4, "hdd": 4,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE LXC Tag", "name": "PVE LXC Tag",
"slug": "add-iptag", "slug": "add-iptag",
"categories": [ "categories": [
1 1

View File

@@ -23,7 +23,7 @@
"ram": 512, "ram": 512,
"hdd": 2, "hdd": 2,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
}, },
{ {
@@ -44,7 +44,7 @@
}, },
"notes": [ "notes": [
{ {
"text": "Adguard Home can be updated via the user interface.", "text": "AdGuard Home can only be updated via the user interface.",
"type": "info" "type": "info"
} }
] ]

View File

@@ -23,7 +23,7 @@
"ram": 2048, "ram": 2048,
"hdd": 7, "hdd": 7,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -23,7 +23,7 @@
"ram": 2048, "ram": 2048,
"hdd": 4, "hdd": 4,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -20,7 +20,7 @@
"script": "ct/booklore.sh", "script": "ct/booklore.sh",
"resources": { "resources": {
"cpu": 3, "cpu": 3,
"ram": 2048, "ram": 3072,
"hdd": 7, "hdd": 7,
"os": "debian", "os": "debian",
"version": "12" "version": "12"

View File

@@ -4,7 +4,7 @@
"categories": [ "categories": [
21 21
], ],
"date_created": "2024-05-11", "date_created": "2025-09-17",
"type": "ct", "type": "ct",
"updateable": true, "updateable": true,
"privileged": false, "privileged": false,
@@ -21,10 +21,21 @@
"resources": { "resources": {
"cpu": 1, "cpu": 1,
"ram": 512, "ram": 512,
"hdd": 4, "hdd": 6,
"os": "debian", "os": "debian",
"version": "12" "version": "12"
} }
},
{
"type": "alpine",
"script": "ct/alpine-caddy.sh",
"resources": {
"cpu": 1,
"ram": 256,
"hdd": 3,
"os": "alpine",
"version": "3.22"
}
} }
], ],
"default_credentials": { "default_credentials": {

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE LXC Cleaner", "name": "PVE LXC Cleaner",
"slug": "clean-lxcs", "slug": "clean-lxcs",
"categories": [ "categories": [
1 1

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox Clean Orphaned LVM", "name": "PVE Clean Orphaned LVM",
"slug": "clean-orphaned-lvm", "slug": "clean-orphaned-lvm",
"categories": [ "categories": [
1 1

View File

@@ -23,7 +23,7 @@
"ram": 512, "ram": 512,
"hdd": 2, "hdd": 2,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -23,7 +23,7 @@
"ram": 1024, "ram": 1024,
"hdd": 4, "hdd": 4,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -32,5 +32,10 @@
"username": null, "username": null,
"password": null "password": null
}, },
"notes": [] "notes": [
{
"type": "info",
"text": "The file `/etc/sysconfig/CosmosCloud` is optional. If you need custom settings, you can create it yourself."
}
]
} }

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE Cron LXC Updater", "name": "PVE Cron LXC Updater",
"slug": "cron-update-lxcs", "slug": "cron-update-lxcs",
"categories": [ "categories": [
1 1
@@ -13,7 +13,7 @@
"website": null, "website": null,
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp", "logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp",
"config_path": "", "config_path": "",
"description": "This script will add/remove a crontab schedule that updates all LXCs every Sunday at midnight.", "description": "This script will add/remove a crontab schedule that updates the operating system of all LXCs every Sunday at midnight.",
"install_methods": [ "install_methods": [
{ {
"type": "default", "type": "default",

View File

@@ -23,7 +23,7 @@
"ram": 512, "ram": 512,
"hdd": 2, "hdd": 2,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -39,6 +39,10 @@
{ {
"type": "info", "type": "info",
"text": "Synapse-Admin is running on port 5173" "text": "Synapse-Admin is running on port 5173"
},
{
"type": "info",
"text": "For bridges Installation methods (WhatsApp, Signal, Discord, etc.), see: ´https://docs.mau.fi/bridges/go/setup.html´"
} }
] ]
} }

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE LXC Filesystem Trim", "name": "PVE LXC Filesystem Trim",
"slug": "fstrim", "slug": "fstrim",
"categories": [ "categories": [
1 1

View File

@@ -0,0 +1,52 @@
{
"name": "Ghostfolio",
"slug": "ghostfolio",
"categories": [
23
],
"date_created": "2025-09-29",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 3333,
"documentation": "https://github.com/ghostfolio/ghostfolio?tab=readme-ov-file#self-hosting",
"website": "https://ghostfol.io/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/ghostfolio.webp",
"config_path": "/opt/ghostfolio/.env",
"description": "Ghostfolio is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.",
"install_methods": [
{
"type": "default",
"script": "ct/ghostfolio.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 8,
"os": "debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Create your first user account by visiting the web interface and clicking 'Get Started'. The first user will automatically get admin privileges.",
"type": "info"
},
{
"text": "Database and Redis credentials: `cat ~/ghostfolio.creds`",
"type": "info"
},
{
"text": "Optional: CoinGecko API keys can be added during installation or later in the .env file for enhanced cryptocurrency data.",
"type": "info"
},
{
"text": "Build process requires 4GB RAM (runtime: ~2GB). A temporary swap file will be created automatically if insufficient memory is detected.",
"type": "warning"
}
]
}

View File

@@ -0,0 +1,35 @@
{
"name": "GlobaLeaks",
"slug": "globaleaks",
"categories": [
0
],
"date_created": "2025-09-18",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 443,
"documentation": "https://docs.globaleaks.org",
"website": "https://www.globaleaks.org/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/globaleaks.webp",
"config_path": "",
"description": "GlobaLeaks is a free and open-source whistleblowing software enabling anyone to easily set up and maintain a secure reporting platform.",
"install_methods": [
{
"type": "default",
"script": "ct/globaleaks.sh",
"resources": {
"cpu": 2,
"ram": 1024,
"hdd": 4,
"os": "debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

40
scripts/json/goaway.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "GoAway",
"slug": "goaway",
"categories": [
5
],
"date_created": "2025-09-25",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 8080,
"documentation": "https://github.com/pommee/goaway#configuration-file",
"config_path": "/opt/goaway/config/settings.yaml",
"website": "https://github.com/pommee/goaway",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/goaway.webp",
"description": "Lightweight DNS sinkhole written in Go with a modern dashboard client. Very good looking new alternative to Pi-Hole and Adguard Home.",
"install_methods": [
{
"type": "default",
"script": "ct/goaway.sh",
"resources": {
"cpu": 1,
"ram": 1024,
"hdd": 4,
"os": "Debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Type `cat ~/goaway.creds` to see login credentials.",
"type": "info"
}
]
}

View File

@@ -13,7 +13,7 @@
"website": "https://www.getgrist.com/", "website": "https://www.getgrist.com/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/grist.webp", "logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/grist.webp",
"config_path": "/opt/grist/.env", "config_path": "/opt/grist/.env",
"description": "Grist is a modern, open source spreadsheet that goes beyond the grid", "description": "Grist is like a spreadsheet + database hybrid. It lets you store structured data, use relational links between tables, apply formulas (even with Python), build custom layouts (cards, forms, dashboards), set fine-grained access rules, and visualize data with charts or pivot-tables.",
"install_methods": [ "install_methods": [
{ {
"type": "default", "type": "default",

View File

@@ -32,6 +32,10 @@
"password": null "password": null
}, },
"notes": [ "notes": [
{
"text": "Containerized version doesn't allow Home Assistant add-ons.",
"type": "warning"
},
{ {
"text": "If the LXC is created Privileged, the script will automatically set up USB passthrough.", "text": "If the LXC is created Privileged, the script will automatically set up USB passthrough.",
"type": "warning" "type": "warning"

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE Host Backup", "name": "PVE Host Backup",
"slug": "host-backup", "slug": "host-backup",
"categories": [ "categories": [
1 1

View File

@@ -23,7 +23,7 @@
"ram": 4096, "ram": 4096,
"hdd": 20, "hdd": 20,
"os": "Debian", "os": "Debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -0,0 +1,40 @@
{
"name": "Joplin Server",
"slug": "joplin-server",
"categories": [
12
],
"date_created": "2025-09-24",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 22300,
"documentation": "https://joplinapp.org/help/",
"config_path": "/opt/joplin-server/.env",
"website": "https://joplinapp.org/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/joplin.webp",
"description": "Joplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.",
"install_methods": [
{
"type": "default",
"script": "ct/joplin-server.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 20,
"os": "Debian",
"version": "12"
}
}
],
"default_credentials": {
"username": "admin@localhost",
"password": "admin"
},
"notes": [
{
"text": "Application can take some time to build, depending on your host speed. Please be patient.",
"type": "info"
}
]
}

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE Kernel Clean", "name": "PVE Kernel Clean",
"slug": "kernel-clean", "slug": "kernel-clean",
"categories": [ "categories": [
1 1

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE Kernel Pin", "name": "PVE Kernel Pin",
"slug": "kernel-pin", "slug": "kernel-pin",
"categories": [ "categories": [
1 1

View File

@@ -23,7 +23,7 @@
"ram": 2048, "ram": 2048,
"hdd": 4, "hdd": 4,
"os": "Debian", "os": "Debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -1,5 +1,5 @@
{ {
"name": "Container LXC Deletion", "name": "PVE LXC Deletion",
"slug": "lxc-delete", "slug": "lxc-delete",
"categories": [ "categories": [
1 1

View File

@@ -0,0 +1,48 @@
{
"name": "PVE LXC Execute Command",
"slug": "lxc-execute",
"categories": [
1
],
"date_created": "2025-09-18",
"type": "pve",
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,
"website": null,
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp",
"config_path": "",
"description": "This script allows administrators to execute a custom command inside one or multiple LXC containers on a Proxmox VE node. Containers can be selectively excluded via an interactive checklist. If a container is stopped, the script will automatically start it, run the command, and then shut it down again. Only Debian and Ubuntu based containers are supported.",
"install_methods": [
{
"type": "default",
"script": "tools/pve/execute.sh",
"resources": {
"cpu": null,
"ram": null,
"hdd": null,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Execute within the Proxmox shell.",
"type": "info"
},
{
"text": "Non-Debian/Ubuntu containers will be skipped automatically.",
"type": "info"
},
{
"text": "Stopped containers will be started temporarily to run the command, then shut down again.",
"type": "warning"
}
]
}

View File

@@ -1,31 +1,186 @@
{ {
"categories": [ "categories": [
{ "name": "Proxmox & Virtualization", "id": 1, "sort_order": 1.0, "description": "Tools and scripts to manage Proxmox VE and virtualization platforms effectively." }, {
{ "name": "Operating Systems", "id": 2, "sort_order": 2.0, "description": "Scripts for deploying and managing various operating systems." }, "name": "Proxmox & Virtualization",
{ "name": "Containers & Docker", "id": 3, "sort_order": 3.0, "description": "Solutions for containerization using Docker and related technologies." }, "id": 1,
{ "name": "Network & Firewall", "id": 4, "sort_order": 4.0, "description": "Enhance network security and configure firewalls with ease." }, "sort_order": 1.0,
{ "name": "Adblock & DNS", "id": 5, "sort_order": 5.0, "description": "Optimize your network with DNS and ad-blocking solutions." }, "description": "Tools and scripts to manage Proxmox VE and virtualization platforms effectively.",
{ "name": "Authentication & Security", "id": 6, "sort_order": 6.0, "description": "Secure your infrastructure with authentication and security tools." }, "icon": "server"
{ "name": "Backup & Recovery", "id": 7, "sort_order": 7.0, "description": "Reliable backup and recovery scripts to protect your data." }, },
{ "name": "Databases", "id": 8, "sort_order": 8.0, "description": "Deploy and manage robust database systems with ease." }, {
{ "name": "Monitoring & Analytics", "id": 9, "sort_order": 9.0, "description": "Monitor system performance and analyze data seamlessly." }, "name": "Operating Systems",
{ "name": "Dashboards & Frontends", "id": 10, "sort_order": 10.0, "description": "Create interactive dashboards and user-friendly frontends." }, "id": 2,
{ "name": "Files & Downloads", "id": 11, "sort_order": 11.0, "description": "Manage file sharing and downloading solutions efficiently." }, "sort_order": 2.0,
{ "name": "Documents & Notes", "id": 12, "sort_order": 12.0, "description": "Organize and manage documents and note-taking tools." }, "description": "Scripts for deploying and managing various operating systems.",
{ "name": "Media & Streaming", "id": 13, "sort_order": 13.0, "description": "Stream and manage media effortlessly across devices." }, "icon": "monitor"
{ "name": "*Arr Suite", "id": 14, "sort_order": 14.0, "description": "Automated media management with the popular *Arr suite tools." }, },
{ "name": "NVR & Cameras", "id": 15, "sort_order": 15.0, "description": "Manage network video recorders and camera setups." }, {
{ "name": "IoT & Smart Home", "id": 16, "sort_order": 16.0, "description": "Control and automate IoT devices and smart home systems." }, "name": "Containers & Docker",
{ "name": "ZigBee, Z-Wave & Matter", "id": 17, "sort_order": 17.0, "description": "Solutions for ZigBee, Z-Wave, and Matter-based device management." }, "id": 3,
{ "name": "MQTT & Messaging", "id": 18, "sort_order": 18.0, "description": "Set up reliable messaging and MQTT-based communication systems." }, "sort_order": 3.0,
{ "name": "Automation & Scheduling", "id": 19, "sort_order": 19.0, "description": "Automate tasks and manage scheduling with powerful tools." }, "description": "Solutions for containerization using Docker and related technologies.",
{ "name": "AI / Coding & Dev-Tools", "id": 20, "sort_order": 20.0, "description": "Leverage AI and developer tools for smarter coding workflows." }, "icon": "box"
{ "name": "Webservers & Proxies", "id": 21, "sort_order": 21.0, "description": "Deploy and configure web servers and proxy solutions." }, },
{ "name": "Bots & ChatOps", "id": 22, "sort_order": 22.0, "description": "Enhance collaboration with bots and ChatOps integrations." }, {
{ "name": "Finance & Budgeting", "id": 23, "sort_order": 23.0, "description": "Track expenses and manage budgets efficiently." }, "name": "Network & Firewall",
{ "name": "Gaming & Leisure", "id": 24, "sort_order": 24.0, "description": "Scripts for gaming servers and leisure-related tools." }, "id": 4,
{ "name": "Business & ERP", "id": 25, "sort_order": 25.0, "description": "Streamline business operations with ERP and management tools." }, "sort_order": 4.0,
{ "name": "Miscellaneous", "id": 0, "sort_order": 99.0, "description": "General scripts and tools that don't fit into other categories." } "description": "Enhance network security and configure firewalls with ease.",
"icon": "shield"
},
{
"name": "Adblock & DNS",
"id": 5,
"sort_order": 5.0,
"description": "Optimize your network with DNS and ad-blocking solutions.",
"icon": "ban"
},
{
"name": "Authentication & Security",
"id": 6,
"sort_order": 6.0,
"description": "Secure your infrastructure with authentication and security tools.",
"icon": "lock"
},
{
"name": "Backup & Recovery",
"id": 7,
"sort_order": 7.0,
"description": "Reliable backup and recovery scripts to protect your data.",
"icon": "archive"
},
{
"name": "Databases",
"id": 8,
"sort_order": 8.0,
"description": "Deploy and manage robust database systems with ease.",
"icon": "database"
},
{
"name": "Monitoring & Analytics",
"id": 9,
"sort_order": 9.0,
"description": "Monitor system performance and analyze data seamlessly.",
"icon": "bar-chart"
},
{
"name": "Dashboards & Frontends",
"id": 10,
"sort_order": 10.0,
"description": "Create interactive dashboards and user-friendly frontends.",
"icon": "layout"
},
{
"name": "Files & Downloads",
"id": 11,
"sort_order": 11.0,
"description": "Manage file sharing and downloading solutions efficiently.",
"icon": "download"
},
{
"name": "Documents & Notes",
"id": 12,
"sort_order": 12.0,
"description": "Organize and manage documents and note-taking tools.",
"icon": "file-text"
},
{
"name": "Media & Streaming",
"id": 13,
"sort_order": 13.0,
"description": "Stream and manage media effortlessly across devices.",
"icon": "play"
},
{
"name": "*Arr Suite",
"id": 14,
"sort_order": 14.0,
"description": "Automated media management with the popular *Arr suite tools.",
"icon": "tv"
},
{
"name": "NVR & Cameras",
"id": 15,
"sort_order": 15.0,
"description": "Manage network video recorders and camera setups.",
"icon": "camera"
},
{
"name": "IoT & Smart Home",
"id": 16,
"sort_order": 16.0,
"description": "Control and automate IoT devices and smart home systems.",
"icon": "home"
},
{
"name": "ZigBee, Z-Wave & Matter",
"id": 17,
"sort_order": 17.0,
"description": "Solutions for ZigBee, Z-Wave, and Matter-based device management.",
"icon": "radio"
},
{
"name": "MQTT & Messaging",
"id": 18,
"sort_order": 18.0,
"description": "Set up reliable messaging and MQTT-based communication systems.",
"icon": "message-circle"
},
{
"name": "Automation & Scheduling",
"id": 19,
"sort_order": 19.0,
"description": "Automate tasks and manage scheduling with powerful tools.",
"icon": "clock"
},
{
"name": "AI / Coding & Dev-Tools",
"id": 20,
"sort_order": 20.0,
"description": "Leverage AI and developer tools for smarter coding workflows.",
"icon": "code"
},
{
"name": "Webservers & Proxies",
"id": 21,
"sort_order": 21.0,
"description": "Deploy and configure web servers and proxy solutions.",
"icon": "globe"
},
{
"name": "Bots & ChatOps",
"id": 22,
"sort_order": 22.0,
"description": "Enhance collaboration with bots and ChatOps integrations.",
"icon": "bot"
},
{
"name": "Finance & Budgeting",
"id": 23,
"sort_order": 23.0,
"description": "Track expenses and manage budgets efficiently.",
"icon": "dollar-sign"
},
{
"name": "Gaming & Leisure",
"id": 24,
"sort_order": 24.0,
"description": "Scripts for gaming servers and leisure-related tools.",
"icon": "gamepad-2"
},
{
"name": "Business & ERP",
"id": 25,
"sort_order": 25.0,
"description": "Streamline business operations with ERP and management tools.",
"icon": "building"
},
{
"name": "Miscellaneous",
"id": 0,
"sort_order": 99.0,
"description": "General scripts and tools that don't fit into other categories.",
"icon": "more-horizontal"
}
] ]
} }

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE Processor Microcode", "name": "PVE Processor Microcode",
"slug": "microcode", "slug": "microcode",
"categories": [ "categories": [
1 1

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE Monitor-All", "name": "PVE Monitor-All",
"slug": "monitor-all", "slug": "monitor-all",
"categories": [ "categories": [
1 1

35
scripts/json/myip.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "MyIP",
"slug": "myip",
"categories": [
4
],
"date_created": "2025-09-29",
"type": "ct",
"updateable": true,
"privileged": false,
"config_path": "/opt/myip/.env",
"interface_port": 18966,
"documentation": "https://github.com/jason5ng32/MyIP#-environment-variable",
"website": "https://ipcheck.ing/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/myip.webp",
"description": "The best IP Toolbox. Easy to check what's your IPs, IP geolocation, check for DNS leaks, examine WebRTC connections, speed test, ping test, MTR test, check website availability, whois search and more!",
"install_methods": [
{
"type": "default",
"script": "ct/myip.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 2,
"os": "Debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

View File

@@ -23,7 +23,7 @@
"ram": 1024, "ram": 1024,
"hdd": 4, "hdd": 4,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE Netdata", "name": "PVE Netdata",
"slug": "netdata", "slug": "netdata",
"categories": [ "categories": [
1 1

View File

@@ -23,7 +23,7 @@
"ram": 1024, "ram": 1024,
"hdd": 4, "hdd": 4,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -23,7 +23,7 @@
"ram": 1024, "ram": 1024,
"hdd": 4, "hdd": 4,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
}, },
{ {

View File

@@ -23,7 +23,7 @@
"ram": 512, "ram": 512,
"hdd": 2, "hdd": 2,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],

View File

@@ -31,5 +31,10 @@
"username": null, "username": null,
"password": null "password": null
}, },
"notes": [] "notes": [
{
"text": "Script contains optional installation of Ollama.",
"type": "info"
}
]
} }

View File

@@ -20,7 +20,7 @@
"script": "ct/overseerr.sh", "script": "ct/overseerr.sh",
"resources": { "resources": {
"cpu": 2, "cpu": 2,
"ram": 2048, "ram": 4096,
"hdd": 8, "hdd": 8,
"os": "debian", "os": "debian",
"version": "12" "version": "12"

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox Backup Server Processor Microcode", "name": "PBS Processor Microcode",
"slug": "pbs-microcode", "slug": "pbs-microcode",
"categories": [ "categories": [
1 1

View File

@@ -0,0 +1,44 @@
{
"name": "PhpMyAdmin",
"slug": "phpmyadmin",
"categories": [
8
],
"date_created": "2025-10-01",
"type": "addon",
"updateable": true,
"privileged": false,
"interface_port": null,
"documentation": "https://www.phpmyadmin.net/docs/",
"config_path": "Debian/Ubuntu: /var/www/html/phpMyAdmin | Alpine: /usr/share/phpmyadmin",
"website": "https://www.phpmyadmin.net/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/phpmyadmin.webp",
"description": "phpMyAdmin is a free software tool written in PHP, intended to handle the administration of MySQL over the Web. phpMyAdmin supports a wide range of operations on MySQL and MariaDB. Frequently used operations (managing databases, tables, columns, relations, indexes, users, permissions, etc) can be performed via the user interface, while you still have the ability to directly execute any SQL statement.",
"install_methods": [
{
"type": "default",
"script": "tools/addon/phpmyadmin.sh",
"resources": {
"cpu": null,
"ram": null,
"hdd": null,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Execute within an existing LXC Console",
"type": "warning"
},
{
"text": "To update or uninstall run bash call again",
"type": "info"
}
]
}

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox Backup Server Post Install", "name": "PBS Post Install",
"slug": "post-pbs-install", "slug": "post-pbs-install",
"categories": [ "categories": [
1 1
@@ -13,7 +13,7 @@
"website": null, "website": null,
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp", "logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp",
"config_path": "", "config_path": "",
"description": "The script will give options to Disable the Enterprise Repo, Add/Correct PBS Sources, Enable the No-Subscription Repo, Add Test Repo, Disable Subscription Nag, Update Proxmox Backup Server and Reboot PBS.", "description": "The script is designed for Proxmox Backup Server (PBS) and will give options to Disable the Enterprise Repo, Add/Correct PBS Sources, Enable the No-Subscription Repo, Add Test Repo, Disable Subscription Nag, Update Proxmox Backup Server and Reboot PBS.",
"install_methods": [ "install_methods": [
{ {
"type": "default", "type": "default",

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox Mail Gateway Post Install", "name": "PMG Post Install",
"slug": "post-pmg-install", "slug": "post-pmg-install",
"categories": [ "categories": [
1 1
@@ -13,7 +13,7 @@
"website": null, "website": null,
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp", "logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp",
"config_path": "", "config_path": "",
"description": "The script will give options to Disable the Enterprise Repo, Add/Correct PMG Sources, Enable the No-Subscription Repo, Add Test Repo, Disable Subscription Nag, Update Proxmox Mail Gateway and Reboot PMG.", "description": "The script is designed for Proxmox Mail Gateway and will give options to Disable the Enterprise Repo, Add/Correct PMG Sources, Enable the No-Subscription Repo, Add Test Repo, Disable Subscription Nag, Update Proxmox Mail Gateway and Reboot PMG.",
"install_methods": [ "install_methods": [
{ {
"type": "default", "type": "default",

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE Post Install", "name": "PVE Post Install",
"slug": "post-pve-install", "slug": "post-pve-install",
"categories": [ "categories": [
1 1

View File

@@ -46,6 +46,10 @@
{ {
"text": "Set a password after installation for postgres user by running `echo \"ALTER USER postgres with encrypted password 'your_password';\" | sudo -u postgres psql`", "text": "Set a password after installation for postgres user by running `echo \"ALTER USER postgres with encrypted password 'your_password';\" | sudo -u postgres psql`",
"type": "info" "type": "info"
},
{
"text": "Debian script offers versions `15, 16, 17, 18`, while Alpine script offers versions `15, 16, 17`.",
"type": "info"
} }
] ]
} }

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox Backup Server", "name": "Proxmox Backup Server (PBS)",
"slug": "proxmox-backup-server", "slug": "proxmox-backup-server",
"categories": [ "categories": [
1 1

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox Datacenter Manager", "name": "Proxmox Datacenter Manager (PDM)",
"slug": "proxmox-datacenter-manager", "slug": "proxmox-datacenter-manager",
"categories": [ "categories": [
1 1

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox Mail Gateway", "name": "Proxmox Mail Gateway (PMG)",
"slug": "proxmox-mail-gateway", "slug": "proxmox-mail-gateway",
"categories": [ "categories": [
1 1

View File

@@ -0,0 +1,35 @@
{
"name": "PVEScriptsLocal",
"slug": "pve-scripts-local",
"categories": [
1
],
"date_created": "2025-10-03",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 3000,
"documentation": "https://github.com/community-scripts/ProxmoxVE-Local",
"config_path": "/opt/PVEScripts-Local/.env",
"website": "https://community-scripts.github.io/ProxmoxVE",
"logo": "https://raw.githubusercontent.com/community-scripts/ProxmoxVE-Local/refs/heads/main/.github/logo.png",
"description": "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.",
"install_methods": [
{
"type": "default",
"script": "ct/pve-scripts-local.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 4,
"os": "Debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE CPU Scaling Governor", "name": "PVE CPU Scaling Governor",
"slug": "scaling-governor", "slug": "scaling-governor",
"categories": [ "categories": [
1 1

View File

@@ -10,7 +10,7 @@
"privileged": false, "privileged": false,
"interface_port": 3000, "interface_port": 3000,
"documentation": "https://tracktor.bytedge.in/introduction.html", "documentation": "https://tracktor.bytedge.in/introduction.html",
"config_path": "/opt/tracktor/app/server/.env", "config_path": "/opt/tracktor.env",
"website": "https://tracktor.bytedge.in/", "website": "https://tracktor.bytedge.in/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/tracktor.webp", "logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/tracktor.webp",
"description": "Tracktor is an open-source web application for comprehensive vehicle management.\nEasily track fuel consumption, maintenance, insurance, and regulatory documents for all your vehicles in one place.", "description": "Tracktor is an open-source web application for comprehensive vehicle management.\nEasily track fuel consumption, maintenance, insurance, and regulatory documents for all your vehicles in one place.",
@@ -23,17 +23,17 @@
"ram": 1024, "ram": 1024,
"hdd": 6, "hdd": 6,
"os": "Debian", "os": "Debian",
"version": "12" "version": "13"
} }
} }
], ],
"default_credentials": { "default_credentials": {
"username": null, "username": null,
"password": null "password": "123456"
}, },
"notes": [ "notes": [
{ {
"text": "Please check and update the '/opt/tracktor/app/backend/.env' file if using behind reverse proxy.", "text": "Please check and update the '/opt/tracktor.env' file if using behind reverse proxy.",
"type": "info" "type": "info"
} }
] ]

35
scripts/json/tunarr.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "Tunarr",
"slug": "tunarr",
"categories": [
13
],
"date_created": "2025-09-19",
"type": "ct",
"updateable": true,
"privileged": false,
"config_path": "/opt/tunarr/.env",
"interface_port": 8000,
"documentation": "https://tunarr.com/",
"website": "https://tunarr.com/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/tunarr.webp",
"description": "Create a classic TV experience using your own media - IPTV backed by Plex/Jellyfin/Emby.",
"install_methods": [
{
"type": "default",
"script": "ct/tunarr.sh",
"resources": {
"cpu": 2,
"ram": 1024,
"hdd": 5,
"os": "Debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox VE LXC Updater", "name": "PVE LXC Updater",
"slug": "update-lxcs", "slug": "update-lxcs",
"categories": [ "categories": [
1 1
@@ -13,7 +13,7 @@
"website": null, "website": null,
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/linuxcontainers.webp", "logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/linuxcontainers.webp",
"config_path": "", "config_path": "",
"description": "This script has been created to simplify and speed up the process of updating all LXC containers across various Linux distributions, such as Ubuntu, Debian, Devuan, Alpine Linux, CentOS-Rocky-Alma, Fedora, and ArchLinux. It's designed to automatically skip templates and specific containers during the update, enhancing its convenience and usability.", "description": "This script has been created to simplify and speed up the process of updating the operating system running inside LXC containers across various Linux distributions, such as Ubuntu, Debian, Devuan, Alpine Linux, CentOS-Rocky-Alma, Fedora, and ArchLinux. It's designed to automatically skip templates and specific containers during the update, enhancing its convenience and usability.",
"install_methods": [ "install_methods": [
{ {
"type": "default", "type": "default",
@@ -35,6 +35,10 @@
{ {
"text": "Execute within the Proxmox shell", "text": "Execute within the Proxmox shell",
"type": "info" "type": "info"
},
{
"text": "The script updates only the operating system of the LXC container. It DOES NOT update the application installed within the container!",
"type": "warning"
} }
] ]
} }

View File

@@ -1,5 +1,5 @@
{ {
"name": "Proxmox Update Repositories", "name": "PVE Update Repositories",
"slug": "update-repo", "slug": "update-repo",
"categories": [ "categories": [
1 1

40
scripts/json/upsnap.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "UpSnap",
"slug": "upsnap",
"categories": [
4
],
"date_created": "2025-09-23",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 8090,
"documentation": "https://github.com/seriousm4x/UpSnap/wiki",
"config_path": "",
"website": "https://github.com/seriousm4x/UpSnap",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/upsnap.webp",
"description": "UpSnap is a self-hosted web app that lets you wake up, manage and monitor devices on your network with ease. Built with SvelteKit, Go and PocketBase, it offers a clean dashboard, scheduled wake-ups, device discovery and secure user management.",
"install_methods": [
{
"type": "default",
"script": "ct/upsnap.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 2,
"os": "Debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "The first user you register will be the admin user.",
"type": "info"
}
]
}

View File

@@ -0,0 +1,40 @@
{
"name": "Verdaccio",
"slug": "verdaccio",
"categories": [
20
],
"date_created": "2025-09-29",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 4873,
"documentation": "https://verdaccio.org/docs/what-is-verdaccio",
"website": "https://verdaccio.org/",
"logo": "https://verdaccio.org/img/logo/symbol/png/verdaccio-tiny.png",
"config_path": "/opt/verdaccio/config/config.yaml",
"description": "Verdaccio is a lightweight private npm proxy registry built with Node.js. It allows you to host your own npm registry with minimal configuration, providing a private npm repository for your projects. Verdaccio supports npm, yarn, and pnpm, and can cache packages from the public npm registry, allowing for faster installs and protection against npm registry outages. It includes a web interface for browsing packages, authentication and authorization features, and can be easily integrated into your development workflow.",
"install_methods": [
{
"type": "default",
"script": "ct/verdaccio.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 8,
"os": "debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "To create the first user, run: npm adduser --registry http://<container-ip>:4873",
"type": "info"
}
]
}

View File

@@ -0,0 +1,40 @@
{
"name": "Warracker",
"slug": "warracker",
"categories": [
12
],
"date_created": "2025-09-29",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 80,
"documentation": null,
"config_path": "/opt/.env",
"website": "https://warracker.com/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/warracker.webp",
"description": "Warracker is an open source, self-hostable warranty tracker to monitor expirations, store receipts, files. You own the data, your rules!",
"install_methods": [
{
"type": "default",
"script": "ct/warracker.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 4,
"os": "Debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "The first user you register will be the admin user.",
"type": "info"
}
]
}

View File

@@ -21,7 +21,7 @@
"resources": { "resources": {
"cpu": 4, "cpu": 4,
"ram": 4096, "ram": 4096,
"hdd": 18, "hdd": 25,
"os": "debian", "os": "debian",
"version": "12" "version": "12"
} }

View File

@@ -23,7 +23,7 @@
"ram": 4096, "ram": 4096,
"hdd": 6, "hdd": 6,
"os": "debian", "os": "debian",
"version": "12" "version": "13"
} }
} }
], ],
@@ -33,11 +33,19 @@
}, },
"notes": [ "notes": [
{ {
"text": "Database credentials: `cat zabbix.creds`", "text": "Database credentials: `cat ~/zabbix.creds`",
"type": "info" "type": "info"
}, },
{ {
"text": "Zabbix agent 2 is used by default", "text": "You can choose between Zabbix agent (classic) and agent2 (modern) during installation",
"type": "info"
},
{
"text": "For agent2 the PostgreSQL plugin is installed by default; all plugins are optional",
"type": "info"
},
{
"text": "If agent2 with NVIDIA plugin is installed in an environment without GPU, the installer disables it automatically",
"type": "info" "type": "info"
} }
] ]

View File

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

View File

@@ -1,50 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
// Mock the environment variables
const mockEnv = {
SCRIPTS_DIRECTORY: '/test/scripts',
ALLOWED_SCRIPT_EXTENSIONS: '.sh,.py,.js,.ts',
ALLOWED_SCRIPT_PATHS: '/,/ct/',
MAX_SCRIPT_EXECUTION_TIME: '30000',
REPO_URL: 'https://github.com/test/repo',
NODE_ENV: 'test',
}
vi.mock('~/env.js', () => ({
env: mockEnv,
}))
describe('Environment Configuration', () => {
it('should have required environment variables', async () => {
const { env } = await import('~/env.js')
expect(env.SCRIPTS_DIRECTORY).toBeDefined()
expect(env.ALLOWED_SCRIPT_EXTENSIONS).toBeDefined()
expect(env.ALLOWED_SCRIPT_PATHS).toBeDefined()
expect(env.MAX_SCRIPT_EXECUTION_TIME).toBeDefined()
})
it('should have correct script directory path', async () => {
const { env } = await import('~/env.js')
expect(env.SCRIPTS_DIRECTORY).toBe('/test/scripts')
})
it('should have correct allowed extensions', async () => {
const { env } = await import('~/env.js')
expect(env.ALLOWED_SCRIPT_EXTENSIONS).toBe('.sh,.py,.js,.ts')
})
it('should have correct allowed paths', async () => {
const { env } = await import('~/env.js')
expect(env.ALLOWED_SCRIPT_PATHS).toBe('/,/ct/')
})
it('should have correct max execution time', async () => {
const { env } = await import('~/env.js')
expect(env.MAX_SCRIPT_EXECUTION_TIME).toBe('30000')
})
})

View File

@@ -1,140 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import Home from '../page'
// Mock tRPC
vi.mock('~/trpc/react', () => ({
api: {
scripts: {
getRepoStatus: {
useQuery: vi.fn(() => ({
data: { isRepo: true, isBehind: false, branch: 'main', lastCommit: 'abc123' },
refetch: vi.fn(),
})),
},
getScriptCards: {
useQuery: vi.fn(() => ({
data: { success: true, cards: [] },
isLoading: false,
error: null,
})),
},
getCtScripts: {
useQuery: vi.fn(() => ({
data: { scripts: [] },
isLoading: false,
error: null,
})),
},
getScriptBySlug: {
useQuery: vi.fn(() => ({
data: null,
})),
},
checkProxmoxVE: {
useQuery: vi.fn(() => ({
data: { success: true, isProxmoxVE: true },
isLoading: false,
error: null,
})),
},
fullUpdateRepo: {
useMutation: vi.fn(() => ({
mutate: vi.fn(),
})),
},
},
},
}))
// Mock child components
vi.mock('../_components/ScriptsGrid', () => ({
ScriptsGrid: ({ onInstallScript }: { onInstallScript?: (path: string, name: string) => void }) => (
<div data-testid="scripts-grid">
<button onClick={() => onInstallScript?.('/test/path', 'test-script')}>
Run Script
</button>
</div>
),
}))
vi.mock('../_components/ResyncButton', () => ({
ResyncButton: () => <div data-testid="resync-button">Resync Button</div>,
}))
vi.mock('../_components/Terminal', () => ({
Terminal: ({ scriptPath, onClose }: { scriptPath: string; onClose: () => void }) => (
<div data-testid="terminal">
<div>Terminal for: {scriptPath}</div>
<button onClick={onClose}>Close Terminal</button>
</div>
),
}))
describe('Home Page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render main page elements', () => {
render(<Home />)
expect(screen.getByText('🚀 PVE Scripts Management')).toBeInTheDocument()
expect(screen.getByText('Manage and execute Proxmox helper scripts locally with live output streaming')).toBeInTheDocument()
expect(screen.getByTestId('resync-button')).toBeInTheDocument()
expect(screen.getByTestId('scripts-grid')).toBeInTheDocument()
})
it('should not show terminal initially', () => {
render(<Home />)
expect(screen.queryByTestId('terminal')).not.toBeInTheDocument()
})
it('should show terminal when script is run', () => {
render(<Home />)
const runButton = screen.getByText('Run Script')
fireEvent.click(runButton)
expect(screen.getByTestId('terminal')).toBeInTheDocument()
expect(screen.getByText('Terminal for: /test/path')).toBeInTheDocument()
})
it('should close terminal when close button is clicked', () => {
render(<Home />)
// First run a script to show terminal
const runButton = screen.getByText('Run Script')
fireEvent.click(runButton)
expect(screen.getByTestId('terminal')).toBeInTheDocument()
// Then close the terminal
const closeButton = screen.getByText('Close Terminal')
fireEvent.click(closeButton)
expect(screen.queryByTestId('terminal')).not.toBeInTheDocument()
})
it('should handle multiple script runs', () => {
render(<Home />)
// Run first script
const runButton = screen.getByText('Run Script')
fireEvent.click(runButton)
expect(screen.getByText('Terminal for: /test/path')).toBeInTheDocument()
// Close terminal
const closeButton = screen.getByText('Close Terminal')
fireEvent.click(closeButton)
expect(screen.queryByTestId('terminal')).not.toBeInTheDocument()
// Run second script
fireEvent.click(runButton)
expect(screen.getByText('Terminal for: /test/path')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,140 @@
'use client';
import React from 'react';
interface BadgeProps {
variant: 'type' | 'updateable' | 'privileged' | 'status' | 'note' | 'execution-mode';
type?: string;
noteType?: 'info' | 'warning' | 'error';
status?: 'success' | 'failed' | 'in_progress';
executionMode?: 'local' | 'ssh';
children: React.ReactNode;
className?: string;
}
export function Badge({ variant, type, noteType, status, executionMode, children, className = '' }: BadgeProps) {
const getTypeStyles = (scriptType: string) => {
switch (scriptType.toLowerCase()) {
case 'ct':
return 'bg-primary/10 text-primary border-primary/20';
case 'addon':
return 'bg-purple-500/10 text-purple-400 border-purple-500/20';
case 'vm':
return 'bg-green-500/10 text-green-400 border-green-500/20';
case 'pve':
return 'bg-orange-500/10 text-orange-400 border-orange-500/20';
default:
return 'bg-muted text-muted-foreground border-border';
}
};
const getVariantStyles = () => {
switch (variant) {
case 'type':
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-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-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-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-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-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-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-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-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-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-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-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-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-muted text-muted-foreground border border-border';
}
};
// Format the text for type badges
const formatText = () => {
if (variant === 'type' && type) {
switch (type.toLowerCase()) {
case 'ct':
return 'LXC';
case 'addon':
return 'ADDON';
case 'vm':
return 'VM';
case 'pve':
return 'PVE';
default:
return type.toUpperCase();
}
}
return children;
};
return (
<span className={`${getVariantStyles()} ${className}`}>
{formatText()}
</span>
);
}
// Convenience components for common use cases
export const TypeBadge = ({ type, className }: { type: string; className?: string }) => (
<Badge variant="type" type={type} className={className}>
{type}
</Badge>
);
export const UpdateableBadge = ({ className }: { className?: string }) => (
<Badge variant="updateable" className={className}>
Updateable
</Badge>
);
export const PrivilegedBadge = ({ className }: { className?: string }) => (
<Badge variant="privileged" className={className}>
Privileged
</Badge>
);
export const StatusBadge = ({ status, children, className }: { status: 'success' | 'failed' | 'in_progress'; children: React.ReactNode; className?: string }) => (
<Badge variant="status" status={status} className={className}>
{children}
</Badge>
);
export const ExecutionModeBadge = ({ mode, children, className }: { mode: 'local' | 'ssh'; children: React.ReactNode; className?: string }) => (
<Badge variant="execution-mode" executionMode={mode} className={className}>
{children}
</Badge>
);
export const NoteBadge = ({ noteType, children, className }: { noteType: 'info' | 'warning' | 'error'; children: React.ReactNode; className?: string }) => (
<Badge variant="note" noteType={noteType} className={className}>
{children}
</Badge>
);

View File

@@ -0,0 +1,363 @@
'use client';
import { useState } from 'react';
interface CategorySidebarProps {
categories: string[];
categoryCounts: Record<string, number>;
totalScripts: number;
selectedCategory: string | null;
onCategorySelect: (category: string | null) => void;
}
// Icon mapping for categories
const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; className?: string }) => {
const iconMap: Record<string, React.ReactElement> = {
server: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
),
monitor: (
<svg className={className} 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>
),
box: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
shield: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
"shield-check": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
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" />
</svg>
),
archive: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
),
database: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
),
"chart-bar": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
template: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
),
"folder-open": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
),
"document-text": (
<svg className={className} 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>
),
film: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m0 0V1.5a.5.5 0 01.5-.5h1a.5.5 0 01.5.5V4m-3 0H9m3 0v16a1 1 0 01-1 1H8a1 1 0 01-1-1V4m6 0h2a2 2 0 012 2v12a2 2 0 01-2 2h-2V4z" />
</svg>
),
download: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
),
"video-camera": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
),
home: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
wifi: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
),
"chat-alt": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
),
clock: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
code: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
"external-link": (
<svg className={className} 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>
),
sparkles: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
),
"currency-dollar": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
</svg>
),
puzzle: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
),
office: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
),
};
return iconMap[iconName] ?? (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4 4 4 0 004-4V5z" />
</svg>
);
};
export function CategorySidebar({
categories,
categoryCounts,
totalScripts,
selectedCategory,
onCategorySelect
}: CategorySidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
// Category to icon mapping (based on metadata.json)
const categoryIconMapping: Record<string, string> = {
'Proxmox & Virtualization': 'server',
'Operating Systems': 'monitor',
'Containers & Docker': 'box',
'Network & Firewall': 'shield',
'Adblock & DNS': 'shield-check',
'Authentication & Security': 'key',
'Backup & Recovery': 'archive',
'Databases': 'database',
'Monitoring & Analytics': 'chart-bar',
'Dashboards & Frontends': 'template',
'Files & Downloads': 'folder-open',
'Documents & Notes': 'document-text',
'Media & Streaming': 'film',
'*Arr Suite': 'download',
'NVR & Cameras': 'video-camera',
'IoT & Smart Home': 'home',
'ZigBee, Z-Wave & Matter': 'wifi',
'MQTT & Messaging': 'chat-alt',
'Automation & Scheduling': 'clock',
'AI / Coding & Dev-Tools': 'code',
'Webservers & Proxies': 'external-link',
'Bots & ChatOps': 'sparkles',
'Finance & Budgeting': 'currency-dollar',
'Gaming & Leisure': 'puzzle',
'Business & ERP': 'office',
'Miscellaneous': 'box'
};
// Sort categories by count (descending) and then alphabetically
const sortedCategories = categories
.map(category => [category, categoryCounts[category] ?? 0] as const)
.sort(([a, countA], [b, countB]) => {
if (countB !== countA) return countB - countA;
return a.localeCompare(b);
});
return (
<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-border">
{!isCollapsed && (
<div>
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
title={isCollapsed ? 'Expand categories' : 'Collapse categories'}
>
<svg
className={`w-5 h-5 text-muted-foreground transition-transform ${
isCollapsed ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
{/* Expanded state - show full categories */}
{!isCollapsed && (
<div className="p-4">
<div className="space-y-2">
{/* "All Categories" option */}
<button
onClick={() => onCategorySelect(null)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
selectedCategory === null
? '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-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-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
{totalScripts}
</span>
</button>
{/* Individual Categories */}
{sortedCategories.map(([category, count]) => {
const isSelected = selectedCategory === category;
return (
<button
key={category}
onClick={() => onCategorySelect(category)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
isSelected
? '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-primary' : 'text-muted-foreground'}`}
/>
<span className="font-medium capitalize">
{category.replace(/[_-]/g, ' ')}
</span>
</div>
<span className={`text-sm px-2 py-1 rounded-full ${
isSelected
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
{count}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Collapsed state - show only icons with counters and tooltips */}
{isCollapsed && (
<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-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-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className={`text-xs mt-1 px-1 rounded ${
selectedCategory === null
? '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 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>
{/* Individual Categories */}
{sortedCategories.map(([category, count]) => {
const isSelected = selectedCategory === category;
return (
<div key={category} className="group relative">
<button
onClick={() => onCategorySelect(category)}
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
isSelected
? '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-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className={`text-xs mt-1 px-1 rounded ${
isSelected
? '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 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>
);
})}
</div>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,413 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { api } from '~/trpc/react';
import { ScriptCard } from './ScriptCard';
import { ScriptDetailModal } from './ScriptDetailModal';
import { CategorySidebar } from './CategorySidebar';
import { FilterBar, type FilterState } from './FilterBar';
import { Button } from './ui/button';
import type { ScriptCard as ScriptCardType } from '~/types/script';
export function DownloadedScriptsTab() {
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [filters, setFilters] = useState<FilterState>({
searchQuery: '',
showUpdatable: null,
selectedTypes: [],
sortBy: 'name',
sortOrder: 'asc',
});
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getCtScripts.useQuery();
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
{ slug: selectedSlug ?? '' },
{ enabled: !!selectedSlug }
);
// Extract categories from metadata
const categories = React.useMemo((): string[] => {
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
return (scriptCardsData.metadata.categories as any[])
.filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
.sort((a, b) => a.sort_order - b.sort_order)
.map((cat) => cat.name as string)
.filter((name): name is string => typeof name === 'string');
}, [scriptCardsData]);
// Get GitHub scripts with download status (deduplicated)
const combinedScripts = React.useMemo((): ScriptCardType[] => {
if (!scriptCardsData?.success) return [];
// Use Map to deduplicate by slug/name
const scriptMap = new Map<string, ScriptCardType>();
scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, {
...script,
source: 'github' as const,
isDownloaded: false, // Will be updated by status check
isUpToDate: false, // Will be updated by status check
});
}
}
});
return Array.from(scriptMap.values());
}, [scriptCardsData]);
// Update scripts with download status and filter to only downloaded scripts
const downloadedScripts = React.useMemo((): ScriptCardType[] => {
return combinedScripts
.map(script => {
if (!script?.name) {
return script; // Return as-is if invalid
}
// Check if there's a corresponding local script
const hasLocalVersion = localScriptsData?.scripts?.some(local => {
if (!local?.name) return false;
const localName = local.name.replace(/\.sh$/, '');
return localName.toLowerCase() === script.name.toLowerCase() ||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
}) ?? false;
return {
...script,
isDownloaded: hasLocalVersion,
};
})
.filter(script => script.isDownloaded); // Only show downloaded scripts
}, [combinedScripts, localScriptsData]);
// Count scripts per category (using downloaded scripts only)
const categoryCounts = React.useMemo((): Record<string, number> => {
if (!scriptCardsData?.success) return {};
const counts: Record<string, number> = {};
// Initialize all categories with 0
categories.forEach((categoryName: string) => {
counts[categoryName] = 0;
});
// Count each unique downloaded script only once per category
downloadedScripts.forEach(script => {
if (script.categoryNames && script.slug) {
const countedCategories = new Set<string>();
script.categoryNames.forEach((categoryName: unknown) => {
if (typeof categoryName === 'string' && counts[categoryName] !== undefined && !countedCategories.has(categoryName)) {
countedCategories.add(categoryName);
counts[categoryName]++;
}
});
}
});
return counts;
}, [categories, downloadedScripts, scriptCardsData?.success]);
// Filter scripts based on all filters and category
const filteredScripts = React.useMemo((): ScriptCardType[] => {
let scripts = downloadedScripts;
// Filter by search query
if (filters.searchQuery?.trim()) {
const query = filters.searchQuery.toLowerCase().trim();
if (query.length >= 1) {
scripts = scripts.filter(script => {
if (!script || typeof script !== 'object') {
return false;
}
const name = (script.name ?? '').toLowerCase();
const slug = (script.slug ?? '').toLowerCase();
return name.includes(query) ?? slug.includes(query);
});
}
}
// Filter by category using real category data from downloaded scripts
if (selectedCategory) {
scripts = scripts.filter(script => {
if (!script) return false;
// Check if the downloaded script has categoryNames that include the selected category
return script.categoryNames?.includes(selectedCategory) ?? false;
});
}
// Filter by updateable status
if (filters.showUpdatable !== null) {
scripts = scripts.filter(script => {
if (!script) return false;
const isUpdatable = script.updateable ?? false;
return filters.showUpdatable ? isUpdatable : !isUpdatable;
});
}
// Filter by script types
if (filters.selectedTypes.length > 0) {
scripts = scripts.filter(script => {
if (!script) return false;
const scriptType = (script.type ?? '').toLowerCase();
return filters.selectedTypes.some(type => type.toLowerCase() === scriptType);
});
}
// Apply sorting
scripts.sort((a, b) => {
if (!a || !b) return 0;
let compareValue = 0;
switch (filters.sortBy) {
case 'name':
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
break;
case 'created':
// Get creation date from script metadata in JSON format (date_created: "YYYY-MM-DD")
const aCreated = a?.date_created ?? '';
const bCreated = b?.date_created ?? '';
// If both have dates, compare them directly
if (aCreated && bCreated) {
// For dates: asc = oldest first (2020 before 2024), desc = newest first (2024 before 2020)
compareValue = aCreated.localeCompare(bCreated);
} else if (aCreated && !bCreated) {
// Scripts with dates come before scripts without dates
compareValue = -1;
} else if (!aCreated && bCreated) {
// Scripts without dates come after scripts with dates
compareValue = 1;
} else {
// Both have no dates, fallback to name comparison
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
}
break;
default:
compareValue = (a.name ?? '').localeCompare(b.name ?? '');
}
// Apply sort order
return filters.sortOrder === 'asc' ? compareValue : -compareValue;
});
return scripts;
}, [downloadedScripts, filters, selectedCategory]);
// Calculate filter counts for FilterBar
const filterCounts = React.useMemo(() => {
const updatableCount = downloadedScripts.filter(script => script?.updateable).length;
return { installedCount: downloadedScripts.length, updatableCount };
}, [downloadedScripts]);
// Handle filter changes
const handleFiltersChange = (newFilters: FilterState) => {
setFilters(newFilters);
};
// Handle category selection with auto-scroll
const handleCategorySelect = (category: string | null) => {
setSelectedCategory(category);
};
// Auto-scroll effect when category changes
useEffect(() => {
if (selectedCategory && gridRef.current) {
const timeoutId = setTimeout(() => {
gridRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
}, 100);
return () => clearTimeout(timeoutId);
}
}, [selectedCategory]);
const handleCardClick = (scriptCard: { slug: string }) => {
// All scripts are GitHub scripts, open modal
setSelectedSlug(scriptCard.slug);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedSlug(null);
};
if (githubLoading || localLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-muted-foreground">Loading downloaded scripts...</span>
</div>
);
}
if (githubError || localError) {
return (
<div className="text-center py-12">
<div className="text-red-600 mb-4">
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p className="text-lg font-medium">Failed to load downloaded scripts</p>
<p className="text-sm text-muted-foreground mt-1">
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
</p>
</div>
<Button
onClick={() => refetch()}
variant="default"
size="default"
className="mt-4"
>
Try Again
</Button>
</div>
);
}
if (!downloadedScripts || downloadedScripts.length === 0) {
return (
<div className="text-center py-12">
<div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-lg font-medium">No downloaded scripts found</p>
<p className="text-sm text-muted-foreground mt-1">
You haven&apos;t downloaded any scripts yet. Visit the Available Scripts tab to download some scripts.
</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header with Stats */}
<div className="bg-card rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-foreground mb-4">Downloaded Scripts</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-400">{downloadedScripts.length}</div>
<div className="text-sm text-blue-300">Total Downloaded</div>
</div>
<div className="bg-green-500/10 border border-green-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-400">{filterCounts.updatableCount}</div>
<div className="text-sm text-green-300">Updatable</div>
</div>
<div className="bg-purple-500/10 border border-purple-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-purple-400">{filteredScripts.length}</div>
<div className="text-sm text-purple-300">Filtered Results</div>
</div>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
{/* Category Sidebar */}
<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}
/>
{/* 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>
) : (
<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>
)}
<ScriptDetailModal
script={scriptData?.success ? scriptData.script : null}
isOpen={isModalOpen}
onClose={handleCloseModal}
onInstallScript={() => {
// Downloaded scripts don't need installation
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { Server } from '../../types/server'; import type { Server } from '../../types/server';
import { Button } from './ui/button';
interface ExecutionModeModalProps { interface ExecutionModeModalProps {
isOpen: boolean; isOpen: boolean;
@@ -60,40 +61,42 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4"> <div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200"> <div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-xl font-bold text-gray-900">Execution Mode</h2> <h2 className="text-xl font-bold text-foreground">Execution Mode</h2>
<button <Button
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors" variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </Button>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-medium text-gray-900 mb-2"> <h3 className="text-lg font-medium text-foreground mb-2">
Where would you like to execute &quot;{scriptName}&quot;? Where would you like to execute &quot;{scriptName}&quot;?
</h3> </h3>
</div> </div>
{error && ( {error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md"> <div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> <svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" /> <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg> </svg>
</div> </div>
<div className="ml-3"> <div className="ml-3">
<p className="text-sm text-red-700">{error}</p> <p className="text-sm text-destructive">{error}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -107,8 +110,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
<div <div
className={`border rounded-lg p-4 cursor-pointer transition-colors ${ className={`border rounded-lg p-4 cursor-pointer transition-colors ${
selectedMode === 'ssh' selectedMode === 'ssh'
? 'border-blue-500 bg-blue-50' ? 'border-primary bg-primary/10'
: 'border-gray-200 hover:border-gray-300' : 'border-border hover:border-primary/50'
}`} }`}
onClick={() => handleModeChange('ssh')} onClick={() => handleModeChange('ssh')}
> >
@@ -120,20 +123,20 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
value="ssh" value="ssh"
checked={selectedMode === 'ssh'} checked={selectedMode === 'ssh'}
onChange={() => handleModeChange('ssh')} onChange={() => handleModeChange('ssh')}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" className="h-4 w-4 text-primary focus:ring-primary border-border"
/> />
<label htmlFor="ssh" className="ml-3 flex-1 cursor-pointer"> <label htmlFor="ssh" className="ml-3 flex-1 cursor-pointer">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg> </svg>
</div> </div>
</div> </div>
<div className="ml-3"> <div className="ml-3">
<h4 className="text-sm font-medium text-gray-900">SSH Execution</h4> <h4 className="text-sm font-medium text-foreground">SSH Execution</h4>
<p className="text-sm text-gray-500">Run the script on a remote server</p> <p className="text-sm text-muted-foreground">Run the script on a remote server</p>
</div> </div>
</div> </div>
</label> </label>
@@ -144,16 +147,16 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
{/* Server Selection (only for SSH mode) */} {/* Server Selection (only for SSH mode) */}
{selectedMode === 'ssh' && ( {selectedMode === 'ssh' && (
<div className="mb-6"> <div className="mb-6">
<label htmlFor="server" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
Select Server Select Server
</label> </label>
{loading ? ( {loading ? (
<div className="text-center py-4"> <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> <div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<p className="mt-2 text-sm text-gray-600">Loading servers...</p> <p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
</div> </div>
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<div className="text-center py-4 text-gray-500"> <div className="text-center py-4 text-muted-foreground">
<p className="text-sm">No servers configured</p> <p className="text-sm">No servers configured</p>
<p className="text-xs mt-1">Add servers in Settings to use SSH execution</p> <p className="text-xs mt-1">Add servers in Settings to use SSH execution</p>
</div> </div>
@@ -166,7 +169,7 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
const server = servers.find(s => s.id === serverId); const server = servers.find(s => s.id === serverId);
setSelectedServer(server ?? null); 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" className="w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary bg-background text-foreground"
> >
<option value="">Select a server...</option> <option value="">Select a server...</option>
{servers.map((server) => ( {servers.map((server) => (
@@ -181,23 +184,22 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
<button <Button
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" variant="outline"
size="default"
> >
Cancel Cancel
</button> </Button>
<button <Button
onClick={handleExecute} onClick={handleExecute}
disabled={selectedMode === 'ssh' && !selectedServer} 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 ${ variant="default"
selectedMode === 'ssh' && !selectedServer size="default"
? 'bg-gray-400 cursor-not-allowed' className={selectedMode === 'ssh' && !selectedServer ? 'bg-gray-400 cursor-not-allowed' : ''}
: 'bg-blue-600 hover:bg-blue-700'
}`}
> >
{selectedMode === 'local' ? 'Run Locally' : 'Run on Server'} {selectedMode === 'local' ? 'Run Locally' : 'Run on Server'}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,411 @@
"use client";
import React, { useState } from "react";
import { Button } from "./ui/button";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
export interface FilterState {
searchQuery: string;
showUpdatable: boolean | null; // null = all, true = only updatable, false = only non-updatable
selectedTypes: string[]; // Array of selected types: 'lxc', 'vm', 'addon', 'pve'
sortBy: "name" | "created"; // Sort criteria (removed 'updated')
sortOrder: "asc" | "desc"; // Sort direction
}
interface FilterBarProps {
filters: FilterState;
onFiltersChange: (filters: FilterState) => void;
totalScripts: number;
filteredCount: number;
updatableCount?: number;
}
const SCRIPT_TYPES = [
{ 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({
filters,
onFiltersChange,
totalScripts,
filteredCount,
updatableCount = 0,
}: FilterBarProps) {
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
const updateFilters = (updates: Partial<FilterState>) => {
onFiltersChange({ ...filters, ...updates });
};
const clearAllFilters = () => {
onFiltersChange({
searchQuery: "",
showUpdatable: null,
selectedTypes: [],
sortBy: "name",
sortOrder: "asc",
});
};
const hasActiveFilters =
filters.searchQuery ||
filters.showUpdatable !== null ||
filters.selectedTypes.length > 0 ||
filters.sortBy !== "name" ||
filters.sortOrder !== "asc";
const getUpdatableButtonText = () => {
if (filters.showUpdatable === null) return "Updatable: All";
if (filters.showUpdatable === true)
return `Updatable: Yes (${updatableCount})`;
return "Updatable: No";
};
const getTypeButtonText = () => {
if (filters.selectedTypes.length === 0) return "All Types";
if (filters.selectedTypes.length === 1) {
const type = SCRIPT_TYPES.find(
(t) => t.value === filters.selectedTypes[0],
);
return type?.label ?? filters.selectedTypes[0];
}
return `${filters.selectedTypes.length} Types`;
};
return (
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
{/* Search Bar */}
<div className="mb-4">
<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-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>
<input
type="text"
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
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
onClick={() => updateFilters({ searchQuery: "" })}
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"
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>
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Updateable Filter */}
<Button
onClick={() => {
const next =
filters.showUpdatable === null
? true
: filters.showUpdatable === true
? false
: null;
updateFilters({ showUpdatable: next });
}}
variant="outline"
size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: filters.showUpdatable === true
? "border border-green-500/20 bg-green-500/10 text-green-400"
: "border border-destructive/20 bg-destructive/10 text-destructive"
}`}
>
<RefreshCw className="h-4 w-4" />
<span>{getUpdatableButtonText()}</span>
</Button>
{/* Type Dropdown */}
<div className="relative w-full sm:w-auto">
<Button
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
variant="outline"
size="default"
className={`w-full flex items-center justify-center space-x-2 ${
filters.selectedTypes.length === 0
? "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" : ""}`}
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>
{isTypeDropdownOpen && (
<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) => {
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-accent"
>
<input
type="checkbox"
checked={filters.selectedTypes.includes(type.value)}
onChange={(e) => {
if (e.target.checked) {
updateFilters({
selectedTypes: [
...filters.selectedTypes,
type.value,
],
});
} else {
updateFilters({
selectedTypes: filters.selectedTypes.filter(
(t) => t !== type.value,
),
});
}
}}
className="rounded border-input text-primary focus:ring-primary"
/>
<IconComponent className="h-4 w-4" />
<span className="text-sm text-muted-foreground">
{type.label}
</span>
</label>
);
})}
</div>
<div className="border-t border-border p-2">
<Button
onClick={() => {
updateFilters({ selectedTypes: [] });
setIsTypeDropdownOpen(false);
}}
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 Order Button */}
<Button
onClick={() =>
updateFilters({
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
})
}
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" ? (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 11l5-5m0 0l5 5m-5-5v12"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
</span>
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 13l-5 5m0 0l-5-5m5 5V6"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
</span>
</>
)}
</Button>
</div>
{/* Filter Summary and Clear All */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<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">
(filtered)
</span>
)}
</span>
)}
</div>
{hasActiveFilters && (
<Button
onClick={clearAllFilters}
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"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span>Clear all filters</span>
</Button>
)}
</div>
{/* Click outside to close dropdowns */}
{(isTypeDropdownOpen || isSortDropdownOpen) && (
<div
className="fixed inset-0 z-0"
onClick={() => {
setIsTypeDropdownOpen(false);
setIsSortDropdownOpen(false);
}}
/>
)}
</div>
);
}

View File

@@ -3,6 +3,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { Terminal } from './Terminal'; import { Terminal } from './Terminal';
import { StatusBadge } from './Badge';
import { Button } from './ui/button';
import { ScriptInstallationCard } from './ScriptInstallationCard';
interface InstalledScript { interface InstalledScript {
id: number; id: number;
@@ -14,7 +17,6 @@ interface InstalledScript {
server_ip: string | null; server_ip: string | null;
server_user: string | null; server_user: string | null;
server_password: string | null; server_password: string | null;
execution_mode: 'local' | 'ssh';
installation_date: string; installation_date: string;
status: 'in_progress' | 'success' | 'failed'; status: 'in_progress' | 'success' | 'failed';
output_log: string | null; output_log: string | null;
@@ -24,11 +26,16 @@ export function InstalledScriptsTab() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
const [serverFilter, setServerFilter] = useState<string>('all'); const [serverFilter, setServerFilter] = useState<string>('all');
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any; mode: 'local' | 'ssh' } | null>(null); const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
const [showAddForm, setShowAddForm] = useState(false);
const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' });
// Fetch installed scripts // Fetch installed scripts
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery(); const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: statsData } = api.installedScripts.getInstallationStats.useQuery(); const { data: statsData } = api.installedScripts.getInstallationStats.useQuery();
const { data: serversData } = api.servers.getAllServers.useQuery();
// Delete script mutation // Delete script mutation
const deleteScriptMutation = api.installedScripts.deleteInstalledScript.useMutation({ const deleteScriptMutation = api.installedScripts.deleteInstalledScript.useMutation({
@@ -37,6 +44,30 @@ export function InstalledScriptsTab() {
} }
}); });
// Update script mutation
const updateScriptMutation = api.installedScripts.updateInstalledScript.useMutation({
onSuccess: () => {
void refetchScripts();
setEditingScriptId(null);
setEditFormData({ script_name: '', container_id: '' });
},
onError: (error) => {
alert(`Error updating script: ${error.message}`);
}
});
// Create script mutation
const createScriptMutation = api.installedScripts.createInstalledScript.useMutation({
onSuccess: () => {
void refetchScripts();
setShowAddForm(false);
setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
},
onError: (error) => {
alert(`Error creating script: ${error.message}`);
}
});
const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? []; const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
const stats = statsData?.stats; const stats = statsData?.stats;
@@ -50,7 +81,7 @@ export function InstalledScriptsTab() {
const matchesStatus = statusFilter === 'all' || script.status === statusFilter; const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
const matchesServer = serverFilter === 'all' || const matchesServer = serverFilter === 'all' ||
(serverFilter === 'local' && script.execution_mode === 'local') || (serverFilter === 'local' && !script.server_name) ||
(script.server_name === serverFilter); (script.server_name === serverFilter);
return matchesSearch && matchesStatus && matchesServer; return matchesSearch && matchesStatus && matchesServer;
@@ -81,7 +112,7 @@ export function InstalledScriptsTab() {
if (confirm(`Are you sure you want to update ${script.script_name}?`)) { if (confirm(`Are you sure you want to update ${script.script_name}?`)) {
// Get server info if it's SSH mode // Get server info if it's SSH mode
let server = null; let server = null;
if (script.execution_mode === 'ssh' && script.server_id && script.server_user && script.server_password) { if (script.server_id && script.server_user && script.server_password) {
server = { server = {
id: script.server_id, id: script.server_id,
name: script.server_name, name: script.server_name,
@@ -94,8 +125,7 @@ export function InstalledScriptsTab() {
setUpdatingScript({ setUpdatingScript({
id: script.id, id: script.id,
containerId: script.container_id, containerId: script.container_id,
server: server, server: server
mode: script.execution_mode
}); });
} }
}; };
@@ -104,41 +134,78 @@ export function InstalledScriptsTab() {
setUpdatingScript(null); setUpdatingScript(null);
}; };
const handleEditScript = (script: InstalledScript) => {
setEditingScriptId(script.id);
setEditFormData({
script_name: script.script_name,
container_id: script.container_id ?? ''
});
};
const handleCancelEdit = () => {
setEditingScriptId(null);
setEditFormData({ script_name: '', container_id: '' });
};
const handleSaveEdit = () => {
if (!editFormData.script_name.trim()) {
alert('Script name is required');
return;
}
if (editingScriptId) {
updateScriptMutation.mutate({
id: editingScriptId,
script_name: editFormData.script_name.trim(),
container_id: editFormData.container_id.trim() || undefined,
});
}
};
const handleInputChange = (field: 'script_name' | 'container_id', value: string) => {
setEditFormData(prev => ({
...prev,
[field]: value
}));
};
const handleAddFormChange = (field: 'script_name' | 'container_id' | 'server_id', value: string) => {
setAddFormData(prev => ({
...prev,
[field]: value
}));
};
const handleAddScript = () => {
if (!addFormData.script_name.trim()) {
alert('Script name is required');
return;
}
createScriptMutation.mutate({
script_name: addFormData.script_name.trim(),
script_path: `manual/${addFormData.script_name.trim()}`,
container_id: addFormData.container_id.trim() || undefined,
server_id: addFormData.server_id === 'local' ? undefined : Number(addFormData.server_id),
execution_mode: addFormData.server_id === 'local' ? 'local' : 'ssh',
status: 'success'
});
};
const handleCancelAdd = () => {
setShowAddForm(false);
setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString(); return new Date(dateString).toLocaleString();
}; };
const getStatusBadge = (status: string): string => {
const baseClasses = 'px-2 py-1 text-xs font-medium rounded-full';
switch (status) {
case 'success':
return `${baseClasses} bg-green-100 text-green-800`;
case 'failed':
return `${baseClasses} bg-red-100 text-red-800`;
case 'in_progress':
return `${baseClasses} bg-yellow-100 text-yellow-800`;
default:
return `${baseClasses} bg-gray-100 text-gray-800`;
}
};
const getModeBadge = (mode: string): string => {
const baseClasses = 'px-2 py-1 text-xs font-medium rounded-full';
switch (mode) {
case 'local':
return `${baseClasses} bg-blue-100 text-blue-800`;
case 'ssh':
return `${baseClasses} bg-purple-100 text-purple-800`;
default:
return `${baseClasses} bg-gray-100 text-gray-800`;
}
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading installed scripts...</div> <div className="text-muted-foreground">Loading installed scripts...</div>
</div> </div>
); );
} }
@@ -151,7 +218,7 @@ export function InstalledScriptsTab() {
<Terminal <Terminal
scriptPath={`update-${updatingScript.containerId}`} scriptPath={`update-${updatingScript.containerId}`}
onClose={handleCloseUpdateTerminal} onClose={handleCloseUpdateTerminal}
mode={updatingScript.mode} mode={updatingScript.server ? 'ssh' : 'local'}
server={updatingScript.server} server={updatingScript.server}
isUpdate={true} isUpdate={true}
containerId={updatingScript.containerId} containerId={updatingScript.containerId}
@@ -160,165 +227,314 @@ export function InstalledScriptsTab() {
)} )}
{/* Header with Stats */} {/* Header with Stats */}
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-card rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Installed Scripts</h2> <h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2>
{stats && ( {stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-blue-50 p-4 rounded-lg"> <div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{stats.total}</div> <div className="text-2xl font-bold text-blue-400">{stats.total}</div>
<div className="text-sm text-blue-800">Total Installations</div> <div className="text-sm text-blue-300">Total Installations</div>
</div> </div>
<div className="bg-green-50 p-4 rounded-lg"> <div className="bg-green-500/10 border border-green-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-600">{stats.byStatus.success}</div> <div className="text-2xl font-bold text-green-400">{stats.byStatus.success}</div>
<div className="text-sm text-green-800">Successful</div> <div className="text-sm text-green-300">Successful</div>
</div> </div>
<div className="bg-red-50 p-4 rounded-lg"> <div className="bg-red-500/10 border border-red-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-red-600">{stats.byStatus.failed}</div> <div className="text-2xl font-bold text-red-400">{stats.byStatus.failed}</div>
<div className="text-sm text-red-800">Failed</div> <div className="text-sm text-red-300">Failed</div>
</div> </div>
<div className="bg-yellow-50 p-4 rounded-lg"> <div className="bg-yellow-500/10 border border-yellow-500/20 p-4 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{stats.byStatus.in_progress}</div> <div className="text-2xl font-bold text-yellow-400">{stats.byStatus.in_progress}</div>
<div className="text-sm text-yellow-800">In Progress</div> <div className="text-sm text-yellow-300">In Progress</div>
</div>
</div>
)}
{/* Add Script Button */}
<div className="mb-4">
<Button
onClick={() => setShowAddForm(!showAddForm)}
variant={showAddForm ? "outline" : "default"}
size="default"
>
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
</Button>
</div>
{/* Add Script Form */}
{showAddForm && (
<div className="mb-6 p-4 sm:p-6 bg-card rounded-lg border border-border shadow-sm">
<h3 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Add Manual Script Entry</h3>
<div className="space-y-4 sm:space-y-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Script Name *
</label>
<input
type="text"
value={addFormData.script_name}
onChange={(e) => handleAddFormChange('script_name', 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="Enter script name"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Container ID
</label>
<input
type="text"
value={addFormData.container_id}
onChange={(e) => handleAddFormChange('container_id', 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="Enter container ID"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Server
</label>
<select
value={addFormData.server_id}
onChange={(e) => handleAddFormChange('server_id', e.target.value)}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
>
<option value="local">Select Server</option>
{serversData?.servers?.map((server: any) => (
<option key={server.id} value={server.id}>
{server.name}
</option>
))}
</select>
</div>
</div>
<div className="flex flex-col sm:flex-row justify-end gap-3 mt-4 sm:mt-6">
<Button
onClick={handleCancelAdd}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
Cancel
</Button>
<Button
onClick={handleAddScript}
disabled={createScriptMutation.isPending}
variant="default"
size="default"
className="w-full sm:w-auto"
>
{createScriptMutation.isPending ? 'Adding...' : 'Add Script'}
</Button>
</div> </div>
</div> </div>
)} )}
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap gap-4"> <div className="space-y-4">
<div className="flex-1 min-w-64"> {/* Search Input - Full Width on Mobile */}
<div className="w-full">
<input <input
type="text" type="text"
placeholder="Search scripts, container IDs, or servers..." placeholder="Search scripts, container IDs, or servers..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/> />
</div> </div>
<select {/* Filter Dropdowns - Responsive Grid */}
value={statusFilter} <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')} <select
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" value={statusFilter}
> onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
<option value="all">All Status</option> className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
<option value="success">Success</option> >
<option value="failed">Failed</option> <option value="all">All Status</option>
<option value="in_progress">In Progress</option> <option value="success">Success</option>
</select> <option value="failed">Failed</option>
<option value="in_progress">In Progress</option>
</select>
<select <select
value={serverFilter} value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)} onChange={(e) => setServerFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
> >
<option value="all">All Servers</option> <option value="all">All Servers</option>
<option value="local">Local</option> <option value="local">Local</option>
{uniqueServers.map(server => ( {uniqueServers.map(server => (
<option key={server} value={server}>{server}</option> <option key={server} value={server}>{server}</option>
))} ))}
</select> </select>
</div>
</div> </div>
</div> </div>
{/* Scripts Table */} {/* Scripts Display - Mobile Cards / Desktop Table */}
<div className="bg-white rounded-lg shadow overflow-hidden"> <div className="bg-card rounded-lg shadow overflow-hidden">
{filteredScripts.length === 0 ? ( {filteredScripts.length === 0 ? (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-muted-foreground">
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'} {scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <>
<table className="min-w-full divide-y divide-gray-200"> {/* Mobile Card Layout */}
<thead className="bg-gray-50"> <div className="block md:hidden p-4 space-y-4">
<tr> {filteredScripts.map((script) => (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <ScriptInstallationCard
Script Name key={script.id}
</th> script={script}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> isEditing={editingScriptId === script.id}
Container ID editFormData={editFormData}
</th> onInputChange={handleInputChange}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> onEdit={() => handleEditScript(script)}
Server onSave={handleSaveEdit}
</th> onCancel={handleCancelEdit}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> onUpdate={() => handleUpdateScript(script)}
Mode onDelete={() => handleDeleteScript(Number(script.id))}
</th> isUpdating={updateScriptMutation.isPending}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> isDeleting={deleteScriptMutation.isPending}
Status />
</th> ))}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </div>
Date
</th> {/* Desktop Table Layout */}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <div className="hidden md:block overflow-x-auto">
Actions <table className="min-w-full divide-y divide-gray-200">
</th> <thead className="bg-muted">
</tr> <tr>
</thead> <th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
<tbody className="bg-white divide-y divide-gray-200"> Script Name
{filteredScripts.map((script) => ( </th>
<tr key={script.id} className="hover:bg-gray-50"> <th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
<td className="px-6 py-4 whitespace-nowrap"> Container ID
<div className="text-sm font-medium text-gray-900">{script.script_name}</div> </th>
<div className="text-sm text-gray-500">{script.script_path}</div> <th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
</td> Server
<td className="px-6 py-4 whitespace-nowrap"> </th>
{script.container_id ? ( <th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
<span className="text-sm font-mono text-gray-900">{String(script.container_id)}</span> Status
) : ( </th>
<span className="text-sm text-gray-400">-</span> <th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
)} Installation Date
</td> </th>
<td className="px-6 py-4 whitespace-nowrap"> <th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
{script.execution_mode === 'local' ? ( Actions
<span className="text-sm text-gray-900">Local</span> </th>
) : (
<div>
<div className="text-sm font-medium text-gray-900">{script.server_name}</div>
<div className="text-sm text-gray-500">{script.server_ip}</div>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={getModeBadge(String(script.execution_mode))}>
{String(script.execution_mode).toUpperCase()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={getStatusBadge(String(script.status))}>
{String(script.status).replace('_', ' ').toUpperCase()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(String(script.installation_date))}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
{script.container_id && (
<button
onClick={() => handleUpdateScript(script)}
className="text-blue-600 hover:text-blue-900"
>
Update
</button>
)}
<button
onClick={() => handleDeleteScript(Number(script.id))}
className="text-red-600 hover:text-red-900"
disabled={deleteScriptMutation.isPending}
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody className="bg-card divide-y divide-gray-200">
</table> {filteredScripts.map((script) => (
</div> <tr key={script.id} className="hover:bg-accent">
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="space-y-2">
<input
type="text"
value={editFormData.script_name}
onChange={(e) => handleInputChange('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">{script.script_name}</div>
<div className="text-sm text-muted-foreground">{script.script_path}</div>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<input
type="text"
value={editFormData.container_id}
onChange={(e) => handleInputChange('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"
/>
) : (
script.container_id ? (
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
) : (
<span className="text-sm text-muted-foreground">-</span>
)
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-muted-foreground">
{script.server_name ?? 'Local'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={script.status}>
{script.status.replace('_', ' ').toUpperCase()}
</StatusBadge>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{formatDate(String(script.installation_date))}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
{editingScriptId === script.id ? (
<>
<Button
onClick={handleSaveEdit}
disabled={updateScriptMutation.isPending}
variant="default"
size="sm"
>
{updateScriptMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button
onClick={handleCancelEdit}
variant="outline"
size="sm"
>
Cancel
</Button>
</>
) : (
<>
<Button
onClick={() => handleEditScript(script)}
variant="default"
size="sm"
>
Edit
</Button>
{script.container_id && (
<Button
onClick={() => handleUpdateScript(script)}
variant="link"
size="sm"
>
Update
</Button>
)}
<Button
onClick={() => handleDeleteScript(Number(script.id))}
variant="destructive"
size="sm"
disabled={deleteScriptMutation.isPending}
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)} )}
</div> </div>
</div> </div>
); );
} }

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import type { ScriptCard } from '~/types/script'; import type { ScriptCard } from '~/types/script';
import { TypeBadge, UpdateableBadge } from './Badge';
interface ScriptCardProps { interface ScriptCardProps {
script: ScriptCard; script: ScriptCard;
@@ -18,7 +19,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
return ( return (
<div <div
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 hover:border-blue-300 h-full flex flex-col" className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col"
onClick={() => onClick(script)} onClick={() => onClick(script)}
> >
<div className="p-6 flex-1 flex flex-col"> <div className="p-6 flex-1 flex flex-col">
@@ -35,34 +36,22 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="w-12 h-12 bg-gray-200 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
<span className="text-gray-500 text-lg font-semibold"> <span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'} {script.name?.charAt(0)?.toUpperCase() || '?'}
</span> </span>
</div> </div>
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate"> <h3 className="text-lg font-semibold text-foreground truncate">
{script.name || 'Unnamed Script'} {script.name || 'Unnamed Script'}
</h3> </h3>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{/* Type and Updateable status on first row */} {/* Type and Updateable status on first row */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${ <TypeBadge type={script.type ?? 'unknown'} />
script.type === 'ct' {script.updateable && <UpdateableBadge />}
? 'bg-blue-100 text-blue-800'
: script.type === 'addon'
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-800'
}`}>
{script.type?.toUpperCase() || 'UNKNOWN'}
</span>
{script.updateable && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-amber-100 text-amber-800">
Updateable
</span>
)}
</div> </div>
{/* Download Status */} {/* Download Status */}
@@ -71,7 +60,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
script.isDownloaded ? 'bg-green-500' : 'bg-red-500' script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
}`}></div> }`}></div>
<span className={`text-xs font-medium ${ <span className={`text-xs font-medium ${
script.isDownloaded ? 'text-green-700' : 'text-red-700' script.isDownloaded ? 'text-green-700' : 'text-destructive'
}`}> }`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'} {script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
</span> </span>
@@ -81,7 +70,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
</div> </div>
{/* Description */} {/* Description */}
<p className="text-gray-600 text-sm line-clamp-3 mb-4 flex-1"> <p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
{script.description || 'No description available'} {script.description || 'No description available'}
</p> </p>

View File

@@ -1,21 +1,33 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import Image from 'next/image'; import Image from "next/image";
import { api } from '~/trpc/react'; import { api } from "~/trpc/react";
import type { Script } from '~/types/script'; import type { Script } from "~/types/script";
import { DiffViewer } from './DiffViewer'; import { DiffViewer } from "./DiffViewer";
import { TextViewer } from './TextViewer'; import { TextViewer } from "./TextViewer";
import { ExecutionModeModal } from './ExecutionModeModal'; import { ExecutionModeModal } from "./ExecutionModeModal";
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
import { Button } from "./ui/button";
interface ScriptDetailModalProps { interface ScriptDetailModalProps {
script: Script | null; script: Script | null;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onInstallScript?: (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => void; onInstallScript?: (
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: any,
) => void;
} }
export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: ScriptDetailModalProps) { export function ScriptDetailModal({
script,
isOpen,
onClose,
onInstallScript,
}: ScriptDetailModalProps) {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loadMessage, setLoadMessage] = useState<string | null>(null); const [loadMessage, setLoadMessage] = useState<string | null>(null);
@@ -25,15 +37,23 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
const [executionModeOpen, setExecutionModeOpen] = useState(false); const [executionModeOpen, setExecutionModeOpen] = useState(false);
// Check if script files exist locally // Check if script files exist locally
const { data: scriptFilesData, refetch: refetchScriptFiles, isLoading: scriptFilesLoading } = api.scripts.checkScriptFiles.useQuery( const {
{ slug: script?.slug ?? '' }, data: scriptFilesData,
{ enabled: !!script && isOpen } refetch: refetchScriptFiles,
isLoading: scriptFilesLoading,
} = api.scripts.checkScriptFiles.useQuery(
{ slug: script?.slug ?? "" },
{ enabled: !!script && isOpen },
); );
// Compare local and remote script content (run in parallel, not dependent on scriptFilesData) // Compare local and remote script content (run in parallel, not dependent on scriptFilesData)
const { data: comparisonData, refetch: refetchComparison, isLoading: comparisonLoading } = api.scripts.compareScriptContent.useQuery( const {
{ slug: script?.slug ?? '' }, data: comparisonData,
{ enabled: !!script && isOpen } refetch: refetchComparison,
isLoading: comparisonLoading,
} = api.scripts.compareScriptContent.useQuery(
{ slug: script?.slug ?? "" },
{ enabled: !!script && isOpen },
); );
// Load script mutation // Load script mutation
@@ -41,21 +61,22 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
onSuccess: (data) => { onSuccess: (data) => {
setIsLoading(false); setIsLoading(false);
if (data.success) { if (data.success) {
const message = 'message' in data ? data.message : 'Script loaded successfully'; const message =
setLoadMessage(`${message}`); "message" in data ? data.message : "Script loaded successfully";
setLoadMessage(`[SUCCESS] ${message}`);
// Refetch script files status and comparison data to update the UI // Refetch script files status and comparison data to update the UI
void refetchScriptFiles(); void refetchScriptFiles();
void refetchComparison(); void refetchComparison();
} else { } else {
const error = 'error' in data ? data.error : 'Failed to load script'; const error = "error" in data ? data.error : "Failed to load script";
setLoadMessage(` ${error}`); setLoadMessage(`[ERROR] ${error}`);
} }
// Clear message after 5 seconds // Clear message after 5 seconds
setTimeout(() => setLoadMessage(null), 5000); setTimeout(() => setLoadMessage(null), 5000);
}, },
onError: (error) => { onError: (error) => {
setIsLoading(false); setIsLoading(false);
setLoadMessage(`❌ Error: ${error.message}`); setLoadMessage(`[ERROR] ${error.message}`);
setTimeout(() => setLoadMessage(null), 5000); setTimeout(() => setLoadMessage(null), 5000);
}, },
}); });
@@ -74,7 +95,7 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
const handleLoadScript = async () => { const handleLoadScript = async () => {
if (!script) return; if (!script) return;
setIsLoading(true); setIsLoading(true);
setLoadMessage(null); setLoadMessage(null);
loadScriptMutation.mutate({ slug: script.slug }); loadScriptMutation.mutate({ slug: script.slug });
@@ -85,133 +106,192 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
setExecutionModeOpen(true); setExecutionModeOpen(true);
}; };
const handleExecuteScript = (mode: 'local' | 'ssh', server?: any) => { const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
if (!script || !onInstallScript) return; if (!script || !onInstallScript) return;
// Find the script path (CT or tools) // Find the script path (CT or tools)
const scriptMethod = script.install_methods?.find(method => method.script); const scriptMethod = script.install_methods?.find(
(method) => method.script,
);
if (scriptMethod?.script) { if (scriptMethod?.script) {
const scriptPath = `scripts/${scriptMethod.script}`; const scriptPath = `scripts/${scriptMethod.script}`;
const scriptName = script.name; const scriptName = script.name;
// Pass execution mode and server info to the parent // Pass execution mode and server info to the parent
onInstallScript(scriptPath, scriptName, mode, server); onInstallScript(scriptPath, scriptName, mode, server);
// Scroll to top of the page to see the terminal // Scroll to top of the page to see the terminal
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: "smooth" });
onClose(); // Close the modal when starting installation onClose(); // Close the modal when starting installation
} }
}; };
const handleViewScript = () => { const handleViewScript = () => {
setTextViewerOpen(true); setTextViewerOpen(true);
}; };
return ( return (
<div <div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
onClick={handleBackdropClick} onClick={handleBackdropClick}
> >
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"> <div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200"> <div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
{script.logo && !imageError ? ( {script.logo && !imageError ? (
<Image <Image
src={script.logo} src={script.logo}
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={64} width={64}
height={64} height={64}
className="w-16 h-16 rounded-lg object-contain" className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
onError={handleImageError} onError={handleImageError}
/> />
) : ( ) : (
<div className="w-16 h-16 bg-gray-200 rounded-lg flex items-center justify-center"> <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-gray-500 text-2xl font-semibold"> <span className="text-lg sm:text-2xl font-semibold text-muted-foreground">
{script.name.charAt(0).toUpperCase()} {script.name.charAt(0).toUpperCase()}
</span> </span>
</div> </div>
)} )}
<div> <div className="min-w-0 flex-1">
<h2 className="text-2xl font-bold text-gray-900">{script.name}</h2> <h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
<div className="flex items-center space-x-2 mt-1"> {script.name}
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${ </h2>
script.type === 'ct' <div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
? 'bg-blue-100 text-blue-800' <TypeBadge type={script.type} />
: 'bg-gray-100 text-gray-800' {script.updateable && <UpdateableBadge />}
}`}> {script.privileged && <PrivilegedBadge />}
{script.type.toUpperCase()}
</span>
{script.updateable && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
Updateable
</span>
)}
{script.privileged && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
Privileged
</span>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4">
{/* Close Button */}
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
>
<svg
className="h-5 w-5 sm:h-6 sm:w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border">
{/* Install Button - only show if script files exist */} {/* Install Button - only show if script files exist */}
{scriptFilesData?.success && scriptFilesData.ctExists && onInstallScript && ( {scriptFilesData?.success &&
<button scriptFilesData.ctExists &&
onClick={handleInstallScript} onInstallScript && (
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors bg-blue-600 text-white hover:bg-blue-700" <Button
> onClick={handleInstallScript}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> variant="outline"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /> size="default"
</svg> className="w-full sm:w-auto flex items-center justify-center space-x-2"
<span>Install</span> >
</button> <svg
)} className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
<span>Install</span>
</Button>
)}
{/* View Button - only show if script files exist */} {/* View Button - only show if script files exist */}
{scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) && ( {scriptFilesData?.success &&
<button (scriptFilesData.ctExists || scriptFilesData.installExists) && (
onClick={handleViewScript} <Button
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors bg-purple-600 text-white hover:bg-purple-700" onClick={handleViewScript}
> variant="outline"
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> size="default"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> className="w-full sm:w-auto flex items-center justify-center space-x-2"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> >
</svg> <svg
<span>View</span> className="h-4 w-4"
</button> fill="none"
)} stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span>View</span>
</Button>
)}
{/* Load/Update Script Button */} {/* Load/Update Script Button */}
{(() => { {(() => {
const hasLocalFiles = scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists); const hasLocalFiles =
const hasDifferences = comparisonData?.success && comparisonData.hasDifferences; scriptFilesData?.success &&
(scriptFilesData.ctExists || scriptFilesData.installExists);
const hasDifferences =
comparisonData?.success && comparisonData.hasDifferences;
const isUpToDate = hasLocalFiles && !hasDifferences; const isUpToDate = hasLocalFiles && !hasDifferences;
if (!hasLocalFiles) { if (!hasLocalFiles) {
// No local files - show Load Script button // No local files - show Load Script button
return ( return (
<button <button
onClick={handleLoadScript} onClick={handleLoadScript}
disabled={isLoading} disabled={isLoading}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${ className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading isLoading
? 'bg-gray-400 text-white cursor-not-allowed' ? "cursor-not-allowed bg-muted text-muted-foreground"
: 'bg-green-600 text-white hover:bg-green-700' : "bg-green-600 text-white hover:bg-green-700"
}`} }`}
> >
{isLoading ? ( {isLoading ? (
<> <>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Loading...</span> <span>Loading...</span>
</> </>
) : ( ) : (
<> <>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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" /> className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<span>Load Script</span> <span>Load Script</span>
</> </>
@@ -223,10 +303,20 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
return ( return (
<button <button
disabled disabled
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors bg-gray-400 text-white cursor-not-allowed" 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="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> className="h-4 w-4"
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> </svg>
<span>Up to Date</span> <span>Up to Date</span>
</button> </button>
@@ -237,21 +327,31 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
<button <button
onClick={handleLoadScript} onClick={handleLoadScript}
disabled={isLoading} disabled={isLoading}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${ className={`flex items-center space-x-2 rounded-lg px-4 py-2 font-medium transition-colors ${
isLoading isLoading
? 'bg-gray-400 text-white cursor-not-allowed' ? "cursor-not-allowed bg-muted text-muted-foreground"
: 'bg-orange-600 text-white hover:bg-orange-700' : "bg-orange-600 text-white hover:bg-orange-700"
}`} }`}
> >
{isLoading ? ( {isLoading ? (
<> <>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
<span>Updating...</span> <span>Updating...</span>
</> </>
) : ( ) : (
<> <>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> className="h-4 w-4"
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> </svg>
<span>Update Script</span> <span>Update Script</span>
</> </>
@@ -260,127 +360,173 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
); );
} }
})()} })()}
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 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" />
</svg>
</button>
</div>
</div> </div>
{/* Load Message */} {/* Load Message */}
{loadMessage && ( {loadMessage && (
<div className="mx-6 mb-4 p-3 rounded-lg bg-blue-50 text-blue-800 text-sm"> <div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
{loadMessage} {loadMessage}
</div> </div>
)} )}
{/* Script Files Status */} {/* Script Files Status */}
{(scriptFilesLoading || comparisonLoading) && ( {(scriptFilesLoading || comparisonLoading) && (
<div className="mx-6 mb-4 p-3 rounded-lg bg-blue-50 text-sm"> <div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div> <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
<span>Loading script status...</span> <span>Loading script status...</span>
</div> </div>
</div> </div>
)} )}
{scriptFilesData?.success && !scriptFilesLoading && (() => {
// Determine script type from the first install method
const firstScript = script?.install_methods?.[0]?.script;
let scriptType = 'Script';
if (firstScript?.startsWith('ct/')) {
scriptType = 'CT Script';
} else if (firstScript?.startsWith('tools/')) {
scriptType = 'Tools Script';
} else if (firstScript?.startsWith('vm/')) {
scriptType = 'VM Script';
} else if (firstScript?.startsWith('vw/')) {
scriptType = 'VW Script';
}
return ( {scriptFilesData?.success &&
<div className="mx-6 mb-4 p-3 rounded-lg bg-gray-50 text-sm"> !scriptFilesLoading &&
<div className="flex items-center space-x-4"> (() => {
<div className="flex items-center space-x-2"> // Determine script type from the first install method
<div className={`w-2 h-2 rounded-full ${scriptFilesData.ctExists ? 'bg-green-500' : 'bg-gray-300'}`}></div> const firstScript = script?.install_methods?.[0]?.script;
<span>{scriptType}: {scriptFilesData.ctExists ? 'Available' : 'Not loaded'}</span> let scriptType = "Script";
</div> if (firstScript?.startsWith("ct/")) {
<div className="flex items-center space-x-2"> scriptType = "CT Script";
<div className={`w-2 h-2 rounded-full ${scriptFilesData.installExists ? 'bg-green-500' : 'bg-gray-300'}`}></div> } else if (firstScript?.startsWith("tools/")) {
<span>Install Script: {scriptFilesData.installExists ? 'Available' : 'Not loaded'}</span> scriptType = "Tools Script";
</div> } else if (firstScript?.startsWith("vm/")) {
{scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) && comparisonData?.success && !comparisonLoading && ( scriptType = "VM Script";
} else if (firstScript?.startsWith("vw/")) {
scriptType = "VW Script";
}
return (
<div className="mx-4 sm:mx-6 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="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${comparisonData.hasDifferences ? 'bg-orange-500' : 'bg-green-500'}`}></div> <div
<span>Status: {comparisonData.hasDifferences ? 'Update available' : 'Up to date'}</span> className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-muted"}`}
></div>
<span>
{scriptType}:{" "}
{scriptFilesData.ctExists ? "Available" : "Not loaded"}
</span>
</div>
<div className="flex items-center space-x-2">
<div
className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-green-500" : "bg-muted"}`}
></div>
<span>
Install Script:{" "}
{scriptFilesData.installExists
? "Available"
: "Not loaded"}
</span>
</div>
{scriptFilesData?.success &&
(scriptFilesData.ctExists ||
scriptFilesData.installExists) &&
comparisonData?.success &&
!comparisonLoading && (
<div className="flex items-center space-x-2">
<div
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-orange-500" : "bg-green-500"}`}
></div>
<span>
Status:{" "}
{comparisonData.hasDifferences
? "Update available"
: "Up to date"}
</span>
</div>
)}
</div>
{scriptFilesData.files.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground break-words">
Files: {scriptFilesData.files.join(", ")}
</div> </div>
)} )}
</div> </div>
{scriptFilesData.files.length > 0 && ( );
<div className="mt-2 text-xs text-gray-600"> })()}
Files: {scriptFilesData.files.join(', ')}
</div>
)}
</div>
);
})()}
{/* Content */} {/* Content */}
<div className="p-6 space-y-6"> <div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* Description */} {/* Description */}
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Description</h3> <h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
<p className="text-gray-600">{script.description}</p> Description
</h3>
<p className="text-sm sm:text-base text-muted-foreground">
{script.description}
</p>
</div> </div>
{/* Basic Information */} {/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Basic Information</h3> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Basic Information
</h3>
<dl className="space-y-2"> <dl className="space-y-2">
<div> <div>
<dt className="text-sm font-medium text-gray-500">Slug</dt> <dt className="text-sm font-medium text-muted-foreground">
<dd className="text-sm text-gray-900 font-mono">{script.slug}</dd> Slug
</dt>
<dd className="font-mono text-sm text-foreground">
{script.slug}
</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-gray-500">Date Created</dt> <dt className="text-sm font-medium text-muted-foreground">
<dd className="text-sm text-gray-900">{script.date_created}</dd> Date Created
</dt>
<dd className="text-sm text-foreground">
{script.date_created}
</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-gray-500">Categories</dt> <dt className="text-sm font-medium text-muted-foreground">
<dd className="text-sm text-gray-900">{script.categories.join(', ')}</dd> Categories
</dt>
<dd className="text-sm text-foreground">
{script.categories.join(", ")}
</dd>
</div> </div>
{script.interface_port && ( {script.interface_port && (
<div> <div>
<dt className="text-sm font-medium text-gray-500">Interface Port</dt> <dt className="text-sm font-medium text-muted-foreground">
<dd className="text-sm text-gray-900">{script.interface_port}</dd> Interface Port
</dt>
<dd className="text-sm text-foreground">
{script.interface_port}
</dd>
</div> </div>
)} )}
{script.config_path && ( {script.config_path && (
<div> <div>
<dt className="text-sm font-medium text-gray-500">Config Path</dt> <dt className="text-sm font-medium text-muted-foreground">
<dd className="text-sm text-gray-900 font-mono">{script.config_path}</dd> Config Path
</dt>
<dd className="font-mono text-sm text-foreground">
{script.config_path}
</dd>
</div> </div>
)} )}
</dl> </dl>
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Links</h3> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Links
</h3>
<dl className="space-y-2"> <dl className="space-y-2">
{script.website && ( {script.website && (
<div> <div>
<dt className="text-sm font-medium text-gray-500">Website</dt> <dt className="text-sm font-medium text-muted-foreground">
Website
</dt>
<dd className="text-sm"> <dd className="text-sm">
<a <a
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 break-all" className="break-all text-primary hover:text-primary/80"
> >
{script.website} {script.website}
</a> </a>
@@ -389,13 +535,15 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
)} )}
{script.documentation && ( {script.documentation && (
<div> <div>
<dt className="text-sm font-medium text-gray-500">Documentation</dt> <dt className="text-sm font-medium text-muted-foreground">
Documentation
</dt>
<dd className="text-sm"> <dd className="text-sm">
<a <a
href={script.documentation} href={script.documentation}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 break-all" className="break-all text-primary hover:text-primary/80"
> >
{script.documentation} {script.documentation}
</a> </a>
@@ -406,56 +554,94 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
</div> </div>
</div> </div>
{/* Install Methods */} {/* Install Methods - Hide for PVE and ADDON types as they typically don't have install methods */}
{script.install_methods.length > 0 && ( {script.install_methods.length > 0 &&
<div> script.type !== "pve" &&
<h3 className="text-lg font-semibold text-gray-900 mb-3">Install Methods</h3> script.type !== "addon" && (
<div className="space-y-4"> <div>
{script.install_methods.map((method, index) => ( <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<div key={index} className="border border-gray-200 rounded-lg p-4"> Install Methods
<div className="flex items-center justify-between mb-3"> </h3>
<h4 className="font-medium text-gray-900 capitalize">{method.type}</h4> <div className="space-y-4">
<span className="text-sm text-gray-500 font-mono">{method.script}</span> {script.install_methods.map((method, index) => (
</div> <div
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> key={index}
<div> className="rounded-lg border border-border bg-card p-3 sm:p-4"
<dt className="font-medium text-gray-500">CPU</dt> >
<dd className="text-gray-900">{method.resources.cpu} cores</dd> <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-xs sm:text-sm text-muted-foreground break-all">
{method.script}
</span>
</div> </div>
<div> <div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
<dt className="font-medium text-gray-500">RAM</dt> <div>
<dd className="text-gray-900">{method.resources.ram} MB</dd> <dt className="font-medium text-muted-foreground">
</div> CPU
<div> </dt>
<dt className="font-medium text-gray-500">HDD</dt> <dd className="text-foreground">
<dd className="text-gray-900">{method.resources.hdd} GB</dd> {method.resources.cpu} cores
</div> </dd>
<div> </div>
<dt className="font-medium text-gray-500">OS</dt> <div>
<dd className="text-gray-900">{method.resources.os} {method.resources.version}</dd> <dt className="font-medium text-muted-foreground">
RAM
</dt>
<dd className="text-foreground">
{method.resources.ram} MB
</dd>
</div>
<div>
<dt className="font-medium text-muted-foreground">
HDD
</dt>
<dd className="text-foreground">
{method.resources.hdd} GB
</dd>
</div>
<div>
<dt className="font-medium text-muted-foreground">
OS
</dt>
<dd className="text-foreground">
{method.resources.os} {method.resources.version}
</dd>
</div>
</div> </div>
</div> </div>
</div> ))}
))} </div>
</div> </div>
</div> )}
)}
{/* Default Credentials */} {/* Default Credentials */}
{(script.default_credentials.username ?? script.default_credentials.password) && ( {(script.default_credentials.username ??
script.default_credentials.password) && (
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Default Credentials</h3> <h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
Default Credentials
</h3>
<dl className="space-y-2"> <dl className="space-y-2">
{script.default_credentials.username && ( {script.default_credentials.username && (
<div> <div>
<dt className="text-sm font-medium text-gray-500">Username</dt> <dt className="text-sm font-medium text-muted-foreground">
<dd className="text-sm text-gray-900 font-mono">{script.default_credentials.username}</dd> Username
</dt>
<dd className="font-mono text-sm text-foreground">
{script.default_credentials.username}
</dd>
</div> </div>
)} )}
{script.default_credentials.password && ( {script.default_credentials.password && (
<div> <div>
<dt className="text-sm font-medium text-gray-500">Password</dt> <dt className="text-sm font-medium text-muted-foreground">
<dd className="text-sm text-gray-900 font-mono">{script.default_credentials.password}</dd> Password
</dt>
<dd className="font-mono text-sm text-foreground">
{script.default_credentials.password}
</dd>
</div> </div>
)} )}
</dl> </dl>
@@ -465,31 +651,31 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
{/* Notes */} {/* Notes */}
{script.notes.length > 0 && ( {script.notes.length > 0 && (
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Notes</h3> <h3 className="mb-3 text-lg font-semibold text-foreground">
Notes
</h3>
<ul className="space-y-2"> <ul className="space-y-2">
{script.notes.map((note, index) => { {script.notes.map((note, index) => {
// Handle both object and string note formats // Handle both object and string note formats
const noteText = typeof note === 'string' ? note : note.text; const noteText = typeof note === "string" ? note : note.text;
const noteType = typeof note === 'string' ? 'info' : note.type; const noteType =
typeof note === "string" ? "info" : note.type;
return ( return (
<li key={index} className={`text-sm p-3 rounded-lg ${ <li
noteType === 'warning' key={index}
? 'bg-yellow-50 text-yellow-800 border-l-4 border-yellow-400' className={`rounded-lg p-3 text-sm ${
: noteType === 'error' noteType === "warning"
? 'bg-red-50 text-red-800 border-l-4 border-red-400' ? "border-l-4 border-yellow-400 bg-yellow-500/10 text-yellow-400"
: 'bg-gray-50 text-gray-600' : noteType === "error"
}`}> ? "border-l-4 border-destructive bg-destructive/10 text-destructive"
: "bg-muted text-muted-foreground"
}`}
>
<div className="flex items-start"> <div className="flex items-start">
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mr-2 ${ <NoteBadge noteType={noteType as 'info' | 'warning' | 'error'} className="mr-2 flex-shrink-0">
noteType === 'warning'
? 'bg-yellow-100 text-yellow-800'
: noteType === 'error'
? 'bg-red-100 text-red-800'
: 'bg-blue-100 text-blue-800'
}`}>
{noteType} {noteType}
</span> </NoteBadge>
<span>{noteText}</span> <span>{noteText}</span>
</div> </div>
</li> </li>
@@ -517,7 +703,12 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
{/* Text Viewer Modal */} {/* Text Viewer Modal */}
{script && ( {script && (
<TextViewer <TextViewer
scriptName={script.install_methods?.find(method => method.script?.startsWith('ct/'))?.script?.split('/').pop() ?? `${script.slug}.sh`} scriptName={
script.install_methods
?.find((method) => method.script?.startsWith("ct/"))
?.script?.split("/")
.pop() ?? `${script.slug}.sh`
}
isOpen={textViewerOpen} isOpen={textViewerOpen}
onClose={() => setTextViewerOpen(false)} onClose={() => setTextViewerOpen(false)}
/> />

View File

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

View File

@@ -1,9 +1,13 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
import { ScriptCard } from './ScriptCard'; import { ScriptCard } from './ScriptCard';
import { ScriptDetailModal } from './ScriptDetailModal'; import { ScriptDetailModal } from './ScriptDetailModal';
import { CategorySidebar } from './CategorySidebar';
import { FilterBar, type FilterState } from './FilterBar';
import { Button } from './ui/button';
import type { ScriptCard as ScriptCardType } from '~/types/script';
interface ScriptsGridProps { interface ScriptsGridProps {
@@ -14,31 +18,88 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
const [selectedSlug, setSelectedSlug] = useState<string | null>(null); const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [filters, setFilters] = useState<FilterState>({
searchQuery: '',
showUpdatable: null,
selectedTypes: [],
sortBy: 'name',
sortOrder: 'asc',
});
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCards.useQuery(); 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.getCtScripts.useQuery();
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery( const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
{ slug: selectedSlug ?? '' }, { slug: selectedSlug ?? '' },
{ enabled: !!selectedSlug } { enabled: !!selectedSlug }
); );
// Get GitHub scripts with download status // Extract categories from metadata
const combinedScripts = React.useMemo(() => { const categories = React.useMemo((): string[] => {
const githubScripts = scriptCardsData?.success ? (scriptCardsData.cards if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
?.filter(script => script?.name) // Filter out invalid scripts
?.map(script => ({ return (scriptCardsData.metadata.categories as any[])
...script, .filter((cat) => cat.id !== 0) // Exclude Miscellaneous for main list
source: 'github' as const, .sort((a, b) => a.sort_order - b.sort_order)
isDownloaded: false, // Will be updated by status check .map((cat) => cat.name as string)
isUpToDate: false, // Will be updated by status check .filter((name): name is string => typeof name === 'string');
})) ?? []) : [];
return githubScripts;
}, [scriptCardsData]); }, [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]);
// Count scripts per category (using deduplicated scripts)
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 script only once per category
combinedScripts.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, combinedScripts, scriptCardsData?.success]);
// Update scripts with download status // Update scripts with download status
const scriptsWithStatus = React.useMemo(() => { const scriptsWithStatus = React.useMemo((): ScriptCardType[] => {
return combinedScripts.map(script => { return combinedScripts.map(script => {
if (!script?.name) { if (!script?.name) {
return script; // Return as-is if invalid return script; // Return as-is if invalid
@@ -60,35 +121,138 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
}); });
}, [combinedScripts, localScriptsData]); }, [combinedScripts, localScriptsData]);
// Filter scripts based on search query (name and slug only) // Filter scripts based on all filters and category
const filteredScripts = React.useMemo(() => { const filteredScripts = React.useMemo((): ScriptCardType[] => {
if (!searchQuery?.trim()) { let scripts = scriptsWithStatus;
return scriptsWithStatus;
}
const query = searchQuery.toLowerCase().trim(); // Filter by search query (use filters.searchQuery instead of deprecated searchQuery)
if (filters.searchQuery?.trim()) {
// If query is too short, don't filter const query = filters.searchQuery.toLowerCase().trim();
if (query.length < 1) {
return scriptsWithStatus; if (query.length >= 1) {
} scripts = scripts.filter(script => {
if (!script || typeof script !== 'object') {
return false;
}
const filtered = scriptsWithStatus.filter(script => { const name = (script.name ?? '').toLowerCase();
// Ensure script exists and has required properties const slug = (script.slug ?? '').toLowerCase();
if (!script || typeof script !== 'object') {
return false; return name.includes(query) ?? slug.includes(query);
});
} }
}
const name = (script.name ?? '').toLowerCase(); // Filter by category using real category data from deduplicated scripts
const slug = (script.slug ?? '').toLowerCase(); if (selectedCategory) {
scripts = scripts.filter(script => {
if (!script) return false;
// Check if the deduplicated script has categoryNames that include the selected category
return script.categoryNames?.includes(selectedCategory) ?? false;
});
}
const matches = name.includes(query) || slug.includes(query); // 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;
});
}
return matches; // 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 filtered; return scripts;
}, [scriptsWithStatus, searchQuery]); }, [scriptsWithStatus, filters, selectedCategory]);
// Calculate filter counts for FilterBar
const filterCounts = React.useMemo(() => {
const installedCount = scriptsWithStatus.filter(script => script?.isDownloaded).length;
const updatableCount = scriptsWithStatus.filter(script => script?.updateable).length;
return { installedCount, updatableCount };
}, [scriptsWithStatus]);
// Sync legacy searchQuery with filters.searchQuery for backward compatibility
useEffect(() => {
if (searchQuery !== filters.searchQuery) {
setFilters(prev => ({ ...prev, searchQuery }));
}
}, [searchQuery, filters.searchQuery]);
// Handle filter changes
const handleFiltersChange = (newFilters: FilterState) => {
setFilters(newFilters);
// Sync searchQuery for backward compatibility
setSearchQuery(newFilters.searchQuery);
};
// 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 }) => { const handleCardClick = (scriptCard: { slug: string }) => {
@@ -106,7 +270,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">Loading scripts...</span> <span className="ml-2 text-muted-foreground">Loading scripts...</span>
</div> </div>
); );
} }
@@ -119,16 +283,18 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg> </svg>
<p className="text-lg font-medium">Failed to load scripts</p> <p className="text-lg font-medium">Failed to load scripts</p>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-muted-foreground mt-1">
{githubError?.message ?? localError?.message ?? 'Unknown error occurred'} {githubError?.message ?? localError?.message ?? 'Unknown error occurred'}
</p> </p>
</div> </div>
<button <Button
onClick={() => refetch()} onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" variant="default"
size="default"
className="mt-4"
> >
Try Again Try Again
</button> </Button>
</div> </div>
); );
} }
@@ -136,12 +302,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
if (!scriptsWithStatus || scriptsWithStatus.length === 0) { if (!scriptsWithStatus || scriptsWithStatus.length === 0) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="text-gray-500"> <div className="text-muted-foreground">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg> </svg>
<p className="text-lg font-medium">No scripts found</p> <p className="text-lg font-medium">No scripts found</p>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-muted-foreground mt-1">
No script files were found in the repository or local directory. No script files were found in the repository or local directory.
</p> </p>
</div> </div>
@@ -150,91 +316,132 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
} }
return ( return (
<> <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
{/* Search Bar */} {/* Category Sidebar */}
<div className="mb-8"> <div className="flex-shrink-0 order-2 lg:order-1">
<div className="relative max-w-md mx-auto"> <CategorySidebar
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> categories={categories}
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> categoryCounts={categoryCounts}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> totalScripts={scriptsWithStatus.length}
</svg> selectedCategory={selectedCategory}
</div> onCategorySelect={handleCategorySelect}
<input />
type="text"
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 rounded-lg leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
>
<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" />
</svg>
</button>
)}
</div>
{searchQuery && (
<div className="text-center mt-2 text-sm text-gray-600">
{filteredScripts.length === 0 ? (
<span>No scripts found matching &quot;{searchQuery}&quot;</span>
) : (
<span>Found {filteredScripts.length} script{filteredScripts.length !== 1 ? 's' : ''} matching &quot;{searchQuery}&quot;</span>
)}
</div>
)}
</div> </div>
{/* Scripts Grid */} {/* Main Content */}
{filteredScripts.length === 0 && searchQuery ? ( <div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
<div className="text-center py-12"> {/* Enhanced Filter Bar */}
<div className="text-gray-500"> <FilterBar
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> filters={filters}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> onFiltersChange={handleFiltersChange}
</svg> totalScripts={scriptsWithStatus.length}
<p className="text-lg font-medium">No matching scripts found</p> filteredCount={filteredScripts.length}
<p className="text-sm text-gray-500 mt-1"> updatableCount={filterCounts.updatableCount}
Try adjusting your search terms or clear the search to see all scripts. />
</p>
<button
onClick={() => setSearchQuery('')}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Clear Search
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredScripts.map((script, index) => {
// Add validation to ensure script has required properties
if (!script || typeof script !== 'object') {
return null;
}
// Create a unique key by combining slug, name, and index to handle duplicates
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<ScriptCard
key={uniqueKey}
script={script}
onClick={handleCardClick}
/>
);
})}
</div>
)}
<ScriptDetailModal {/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
script={scriptData?.success ? scriptData.script : null} <div className="hidden mb-8">
isOpen={isModalOpen} <div className="relative max-w-md mx-auto">
onClose={handleCloseModal} <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
onInstallScript={onInstallScript} <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>
<input
type="text"
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-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-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" />
</svg>
</button>
)}
</div>
{(searchQuery || selectedCategory) && (
<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>
) : (
<span>
Found {filteredScripts.length} script{filteredScripts.length !== 1 ? 's' : ''}
{searchQuery ? ` matching "${searchQuery}"` : ''}
{selectedCategory ? ` in category "${selectedCategory}"` : ''}
</span>
)}
</div>
)}
</div>
{/* 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 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>
) : (
<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>
)}
<ScriptDetailModal
script={scriptData?.success ? scriptData.script : null}
isOpen={isModalOpen}
onClose={handleCloseModal}
onInstallScript={onInstallScript}
/>
</div>
</div>
); );
} }

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { CreateServerData } from '../../types/server'; import type { CreateServerData } from '../../types/server';
import { Button } from './ui/button';
interface ServerFormProps { interface ServerFormProps {
onSubmit: (data: CreateServerData) => void; onSubmit: (data: CreateServerData) => void;
@@ -73,9 +74,9 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
Server Name * Server Name *
</label> </label>
<input <input
@@ -83,16 +84,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="name" id="name"
value={formData.name} value={formData.name}
onChange={handleChange('name')} onChange={handleChange('name')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.name ? 'border-red-300' : 'border-gray-300' errors.name ? 'border-destructive' : 'border-border'
}`} }`}
placeholder="e.g., Production Server" placeholder="e.g., Production Server"
/> />
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name}</p>} {errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
</div> </div>
<div> <div>
<label htmlFor="ip" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
IP Address * IP Address *
</label> </label>
<input <input
@@ -100,16 +101,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="ip" id="ip"
value={formData.ip} value={formData.ip}
onChange={handleChange('ip')} onChange={handleChange('ip')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ip ? 'border-red-300' : 'border-gray-300' errors.ip ? 'border-destructive' : 'border-border'
}`} }`}
placeholder="e.g., 192.168.1.100" placeholder="e.g., 192.168.1.100"
/> />
{errors.ip && <p className="mt-1 text-sm text-red-600">{errors.ip}</p>} {errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
</div> </div>
<div> <div>
<label htmlFor="user" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="user" className="block text-sm font-medium text-muted-foreground mb-1">
Username * Username *
</label> </label>
<input <input
@@ -117,16 +118,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="user" id="user"
value={formData.user} value={formData.user}
onChange={handleChange('user')} onChange={handleChange('user')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.user ? 'border-red-300' : 'border-gray-300' errors.user ? 'border-destructive' : 'border-border'
}`} }`}
placeholder="e.g., root" placeholder="e.g., root"
/> />
{errors.user && <p className="mt-1 text-sm text-red-600">{errors.user}</p>} {errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
</div> </div>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
Password * Password *
</label> </label>
<input <input
@@ -134,31 +135,35 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
id="password" id="password"
value={formData.password} value={formData.password}
onChange={handleChange('password')} onChange={handleChange('password')}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${ className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.password ? 'border-red-300' : 'border-gray-300' errors.password ? 'border-destructive' : 'border-border'
}`} }`}
placeholder="Enter password" placeholder="Enter password"
/> />
{errors.password && <p className="mt-1 text-sm text-red-600">{errors.password}</p>} {errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
</div> </div>
</div> </div>
<div className="flex justify-end space-x-3 pt-4"> <div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
{isEditing && onCancel && ( {isEditing && onCancel && (
<button <Button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 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"
className="w-full sm:w-auto order-2 sm:order-1"
> >
Cancel Cancel
</button> </Button>
)} )}
<button <Button
type="submit" type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" variant="default"
size="default"
className="w-full sm:w-auto order-1 sm:order-2"
> >
{isEditing ? 'Update Server' : 'Add Server'} {isEditing ? 'Update Server' : 'Add Server'}
</button> </Button>
</div> </div>
</form> </form>
); );

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Server, CreateServerData } from '../../types/server'; import type { Server, CreateServerData } from '../../types/server';
import { ServerForm } from './ServerForm'; import { ServerForm } from './ServerForm';
import { Button } from './ui/button';
interface ServerListProps { interface ServerListProps {
servers: Server[]; servers: Server[];
@@ -71,12 +72,12 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
if (servers.length === 0) { if (servers.length === 0) {
return ( return (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-muted-foreground">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="mx-auto h-12 w-12 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg> </svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No servers configured</h3> <h3 className="mt-2 text-sm font-medium text-foreground">No servers configured</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding a new server configuration above.</p> <p className="mt-1 text-sm text-muted-foreground">Get started by adding a new server configuration above.</p>
</div> </div>
); );
} }
@@ -84,10 +85,10 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{servers.map((server) => ( {servers.map((server) => (
<div key={server.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm"> <div key={server.id} className="bg-card border border-border rounded-lg p-4 shadow-sm">
{editingId === server.id ? ( {editingId === server.id ? (
<div> <div>
<h4 className="text-lg font-medium text-gray-900 mb-4">Edit Server</h4> <h4 className="text-lg font-medium text-foreground mb-4">Edit Server</h4>
<ServerForm <ServerForm
initialData={{ initialData={{
name: server.name, name: server.name,
@@ -101,33 +102,33 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
/> />
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between space-y-4 sm:space-y-0">
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="flex items-center space-x-3"> <div className="flex items-start sm:items-center space-x-3">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 sm:w-6 sm:h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg> </svg>
</div> </div>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-gray-900 truncate">{server.name}</h3> <h3 className="text-base sm:text-lg font-medium text-foreground truncate">{server.name}</h3>
<div className="mt-1 flex items-center space-x-4 text-sm text-gray-500"> <div className="mt-1 flex flex-col sm:flex-row sm:items-center space-y-1 sm:space-y-0 sm:space-x-4 text-sm text-muted-foreground">
<span className="flex items-center"> <span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
</svg> </svg>
{server.ip} <span className="truncate">{server.ip}</span>
</span> </span>
<span className="flex items-center"> <span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg> </svg>
{server.user} <span className="truncate">{server.user}</span>
</span> </span>
</div> </div>
<div className="mt-1 text-xs text-gray-400"> <div className="mt-1 text-xs text-muted-foreground">
Created: {new Date(server.created_at).toLocaleDateString()} Created: {new Date(server.created_at).toLocaleDateString()}
{server.updated_at !== server.created_at && ( {server.updated_at !== server.created_at && (
<span> Updated: {new Date(server.updated_at).toLocaleDateString()}</span> <span> Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
@@ -161,46 +162,58 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
<button <Button
onClick={() => handleTestConnection(server)} onClick={() => handleTestConnection(server)}
disabled={testingConnections.has(server.id)} disabled={testingConnections.has(server.id)}
className="inline-flex items-center px-3 py-1.5 border border-green-300 text-xs font-medium rounded text-green-700 bg-white hover:bg-green-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed" variant="outline"
size="sm"
className="w-full sm:w-auto border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
> >
{testingConnections.has(server.id) ? ( {testingConnections.has(server.id) ? (
<> <>
<svg className="w-4 h-4 mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg> </svg>
Testing... <span className="hidden sm:inline">Testing...</span>
<span className="sm:hidden">Test...</span>
</> </>
) : ( ) : (
<> <>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
Test Connection <span className="hidden sm:inline">Test Connection</span>
<span className="sm:hidden">Test</span>
</> </>
)} )}
</button> </Button>
<button <div className="flex space-x-2">
onClick={() => handleEdit(server)} <Button
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" onClick={() => handleEdit(server)}
> variant="outline"
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> size="sm"
<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" /> className="flex-1 sm:flex-none"
</svg> >
Edit <svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</button> <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" />
<button </svg>
onClick={() => handleDelete(server.id)} <span className="hidden sm:inline">Edit</span>
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" <span className="sm:hidden"></span>
> </Button>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Button
<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" /> onClick={() => handleDelete(server.id)}
</svg> variant="outline"
Delete size="sm"
</button> 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>
<span className="hidden sm:inline">Delete</span>
<span className="sm:hidden">🗑</span>
</Button>
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -2,39 +2,47 @@
import { useState } from 'react'; import { useState } from 'react';
import { SettingsModal } from './SettingsModal'; import { SettingsModal } from './SettingsModal';
import { Button } from './ui/button';
export function SettingsButton() { export function SettingsButton() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<> <>
<button <div className="flex flex-col sm:flex-row sm:items-center gap-3">
onClick={() => setIsOpen(true)} <div className="text-sm text-muted-foreground font-medium">
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200" Add and manage PVE Servers:
title="Add PVE Server" </div>
> <Button
<svg onClick={() => setIsOpen(true)}
className="w-5 h-5 mr-2" variant="outline"
fill="none" size="default"
stroke="currentColor" className="inline-flex items-center"
viewBox="0 0 24 24" title="Add PVE Server"
xmlns="http://www.w3.org/2000/svg"
> >
<path <svg
strokeLinecap="round" className="w-5 h-5 mr-2"
strokeLinejoin="round" fill="none"
strokeWidth={2} stroke="currentColor"
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" viewBox="0 0 24 24"
/> xmlns="http://www.w3.org/2000/svg"
<path >
strokeLinecap="round" <path
strokeLinejoin="round" strokeLinecap="round"
strokeWidth={2} strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" 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"
</svg> />
Add PVE Server <path
</button> 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)} /> <SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
</> </>

View File

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

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
import { Button } from './ui/button';
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
interface TerminalProps { interface TerminalProps {
scriptPath: string; scriptPath: string;
@@ -22,6 +24,11 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
const [mobileInput, setMobileInput] = useState('');
const [showMobileInput, setShowMobileInput] = useState(false);
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
const [inWhiptailSession, setInWhiptailSession] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<any>(null); const xtermRef = useRef<any>(null);
const fitAddonRef = useRef<any>(null); const fitAddonRef = useRef<any>(null);
@@ -32,32 +39,126 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script'; const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
const handleMessage = useCallback((message: TerminalMessage) => {
if (!xtermRef.current) return;
const timestamp = new Date(message.timestamp).toLocaleTimeString();
const prefix = `[${timestamp}] `;
switch (message.type) {
case 'start':
xtermRef.current.writeln(`${prefix}[START] ${message.data}`);
setIsRunning(true);
break;
case 'output':
// Write directly to terminal - xterm.js handles ANSI codes natively
// Detect whiptail sessions and clear immediately
if (message.data.includes('whiptail') || message.data.includes('dialog') || message.data.includes('Choose an option')) {
setInWhiptailSession(true);
// Clear terminal immediately when whiptail starts
xtermRef.current.clear();
xtermRef.current.write('\x1b[2J\x1b[H');
}
// Check for screen clearing sequences and handle them properly
if (message.data.includes('\x1b[2J') || message.data.includes('\x1b[H\x1b[2J')) {
// This is a clear screen sequence, ensure it's processed correctly
xtermRef.current.write(message.data);
} else if (message.data.includes('\x1b[') && message.data.includes('H')) {
// This is a cursor positioning sequence, often implies a redraw of the entire screen
if (inWhiptailSession) {
// In whiptail session, completely reset the terminal
// Completely clear everything
xtermRef.current.clear();
xtermRef.current.write('\x1b[2J\x1b[H\x1b[3J\x1b[2J');
// Reset the terminal state
xtermRef.current.reset();
// Write the new content after reset
setTimeout(() => {
xtermRef.current.write(message.data);
}, 100);
} else {
xtermRef.current.write(message.data);
}
} else {
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':
// Reset whiptail session
setInWhiptailSession(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}`);
}
setIsRunning(false);
break;
}
}, [scriptPath, containerId, scriptName, inWhiptailSession]);
// Ensure we're on the client side // Ensure we're on the client side
useEffect(() => { useEffect(() => {
setIsClient(true); setIsClient(true);
// Detect mobile on mount
setIsMobile(window.innerWidth < 768);
}, []); }, []);
useEffect(() => { useEffect(() => {
// Only initialize on client side // Only initialize on client side
if (!isClient || !terminalRef.current || xtermRef.current) return; if (!isClient || !terminalRef.current || xtermRef.current) return;
// Store ref value to avoid stale closure
const terminalElement = terminalRef.current;
// Use setTimeout to ensure DOM is fully ready // Use setTimeout to ensure DOM is fully ready
const initTerminal = async () => { const initTerminal = async () => {
if (!terminalRef.current || xtermRef.current) return; if (!terminalElement || xtermRef.current) return;
// Dynamically import xterm modules to avoid SSR issues // Dynamically import xterm modules to avoid SSR issues
const { Terminal: XTerm } = await import('@xterm/xterm'); const { Terminal: XTerm } = await import('@xterm/xterm');
const { FitAddon } = await import('@xterm/addon-fit'); const { FitAddon } = await import('@xterm/addon-fit');
const { WebLinksAddon } = await import('@xterm/addon-web-links'); const { WebLinksAddon } = await import('@xterm/addon-web-links');
// Use the mobile state
const terminal = new XTerm({ const terminal = new XTerm({
theme: { theme: {
background: '#000000', background: '#000000',
foreground: '#00ff00', foreground: '#00ff00',
cursor: '#00ff00', cursor: '#00ff00',
}, },
fontSize: 14, fontSize: isMobile ? 7 : 14,
fontFamily: 'Courier New, monospace', fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
cursorBlink: true, cursorBlink: true,
cursorStyle: 'block', cursorStyle: 'block',
scrollback: 1000, scrollback: 1000,
@@ -68,6 +169,12 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
macOptionIsMeta: false, macOptionIsMeta: false,
rightClickSelectsWord: false, rightClickSelectsWord: false,
wordSeparator: ' ()[]{}\'"`<>|', wordSeparator: ' ()[]{}\'"`<>|',
// Better ANSI handling
allowProposedApi: true,
// Force proper terminal behavior for interactive applications
// Use smaller dimensions on mobile but ensure proper fit
cols: isMobile ? 45 : 80,
rows: isMobile ? 18 : 24,
}); });
// Add addons // Add addons
@@ -75,15 +182,41 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const webLinksAddon = new WebLinksAddon(); const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon); terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon); terminal.loadAddon(webLinksAddon);
// Enable better ANSI handling
terminal.options.allowProposedApi = true;
// Open terminal // Open terminal
terminal.open(terminalRef.current); terminal.open(terminalElement);
// Fit after a small delay to ensure proper sizing // Fit after a small delay to ensure proper sizing
setTimeout(() => { setTimeout(() => {
fitAddon.fit(); fitAddon.fit();
// Force fit multiple times for mobile to ensure proper sizing
if (isMobile) {
setTimeout(() => {
fitAddon.fit();
setTimeout(() => {
fitAddon.fit();
}, 200);
}, 300);
}
}, 100); }, 100);
// 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 // Store references
xtermRef.current = terminal; xtermRef.current = terminal;
fitAddonRef.current = fitAddon; fitAddonRef.current = fitAddon;
@@ -91,25 +224,16 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// Handle terminal input // Handle terminal input
terminal.onData((data) => { terminal.onData((data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ const message = {
action: 'input', action: 'input',
executionId, executionId,
input: data input: data
})); };
wsRef.current.send(JSON.stringify(message));
} }
}); });
// Handle terminal resize
const handleResize = () => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
};
window.addEventListener('resize', handleResize);
return () => { return () => {
window.removeEventListener('resize', handleResize);
terminal.dispose(); terminal.dispose();
}; };
}; };
@@ -121,13 +245,16 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
return () => { return () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (terminalElement && (terminalElement as any).resizeHandler) {
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
}
if (xtermRef.current) { if (xtermRef.current) {
xtermRef.current.dispose(); xtermRef.current.dispose();
xtermRef.current = null; xtermRef.current = null;
fitAddonRef.current = null; fitAddonRef.current = null;
} }
}; };
}, [executionId, isClient]); }, [executionId, isClient, inWhiptailSession, isMobile]);
useEffect(() => { useEffect(() => {
// Prevent multiple connections in React Strict Mode // Prevent multiple connections in React Strict Mode
@@ -173,6 +300,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data as string) as TerminalMessage; const message = JSON.parse(event.data as string) as TerminalMessage;
console.log('WebSocket message received:', message);
handleMessage(message); handleMessage(message);
} catch (error) { } catch (error) {
console.error('Error parsing WebSocket message:', error); console.error('Error parsing WebSocket message:', error);
@@ -204,45 +332,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
wsRef.current.close(); wsRef.current.close();
} }
}; };
}, [scriptPath, executionId, mode, server, isUpdate, containerId]); }, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]);
const handleMessage = (message: TerminalMessage) => {
if (!xtermRef.current) return;
const timestamp = new Date(message.timestamp).toLocaleTimeString();
const prefix = `[${timestamp}] `;
switch (message.type) {
case 'start':
xtermRef.current.writeln(`${prefix}🚀 ${message.data}`);
setIsRunning(true);
break;
case 'output':
// Write directly to terminal - xterm.js handles ANSI codes natively
xtermRef.current.write(message.data);
break;
case 'error':
// Check if this looks like ANSI terminal output (contains escape codes)
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
// This is likely terminal output sent to stderr, treat it as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('TERM environment variable not set')) {
// This is a common warning, treat as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
// This is a script error, show it with error prefix
xtermRef.current.writeln(`${prefix}${message.data}`);
} else {
// This is a real error, show it with error prefix
xtermRef.current.writeln(`${prefix}${message.data}`);
}
break;
case 'end':
xtermRef.current.writeln(`${prefix}${message.data}`);
setIsRunning(false);
break;
}
};
const startScript = () => { const startScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
@@ -273,47 +363,71 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
} }
}; };
const sendInput = (input: string) => {
setLastInputSent(input);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
action: 'input',
executionId,
input: input
};
wsRef.current.send(JSON.stringify(message));
// Clear the feedback after 2 seconds
setTimeout(() => setLastInputSent(null), 2000);
}
};
const handleMobileInput = (input: string) => {
sendInput(input);
setMobileInput('');
};
const handleEnterKey = () => {
sendInput('\r');
};
// Don't render on server side // Don't render on server side
if (!isClient) { if (!isClient) {
return ( return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden"> <div className="bg-card rounded-lg border border-border overflow-hidden">
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700"> <div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="flex space-x-1"> <div className="flex space-x-1">
<div className="w-3 h-3 bg-red-500 rounded-full"></div> <div className="w-3 h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div> <div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div> <div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div> </div>
<span className="text-gray-300 font-mono text-sm ml-2"> <span className="text-foreground font-mono text-sm ml-2">
{scriptName} {scriptName}
</span> </span>
</div> </div>
</div> </div>
<div className="h-96 w-full flex items-center justify-center"> <div className="h-96 w-full flex items-center justify-center">
<div className="text-gray-400">Loading terminal...</div> <div className="text-muted-foreground">Loading terminal...</div>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden"> <div className="bg-card rounded-lg border border-border overflow-hidden">
{/* Terminal Header */} {/* Terminal Header */}
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-b border-gray-700"> <div className="bg-muted px-2 sm:px-4 py-2 flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2 min-w-0 flex-1">
<div className="flex space-x-1"> <div className="flex space-x-1 flex-shrink-0">
<div className="w-3 h-3 bg-red-500 rounded-full"></div> <div className="w-2 h-2 sm:w-3 sm:h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div> <div className="w-2 h-2 sm:w-3 sm:h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div> <div className="w-2 h-2 sm:w-3 sm:h-3 bg-green-500 rounded-full"></div>
</div> </div>
<span className="text-gray-300 font-mono text-sm ml-2"> <span className="text-foreground font-mono text-xs sm:text-sm ml-1 sm:ml-2 truncate">
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`} {scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
</span> </span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-1 sm:space-x-2 flex-shrink-0">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div> <div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="text-gray-400 text-xs"> <span className="text-muted-foreground text-xs hidden sm:inline">
{isConnected ? 'Connected' : 'Disconnected'} {isConnected ? 'Connected' : 'Disconnected'}
</span> </span>
</div> </div>
@@ -322,51 +436,199 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
{/* Terminal Output */} {/* Terminal Output */}
<div <div
ref={terminalRef} ref={terminalRef}
className="h-96 w-full" className={`h-[16rem] sm:h-[24rem] lg:h-[32rem] w-full max-w-4xl mx-auto ${isMobile ? 'mobile-terminal' : ''}`}
style={{ minHeight: '384px' }} style={{
minHeight: '256px'
}}
/> />
{/* 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"
>
<Keyboard className="h-4 w-4 mr-1" />
{showMobileInput ? 'Hide' : 'Show'} Input
</Button>
</div>
{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}
>
<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 */} {/* Terminal Controls */}
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between border-t border-gray-700"> <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 space-x-2"> <div className="flex flex-wrap gap-1 sm:gap-2">
<button <Button
onClick={startScript} onClick={startScript}
disabled={!isConnected || isRunning} disabled={!isConnected || isRunning}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${ variant="default"
isConnected && !isRunning size="sm"
? 'bg-green-600 text-white hover:bg-green-700' className={`text-xs sm:text-sm ${isConnected && !isRunning ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
}`}
> >
Start <Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</button> <span className="hidden sm:inline">Start</span>
<span className="sm:hidden"></span>
</Button>
<button <Button
onClick={stopScript} onClick={stopScript}
disabled={!isRunning} disabled={!isRunning}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${ variant="default"
isRunning size="sm"
? 'bg-red-600 text-white hover:bg-red-700' className={`text-xs sm:text-sm ${isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
}`}
> >
Stop <Square className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</button> <span className="hidden sm:inline">Stop</span>
<span className="sm:hidden"></span>
</Button>
<button <Button
onClick={clearOutput} onClick={clearOutput}
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors" variant="secondary"
size="sm"
className="text-xs sm:text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
> >
🗑 Clear <Trash2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</button> <span className="hidden sm:inline">Clear</span>
<span className="sm:hidden">🗑</span>
</Button>
</div> </div>
<button <Button
onClick={onClose} onClick={onClose}
className="px-3 py-1 text-xs font-medium bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors" variant="secondary"
size="sm"
className="text-xs sm:text-sm bg-gray-600 text-white hover:bg-gray-700 w-full sm:w-auto"
> >
Close <X className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</button> Close
</Button>
</div> </div>
</div> </div>
); );

View File

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

View File

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

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